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