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