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