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