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