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