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