Home | History | Annotate | Download | only in browse
      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 android.support.v4.util.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 
    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 
    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      */
    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      */
    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      */
    237     public void resume() {
    238         mPaused = false;
    239         if (DEBUG) LogUtils.i(LOG_TAG, "[Resumed: %s]", this);
    240         checkNotifyUI();
    241     }
    242 
    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 
    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 
    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 
    287             CacheLoaderTask(int startPosition) {
    288                 mStartPos = startPosition;
    289             }
    290 
    291             @Override
    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
    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 {
    336             public NewCursorUpdateObserver(Handler handler) {
    337                 super(handler);
    338             }
    339 
    340             @Override
    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 
    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          */
    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 
    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 
    494         public void stopCaching() {
    495             pauseCaching();
    496             mCachingEnabled = false;
    497         }
    498 
    499         public boolean contains(String uri) {
    500             return mConversationUriPositionMap.containsKey(uri);
    501         }
    502 
    503         public Set<Long> conversationIds() {
    504             return mConversationIdPositionMap.keySet();
    505         }
    506 
    507         public int getPosition(long conversationId) {
    508             final Integer position = mConversationIdPositionMap.get(conversationId);
    509             return position != null ? position.intValue() : -1;
    510         }
    511 
    512         public int getPosition(String conversationUri) {
    513             final Integer position = mConversationUriPositionMap.get(conversationUri);
    514             return position != null ? position.intValue() : -1;
    515         }
    516 
    517         public String getInnerUri() {
    518             return mRowCache.get(getPosition()).innerUri;
    519         }
    520 
    521         public Conversation getConversation() {
    522             return mRowCache.get(getPosition()).conversation;
    523         }
    524 
    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 
    532         private void notifyConversationUIPositionChange() {
    533             Utils.notifyCursorUIPositionChange(this, getPosition());
    534         }
    535 
    536         /**
    537          * Returns a boolean indicating whether the cursor has been updated
    538          */
    539         public boolean isDataUpdated() {
    540             return mCursorUpdated;
    541         }
    542 
    543         public void disableUpdateNotifications() {
    544             if (mUpdateObserverRegistered) {
    545                 getWrappedCursor().unregisterContentObserver(mCursorUpdateObserver);
    546                 mUpdateObserverRegistered = false;
    547             }
    548         }
    549 
    550         @Override
    551         public void close() {
    552             stopCaching();
    553             disableUpdateNotifications();
    554             super.close();
    555         }
    556 
    557         @Override
    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> {
    581         private RefreshTask() {
    582         }
    583 
    584         @Override
    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
    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
    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 
    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 
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    835     public void removeListener(ConversationListener listener) {
    836         synchronized(mListeners) {
    837             mListeners.remove(listener);
    838         }
    839     }
    840 
    841     @Override
    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      */
    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      */
    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 
    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 
    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      */
    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      */
    968     private Object getCachedValue(int columnIndex) {
    969         final String uri = mUnderlyingCursor.getInnerUri();
    970         return getCachedValue(uri, columnIndex);
    971     }
    972 
    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      */
    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      */
   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      */
   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      */
   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      */
   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 
   1078     public boolean isRefreshRequired() {
   1079         return mRefreshRequired;
   1080     }
   1081 
   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      */
   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 
   1110     public void disable() {
   1111         close();
   1112         mCacheMap.clear();
   1113         mListeners.clear();
   1114         mUnderlyingCursor = null;
   1115     }
   1116 
   1117     @Override
   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
   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
   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
   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
   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
   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
   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      */
   1251     private void recalibratePosition() {
   1252         final int pos = mPosition;
   1253         moveToFirst();
   1254         moveToPosition(pos);
   1255     }
   1256 
   1257     @Override
   1258     public boolean moveToLast() {
   1259         throw new UnsupportedOperationException("moveToLast unsupported!");
   1260     }
   1261 
   1262     @Override
   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
   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
   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
   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
   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
   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
   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
   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 
   1325     public byte[] getCachedBlob(int columnIndex) {
   1326         return (byte[]) getCachedValue(columnIndex);
   1327     }
   1328 
   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      */
   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      */
   1378     public void notifyUIPositionChange() {
   1379         mUnderlyingCursor.notifyConversationUIPositionChange();
   1380     }
   1381 
   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 {
   1405         public CursorObserver(Handler handler) {
   1406             super(handler);
   1407         }
   1408 
   1409         @Override
   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          */
   1431         protected abstract String getAuthority();
   1432 
   1433         @Override
   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
   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
   1450         public Uri insert(Uri uri, ContentValues values) {
   1451             insertLocal(uri, values);
   1452             return ProviderExecute.opInsert(mResolver, uri, values);
   1453         }
   1454 
   1455         @Override
   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
   1461         public int delete(Uri uri, String selection, String[] selectionArgs) {
   1462             throw new IllegalStateException("Unexpected call to ConversationProvider.delete");
   1463         }
   1464 
   1465         @Override
   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 
   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 
   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
   1499             public void run() {
   1500                 go();
   1501             }
   1502 
   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 
   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 
   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
   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
   1543         void undeleteLocal(Uri uri, ConversationCursor conversationCursor) {
   1544             String uriString = uriStringFromCachingUri(uri);
   1545             conversationCursor.cacheValue(uriString, DELETED_COLUMN, false);
   1546         }
   1547 
   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 
   1556         void commitMostlyDead(Conversation conv, ConversationCursor conversationCursor) {
   1557             conversationCursor.commitMostlyDead(conv);
   1558         }
   1559 
   1560         boolean clearMostlyDead(Uri uri, ConversationCursor conversationCursor) {
   1561             String uriString = uriStringFromCachingUri(uri);
   1562             return conversationCursor.clearMostlyDead(uriString);
   1563         }
   1564 
   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
   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 
   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 
   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 
   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 
   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 
   1736         public ConversationOperation(int type, Conversation conv, UndoCallback undoCallback) {
   1737             this(type, conv, null, undoCallback);
   1738         }
   1739 
   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 
   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          */
   1880         public void onRefreshRequired();
   1881         /**
   1882          * We've completed a requested refresh of the underlying cursor
   1883          */
   1884         public void onRefreshReady();
   1885         /**
   1886          * The data underlying the cursor has changed; the UI should redraw the list
   1887          */
   1888         public void onDataSetChanged();
   1889     }
   1890 
   1891     @Override
   1892     public boolean isFirst() {
   1893         throw new UnsupportedOperationException();
   1894     }
   1895 
   1896     @Override
   1897     public boolean isLast() {
   1898         throw new UnsupportedOperationException();
   1899     }
   1900 
   1901     @Override
   1902     public boolean isBeforeFirst() {
   1903         throw new UnsupportedOperationException();
   1904     }
   1905 
   1906     @Override
   1907     public boolean isAfterLast() {
   1908         throw new UnsupportedOperationException();
   1909     }
   1910 
   1911     @Override
   1912     public int getColumnIndex(String columnName) {
   1913         return mUnderlyingCursor.getColumnIndex(columnName);
   1914     }
   1915 
   1916     @Override
   1917     public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException {
   1918         return mUnderlyingCursor.getColumnIndexOrThrow(columnName);
   1919     }
   1920 
   1921     @Override
   1922     public String getColumnName(int columnIndex) {
   1923         return mUnderlyingCursor.getColumnName(columnIndex);
   1924     }
   1925 
   1926     @Override
   1927     public String[] getColumnNames() {
   1928         return mUnderlyingCursor.getColumnNames();
   1929     }
   1930 
   1931     @Override
   1932     public int getColumnCount() {
   1933         return mUnderlyingCursor.getColumnCount();
   1934     }
   1935 
   1936     @Override
   1937     public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) {
   1938         throw new UnsupportedOperationException();
   1939     }
   1940 
   1941     @Override
   1942     public int getType(int columnIndex) {
   1943         return mUnderlyingCursor.getType(columnIndex);
   1944     }
   1945 
   1946     @Override
   1947     public boolean isNull(int columnIndex) {
   1948         throw new UnsupportedOperationException();
   1949     }
   1950 
   1951     @Override
   1952     public void deactivate() {
   1953         throw new UnsupportedOperationException();
   1954     }
   1955 
   1956     @Override
   1957     public boolean isClosed() {
   1958         return mUnderlyingCursor == null || mUnderlyingCursor.isClosed();
   1959     }
   1960 
   1961     @Override
   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
   1968     public void unregisterContentObserver(ContentObserver observer) {
   1969         // See above.
   1970     }
   1971 
   1972     @Override
   1973     public void registerDataSetObserver(DataSetObserver observer) {
   1974         // Nope. We use ConversationListener to accomplish this.
   1975     }
   1976 
   1977     @Override
   1978     public void unregisterDataSetObserver(DataSetObserver observer) {
   1979         // See above.
   1980     }
   1981 
   1982     @Override
   1983     public Uri getNotificationUri() {
   1984         if (mUnderlyingCursor == null) {
   1985             return null;
   1986         } else {
   1987             return mUnderlyingCursor.getNotificationUri();
   1988         }
   1989     }
   1990 
   1991     @Override
   1992     public void setNotificationUri(ContentResolver cr, Uri uri) {
   1993         throw new UnsupportedOperationException();
   1994     }
   1995 
   1996     @Override
   1997     public boolean getWantsAllOnMoveCalls() {
   1998         throw new UnsupportedOperationException();
   1999     }
   2000 
   2001     @Override
   2002     public void setExtras(Bundle extras) {
   2003         if (mUnderlyingCursor != null) {
   2004             mUnderlyingCursor.setExtras(extras);
   2005         }
   2006     }
   2007 
   2008     @Override
   2009     public Bundle getExtras() {
   2010         return mUnderlyingCursor != null ? mUnderlyingCursor.getExtras() : Bundle.EMPTY;
   2011     }
   2012 
   2013     @Override
   2014     public Bundle respond(Bundle extras) {
   2015         if (mUnderlyingCursor != null) {
   2016             return mUnderlyingCursor.respond(extras);
   2017         }
   2018         return Bundle.EMPTY;
   2019     }
   2020 
   2021     @Override
   2022     public boolean requery() {
   2023         return true;
   2024     }
   2025 
   2026     // Below are methods that update Conversation data (update/delete)
   2027 
   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      */
   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      */
   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      */
   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      */
   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      */
   2087     public int updateValues(Collection<Conversation> conversations, ContentValues values) {
   2088         return updateValues(conversations, values, null);
   2089     }
   2090 
   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      */
   2104     public int updateBulkValues(Collection<ConversationOperation> op) {
   2105         return apply(op);
   2106     }
   2107 
   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 
   2118     public ConversationOperation getOperationForConversation(Conversation conv, int type,
   2119             ContentValues values) {
   2120         return getOperationForConversation(conv, type, values, null);
   2121     }
   2122 
   2123     public ConversationOperation getOperationForConversation(Conversation conv, int type,
   2124             ContentValues values, UndoCallback undoCallback) {
   2125         return new ConversationOperation(type, conv, values, undoCallback);
   2126     }
   2127 
   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 
   2138     public static void addTargetFolders(Collection<Folder> targetFolders, ContentValues values) {
   2139         values.put(Conversation.UPDATE_FOLDER_COLUMN, FolderList.copyOf(targetFolders).toBlob());
   2140     }
   2141 
   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 
   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 
   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 
   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
   2170     private int apply(Collection<ConversationOperation> operations) {
   2171         return sProvider.apply(operations, this);
   2172     }
   2173 
   2174     private void undoLocal() {
   2175         sProvider.undo(this);
   2176     }
   2177 
   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      */
   2196     public int delete(Collection<Conversation> conversations) {
   2197         return delete(conversations, null);
   2198     }
   2199 
   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      */
   2207     public int archive(Collection<Conversation> conversations) {
   2208         return archive(conversations, null);
   2209     }
   2210 
   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      */
   2218     public int mute(Collection<Conversation> conversations) {
   2219         return mute(conversations, null);
   2220     }
   2221 
   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      */
   2229     public int reportSpam(Collection<Conversation> conversations) {
   2230         return reportSpam(conversations, null);
   2231     }
   2232 
   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      */
   2240     public int reportNotSpam(Collection<Conversation> conversations) {
   2241         return reportNotSpam(conversations, null);
   2242     }
   2243 
   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      */
   2251     public int reportPhishing(Collection<Conversation> conversations) {
   2252         return reportPhishing(conversations, null);
   2253     }
   2254 
   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      */
   2262     public int discardDrafts(Collection<Conversation> conversations) {
   2263         return discardDrafts(conversations, null);
   2264     }
   2265 
   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      */
   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      */
   2281     public int mostlyArchive(Collection<Conversation> conversations) {
   2282         return mostlyArchive(conversations, null);
   2283     }
   2284 
   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      */
   2292     public int mostlyDelete(Collection<Conversation> conversations) {
   2293         return mostlyDelete(conversations, null);
   2294     }
   2295 
   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      */
   2303     public int mostlyDestructiveUpdate(Collection<Conversation> conversations,
   2304             ContentValues values) {
   2305         return mostlyDestructiveUpdate(conversations, values, null);
   2306     }
   2307 
   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      */
   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
   2345     public int hashCode() {
   2346         return super.hashCode();
   2347     }
   2348 
   2349     @Override
   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 
   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      */
   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
   2480     public void markContentsSeen() {
   2481         ConversationCursorOperationListener.OperationHelper.markContentsSeen(mUnderlyingCursor);
   2482     }
   2483 
   2484     @Override
   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      */
   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