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