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