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