ConversationCursor.java revision 5cc0ab20009100e3ef259fe3d2e3ddc357f79285
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            Iterator<HashMap.Entry<String, ContentValues>> iter = mCacheMap.entrySet().iterator();
368            final long now = System.currentTimeMillis();
369            while (iter.hasNext()) {
370                HashMap.Entry<String, ContentValues> entry = iter.next();
371                final ContentValues values = entry.getValue();
372                final String key = entry.getKey();
373                boolean withinTimeWindow = false;
374                boolean removed = false;
375                if (values != null) {
376                    Long updateTime = values.getAsLong(UPDATE_TIME_COLUMN);
377                    if (updateTime != null && ((now - updateTime) < REQUERY_ALLOWANCE_TIME)) {
378                        LogUtils.i(LOG_TAG, "IN resetCursor, keep recent changes to %s", key);
379                        withinTimeWindow = true;
380                    } else if (updateTime == null) {
381                        LogUtils.e(LOG_TAG, "null updateTime from mCacheMap for key: %s", key);
382                    }
383                    if (values.containsKey(DELETED_COLUMN)) {
384                        // Item is deleted locally AND deleted in the new cursor.
385                        if (!newCursorWrapper.contains(key)) {
386                            // Keep the deleted count up-to-date; remove the
387                            // cache entry
388                            mDeletedCount--;
389                            removed = true;
390                            LogUtils.i(LOG_TAG,
391                                    "IN resetCursor, sDeletedCount decremented to: %d by %s",
392                                    mDeletedCount, key);
393                        }
394                    }
395                } else {
396                    LogUtils.e(LOG_TAG, "null ContentValues from mCacheMap for key: %s", key);
397                }
398                // Remove the entry if it was time for an update or the item was deleted by the user.
399                if (!withinTimeWindow || removed) {
400                    iter.remove();
401                }
402            }
403
404            // Swap cursor
405            if (mUnderlyingCursor != null) {
406                close();
407            }
408            mUnderlyingCursor = newCursorWrapper;
409
410            mPosition = -1;
411            mUnderlyingCursor.moveToPosition(mPosition);
412            if (!mCursorObserverRegistered) {
413                mUnderlyingCursor.registerContentObserver(mCursorObserver);
414                mCursorObserverRegistered = true;
415            }
416            mRefreshRequired = false;
417        }
418    }
419
420    /**
421     * Returns the conversation uris for the Conversations that the ConversationCursor is treating
422     * as deleted.  This is an optimization to allow clients to determine if an item has been
423     * removed, without having to iterate through the whole cursor
424     */
425    public Set<String> getDeletedItems() {
426        synchronized (mCacheMapLock) {
427            // Walk through the cache and return the list of uris that have been deleted
428            final Set<String> deletedItems = Sets.newHashSet();
429            final Iterator<HashMap.Entry<String, ContentValues>> iter =
430                    mCacheMap.entrySet().iterator();
431            while (iter.hasNext()) {
432                final HashMap.Entry<String, ContentValues> entry = iter.next();
433                final ContentValues values = entry.getValue();
434                if (values.containsKey(DELETED_COLUMN)) {
435                    // Since clients of the conversation cursor see conversation ConversationCursor
436                    // provider uris, we need to make sure that this also returns these uris
437                    final Uri conversationUri = Uri.parse(entry.getKey());
438                    deletedItems.add(uriToCachingUriString(conversationUri)) ;
439                }
440            }
441            return deletedItems;
442        }
443    }
444
445    /**
446     * Add a listener for this cursor; we'll notify it when our data changes
447     */
448    public void addListener(ConversationListener listener) {
449        synchronized (mListeners) {
450            if (!mListeners.contains(listener)) {
451                mListeners.add(listener);
452            } else {
453                LogUtils.i(LOG_TAG, "Ignoring duplicate add of listener");
454            }
455        }
456    }
457
458    /**
459     * Remove a listener for this cursor
460     */
461    public void removeListener(ConversationListener listener) {
462        synchronized(mListeners) {
463            mListeners.remove(listener);
464        }
465    }
466
467    /**
468     * Generate a forwarding Uri to ConversationProvider from an original Uri.  We do this by
469     * changing the authority to ours, but otherwise leaving the Uri intact.
470     * NOTE: This won't handle query parameters, so the functionality will need to be added if
471     * parameters are used in the future
472     * @param uri the uri
473     * @return a forwarding uri to ConversationProvider
474     */
475    private static String uriToCachingUriString (Uri uri) {
476        final String provider = uri.getAuthority();
477        return uri.getScheme() + "://" + ConversationProvider.AUTHORITY
478                + "/" + provider + uri.getPath();
479    }
480
481    /**
482     * Regenerate the original Uri from a forwarding (ConversationProvider) Uri
483     * NOTE: See note above for uriToCachingUri
484     * @param uri the forwarding Uri
485     * @return the original Uri
486     */
487    private static Uri uriFromCachingUri(Uri uri) {
488        String authority = uri.getAuthority();
489        // Don't modify uri's that aren't ours
490        if (!authority.equals(ConversationProvider.AUTHORITY)) {
491            return uri;
492        }
493        List<String> path = uri.getPathSegments();
494        Uri.Builder builder = new Uri.Builder().scheme(uri.getScheme()).authority(path.get(0));
495        for (int i = 1; i < path.size(); i++) {
496            builder.appendPath(path.get(i));
497        }
498        return builder.build();
499    }
500
501    private static String uriStringFromCachingUri(Uri uri) {
502        Uri underlyingUri = uriFromCachingUri(uri);
503        // Remember to decode the underlying Uri as it might be encoded (as w/ Gmail)
504        return Uri.decode(underlyingUri.toString());
505    }
506
507    public void setConversationColumn(Uri conversationUri, String columnName, Object value) {
508        final String uriStr = uriStringFromCachingUri(conversationUri);
509        synchronized (mCacheMapLock) {
510            cacheValue(uriStr, columnName, value);
511        }
512        notifyDataChanged();
513    }
514
515    /**
516     * Cache a column name/value pair for a given Uri
517     * @param uriString the Uri for which the column name/value pair applies
518     * @param columnName the column name
519     * @param value the value to be cached
520     */
521    private void cacheValue(String uriString, String columnName, Object value) {
522        // Calling this method off the UI thread will mess with ListView's reading of the cursor's
523        // count
524        if (offUiThread()) {
525            LogUtils.e(LOG_TAG, new Error(),
526                    "cacheValue incorrectly being called from non-UI thread");
527        }
528
529        synchronized (mCacheMapLock) {
530            // Get the map for our uri
531            ContentValues map = mCacheMap.get(uriString);
532            // Create one if necessary
533            if (map == null) {
534                map = new ContentValues();
535                mCacheMap.put(uriString, map);
536            }
537            // If we're caching a deletion, add to our count
538            if (columnName == DELETED_COLUMN) {
539                final boolean state = (Boolean)value;
540                final boolean hasValue = map.get(columnName) != null;
541                if (state && !hasValue) {
542                    mDeletedCount++;
543                    if (DEBUG) {
544                        LogUtils.i(LOG_TAG, "Deleted %s, incremented deleted count=%d", uriString,
545                                mDeletedCount);
546                    }
547                } else if (!state && hasValue) {
548                    mDeletedCount--;
549                    map.remove(columnName);
550                    if (DEBUG) {
551                        LogUtils.i(LOG_TAG, "Undeleted %s, decremented deleted count=%d", uriString,
552                                mDeletedCount);
553                    }
554                    return;
555                } else if (!state) {
556                    // Trying to undelete, but it's not deleted; just return
557                    if (DEBUG) {
558                        LogUtils.i(LOG_TAG, "Undeleted %s, IGNORING, deleted count=%d", uriString,
559                                mDeletedCount);
560                    }
561                    return;
562                }
563            }
564            // ContentValues has no generic "put", so we must test.  For now, the only classes
565            // of values implemented are Boolean/Integer/String/Blob, though others are trivially
566            // added
567            if (value instanceof Boolean) {
568                map.put(columnName, ((Boolean) value).booleanValue() ? 1 : 0);
569            } else if (value instanceof Integer) {
570                map.put(columnName, (Integer) value);
571            } else if (value instanceof String) {
572                map.put(columnName, (String) value);
573            } else if (value instanceof byte[]) {
574                map.put(columnName, (byte[])value);
575            } else {
576                final String cname = value.getClass().getName();
577                throw new IllegalArgumentException("Value class not compatible with cache: "
578                        + cname);
579            }
580            map.put(UPDATE_TIME_COLUMN, System.currentTimeMillis());
581            if (DEBUG && (columnName != DELETED_COLUMN)) {
582                LogUtils.i(LOG_TAG, "Caching value for %s: %s", uriString, columnName);
583            }
584        }
585    }
586
587    /**
588     * Get the cached value for the provided column; we special case -1 as the "deleted" column
589     * @param columnIndex the index of the column whose cached value we want to retrieve
590     * @return the cached value for this column, or null if there is none
591     */
592    private Object getCachedValue(int columnIndex) {
593        String uri = mUnderlyingCursor.getString(sUriColumnIndex);
594        return getCachedValue(uri, columnIndex);
595    }
596
597    private Object getCachedValue(String uri, int columnIndex) {
598        ContentValues uriMap = mCacheMap.get(uri);
599        if (uriMap != null) {
600            String columnName;
601            if (columnIndex == DELETED_COLUMN_INDEX) {
602                columnName = DELETED_COLUMN;
603            } else {
604                columnName = mColumnNames[columnIndex];
605            }
606            return uriMap.get(columnName);
607        }
608        return null;
609    }
610
611    /**
612     * When the underlying cursor changes, we want to alert the listener
613     */
614    private void underlyingChanged() {
615        synchronized(mCacheMapLock) {
616            if (mCursorObserverRegistered) {
617                try {
618                    mUnderlyingCursor.unregisterContentObserver(mCursorObserver);
619                } catch (IllegalStateException e) {
620                    // Maybe the cursor was GC'd?
621                }
622                mCursorObserverRegistered = false;
623            }
624            mRefreshRequired = true;
625            if (!mPaused) {
626                notifyRefreshRequired();
627            }
628        }
629    }
630
631    /**
632     * Must be called on UI thread; notify listeners that a refresh is required
633     */
634    private void notifyRefreshRequired() {
635        if (DEBUG) {
636            LogUtils.i(LOG_TAG, "[Notify %s: onRefreshRequired()]", mName);
637        }
638        if (!mDeferSync) {
639            synchronized(mListeners) {
640                for (ConversationListener listener: mListeners) {
641                    listener.onRefreshRequired();
642                }
643            }
644        }
645    }
646
647    /**
648     * Must be called on UI thread; notify listeners that a new cursor is ready
649     */
650    private void notifyRefreshReady() {
651        if (DEBUG) {
652            LogUtils.i(LOG_TAG, "[Notify %s: onRefreshReady(), %d listeners]",
653                    mName, mListeners.size());
654        }
655        synchronized(mListeners) {
656            for (ConversationListener listener: mListeners) {
657                listener.onRefreshReady();
658            }
659        }
660    }
661
662    /**
663     * Must be called on UI thread; notify listeners that data has changed
664     */
665    private void notifyDataChanged() {
666        if (DEBUG) {
667            LogUtils.i(LOG_TAG, "[Notify %s: onDataSetChanged()]", mName);
668        }
669        synchronized(mListeners) {
670            for (ConversationListener listener: mListeners) {
671                listener.onDataSetChanged();
672            }
673        }
674    }
675
676    /**
677     * Put the refreshed cursor in place (called by the UI)
678     */
679    public void sync() {
680        if (mRequeryCursor == null) {
681            // This can happen during an animated deletion, if the UI isn't keeping track, or
682            // if a new query intervened (i.e. user changed folders)
683            if (DEBUG) {
684                LogUtils.i(LOG_TAG, "[sync() %s; no requery cursor]", mName);
685            }
686            return;
687        }
688        synchronized(mCacheMapLock) {
689            if (DEBUG) {
690                LogUtils.i(LOG_TAG, "[sync() %s]", mName);
691            }
692            resetCursor(mRequeryCursor);
693            mRequeryCursor = null;
694            mRefreshTask = null;
695            mRefreshReady = false;
696        }
697        notifyDataChanged();
698    }
699
700    public boolean isRefreshRequired() {
701        return mRefreshRequired;
702    }
703
704    public boolean isRefreshReady() {
705        return mRefreshReady;
706    }
707
708    /**
709     * Cancel a refresh in progress
710     */
711    public void cancelRefresh() {
712        if (DEBUG) {
713            LogUtils.i(LOG_TAG, "[cancelRefresh() %s]", mName);
714        }
715        synchronized(mCacheMapLock) {
716            if (mRefreshTask != null) {
717                mRefreshTask.cancel(true);
718                mRefreshTask = null;
719            }
720            mRefreshReady = false;
721            // If we have the cursor, close it; otherwise, it will get closed when the query
722            // finishes (it checks sRefreshInProgress)
723            if (mRequeryCursor != null) {
724                mRequeryCursor.close();
725                mRequeryCursor = null;
726            }
727        }
728    }
729
730    /**
731     * Get a list of deletions from ConversationCursor to the refreshed cursor that hasn't yet
732     * been swapped into place; this allows the UI to animate these away if desired
733     * @return a list of positions deleted in ConversationCursor
734     */
735    public Collection<Conversation> getRefreshDeletions () {
736        return EMPTY_DELETION_LIST;
737    }
738
739    /**
740     * When we get a requery from the UI, we'll do it, but also clear the cache. The listener is
741     * notified when the requery is complete
742     * NOTE: This will have to change, of course, when we start using loaders...
743     */
744    public boolean refresh() {
745        if (DEBUG) {
746            LogUtils.i(LOG_TAG, "[refresh() %s]", mName);
747        }
748        synchronized(mCacheMapLock) {
749            if (mRefreshTask != null) {
750                if (DEBUG) {
751                    LogUtils.i(LOG_TAG, "[refresh() %s returning; already running %d]",
752                            mName, mRefreshTask.hashCode());
753                }
754                return false;
755            }
756            mRefreshTask = new RefreshTask();
757            mRefreshTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
758        }
759        return true;
760    }
761
762    public void disable() {
763        close();
764        mCacheMap.clear();
765        mListeners.clear();
766        mUnderlyingCursor = null;
767    }
768
769    @Override
770    public void close() {
771        if (mUnderlyingCursor != null && !mUnderlyingCursor.isClosed()) {
772            // Unregister our observer on the underlying cursor and close as usual
773            if (mCursorObserverRegistered) {
774                try {
775                    mUnderlyingCursor.unregisterContentObserver(mCursorObserver);
776                } catch (IllegalStateException e) {
777                    // Maybe the cursor got GC'd?
778                }
779                mCursorObserverRegistered = false;
780            }
781            mUnderlyingCursor.close();
782        }
783    }
784
785    /**
786     * Move to the next not-deleted item in the conversation
787     */
788    @Override
789    public boolean moveToNext() {
790        while (true) {
791            boolean ret = mUnderlyingCursor.moveToNext();
792            if (!ret) {
793                mPosition = getCount();
794                // STOPSHIP
795                LogUtils.i(LOG_TAG, "*** moveToNext returns false; pos = %d, und = %d, del = %d",
796                        mPosition, mUnderlyingCursor.getPosition(), mDeletedCount);
797                return false;
798            }
799            if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue;
800            mPosition++;
801            return true;
802        }
803    }
804
805    /**
806     * Move to the previous not-deleted item in the conversation
807     */
808    @Override
809    public boolean moveToPrevious() {
810        while (true) {
811            boolean ret = mUnderlyingCursor.moveToPrevious();
812            if (!ret) {
813                // Make sure we're before the first position
814                mPosition = -1;
815                return false;
816            }
817            if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue;
818            mPosition--;
819            return true;
820        }
821    }
822
823    @Override
824    public int getPosition() {
825        return mPosition;
826    }
827
828    /**
829     * The actual cursor's count must be decremented by the number we've deleted from the UI
830     */
831    @Override
832    public int getCount() {
833        if (mUnderlyingCursor == null) {
834            throw new IllegalStateException(
835                    "getCount() on disabled cursor: " + mName + "(" + qUri + ")");
836        }
837        return mUnderlyingCursor.getCount() - mDeletedCount;
838    }
839
840    @Override
841    public boolean moveToFirst() {
842        if (mUnderlyingCursor == null) {
843            throw new IllegalStateException(
844                    "moveToFirst() on disabled cursor: " + mName + "(" + qUri + ")");
845        }
846        mUnderlyingCursor.moveToPosition(-1);
847        mPosition = -1;
848        return moveToNext();
849    }
850
851    @Override
852    public boolean moveToPosition(int pos) {
853        if (mUnderlyingCursor == null) {
854            throw new IllegalStateException(
855                    "moveToPosition() on disabled cursor: " + mName + "(" + qUri + ")");
856        }
857        // Handle the "move to first" case before anything else; moveToPosition(0) in an empty
858        // SQLiteCursor moves the position to 0 when returning false, which we will mirror.
859        // But we don't want to return true on a subsequent "move to first", which we would if we
860        // check pos vs mPosition first
861        if (mUnderlyingCursor.getPosition() == -1) {
862            LogUtils.i(LOG_TAG, "*** Underlying cursor position is -1 asking to move from %d to %d",
863                    mPosition, pos);
864        }
865        if (pos == 0) {
866            return moveToFirst();
867        } else if (pos < 0) {
868            mPosition = -1;
869            mUnderlyingCursor.moveToPosition(mPosition);
870            return false;
871        } else if (pos == mPosition) {
872            // Return false if we're past the end of the cursor
873            return pos < getCount();
874        } else if (pos > mPosition) {
875            while (pos > mPosition) {
876                if (!moveToNext()) {
877                    return false;
878                }
879            }
880            return true;
881        } else if ((pos >= 0) && (mPosition - pos) > pos) {
882            // Optimization if it's easier to move forward to position instead of backward
883            // STOPSHIP (Remove logging)
884            LogUtils.i(LOG_TAG, "*** Move from %d to %d, starting from first", mPosition, pos);
885            moveToFirst();
886            return moveToPosition(pos);
887        } else {
888            while (pos < mPosition) {
889                if (!moveToPrevious()) {
890                    return false;
891                }
892            }
893            return true;
894        }
895    }
896
897    /**
898     * Make sure mPosition is correct after locally deleting/undeleting items
899     */
900    private void recalibratePosition() {
901        final int pos = mPosition;
902        moveToFirst();
903        moveToPosition(pos);
904    }
905
906    @Override
907    public boolean moveToLast() {
908        throw new UnsupportedOperationException("moveToLast unsupported!");
909    }
910
911    @Override
912    public boolean move(int offset) {
913        throw new UnsupportedOperationException("move unsupported!");
914    }
915
916    /**
917     * We need to override all of the getters to make sure they look at cached values before using
918     * the values in the underlying cursor
919     */
920    @Override
921    public double getDouble(int columnIndex) {
922        Object obj = getCachedValue(columnIndex);
923        if (obj != null) return (Double)obj;
924        return mUnderlyingCursor.getDouble(columnIndex);
925    }
926
927    @Override
928    public float getFloat(int columnIndex) {
929        Object obj = getCachedValue(columnIndex);
930        if (obj != null) return (Float)obj;
931        return mUnderlyingCursor.getFloat(columnIndex);
932    }
933
934    @Override
935    public int getInt(int columnIndex) {
936        Object obj = getCachedValue(columnIndex);
937        if (obj != null) return (Integer)obj;
938        return mUnderlyingCursor.getInt(columnIndex);
939    }
940
941    @Override
942    public long getLong(int columnIndex) {
943        Object obj = getCachedValue(columnIndex);
944        if (obj != null) return (Long)obj;
945        return mUnderlyingCursor.getLong(columnIndex);
946    }
947
948    @Override
949    public short getShort(int columnIndex) {
950        Object obj = getCachedValue(columnIndex);
951        if (obj != null) return (Short)obj;
952        return mUnderlyingCursor.getShort(columnIndex);
953    }
954
955    @Override
956    public String getString(int columnIndex) {
957        // If we're asking for the Uri for the conversation list, we return a forwarding URI
958        // so that we can intercept update/delete and handle it ourselves
959        if (columnIndex == sUriColumnIndex) {
960            Uri uri = Uri.parse(mUnderlyingCursor.getString(columnIndex));
961            return uriToCachingUriString(uri);
962        }
963        Object obj = getCachedValue(columnIndex);
964        if (obj != null) return (String)obj;
965        return mUnderlyingCursor.getString(columnIndex);
966    }
967
968    @Override
969    public byte[] getBlob(int columnIndex) {
970        Object obj = getCachedValue(columnIndex);
971        if (obj != null) return (byte[])obj;
972        return mUnderlyingCursor.getBlob(columnIndex);
973    }
974
975    /**
976     * Observer of changes to underlying data
977     */
978    private class CursorObserver extends ContentObserver {
979        public CursorObserver(Handler handler) {
980            super(handler);
981        }
982
983        @Override
984        public void onChange(boolean selfChange) {
985            // If we're here, then something outside of the UI has changed the data, and we
986            // must query the underlying provider for that data;
987            ConversationCursor.this.underlyingChanged();
988        }
989    }
990
991    /**
992     * ConversationProvider is the ContentProvider for our forwarding Uri's; it passes queries
993     * and inserts directly, and caches updates/deletes before passing them through.  The caching
994     * will cause a redraw of the list with updated values.
995     */
996    public abstract static class ConversationProvider extends ContentProvider {
997        public static String AUTHORITY;
998
999        /**
1000         * Allows the implementing provider to specify the authority that should be used.
1001         */
1002        protected abstract String getAuthority();
1003
1004        @Override
1005        public boolean onCreate() {
1006            sProvider = this;
1007            AUTHORITY = getAuthority();
1008            return true;
1009        }
1010
1011        @Override
1012        public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
1013                String sortOrder) {
1014            return sResolver.query(
1015                    uriFromCachingUri(uri), projection, selection, selectionArgs, sortOrder);
1016        }
1017
1018        @Override
1019        public Uri insert(Uri uri, ContentValues values) {
1020            insertLocal(uri, values);
1021            return ProviderExecute.opInsert(uri, values);
1022        }
1023
1024        @Override
1025        public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
1026            throw new IllegalStateException("Unexpected call to ConversationProvider.delete");
1027        }
1028
1029        @Override
1030        public int delete(Uri uri, String selection, String[] selectionArgs) {
1031            throw new IllegalStateException("Unexpected call to ConversationProvider.delete");
1032        }
1033
1034        @Override
1035        public String getType(Uri uri) {
1036            return null;
1037        }
1038
1039        /**
1040         * Quick and dirty class that executes underlying provider CRUD operations on a background
1041         * thread.
1042         */
1043        static class ProviderExecute implements Runnable {
1044            static final int DELETE = 0;
1045            static final int INSERT = 1;
1046            static final int UPDATE = 2;
1047
1048            final int mCode;
1049            final Uri mUri;
1050            final ContentValues mValues; //HEHEH
1051
1052            ProviderExecute(int code, Uri uri, ContentValues values) {
1053                mCode = code;
1054                mUri = uriFromCachingUri(uri);
1055                mValues = values;
1056            }
1057
1058            ProviderExecute(int code, Uri uri) {
1059                this(code, uri, null);
1060            }
1061
1062            static Uri opInsert(Uri uri, ContentValues values) {
1063                ProviderExecute e = new ProviderExecute(INSERT, uri, values);
1064                if (offUiThread()) return (Uri)e.go();
1065                new Thread(e).start();
1066                return null;
1067            }
1068
1069            static int opDelete(Uri uri) {
1070                ProviderExecute e = new ProviderExecute(DELETE, uri);
1071                if (offUiThread()) return (Integer)e.go();
1072                new Thread(new ProviderExecute(DELETE, uri)).start();
1073                return 0;
1074            }
1075
1076            static int opUpdate(Uri uri, ContentValues values) {
1077                ProviderExecute e = new ProviderExecute(UPDATE, uri, values);
1078                if (offUiThread()) return (Integer)e.go();
1079                new Thread(e).start();
1080                return 0;
1081            }
1082
1083            @Override
1084            public void run() {
1085                go();
1086            }
1087
1088            public Object go() {
1089                switch(mCode) {
1090                    case DELETE:
1091                        return sResolver.delete(mUri, null, null);
1092                    case INSERT:
1093                        return sResolver.insert(mUri, mValues);
1094                    case UPDATE:
1095                        return sResolver.update(mUri,  mValues, null, null);
1096                    default:
1097                        return null;
1098                }
1099            }
1100        }
1101
1102        private void insertLocal(Uri uri, ContentValues values) {
1103            // Placeholder for now; there's no local insert
1104        }
1105
1106        private int mUndoSequence = 0;
1107        private ArrayList<Uri> mUndoDeleteUris = new ArrayList<Uri>();
1108
1109        void addToUndoSequence(Uri uri) {
1110            if (sSequence != mUndoSequence) {
1111                mUndoSequence = sSequence;
1112                mUndoDeleteUris.clear();
1113            }
1114            mUndoDeleteUris.add(uri);
1115        }
1116
1117        @VisibleForTesting
1118        void deleteLocal(Uri uri, ConversationCursor conversationCursor) {
1119            String uriString = uriStringFromCachingUri(uri);
1120            conversationCursor.cacheValue(uriString, DELETED_COLUMN, true);
1121            addToUndoSequence(uri);
1122        }
1123
1124        @VisibleForTesting
1125        void undeleteLocal(Uri uri, ConversationCursor conversationCursor) {
1126            String uriString = uriStringFromCachingUri(uri);
1127            conversationCursor.cacheValue(uriString, DELETED_COLUMN, false);
1128        }
1129
1130        void setMostlyDead(Conversation conv, ConversationCursor conversationCursor) {
1131            Uri uri = conv.uri;
1132            String uriString = uriStringFromCachingUri(uri);
1133            conversationCursor.setMostlyDead(uriString, conv);
1134            addToUndoSequence(uri);
1135        }
1136
1137        void commitMostlyDead(Conversation conv, ConversationCursor conversationCursor) {
1138            conversationCursor.commitMostlyDead(conv);
1139        }
1140
1141        boolean clearMostlyDead(Uri uri, ConversationCursor conversationCursor) {
1142            String uriString =  uriStringFromCachingUri(uri);
1143            return conversationCursor.clearMostlyDead(uriString);
1144        }
1145
1146        public void undo(ConversationCursor conversationCursor) {
1147            if (sSequence == mUndoSequence) {
1148                for (Uri uri: mUndoDeleteUris) {
1149                    if (!clearMostlyDead(uri, conversationCursor)) {
1150                        undeleteLocal(uri, conversationCursor);
1151                    }
1152                }
1153                mUndoSequence = 0;
1154                conversationCursor.recalibratePosition();
1155                // Notify listeners that there was a change to the underlying
1156                // cursor to add back in some items.
1157                conversationCursor.notifyDataChanged();
1158            }
1159        }
1160
1161        @VisibleForTesting
1162        void updateLocal(Uri uri, ContentValues values, ConversationCursor conversationCursor) {
1163            if (values == null) {
1164                return;
1165            }
1166            String uriString = uriStringFromCachingUri(uri);
1167            for (String columnName: values.keySet()) {
1168                conversationCursor.cacheValue(uriString, columnName, values.get(columnName));
1169            }
1170        }
1171
1172        public int apply(Collection<ConversationOperation> ops,
1173                ConversationCursor conversationCursor) {
1174            final HashMap<String, ArrayList<ContentProviderOperation>> batchMap =
1175                    new HashMap<String, ArrayList<ContentProviderOperation>>();
1176            // Increment sequence count
1177            sSequence++;
1178
1179            // Execute locally and build CPO's for underlying provider
1180            boolean recalibrateRequired = false;
1181            for (ConversationOperation op: ops) {
1182                Uri underlyingUri = uriFromCachingUri(op.mUri);
1183                String authority = underlyingUri.getAuthority();
1184                ArrayList<ContentProviderOperation> authOps = batchMap.get(authority);
1185                if (authOps == null) {
1186                    authOps = new ArrayList<ContentProviderOperation>();
1187                    batchMap.put(authority, authOps);
1188                }
1189                ContentProviderOperation cpo = op.execute(underlyingUri);
1190                if (cpo != null) {
1191                    authOps.add(cpo);
1192                }
1193                // Keep track of whether our operations require recalibrating the cursor position
1194                if (op.mRecalibrateRequired) {
1195                    recalibrateRequired = true;
1196                }
1197            }
1198
1199            // Recalibrate cursor position if required
1200            if (recalibrateRequired) {
1201                conversationCursor.recalibratePosition();
1202            }
1203
1204            // Notify listeners that data has changed
1205            conversationCursor.notifyDataChanged();
1206
1207            // Send changes to underlying provider
1208            final boolean notUiThread = offUiThread();
1209            for (final String authority: batchMap.keySet()) {
1210                final ArrayList<ContentProviderOperation> opList = batchMap.get(authority);
1211                if (notUiThread) {
1212                    try {
1213                        sResolver.applyBatch(authority, opList);
1214                    } catch (RemoteException e) {
1215                    } catch (OperationApplicationException e) {
1216                    }
1217                } else {
1218                    new Thread(new Runnable() {
1219                        @Override
1220                        public void run() {
1221                            try {
1222                                sResolver.applyBatch(authority, opList);
1223                            } catch (RemoteException e) {
1224                            } catch (OperationApplicationException e) {
1225                            }
1226                        }
1227                    }).start();
1228                }
1229            }
1230            return sSequence;
1231        }
1232    }
1233
1234    void setMostlyDead(String uriString, Conversation conv) {
1235        LogUtils.i(LOG_TAG, "[Mostly dead, deferring: %s] ", uriString);
1236        cacheValue(uriString,
1237                UIProvider.ConversationColumns.FLAGS, Conversation.FLAG_MOSTLY_DEAD);
1238        conv.convFlags |= Conversation.FLAG_MOSTLY_DEAD;
1239        sMostlyDead.add(conv);
1240        mDeferSync = true;
1241    }
1242
1243    void commitMostlyDead(Conversation conv) {
1244        conv.convFlags &= ~Conversation.FLAG_MOSTLY_DEAD;
1245        sMostlyDead.remove(conv);
1246        LogUtils.i(LOG_TAG, "[All dead: %s]", conv.uri);
1247        if (sMostlyDead.isEmpty()) {
1248            mDeferSync = false;
1249            checkNotifyUI();
1250        }
1251    }
1252
1253    boolean clearMostlyDead(String uriString) {
1254        Object val = getCachedValue(uriString,
1255                UIProvider.CONVERSATION_FLAGS_COLUMN);
1256        if (val != null) {
1257            int flags = ((Integer)val).intValue();
1258            if ((flags & Conversation.FLAG_MOSTLY_DEAD) != 0) {
1259                cacheValue(uriString, UIProvider.ConversationColumns.FLAGS,
1260                        flags &= ~Conversation.FLAG_MOSTLY_DEAD);
1261                return true;
1262            }
1263        }
1264        return false;
1265    }
1266
1267
1268
1269
1270    /**
1271     * ConversationOperation is the encapsulation of a ContentProvider operation to be performed
1272     * atomically as part of a "batch" operation.
1273     */
1274    public class ConversationOperation {
1275        private static final int MOSTLY = 0x80;
1276        public static final int DELETE = 0;
1277        public static final int INSERT = 1;
1278        public static final int UPDATE = 2;
1279        public static final int ARCHIVE = 3;
1280        public static final int MUTE = 4;
1281        public static final int REPORT_SPAM = 5;
1282        public static final int REPORT_NOT_SPAM = 6;
1283        public static final int REPORT_PHISHING = 7;
1284        public static final int DISCARD_DRAFTS = 8;
1285        public static final int MOSTLY_ARCHIVE = MOSTLY | ARCHIVE;
1286        public static final int MOSTLY_DELETE = MOSTLY | DELETE;
1287        public static final int MOSTLY_DESTRUCTIVE_UPDATE = MOSTLY | UPDATE;
1288
1289        private final int mType;
1290        private final Uri mUri;
1291        private final Conversation mConversation;
1292        private final ContentValues mValues;
1293        // True if an updated item should be removed locally (from ConversationCursor)
1294        // This would be the case for a folder change in which the conversation is no longer
1295        // in the folder represented by the ConversationCursor
1296        private final boolean mLocalDeleteOnUpdate;
1297        // After execution, this indicates whether or not the operation requires recalibration of
1298        // the current cursor position (i.e. it removed or added items locally)
1299        private boolean mRecalibrateRequired = true;
1300        // Whether this item is already mostly dead
1301        private final boolean mMostlyDead;
1302
1303        public ConversationOperation(int type, Conversation conv) {
1304            this(type, conv, null);
1305        }
1306
1307        public ConversationOperation(int type, Conversation conv, ContentValues values) {
1308            mType = type;
1309            mUri = conv.uri;
1310            mConversation = conv;
1311            mValues = values;
1312            mLocalDeleteOnUpdate = conv.localDeleteOnUpdate;
1313            mMostlyDead = conv.isMostlyDead();
1314        }
1315
1316        private ContentProviderOperation execute(Uri underlyingUri) {
1317            Uri uri = underlyingUri.buildUpon()
1318                    .appendQueryParameter(UIProvider.SEQUENCE_QUERY_PARAMETER,
1319                            Integer.toString(sSequence))
1320                    .build();
1321            ContentProviderOperation op = null;
1322            switch(mType) {
1323                case UPDATE:
1324                    if (mLocalDeleteOnUpdate) {
1325                        sProvider.deleteLocal(mUri, ConversationCursor.this);
1326                    } else {
1327                        sProvider.updateLocal(mUri, mValues, ConversationCursor.this);
1328                        mRecalibrateRequired = false;
1329                    }
1330                    if (!mMostlyDead) {
1331                        op = ContentProviderOperation.newUpdate(uri)
1332                                .withValues(mValues)
1333                                .build();
1334                    } else {
1335                        sProvider.commitMostlyDead(mConversation, ConversationCursor.this);
1336                    }
1337                    break;
1338                case MOSTLY_DESTRUCTIVE_UPDATE:
1339                    sProvider.setMostlyDead(mConversation, ConversationCursor.this);
1340                    op = ContentProviderOperation.newUpdate(uri).withValues(mValues).build();
1341                    break;
1342                case INSERT:
1343                    sProvider.insertLocal(mUri, mValues);
1344                    op = ContentProviderOperation.newInsert(uri)
1345                            .withValues(mValues).build();
1346                    break;
1347                // Destructive actions below!
1348                // "Mostly" operations are reflected globally, but not locally, except to set
1349                // FLAG_MOSTLY_DEAD in the conversation itself
1350                case DELETE:
1351                    sProvider.deleteLocal(mUri, ConversationCursor.this);
1352                    if (!mMostlyDead) {
1353                        op = ContentProviderOperation.newDelete(uri).build();
1354                    } else {
1355                        sProvider.commitMostlyDead(mConversation, ConversationCursor.this);
1356                    }
1357                    break;
1358                case MOSTLY_DELETE:
1359                    sProvider.setMostlyDead(mConversation,ConversationCursor.this);
1360                    op = ContentProviderOperation.newDelete(uri).build();
1361                    break;
1362                case ARCHIVE:
1363                    sProvider.deleteLocal(mUri, ConversationCursor.this);
1364                    if (!mMostlyDead) {
1365                        // Create an update operation that represents archive
1366                        op = ContentProviderOperation.newUpdate(uri).withValue(
1367                                ConversationOperations.OPERATION_KEY,
1368                                ConversationOperations.ARCHIVE)
1369                                .build();
1370                    } else {
1371                        sProvider.commitMostlyDead(mConversation, ConversationCursor.this);
1372                    }
1373                    break;
1374                case MOSTLY_ARCHIVE:
1375                    sProvider.setMostlyDead(mConversation, ConversationCursor.this);
1376                    // Create an update operation that represents archive
1377                    op = ContentProviderOperation.newUpdate(uri).withValue(
1378                            ConversationOperations.OPERATION_KEY, ConversationOperations.ARCHIVE)
1379                            .build();
1380                    break;
1381                case MUTE:
1382                    if (mLocalDeleteOnUpdate) {
1383                        sProvider.deleteLocal(mUri, ConversationCursor.this);
1384                    }
1385
1386                    // Create an update operation that represents mute
1387                    op = ContentProviderOperation.newUpdate(uri).withValue(
1388                            ConversationOperations.OPERATION_KEY, ConversationOperations.MUTE)
1389                            .build();
1390                    break;
1391                case REPORT_SPAM:
1392                case REPORT_NOT_SPAM:
1393                    sProvider.deleteLocal(mUri, ConversationCursor.this);
1394
1395                    final String operation = mType == REPORT_SPAM ?
1396                            ConversationOperations.REPORT_SPAM :
1397                            ConversationOperations.REPORT_NOT_SPAM;
1398
1399                    // Create an update operation that represents report spam
1400                    op = ContentProviderOperation.newUpdate(uri).withValue(
1401                            ConversationOperations.OPERATION_KEY, operation).build();
1402                    break;
1403                case REPORT_PHISHING:
1404                    sProvider.deleteLocal(mUri, ConversationCursor.this);
1405
1406                    // Create an update operation that represents report phishing
1407                    op = ContentProviderOperation.newUpdate(uri).withValue(
1408                            ConversationOperations.OPERATION_KEY,
1409                            ConversationOperations.REPORT_PHISHING).build();
1410                    break;
1411                case DISCARD_DRAFTS:
1412                    sProvider.deleteLocal(mUri, ConversationCursor.this);
1413
1414                    // Create an update operation that represents discarding drafts
1415                    op = ContentProviderOperation.newUpdate(uri).withValue(
1416                            ConversationOperations.OPERATION_KEY,
1417                            ConversationOperations.DISCARD_DRAFTS).build();
1418                    break;
1419                default:
1420                    throw new UnsupportedOperationException(
1421                            "No such ConversationOperation type: " + mType);
1422            }
1423
1424            return op;
1425        }
1426    }
1427
1428    /**
1429     * For now, a single listener can be associated with the cursor, and for now we'll just
1430     * notify on deletions
1431     */
1432    public interface ConversationListener {
1433        /**
1434         * Data in the underlying provider has changed; a refresh is required to sync up
1435         */
1436        public void onRefreshRequired();
1437        /**
1438         * We've completed a requested refresh of the underlying cursor
1439         */
1440        public void onRefreshReady();
1441        /**
1442         * The data underlying the cursor has changed; the UI should redraw the list
1443         */
1444        public void onDataSetChanged();
1445    }
1446
1447    @Override
1448    public boolean isFirst() {
1449        throw new UnsupportedOperationException();
1450    }
1451
1452    @Override
1453    public boolean isLast() {
1454        throw new UnsupportedOperationException();
1455    }
1456
1457    @Override
1458    public boolean isBeforeFirst() {
1459        throw new UnsupportedOperationException();
1460    }
1461
1462    @Override
1463    public boolean isAfterLast() {
1464        throw new UnsupportedOperationException();
1465    }
1466
1467    @Override
1468    public int getColumnIndex(String columnName) {
1469        return mUnderlyingCursor.getColumnIndex(columnName);
1470    }
1471
1472    @Override
1473    public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException {
1474        return mUnderlyingCursor.getColumnIndexOrThrow(columnName);
1475    }
1476
1477    @Override
1478    public String getColumnName(int columnIndex) {
1479        return mUnderlyingCursor.getColumnName(columnIndex);
1480    }
1481
1482    @Override
1483    public String[] getColumnNames() {
1484        return mUnderlyingCursor.getColumnNames();
1485    }
1486
1487    @Override
1488    public int getColumnCount() {
1489        return mUnderlyingCursor.getColumnCount();
1490    }
1491
1492    @Override
1493    public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) {
1494        throw new UnsupportedOperationException();
1495    }
1496
1497    @Override
1498    public int getType(int columnIndex) {
1499        return mUnderlyingCursor.getType(columnIndex);
1500    }
1501
1502    @Override
1503    public boolean isNull(int columnIndex) {
1504        throw new UnsupportedOperationException();
1505    }
1506
1507    @Override
1508    public void deactivate() {
1509        throw new UnsupportedOperationException();
1510    }
1511
1512    @Override
1513    public boolean isClosed() {
1514        return mUnderlyingCursor == null || mUnderlyingCursor.isClosed();
1515    }
1516
1517    @Override
1518    public void registerContentObserver(ContentObserver observer) {
1519        // Nope. We never notify of underlying changes on this channel, since the cursor watches
1520        // internally and offers onRefreshRequired/onRefreshReady to accomplish the same thing.
1521    }
1522
1523    @Override
1524    public void unregisterContentObserver(ContentObserver observer) {
1525        // See above.
1526    }
1527
1528    @Override
1529    public void registerDataSetObserver(DataSetObserver observer) {
1530        // Nope. We use ConversationListener to accomplish this.
1531    }
1532
1533    @Override
1534    public void unregisterDataSetObserver(DataSetObserver observer) {
1535        // See above.
1536    }
1537
1538    @Override
1539    public void setNotificationUri(ContentResolver cr, Uri uri) {
1540        throw new UnsupportedOperationException();
1541    }
1542
1543    @Override
1544    public boolean getWantsAllOnMoveCalls() {
1545        throw new UnsupportedOperationException();
1546    }
1547
1548    @Override
1549    public Bundle getExtras() {
1550        return mUnderlyingCursor != null ? mUnderlyingCursor.getExtras() : Bundle.EMPTY;
1551    }
1552
1553    @Override
1554    public Bundle respond(Bundle extras) {
1555        if (mUnderlyingCursor != null) {
1556            return mUnderlyingCursor.respond(extras);
1557        }
1558        return Bundle.EMPTY;
1559    }
1560
1561    @Override
1562    public boolean requery() {
1563        return true;
1564    }
1565
1566    // Below are methods that update Conversation data (update/delete)
1567
1568    public int updateBoolean(Context context, Conversation conversation, String columnName,
1569            boolean value) {
1570        return updateBoolean(context, Arrays.asList(conversation), columnName, value);
1571    }
1572
1573    /**
1574     * Update an integer column for a group of conversations (see updateValues below)
1575     */
1576    public int updateInt(Context context, Collection<Conversation> conversations,
1577            String columnName, int value) {
1578        ContentValues cv = new ContentValues();
1579        cv.put(columnName, value);
1580        return updateValues(context, conversations, cv);
1581    }
1582
1583    /**
1584     * Update a string column for a group of conversations (see updateValues below)
1585     */
1586    public int updateBoolean(Context context, Collection<Conversation> conversations,
1587            String columnName, boolean value) {
1588        ContentValues cv = new ContentValues();
1589        cv.put(columnName, value);
1590        return updateValues(context, conversations, cv);
1591    }
1592
1593    /**
1594     * Update a string column for a group of conversations (see updateValues below)
1595     */
1596    public int updateString(Context context, Collection<Conversation> conversations,
1597            String columnName, String value) {
1598        return updateStrings(context, conversations, new String[]{
1599                columnName
1600        }, new String[]{
1601                value
1602        });
1603    }
1604
1605    /**
1606     * Update a string columns for a group of conversations (see updateValues below)
1607     */
1608    public int updateStrings(Context context, Collection<Conversation> conversations,
1609            String columnName, ArrayList<String> values) {
1610        ArrayList<ConversationOperation> operations = new ArrayList<ConversationOperation>();
1611        int i = 0;
1612        ContentValues cv = new ContentValues();
1613        for (Conversation c : conversations) {
1614            cv.put(columnName, values.get(i));
1615            operations.add(getOperationForConversation(c, ConversationOperation.UPDATE, cv));
1616        }
1617        return apply(context, operations);
1618    }
1619
1620    /**
1621     * Update a string columns for a group of conversations (see updateValues below)
1622     */
1623    public int updateStrings(Context context, Collection<Conversation> conversations,
1624            String[] columnNames, String[] values) {
1625        ContentValues cv = new ContentValues();
1626        for (int i = 0; i < columnNames.length; i++) {
1627            cv.put(columnNames[i], values[i]);
1628        }
1629        return updateValues(context, conversations, cv);
1630    }
1631
1632    /**
1633     * Update a boolean column for a group of conversations, immediately in the UI and in a single
1634     * transaction in the underlying provider
1635     * @param context the caller's context
1636     * @param conversations a collection of conversations
1637     * @param values the data to update
1638     * @return the sequence number of the operation (for undo)
1639     */
1640    public int updateValues(Context context, Collection<Conversation> conversations,
1641            ContentValues values) {
1642        return apply(context,
1643                getOperationsForConversations(conversations, ConversationOperation.UPDATE, values));
1644    }
1645
1646    /**
1647     * Apply many operations in a single batch transaction.
1648     * @param context the caller's context
1649     * @param op the collection of operations obtained through successive calls to
1650     * {@link #getOperationForConversation(Conversation, int, ContentValues)}.
1651     * @return the sequence number of the operation (for undo)
1652     */
1653    public int updateBulkValues(Context context, Collection<ConversationOperation> op) {
1654        return apply(context, op);
1655    }
1656
1657    private ArrayList<ConversationOperation> getOperationsForConversations(
1658            Collection<Conversation> conversations, int type, ContentValues values) {
1659        final ArrayList<ConversationOperation> ops = Lists.newArrayList();
1660        for (Conversation conv: conversations) {
1661            ops.add(getOperationForConversation(conv, type, values));
1662        }
1663        return ops;
1664    }
1665
1666    public ConversationOperation getOperationForConversation(Conversation conv, int type,
1667            ContentValues values) {
1668        return new ConversationOperation(type, conv, values);
1669    }
1670
1671    public void addFolderUpdates(ArrayList<Uri> folderUris, ArrayList<Boolean> add,
1672            ContentValues values) {
1673        ArrayList<String> folders = new ArrayList<String>();
1674        for (int i = 0; i < folderUris.size(); i++) {
1675            folders.add(folderUris.get(i).buildUpon().appendPath(add.get(i) + "").toString());
1676        }
1677        values.put(ConversationOperations.FOLDERS_UPDATED,
1678                TextUtils.join(ConversationOperations.FOLDERS_UPDATED_SPLIT_PATTERN, folders));
1679    }
1680
1681    public void addTargetFolders(Collection<Folder> targetFolders, ContentValues values) {
1682        values.put(Conversation.UPDATE_FOLDER_COLUMN, FolderList.copyOf(targetFolders).toBlob());
1683    }
1684
1685    public ConversationOperation getConversationFolderOperation(Conversation conv,
1686            ArrayList<Uri> folderUris, ArrayList<Boolean> add, Collection<Folder> targetFolders) {
1687        return getConversationFolderOperation(conv, folderUris, add, targetFolders,
1688                new ContentValues());
1689    }
1690
1691    public ConversationOperation getConversationFolderOperation(Conversation conv,
1692            ArrayList<Uri> folderUris, ArrayList<Boolean> add, Collection<Folder> targetFolders,
1693            ContentValues values) {
1694        addFolderUpdates(folderUris, add, values);
1695        addTargetFolders(targetFolders, values);
1696        return getOperationForConversation(conv, ConversationOperation.UPDATE, values);
1697    }
1698
1699    /**
1700     * Delete a single conversation
1701     * @param context the caller's context
1702     * @return the sequence number of the operation (for undo)
1703     */
1704    public int delete(Context context, Conversation conversation) {
1705        ArrayList<Conversation> conversations = new ArrayList<Conversation>();
1706        conversations.add(conversation);
1707        return delete(context, conversations);
1708    }
1709
1710    /**
1711     * Delete a single conversation
1712     * @param context the caller's context
1713     * @return the sequence number of the operation (for undo)
1714     */
1715    public int mostlyArchive(Context context, Conversation conversation) {
1716        ArrayList<Conversation> conversations = new ArrayList<Conversation>();
1717        conversations.add(conversation);
1718        return archive(context, conversations);
1719    }
1720
1721    /**
1722     * Delete a single conversation
1723     * @param context the caller's context
1724     * @return the sequence number of the operation (for undo)
1725     */
1726    public int mostlyDelete(Context context, Conversation conversation) {
1727        ArrayList<Conversation> conversations = new ArrayList<Conversation>();
1728        conversations.add(conversation);
1729        return delete(context, conversations);
1730    }
1731
1732    // Convenience methods
1733    private int apply(Context context, Collection<ConversationOperation> operations) {
1734        return sProvider.apply(operations, this);
1735    }
1736
1737    private void undoLocal() {
1738        sProvider.undo(this);
1739    }
1740
1741    public void undo(final Context context, final Uri undoUri) {
1742        new Thread(new Runnable() {
1743            @Override
1744            public void run() {
1745                Cursor c = context.getContentResolver().query(undoUri, UIProvider.UNDO_PROJECTION,
1746                        null, null, null);
1747                if (c != null) {
1748                    c.close();
1749                }
1750            }
1751        }).start();
1752        undoLocal();
1753    }
1754
1755    /**
1756     * Delete a group of conversations immediately in the UI and in a single transaction in the
1757     * underlying provider. See applyAction for argument descriptions
1758     */
1759    public int delete(Context context, Collection<Conversation> conversations) {
1760        return applyAction(context, conversations, ConversationOperation.DELETE);
1761    }
1762
1763    /**
1764     * As above, for archive
1765     */
1766    public int archive(Context context, Collection<Conversation> conversations) {
1767        return applyAction(context, conversations, ConversationOperation.ARCHIVE);
1768    }
1769
1770    /**
1771     * As above, for mute
1772     */
1773    public int mute(Context context, Collection<Conversation> conversations) {
1774        return applyAction(context, conversations, ConversationOperation.MUTE);
1775    }
1776
1777    /**
1778     * As above, for report spam
1779     */
1780    public int reportSpam(Context context, Collection<Conversation> conversations) {
1781        return applyAction(context, conversations, ConversationOperation.REPORT_SPAM);
1782    }
1783
1784    /**
1785     * As above, for report not spam
1786     */
1787    public int reportNotSpam(Context context, Collection<Conversation> conversations) {
1788        return applyAction(context, conversations, ConversationOperation.REPORT_NOT_SPAM);
1789    }
1790
1791    /**
1792     * As above, for report phishing
1793     */
1794    public int reportPhishing(Context context, Collection<Conversation> conversations) {
1795        return applyAction(context, conversations, ConversationOperation.REPORT_PHISHING);
1796    }
1797
1798    /**
1799     * Discard the drafts in the specified conversations
1800     */
1801    public int discardDrafts(Context context, Collection<Conversation> conversations) {
1802        return applyAction(context, conversations, ConversationOperation.DISCARD_DRAFTS);
1803    }
1804
1805    /**
1806     * As above, for mostly archive
1807     */
1808    public int mostlyArchive(Context context, Collection<Conversation> conversations) {
1809        return applyAction(context, conversations, ConversationOperation.MOSTLY_ARCHIVE);
1810    }
1811
1812    /**
1813     * As above, for mostly delete
1814     */
1815    public int mostlyDelete(Context context, Collection<Conversation> conversations) {
1816        return applyAction(context, conversations, ConversationOperation.MOSTLY_DELETE);
1817    }
1818
1819    /**
1820     * As above, for mostly destructive updates.
1821     */
1822    public int mostlyDestructiveUpdate(Context context, Collection<Conversation> conversations,
1823            ContentValues values) {
1824        return apply(
1825                context,
1826                getOperationsForConversations(conversations,
1827                        ConversationOperation.MOSTLY_DESTRUCTIVE_UPDATE, values));
1828    }
1829
1830    /**
1831     * Convenience method for performing an operation on a group of conversations
1832     * @param context the caller's context
1833     * @param conversations the conversations to be affected
1834     * @param opAction the action to take
1835     * @return the sequence number of the operation applied in CC
1836     */
1837    private int applyAction(Context context, Collection<Conversation> conversations,
1838            int opAction) {
1839        ArrayList<ConversationOperation> ops = Lists.newArrayList();
1840        for (Conversation conv: conversations) {
1841            ConversationOperation op =
1842                    new ConversationOperation(opAction, conv);
1843            ops.add(op);
1844        }
1845        return apply(context, ops);
1846    }
1847
1848    /**
1849     * Do not make this method dependent on the internal mechanism of the cursor.
1850     * Currently just calls the parent implementation. If this is ever overriden, take care to
1851     * ensure that two references map to the same hashcode. If
1852     * ConversationCursor first == ConversationCursor second,
1853     * then
1854     * first.hashCode() == second.hashCode().
1855     * The {@link ConversationListFragment} relies on this behavior of
1856     * {@link ConversationCursor#hashCode()} to avoid storing dangerous references to the cursor.
1857     * {@inheritDoc}
1858     */
1859    @Override
1860    public int hashCode() {
1861        return super.hashCode();
1862    }
1863}
1864