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