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