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