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