ConversationCursor.java revision 8883f228890572a8d85d06b1e0bf08c65bd42db4
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.Maps;
53import com.google.common.collect.Sets;
54
55import java.util.ArrayList;
56import java.util.Arrays;
57import java.util.Collection;
58import java.util.HashMap;
59import java.util.Iterator;
60import java.util.List;
61import java.util.Map;
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 static ContentResolver sResolver;
92    @VisibleForTesting
93    static ConversationProvider sProvider;
94
95    // The cursor underlying the caching cursor
96    @VisibleForTesting
97    UnderlyingCursorWrapper mUnderlyingCursor;
98    // The new cursor obtained via a requery
99    private volatile UnderlyingCursorWrapper mRequeryCursor;
100    // A mapping from Uri to updated ContentValues
101    private HashMap<String, ContentValues> mCacheMap = new HashMap<String, ContentValues>();
102    // Cache map lock (will be used only very briefly - few ms at most)
103    private Object mCacheMapLock = new Object();
104    // The listeners registered for this cursor
105    private List<ConversationListener> mListeners = Lists.newArrayList();
106    // The ConversationProvider instance
107    // The runnable executing a refresh (query of underlying provider)
108    private RefreshTask mRefreshTask;
109    // Set when we've sent refreshReady() to listeners
110    private boolean mRefreshReady = false;
111    // Set when we've sent refreshRequired() to listeners
112    private boolean mRefreshRequired = false;
113    // Whether our first query on this cursor should include a limit
114    private boolean mInitialConversationLimit = false;
115    // A list of mostly-dead items
116    private List<Conversation> sMostlyDead = Lists.newArrayList();
117    // The name of the loader
118    private final String mName;
119    // Column names for this cursor
120    private String[] mColumnNames;
121    // An observer on the underlying cursor (so we can detect changes from outside the UI)
122    private final CursorObserver mCursorObserver;
123    // Whether our observer is currently registered with the underlying cursor
124    private boolean mCursorObserverRegistered = false;
125    // Whether our loader is paused
126    private boolean mPaused = false;
127    // Whether or not sync from underlying provider should be deferred
128    private boolean mDeferSync = false;
129
130    // The current position of the cursor
131    private int mPosition = -1;
132
133    // The number of cached deletions from this cursor (used to quickly generate an accurate count)
134    private int mDeletedCount = 0;
135
136    // Parameters passed to the underlying query
137    private Uri qUri;
138    private String[] qProjection;
139
140    private void setCursor(UnderlyingCursorWrapper cursor) {
141        // If we have an existing underlying cursor, make sure it's closed
142        if (mUnderlyingCursor != null) {
143            close();
144        }
145        mColumnNames = cursor.getColumnNames();
146        mRefreshRequired = false;
147        mRefreshReady = false;
148        mRefreshTask = null;
149        resetCursor(cursor);
150    }
151
152    public ConversationCursor(Activity activity, Uri uri, boolean initialConversationLimit,
153            String name) {
154        mInitialConversationLimit = initialConversationLimit;
155        sResolver = activity.getContentResolver();
156        sUriColumnIndex = UIProvider.CONVERSATION_URI_COLUMN;
157        qUri = uri;
158        mName = name;
159        qProjection = UIProvider.CONVERSATION_PROJECTION;
160        mCursorObserver = new CursorObserver(new Handler(Looper.getMainLooper()));
161    }
162
163    /**
164     * Create a ConversationCursor; this should be called by the ListActivity using that cursor
165     * @param activity the activity creating the cursor
166     * @param messageListColumn the column used for individual cursor items
167     * @param uri the query uri
168     * @param projection the query projecion
169     * @param selection the query selection
170     * @param selectionArgs the query selection args
171     * @param sortOrder the query sort order
172     * @return a ConversationCursor
173     */
174    public void load() {
175        synchronized (mCacheMapLock) {
176            try {
177                // Create new ConversationCursor
178                LogUtils.i(LOG_TAG, "Create: initial creation");
179                setCursor(doQuery(mInitialConversationLimit));
180            } finally {
181                // If we used a limit, queue up a query without limit
182                if (mInitialConversationLimit) {
183                    mInitialConversationLimit = false;
184                    refresh();
185                }
186            }
187        }
188    }
189
190    /**
191     * Pause notifications to UI
192     */
193    public void pause() {
194        if (DEBUG) {
195            LogUtils.i(LOG_TAG, "[Paused: %s]", mName);
196        }
197        mPaused = true;
198    }
199
200    /**
201     * Resume notifications to UI; if any are pending, send them
202     */
203    public void resume() {
204        if (DEBUG) {
205            LogUtils.i(LOG_TAG, "[Resumed: %s]", mName);
206        }
207        mPaused = false;
208        checkNotifyUI();
209    }
210
211    private void checkNotifyUI() {
212        LogUtils.d(
213                LOG_TAG,
214                "Received notify ui callback and sending a notification is enabled?" +
215                " %s and refresh ready ? %s",
216                (!mPaused && !mDeferSync),
217                (mRefreshReady || (mRefreshRequired && mRefreshTask == null)));
218        if (!mPaused && !mDeferSync) {
219            if (mRefreshRequired && (mRefreshTask == null)) {
220                notifyRefreshRequired();
221            } else if (mRefreshReady) {
222                notifyRefreshReady();
223            }
224        } else {
225            LogUtils.i(LOG_TAG, "[checkNotifyUI: %s%s",
226                    (mPaused ? "Paused " : ""), (mDeferSync ? "Defer" : ""));
227        }
228    }
229
230    public Integer getConversationPosition(Uri conversationUri) {
231        return mUnderlyingCursor == null ? null :
232                mUnderlyingCursor.conversationPosition(uriStringFromCachingUri(conversationUri));
233    }
234
235    public Set<Long> getConversationIds() {
236        return mUnderlyingCursor != null ? mUnderlyingCursor.conversationIds() : null;
237    }
238
239    /**
240     * Simple wrapper for a cursor that provides methods for quickly determining
241     * the existence of a row.
242     */
243    private class UnderlyingCursorWrapper extends CursorWrapper {
244        // Ideally these two objects could be combined into a Map from
245        // conversationId -> position, but the cached values uses the conversation
246        // uri as a key.
247        private final Map<String, Integer> mConversationPositions;
248        private final Set<Long> mConversationIds;
249
250        public UnderlyingCursorWrapper(Cursor result) {
251            super(result);
252            final ImmutableMap.Builder<String, Integer> conversationPositionsMapBuilder =
253                    new ImmutableMap.Builder<String, Integer>();
254            final ImmutableSet.Builder<Long> conversationSetBuilder =
255                    new ImmutableSet.Builder<Long>();
256            if (result != null && result.moveToFirst()) {
257                // We don't want iterating over this cursor to trigger a network
258                // request
259                final boolean networkWasEnabled =
260                        Utils.disableConversationCursorNetworkAccess(result);
261                do {
262                    conversationPositionsMapBuilder.put(result.getString(sUriColumnIndex),
263                            result.getPosition());
264                    conversationSetBuilder.add(result.getLong(UIProvider.CONVERSATION_ID_COLUMN));
265                } while (result.moveToNext());
266
267                if (networkWasEnabled) {
268                    Utils.enableConversationCursorNetworkAccess(result);
269                }
270            }
271            mConversationPositions = conversationPositionsMapBuilder.build();
272            mConversationIds = conversationSetBuilder.build();
273        }
274
275        public boolean contains(String uri) {
276            return mConversationPositions.containsKey(uri);
277        }
278
279        public Integer conversationPosition(String uri) {
280            return mConversationPositions.get(uri);
281        }
282
283        public Set<Long> conversationIds() {
284            return mConversationIds;
285        }
286    }
287
288    /**
289     * Runnable that performs the query on the underlying provider
290     */
291    private class RefreshTask extends AsyncTask<Void, Void, Void> {
292        private UnderlyingCursorWrapper mCursor = null;
293
294        private RefreshTask() {
295        }
296
297        @Override
298        protected Void doInBackground(Void... params) {
299            if (DEBUG) {
300                LogUtils.i(LOG_TAG, "[Start refresh of %s: %d]", mName, hashCode());
301            }
302            // Get new data
303            mCursor = doQuery(false);
304            // Make sure window is full
305            mCursor.getCount();
306            return null;
307        }
308
309        @Override
310        protected void onPostExecute(Void param) {
311            synchronized(mCacheMapLock) {
312                LogUtils.d(
313                        LOG_TAG,
314                        "Received notify ui callback and sending a notification is enabled? %s",
315                        (!mPaused && !mDeferSync));
316                // If cursor got closed (e.g. reset loader) in the meantime, cancel the refresh
317                if (isClosed()) {
318                    onCancelled();
319                    return;
320                }
321                mRequeryCursor = mCursor;
322                mRefreshReady = true;
323                if (DEBUG) {
324                    LogUtils.i(LOG_TAG, "[Query done %s: %d]", mName, hashCode());
325                }
326                if (!mDeferSync && !mPaused) {
327                    notifyRefreshReady();
328                }
329            }
330        }
331
332        @Override
333        protected void onCancelled() {
334            if (DEBUG) {
335                LogUtils.i(LOG_TAG, "[Ignoring refresh result: %d]", hashCode());
336            }
337            if (mCursor != null) {
338                mCursor.close();
339            }
340        }
341    }
342
343    private UnderlyingCursorWrapper doQuery(boolean withLimit) {
344        Uri uri = qUri;
345        if (withLimit) {
346            uri = uri.buildUpon().appendQueryParameter(ConversationListQueryParameters.LIMIT,
347                    ConversationListQueryParameters.DEFAULT_LIMIT).build();
348        }
349        long time = System.currentTimeMillis();
350
351        Cursor result = sResolver.query(uri, qProjection, null, null, null);
352        if (result == null) {
353            Log.w(LOG_TAG, "doQuery returning null cursor, uri: " + uri);
354        } else if (DEBUG) {
355            time = System.currentTimeMillis() - time;
356            LogUtils.i(LOG_TAG, "ConversationCursor query: %s, %dms, %d results",
357                    uri, time, result.getCount());
358        }
359        return new UnderlyingCursorWrapper(result);
360    }
361
362    static boolean offUiThread() {
363        return Looper.getMainLooper().getThread() != Thread.currentThread();
364    }
365
366    /**
367     * Reset the cursor; this involves clearing out our cache map and resetting our various counts
368     * The cursor should be reset whenever we get fresh data from the underlying cursor. The cache
369     * is locked during the reset, which will block the UI, but for only a very short time
370     * (estimated at a few ms, but we can profile this; remember that the cache will usually
371     * be empty or have a few entries)
372     */
373    private void resetCursor(UnderlyingCursorWrapper newCursorWrapper) {
374        synchronized (mCacheMapLock) {
375            // Walk through the cache
376            Iterator<HashMap.Entry<String, ContentValues>> iter = mCacheMap.entrySet().iterator();
377            final long now = System.currentTimeMillis();
378            while (iter.hasNext()) {
379                HashMap.Entry<String, ContentValues> entry = iter.next();
380                final ContentValues values = entry.getValue();
381                final String key = entry.getKey();
382                boolean withinTimeWindow = false;
383                boolean removed = false;
384                if (values != null) {
385                    Long updateTime = values.getAsLong(UPDATE_TIME_COLUMN);
386                    if (updateTime != null && ((now - updateTime) < REQUERY_ALLOWANCE_TIME)) {
387                        LogUtils.i(LOG_TAG, "IN resetCursor, keep recent changes to %s", key);
388                        withinTimeWindow = true;
389                    } else if (updateTime == null) {
390                        LogUtils.e(LOG_TAG, "null updateTime from mCacheMap for key: %s", key);
391                    }
392                    if (values.containsKey(DELETED_COLUMN)) {
393                        // Item is deleted locally AND deleted in the new cursor.
394                        if (!newCursorWrapper.contains(key)) {
395                            // Keep the deleted count up-to-date; remove the
396                            // cache entry
397                            mDeletedCount--;
398                            removed = true;
399                            LogUtils.i(LOG_TAG,
400                                    "IN resetCursor, sDeletedCount decremented to: %d by %s",
401                                    mDeletedCount, key);
402                        }
403                    }
404                } else {
405                    LogUtils.e(LOG_TAG, "null ContentValues from mCacheMap for key: %s", key);
406                }
407                // Remove the entry if it was time for an update or the item was deleted by the user.
408                if (!withinTimeWindow || removed) {
409                    iter.remove();
410                }
411            }
412
413            // Swap cursor
414            if (mUnderlyingCursor != null) {
415                close();
416            }
417            mUnderlyingCursor = newCursorWrapper;
418
419            mPosition = -1;
420            mUnderlyingCursor.moveToPosition(mPosition);
421            if (!mCursorObserverRegistered) {
422                mUnderlyingCursor.registerContentObserver(mCursorObserver);
423                mCursorObserverRegistered = true;
424            }
425            mRefreshRequired = false;
426        }
427    }
428
429    /**
430     * Returns the conversation uris for the Conversations that the ConversationCursor is treating
431     * as deleted.  This is an optimization to allow clients to determine if an item has been
432     * removed, without having to iterate through the whole cursor
433     */
434    public Set<String> getDeletedItems() {
435        synchronized (mCacheMapLock) {
436            // Walk through the cache and return the list of uris that have been deleted
437            final Set<String> deletedItems = Sets.newHashSet();
438            final Iterator<HashMap.Entry<String, ContentValues>> iter =
439                    mCacheMap.entrySet().iterator();
440            while (iter.hasNext()) {
441                final HashMap.Entry<String, ContentValues> entry = iter.next();
442                final ContentValues values = entry.getValue();
443                if (values.containsKey(DELETED_COLUMN)) {
444                    // Since clients of the conversation cursor see conversation ConversationCursor
445                    // provider uris, we need to make sure that this also returns these uris
446                    final Uri conversationUri = Uri.parse(entry.getKey());
447                    deletedItems.add(uriToCachingUriString(conversationUri)) ;
448                }
449            }
450            return deletedItems;
451        }
452    }
453
454    /**
455     * Add a listener for this cursor; we'll notify it when our data changes
456     */
457    public void addListener(ConversationListener listener) {
458        synchronized (mListeners) {
459            if (!mListeners.contains(listener)) {
460                mListeners.add(listener);
461            } else {
462                LogUtils.i(LOG_TAG, "Ignoring duplicate add of listener");
463            }
464        }
465    }
466
467    /**
468     * Remove a listener for this cursor
469     */
470    public void removeListener(ConversationListener listener) {
471        synchronized(mListeners) {
472            mListeners.remove(listener);
473        }
474    }
475
476    /**
477     * Generate a forwarding Uri to ConversationProvider from an original Uri.  We do this by
478     * changing the authority to ours, but otherwise leaving the Uri intact.
479     * NOTE: This won't handle query parameters, so the functionality will need to be added if
480     * parameters are used in the future
481     * @param uri the uri
482     * @return a forwarding uri to ConversationProvider
483     */
484    private static String uriToCachingUriString (Uri uri) {
485        final String provider = uri.getAuthority();
486        return uri.getScheme() + "://" + ConversationProvider.AUTHORITY
487                + "/" + provider + uri.getPath();
488    }
489
490    /**
491     * Regenerate the original Uri from a forwarding (ConversationProvider) Uri
492     * NOTE: See note above for uriToCachingUri
493     * @param uri the forwarding Uri
494     * @return the original Uri
495     */
496    private static Uri uriFromCachingUri(Uri uri) {
497        String authority = uri.getAuthority();
498        // Don't modify uri's that aren't ours
499        if (!authority.equals(ConversationProvider.AUTHORITY)) {
500            return uri;
501        }
502        List<String> path = uri.getPathSegments();
503        Uri.Builder builder = new Uri.Builder().scheme(uri.getScheme()).authority(path.get(0));
504        for (int i = 1; i < path.size(); i++) {
505            builder.appendPath(path.get(i));
506        }
507        return builder.build();
508    }
509
510    private static String uriStringFromCachingUri(Uri uri) {
511        Uri underlyingUri = uriFromCachingUri(uri);
512        // Remember to decode the underlying Uri as it might be encoded (as w/ Gmail)
513        return Uri.decode(underlyingUri.toString());
514    }
515
516    public void setConversationColumn(Uri conversationUri, String columnName, Object value) {
517        final String uriStr = uriStringFromCachingUri(conversationUri);
518        synchronized (mCacheMapLock) {
519            cacheValue(uriStr, columnName, value);
520        }
521        notifyDataChanged();
522    }
523
524    /**
525     * Cache a column name/value pair for a given Uri
526     * @param uriString the Uri for which the column name/value pair applies
527     * @param columnName the column name
528     * @param value the value to be cached
529     */
530    private void cacheValue(String uriString, String columnName, Object value) {
531        // Calling this method off the UI thread will mess with ListView's reading of the cursor's
532        // count
533        if (offUiThread()) {
534            LogUtils.e(LOG_TAG, new Error(),
535                    "cacheValue incorrectly being called from non-UI thread");
536        }
537
538        synchronized (mCacheMapLock) {
539            // Get the map for our uri
540            ContentValues map = mCacheMap.get(uriString);
541            // Create one if necessary
542            if (map == null) {
543                map = new ContentValues();
544                mCacheMap.put(uriString, map);
545            }
546            // If we're caching a deletion, add to our count
547            if (columnName == DELETED_COLUMN) {
548                final boolean state = (Boolean)value;
549                final boolean hasValue = map.get(columnName) != null;
550                if (state && !hasValue) {
551                    mDeletedCount++;
552                    if (DEBUG) {
553                        LogUtils.i(LOG_TAG, "Deleted %s, incremented deleted count=%d", uriString,
554                                mDeletedCount);
555                    }
556                } else if (!state && hasValue) {
557                    mDeletedCount--;
558                    map.remove(columnName);
559                    if (DEBUG) {
560                        LogUtils.i(LOG_TAG, "Undeleted %s, decremented deleted count=%d", uriString,
561                                mDeletedCount);
562                    }
563                    return;
564                } else if (!state) {
565                    // Trying to undelete, but it's not deleted; just return
566                    if (DEBUG) {
567                        LogUtils.i(LOG_TAG, "Undeleted %s, IGNORING, deleted count=%d", uriString,
568                                mDeletedCount);
569                    }
570                    return;
571                }
572            }
573            // ContentValues has no generic "put", so we must test.  For now, the only classes
574            // of values implemented are Boolean/Integer/String, though others are trivially
575            // added
576            if (value instanceof Boolean) {
577                map.put(columnName, ((Boolean) value).booleanValue() ? 1 : 0);
578            } else if (value instanceof Integer) {
579                map.put(columnName, (Integer) value);
580            } else if (value instanceof String) {
581                map.put(columnName, (String) value);
582            } else {
583                final String cname = value.getClass().getName();
584                throw new IllegalArgumentException("Value class not compatible with cache: "
585                        + cname);
586            }
587            map.put(UPDATE_TIME_COLUMN, System.currentTimeMillis());
588            if (DEBUG && (columnName != DELETED_COLUMN)) {
589                LogUtils.i(LOG_TAG, "Caching value for %s: %s", uriString, columnName);
590            }
591        }
592    }
593
594    /**
595     * Get the cached value for the provided column; we special case -1 as the "deleted" column
596     * @param columnIndex the index of the column whose cached value we want to retrieve
597     * @return the cached value for this column, or null if there is none
598     */
599    private Object getCachedValue(int columnIndex) {
600        String uri = mUnderlyingCursor.getString(sUriColumnIndex);
601        return getCachedValue(uri, columnIndex);
602    }
603
604    private Object getCachedValue(String uri, int columnIndex) {
605        ContentValues uriMap = mCacheMap.get(uri);
606        if (uriMap != null) {
607            String columnName;
608            if (columnIndex == DELETED_COLUMN_INDEX) {
609                columnName = DELETED_COLUMN;
610            } else {
611                columnName = mColumnNames[columnIndex];
612            }
613            return uriMap.get(columnName);
614        }
615        return null;
616    }
617
618    /**
619     * When the underlying cursor changes, we want to alert the listener
620     */
621    private void underlyingChanged() {
622        synchronized(mCacheMapLock) {
623            if (mCursorObserverRegistered) {
624                try {
625                    mUnderlyingCursor.unregisterContentObserver(mCursorObserver);
626                } catch (IllegalStateException e) {
627                    // Maybe the cursor was GC'd?
628                }
629                mCursorObserverRegistered = false;
630            }
631            mRefreshRequired = true;
632            if (!mPaused) {
633                notifyRefreshRequired();
634            }
635        }
636    }
637
638    /**
639     * Must be called on UI thread; notify listeners that a refresh is required
640     */
641    private void notifyRefreshRequired() {
642        if (DEBUG) {
643            LogUtils.i(LOG_TAG, "[Notify %s: onRefreshRequired()]", mName);
644        }
645        if (!mDeferSync) {
646            synchronized(mListeners) {
647                for (ConversationListener listener: mListeners) {
648                    listener.onRefreshRequired();
649                }
650            }
651        }
652    }
653
654    /**
655     * Must be called on UI thread; notify listeners that a new cursor is ready
656     */
657    private void notifyRefreshReady() {
658        if (DEBUG) {
659            LogUtils.i(LOG_TAG, "[Notify %s: onRefreshReady(), %d listeners]",
660                    mName, mListeners.size());
661        }
662        synchronized(mListeners) {
663            for (ConversationListener listener: mListeners) {
664                listener.onRefreshReady();
665            }
666        }
667    }
668
669    /**
670     * Must be called on UI thread; notify listeners that data has changed
671     */
672    private void notifyDataChanged() {
673        if (DEBUG) {
674            LogUtils.i(LOG_TAG, "[Notify %s: onDataSetChanged()]", mName);
675        }
676        synchronized(mListeners) {
677            for (ConversationListener listener: mListeners) {
678                listener.onDataSetChanged();
679            }
680        }
681    }
682
683    /**
684     * Put the refreshed cursor in place (called by the UI)
685     */
686    public void sync() {
687        if (mRequeryCursor == null) {
688            // This can happen during an animated deletion, if the UI isn't keeping track, or
689            // if a new query intervened (i.e. user changed folders)
690            if (DEBUG) {
691                LogUtils.i(LOG_TAG, "[sync() %s; no requery cursor]", mName);
692            }
693            return;
694        }
695        synchronized(mCacheMapLock) {
696            if (DEBUG) {
697                LogUtils.i(LOG_TAG, "[sync() %s]", mName);
698            }
699            resetCursor(mRequeryCursor);
700            mRequeryCursor = null;
701            mRefreshTask = null;
702            mRefreshReady = false;
703        }
704        notifyDataChanged();
705    }
706
707    public boolean isRefreshRequired() {
708        return mRefreshRequired;
709    }
710
711    public boolean isRefreshReady() {
712        return mRefreshReady;
713    }
714
715    /**
716     * Cancel a refresh in progress
717     */
718    public void cancelRefresh() {
719        if (DEBUG) {
720            LogUtils.i(LOG_TAG, "[cancelRefresh() %s]", mName);
721        }
722        synchronized(mCacheMapLock) {
723            if (mRefreshTask != null) {
724                mRefreshTask.cancel(true);
725                mRefreshTask = null;
726            }
727            mRefreshReady = false;
728            // If we have the cursor, close it; otherwise, it will get closed when the query
729            // finishes (it checks sRefreshInProgress)
730            if (mRequeryCursor != null) {
731                mRequeryCursor.close();
732                mRequeryCursor = null;
733            }
734        }
735    }
736
737    /**
738     * Get a list of deletions from ConversationCursor to the refreshed cursor that hasn't yet
739     * been swapped into place; this allows the UI to animate these away if desired
740     * @return a list of positions deleted in ConversationCursor
741     */
742    public Collection<Conversation> getRefreshDeletions () {
743        return EMPTY_DELETION_LIST;
744    }
745
746    /**
747     * When we get a requery from the UI, we'll do it, but also clear the cache. The listener is
748     * notified when the requery is complete
749     * NOTE: This will have to change, of course, when we start using loaders...
750     */
751    public boolean refresh() {
752        if (DEBUG) {
753            LogUtils.i(LOG_TAG, "[refresh() %s]", mName);
754        }
755        synchronized(mCacheMapLock) {
756            if (mRefreshTask != null) {
757                if (DEBUG) {
758                    LogUtils.i(LOG_TAG, "[refresh() %s returning; already running %d]",
759                            mName, mRefreshTask.hashCode());
760                }
761                return false;
762            }
763            mRefreshTask = new RefreshTask();
764            mRefreshTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
765        }
766        return true;
767    }
768
769    public void disable() {
770        close();
771        mCacheMap.clear();
772        mListeners.clear();
773        mUnderlyingCursor = null;
774    }
775
776    @Override
777    public void close() {
778        if (mUnderlyingCursor != null && !mUnderlyingCursor.isClosed()) {
779            // Unregister our observer on the underlying cursor and close as usual
780            if (mCursorObserverRegistered) {
781                try {
782                    mUnderlyingCursor.unregisterContentObserver(mCursorObserver);
783                } catch (IllegalStateException e) {
784                    // Maybe the cursor got GC'd?
785                }
786                mCursorObserverRegistered = false;
787            }
788            mUnderlyingCursor.close();
789        }
790    }
791
792    /**
793     * Move to the next not-deleted item in the conversation
794     */
795    @Override
796    public boolean moveToNext() {
797        while (true) {
798            boolean ret = mUnderlyingCursor.moveToNext();
799            if (!ret) {
800                mPosition = getCount();
801                // STOPSHIP
802                LogUtils.i(LOG_TAG, "*** moveToNext returns false; pos = %d, und = %d, del = %d",
803                        mPosition, mUnderlyingCursor.getPosition(), mDeletedCount);
804                return false;
805            }
806            if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue;
807            mPosition++;
808            return true;
809        }
810    }
811
812    /**
813     * Move to the previous not-deleted item in the conversation
814     */
815    @Override
816    public boolean moveToPrevious() {
817        while (true) {
818            boolean ret = mUnderlyingCursor.moveToPrevious();
819            if (!ret) {
820                // Make sure we're before the first position
821                mPosition = -1;
822                return false;
823            }
824            if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue;
825            mPosition--;
826            return true;
827        }
828    }
829
830    @Override
831    public int getPosition() {
832        return mPosition;
833    }
834
835    /**
836     * The actual cursor's count must be decremented by the number we've deleted from the UI
837     */
838    @Override
839    public int getCount() {
840        if (mUnderlyingCursor == null) {
841            throw new IllegalStateException(
842                    "getCount() on disabled cursor: " + mName + "(" + qUri + ")");
843        }
844        return mUnderlyingCursor.getCount() - mDeletedCount;
845    }
846
847    @Override
848    public boolean moveToFirst() {
849        if (mUnderlyingCursor == null) {
850            throw new IllegalStateException(
851                    "moveToFirst() on disabled cursor: " + mName + "(" + qUri + ")");
852        }
853        mUnderlyingCursor.moveToPosition(-1);
854        mPosition = -1;
855        return moveToNext();
856    }
857
858    @Override
859    public boolean moveToPosition(int pos) {
860        if (mUnderlyingCursor == null) {
861            throw new IllegalStateException(
862                    "moveToPosition() on disabled cursor: " + mName + "(" + qUri + ")");
863        }
864        // Handle the "move to first" case before anything else; moveToPosition(0) in an empty
865        // SQLiteCursor moves the position to 0 when returning false, which we will mirror.
866        // But we don't want to return true on a subsequent "move to first", which we would if we
867        // check pos vs mPosition first
868        if (mUnderlyingCursor.getPosition() == -1) {
869            LogUtils.i(LOG_TAG, "*** Underlying cursor position is -1 asking to move from %d to %d",
870                    mPosition, pos);
871        }
872        if (pos == 0) {
873            return moveToFirst();
874        } else if (pos < 0) {
875            mPosition = -1;
876            mUnderlyingCursor.moveToPosition(mPosition);
877            return false;
878        } else if (pos == mPosition) {
879            // Return false if we're past the end of the cursor
880            return pos < getCount();
881        } else if (pos > mPosition) {
882            while (pos > mPosition) {
883                if (!moveToNext()) {
884                    return false;
885                }
886            }
887            return true;
888        } else if ((pos >= 0) && (mPosition - pos) > pos) {
889            // Optimization if it's easier to move forward to position instead of backward
890            // STOPSHIP (Remove logging)
891            LogUtils.i(LOG_TAG, "*** Move from %d to %d, starting from first", mPosition, pos);
892            moveToFirst();
893            return moveToPosition(pos);
894        } else {
895            while (pos < mPosition) {
896                if (!moveToPrevious()) {
897                    return false;
898                }
899            }
900            return true;
901        }
902    }
903
904    /**
905     * Make sure mPosition is correct after locally deleting/undeleting items
906     */
907    private void recalibratePosition() {
908        final int pos = mPosition;
909        moveToFirst();
910        moveToPosition(pos);
911    }
912
913    @Override
914    public boolean moveToLast() {
915        throw new UnsupportedOperationException("moveToLast unsupported!");
916    }
917
918    @Override
919    public boolean move(int offset) {
920        throw new UnsupportedOperationException("move unsupported!");
921    }
922
923    /**
924     * We need to override all of the getters to make sure they look at cached values before using
925     * the values in the underlying cursor
926     */
927    @Override
928    public double getDouble(int columnIndex) {
929        Object obj = getCachedValue(columnIndex);
930        if (obj != null) return (Double)obj;
931        return mUnderlyingCursor.getDouble(columnIndex);
932    }
933
934    @Override
935    public float getFloat(int columnIndex) {
936        Object obj = getCachedValue(columnIndex);
937        if (obj != null) return (Float)obj;
938        return mUnderlyingCursor.getFloat(columnIndex);
939    }
940
941    @Override
942    public int getInt(int columnIndex) {
943        Object obj = getCachedValue(columnIndex);
944        if (obj != null) return (Integer)obj;
945        return mUnderlyingCursor.getInt(columnIndex);
946    }
947
948    @Override
949    public long getLong(int columnIndex) {
950        Object obj = getCachedValue(columnIndex);
951        if (obj != null) return (Long)obj;
952        return mUnderlyingCursor.getLong(columnIndex);
953    }
954
955    @Override
956    public short getShort(int columnIndex) {
957        Object obj = getCachedValue(columnIndex);
958        if (obj != null) return (Short)obj;
959        return mUnderlyingCursor.getShort(columnIndex);
960    }
961
962    @Override
963    public String getString(int columnIndex) {
964        // If we're asking for the Uri for the conversation list, we return a forwarding URI
965        // so that we can intercept update/delete and handle it ourselves
966        if (columnIndex == sUriColumnIndex) {
967            Uri uri = Uri.parse(mUnderlyingCursor.getString(columnIndex));
968            return uriToCachingUriString(uri);
969        }
970        Object obj = getCachedValue(columnIndex);
971        if (obj != null) return (String)obj;
972        return mUnderlyingCursor.getString(columnIndex);
973    }
974
975    @Override
976    public byte[] getBlob(int columnIndex) {
977        Object obj = getCachedValue(columnIndex);
978        if (obj != null) return (byte[])obj;
979        return mUnderlyingCursor.getBlob(columnIndex);
980    }
981
982    /**
983     * Observer of changes to underlying data
984     */
985    private class CursorObserver extends ContentObserver {
986        public CursorObserver(Handler handler) {
987            super(handler);
988        }
989
990        @Override
991        public void onChange(boolean selfChange) {
992            // If we're here, then something outside of the UI has changed the data, and we
993            // must query the underlying provider for that data;
994            ConversationCursor.this.underlyingChanged();
995        }
996    }
997
998    /**
999     * ConversationProvider is the ContentProvider for our forwarding Uri's; it passes queries
1000     * and inserts directly, and caches updates/deletes before passing them through.  The caching
1001     * will cause a redraw of the list with updated values.
1002     */
1003    public abstract static class ConversationProvider extends ContentProvider {
1004        public static String AUTHORITY;
1005
1006        /**
1007         * Allows the implementing provider to specify the authority that should be used.
1008         */
1009        protected abstract String getAuthority();
1010
1011        @Override
1012        public boolean onCreate() {
1013            sProvider = this;
1014            AUTHORITY = getAuthority();
1015            return true;
1016        }
1017
1018        @Override
1019        public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
1020                String sortOrder) {
1021            return sResolver.query(
1022                    uriFromCachingUri(uri), projection, selection, selectionArgs, sortOrder);
1023        }
1024
1025        @Override
1026        public Uri insert(Uri uri, ContentValues values) {
1027            insertLocal(uri, values);
1028            return ProviderExecute.opInsert(uri, values);
1029        }
1030
1031        @Override
1032        public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
1033            throw new IllegalStateException("Unexpected call to ConversationProvider.delete");
1034        }
1035
1036        @Override
1037        public int delete(Uri uri, String selection, String[] selectionArgs) {
1038            throw new IllegalStateException("Unexpected call to ConversationProvider.delete");
1039        }
1040
1041        @Override
1042        public String getType(Uri uri) {
1043            return null;
1044        }
1045
1046        /**
1047         * Quick and dirty class that executes underlying provider CRUD operations on a background
1048         * thread.
1049         */
1050        static class ProviderExecute implements Runnable {
1051            static final int DELETE = 0;
1052            static final int INSERT = 1;
1053            static final int UPDATE = 2;
1054
1055            final int mCode;
1056            final Uri mUri;
1057            final ContentValues mValues; //HEHEH
1058
1059            ProviderExecute(int code, Uri uri, ContentValues values) {
1060                mCode = code;
1061                mUri = uriFromCachingUri(uri);
1062                mValues = values;
1063            }
1064
1065            ProviderExecute(int code, Uri uri) {
1066                this(code, uri, null);
1067            }
1068
1069            static Uri opInsert(Uri uri, ContentValues values) {
1070                ProviderExecute e = new ProviderExecute(INSERT, uri, values);
1071                if (offUiThread()) return (Uri)e.go();
1072                new Thread(e).start();
1073                return null;
1074            }
1075
1076            static int opDelete(Uri uri) {
1077                ProviderExecute e = new ProviderExecute(DELETE, uri);
1078                if (offUiThread()) return (Integer)e.go();
1079                new Thread(new ProviderExecute(DELETE, uri)).start();
1080                return 0;
1081            }
1082
1083            static int opUpdate(Uri uri, ContentValues values) {
1084                ProviderExecute e = new ProviderExecute(UPDATE, uri, values);
1085                if (offUiThread()) return (Integer)e.go();
1086                new Thread(e).start();
1087                return 0;
1088            }
1089
1090            @Override
1091            public void run() {
1092                go();
1093            }
1094
1095            public Object go() {
1096                switch(mCode) {
1097                    case DELETE:
1098                        return sResolver.delete(mUri, null, null);
1099                    case INSERT:
1100                        return sResolver.insert(mUri, mValues);
1101                    case UPDATE:
1102                        return sResolver.update(mUri,  mValues, null, null);
1103                    default:
1104                        return null;
1105                }
1106            }
1107        }
1108
1109        private void insertLocal(Uri uri, ContentValues values) {
1110            // Placeholder for now; there's no local insert
1111        }
1112
1113        private int mUndoSequence = 0;
1114        private ArrayList<Uri> mUndoDeleteUris = new ArrayList<Uri>();
1115
1116        void addToUndoSequence(Uri uri) {
1117            if (sSequence != mUndoSequence) {
1118                mUndoSequence = sSequence;
1119                mUndoDeleteUris.clear();
1120            }
1121            mUndoDeleteUris.add(uri);
1122        }
1123
1124        @VisibleForTesting
1125        void deleteLocal(Uri uri, ConversationCursor conversationCursor) {
1126            String uriString = uriStringFromCachingUri(uri);
1127            conversationCursor.cacheValue(uriString, DELETED_COLUMN, true);
1128            addToUndoSequence(uri);
1129        }
1130
1131        @VisibleForTesting
1132        void undeleteLocal(Uri uri, ConversationCursor conversationCursor) {
1133            String uriString = uriStringFromCachingUri(uri);
1134            conversationCursor.cacheValue(uriString, DELETED_COLUMN, false);
1135        }
1136
1137        void setMostlyDead(Conversation conv, ConversationCursor conversationCursor) {
1138            Uri uri = conv.uri;
1139            String uriString = uriStringFromCachingUri(uri);
1140            conversationCursor.setMostlyDead(uriString, conv);
1141            addToUndoSequence(uri);
1142        }
1143
1144        void commitMostlyDead(Conversation conv, ConversationCursor conversationCursor) {
1145            conversationCursor.commitMostlyDead(conv);
1146        }
1147
1148        boolean clearMostlyDead(Uri uri, ConversationCursor conversationCursor) {
1149            String uriString =  uriStringFromCachingUri(uri);
1150            return conversationCursor.clearMostlyDead(uriString);
1151        }
1152
1153        public void undo(ConversationCursor conversationCursor) {
1154            if (sSequence == mUndoSequence) {
1155                for (Uri uri: mUndoDeleteUris) {
1156                    if (!clearMostlyDead(uri, conversationCursor)) {
1157                        undeleteLocal(uri, conversationCursor);
1158                    }
1159                }
1160                mUndoSequence = 0;
1161                conversationCursor.recalibratePosition();
1162                // Notify listeners that there was a change to the underlying
1163                // cursor to add back in some items.
1164                conversationCursor.notifyDataChanged();
1165            }
1166        }
1167
1168        @VisibleForTesting
1169        void updateLocal(Uri uri, ContentValues values, ConversationCursor conversationCursor) {
1170            if (values == null) {
1171                return;
1172            }
1173            String uriString = uriStringFromCachingUri(uri);
1174            for (String columnName: values.keySet()) {
1175                conversationCursor.cacheValue(uriString, columnName, values.get(columnName));
1176            }
1177        }
1178
1179        public int apply(Collection<ConversationOperation> ops,
1180                ConversationCursor conversationCursor) {
1181            final HashMap<String, ArrayList<ContentProviderOperation>> batchMap =
1182                    new HashMap<String, ArrayList<ContentProviderOperation>>();
1183            // Increment sequence count
1184            sSequence++;
1185
1186            // Execute locally and build CPO's for underlying provider
1187            boolean recalibrateRequired = false;
1188            for (ConversationOperation op: ops) {
1189                Uri underlyingUri = uriFromCachingUri(op.mUri);
1190                String authority = underlyingUri.getAuthority();
1191                ArrayList<ContentProviderOperation> authOps = batchMap.get(authority);
1192                if (authOps == null) {
1193                    authOps = new ArrayList<ContentProviderOperation>();
1194                    batchMap.put(authority, authOps);
1195                }
1196                ContentProviderOperation cpo = op.execute(underlyingUri);
1197                if (cpo != null) {
1198                    authOps.add(cpo);
1199                }
1200                // Keep track of whether our operations require recalibrating the cursor position
1201                if (op.mRecalibrateRequired) {
1202                    recalibrateRequired = true;
1203                }
1204            }
1205
1206            // Recalibrate cursor position if required
1207            if (recalibrateRequired) {
1208                conversationCursor.recalibratePosition();
1209            }
1210
1211            // Notify listeners that data has changed
1212            conversationCursor.notifyDataChanged();
1213
1214            // Send changes to underlying provider
1215            for (String authority: batchMap.keySet()) {
1216                try {
1217                    if (offUiThread()) {
1218                        sResolver.applyBatch(authority, batchMap.get(authority));
1219                    } else {
1220                        final String auth = authority;
1221                        new Thread(new Runnable() {
1222                            @Override
1223                            public void run() {
1224                                try {
1225                                    sResolver.applyBatch(auth, batchMap.get(auth));
1226                                } catch (RemoteException e) {
1227                                } catch (OperationApplicationException e) {
1228                                }
1229                           }
1230                        }).start();
1231                    }
1232                } catch (RemoteException e) {
1233                } catch (OperationApplicationException e) {
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}