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