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