ConversationCursor.java revision 489dd22c64c718b6953b4bd6acef925e82c53c87
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.OperationApplicationException;
26import android.database.CharArrayBuffer;
27import android.database.ContentObserver;
28import android.database.Cursor;
29import android.database.CursorIndexOutOfBoundsException;
30import android.database.CursorWrapper;
31import android.database.DataSetObservable;
32import android.database.DataSetObserver;
33import android.net.Uri;
34import android.os.Bundle;
35import android.os.Looper;
36import android.os.RemoteException;
37import android.util.Log;
38
39import com.android.mail.providers.Conversation;
40import com.android.mail.providers.UIProvider;
41import com.android.mail.providers.UIProvider.ConversationListQueryParameters;
42import com.android.mail.providers.UIProvider.ConversationOperations;
43import com.android.mail.utils.LogUtils;
44import com.google.common.annotations.VisibleForTesting;
45
46import java.util.ArrayList;
47import java.util.HashMap;
48import java.util.Iterator;
49import java.util.List;
50
51/**
52 * ConversationCursor is a wrapper around a conversation list cursor that provides update/delete
53 * caching for quick UI response. This is effectively a singleton class, as the cache is
54 * implemented as a static HashMap.
55 */
56public final class ConversationCursor implements Cursor {
57    private static final String TAG = "ConversationCursor";
58    private static final boolean DEBUG = true;  // STOPSHIP Set to false before shipping
59
60    // The cursor instantiator's activity
61    private static Activity sActivity;
62    // The cursor underlying the caching cursor
63    @VisibleForTesting
64    static Wrapper sUnderlyingCursor;
65    // The new cursor obtained via a requery
66    private static volatile Wrapper sRequeryCursor;
67    // A mapping from Uri to updated ContentValues
68    private static HashMap<String, ContentValues> sCacheMap = new HashMap<String, ContentValues>();
69    // Cache map lock (will be used only very briefly - few ms at most)
70    private static Object sCacheMapLock = new Object();
71    // A deleted row is indicated by the presence of DELETED_COLUMN in the cache map
72    private static final String DELETED_COLUMN = "__deleted__";
73    // An row cached during a requery is indicated by the presence of REQUERY_COLUMN in the map
74    private static final String REQUERY_COLUMN = "__requery__";
75    // A sentinel value for the "index" of the deleted column; it's an int that is otherwise invalid
76    private static final int DELETED_COLUMN_INDEX = -1;
77    // Empty deletion list
78    private static final ArrayList<Integer> EMPTY_DELETION_LIST = new ArrayList<Integer>();
79    // The current conversation cursor
80    private static ConversationCursor sConversationCursor;
81    // The index of the Uri whose data is reflected in the cached row
82    // Updates/Deletes to this Uri are cached
83    private static int sUriColumnIndex;
84    // The listeners registered for this cursor
85    private static ArrayList<ConversationListener> sListeners =
86        new ArrayList<ConversationListener>();
87    // The ConversationProvider instance
88    @VisibleForTesting
89    static ConversationProvider sProvider;
90    // Set when we're in the middle of a refresh of the underlying cursor
91    private static boolean sRefreshInProgress = false;
92    // Set when we've sent refreshReady() to listeners
93    private static boolean sRefreshReady = false;
94    // Set when we've sent refreshRequired() to listeners
95    private static boolean sRefreshRequired = false;
96    // Our sequence count (for changes sent to underlying provider)
97    private static int sSequence = 0;
98    // Whether our first query on this cursor should include a limit
99    private static boolean sInitialConversationLimit = false;
100
101    // Column names for this cursor
102    private final String[] mColumnNames;
103    // The resolver for the cursor instantiator's context
104    private static ContentResolver mResolver;
105    // An observer on the underlying cursor (so we can detect changes from outside the UI)
106    private final CursorObserver mCursorObserver;
107    // Whether our observer is currently registered with the underlying cursor
108    private boolean mCursorObserverRegistered = false;
109
110    // The current position of the cursor
111    private int mPosition = -1;
112
113    /**
114     * Allow UI elements to subscribe to changes that other UI elements might make to this data.
115     * This short circuits the usual DB round-trip needed for data to propagate across disparate
116     * UI elements.
117     * <p>
118     * A UI element that receives a notification on this channel should just update its existing
119     * view, and should not trigger a full refresh.
120     */
121    private final DataSetObservable mDataSetObservable = new DataSetObservable();
122
123    // The number of cached deletions from this cursor (used to quickly generate an accurate count)
124    private static int sDeletedCount = 0;
125
126    // Parameters passed to the underlying query
127    private static Uri qUri;
128    private static String[] qProjection;
129
130    private ConversationCursor(Wrapper cursor, Activity activity, String messageListColumn) {
131        sConversationCursor = this;
132        // If we have an existing underlying cursor, make sure it's closed
133        if (sUnderlyingCursor != null) {
134            sUnderlyingCursor.close();
135        }
136        sUnderlyingCursor = cursor;
137        sListeners.clear();
138        sRefreshRequired = false;
139        sRefreshReady = false;
140        sRefreshInProgress = false;
141        mCursorObserver = new CursorObserver();
142        resetCursor(null);
143        mColumnNames = cursor.getColumnNames();
144        sUriColumnIndex = cursor.getColumnIndex(messageListColumn);
145        if (sUriColumnIndex < 0) {
146            throw new IllegalArgumentException("Cursor must include a message list column");
147        }
148    }
149
150    /**
151     * Method to initiaze the ConversationCursor state before an instance is created
152     * This is needed to workaround the crash reported in bug 6185304
153     * Also, we set the flag indicating whether to use a limit on the first conversation query
154     */
155    public static void initialize(Activity activity, boolean initialConversationLimit) {
156        sActivity = activity;
157        sInitialConversationLimit = initialConversationLimit;
158        mResolver = activity.getContentResolver();
159    }
160
161    /**
162     * Create a ConversationCursor; this should be called by the ListActivity using that cursor
163     * @param activity the activity creating the cursor
164     * @param messageListColumn the column used for individual cursor items
165     * @param uri the query uri
166     * @param projection the query projecion
167     * @param selection the query selection
168     * @param selectionArgs the query selection args
169     * @param sortOrder the query sort order
170     * @return a ConversationCursor
171     */
172    public static ConversationCursor create(Activity activity, String messageListColumn, Uri uri,
173            String[] projection) {
174        sActivity = activity;
175        mResolver = activity.getContentResolver();
176        synchronized (sCacheMapLock) {
177            try {
178                // First, let's see if we already have a cursor
179                if (sConversationCursor != null) {
180                    // If it's the same, just clean up
181                    if (qUri.equals(uri) && !sRefreshRequired && !sRefreshInProgress) {
182                        if (sRefreshReady) {
183                            // If we already have a refresh ready, return
184                            LogUtils.i(TAG, "Create: refreshed cursor ready, needs sync");
185                        } else {
186                            // We're done
187                            LogUtils.i(TAG, "Create: cursor good");
188                        }
189                    } else {
190                        // We need a new query here; cancel any existing one, ensuring that a sync
191                        // from another thread won't be stalled on the query
192                        cancelRefresh();
193                        LogUtils.i(TAG, "Create: performing refresh()");
194                        qUri = uri;
195                        qProjection = projection;
196                        sConversationCursor.refresh();
197                    }
198                    return sConversationCursor;
199                }
200                // Create new ConversationCursor
201                LogUtils.i(TAG, "Create: initial creation");
202                Wrapper c = doQuery(uri, projection, sInitialConversationLimit);
203                return new ConversationCursor(c, activity, messageListColumn);
204            } finally {
205                // If we used a limit, queue up a query without limit
206                if (sInitialConversationLimit) {
207                    sInitialConversationLimit = false;
208                    sConversationCursor.refresh();
209                }
210            }
211        }
212    }
213
214    /**
215     * Wrapper that includes the Uri used to create the cursor
216     */
217    private static class Wrapper extends CursorWrapper {
218        private final Uri mUri;
219
220        Wrapper(Cursor cursor, Uri uri) {
221            super(cursor);
222            mUri = uri;
223        }
224
225        Uri getUri() {
226            return mUri;
227        }
228    }
229
230    private static Wrapper doQuery(Uri uri, String[] projection, boolean withLimit) {
231        qProjection = projection;
232        qUri = uri;
233        if (mResolver == null) {
234            mResolver = sActivity.getContentResolver();
235        }
236        if (withLimit) {
237            uri = uri.buildUpon().appendQueryParameter(ConversationListQueryParameters.LIMIT,
238                    ConversationListQueryParameters.DEFAULT_LIMIT).build();
239        }
240        long time = System.currentTimeMillis();
241
242        Wrapper result = new Wrapper(mResolver.query(uri, qProjection, null, null, null), uri);
243        if (DEBUG) {
244            time = System.currentTimeMillis() - time;
245            LogUtils.i(TAG, "ConversationCursor query: %s, %dms, %d results",
246                    uri, time, result.getCount());
247        }
248        return result;
249    }
250
251    /**
252     * Return whether the uri string (message list uri) is in the underlying cursor
253     * @param uriString the uri string we're looking for
254     * @return true if the uri string is in the cursor; false otherwise
255     */
256    private boolean isInUnderlyingCursor(String uriString) {
257        sUnderlyingCursor.moveToPosition(-1);
258        while (sUnderlyingCursor.moveToNext()) {
259            if (uriString.equals(sUnderlyingCursor.getString(sUriColumnIndex))) {
260                return true;
261            }
262        }
263        return false;
264    }
265
266    static boolean offUiThread() {
267        return Looper.getMainLooper().getThread() != Thread.currentThread();
268    }
269
270    /**
271     * Reset the cursor; this involves clearing out our cache map and resetting our various counts
272     * The cursor should be reset whenever we get fresh data from the underlying cursor. The cache
273     * is locked during the reset, which will block the UI, but for only a very short time
274     * (estimated at a few ms, but we can profile this; remember that the cache will usually
275     * be empty or have a few entries)
276     */
277    private void resetCursor(Wrapper newCursor) {
278        if (DEBUG) {
279            LogUtils.i(TAG, "[--resetCursor--]");
280        }
281        synchronized (sCacheMapLock) {
282            // Walk through the cache.  Here are the cases:
283            // 1) The entry isn't marked with REQUERY - remove it from the cache. If DELETED is
284            //    set, decrement the deleted count
285            // 2) The REQUERY entry is still in the UP
286            //    2a) The REQUERY entry isn't DELETED; we're good, and the client change will remain
287            //    (i.e. client wins, it's on its way to the UP)
288            //    2b) The REQUERY entry is DELETED; we're good (client change remains, it's on
289            //        its way to the UP)
290            // 3) the REQUERY was deleted on the server (sheesh; this would be bizarre timing!) -
291            //    we need to throw the item out of the cache
292            // So ... the only interesting case is #3, we need to look for remaining deleted items
293            // and see if they're still in the UP
294            Iterator<HashMap.Entry<String, ContentValues>> iter = sCacheMap.entrySet().iterator();
295            while (iter.hasNext()) {
296                HashMap.Entry<String, ContentValues> entry = iter.next();
297                ContentValues values = entry.getValue();
298                if (values.containsKey(REQUERY_COLUMN) && isInUnderlyingCursor(entry.getKey())) {
299                    // If we're in a requery and we're still around, remove the requery key
300                    // We're good here, the cached change (delete/update) is on its way to UP
301                    values.remove(REQUERY_COLUMN);
302                } else {
303                    // Keep the deleted count up-to-date; remove the cache entry
304                    if (values.containsKey(DELETED_COLUMN)) {
305                        sDeletedCount--;
306                        LogUtils.i(TAG, new Error(),
307                                "IN resetCursor, sDeletedCount decremented to: %d", sDeletedCount);
308                    }
309                    // Remove the entry
310                    iter.remove();
311                }
312            }
313
314            // Swap cursor
315            if (newCursor != null) {
316                close();
317                sUnderlyingCursor = newCursor;
318            }
319
320            mPosition = -1;
321            sUnderlyingCursor.moveToPosition(mPosition);
322            if (!mCursorObserverRegistered) {
323                sUnderlyingCursor.registerContentObserver(mCursorObserver);
324                mCursorObserverRegistered = true;
325            }
326            sRefreshRequired = false;
327        }
328    }
329
330    /**
331     * Add a listener for this cursor; we'll notify it when our data changes
332     */
333    public void addListener(ConversationListener listener) {
334        synchronized (sListeners) {
335            if (!sListeners.contains(listener)) {
336                sListeners.add(listener);
337            } else {
338                LogUtils.i(TAG, "Ignoring duplicate add of listener");
339            }
340        }
341    }
342
343    /**
344     * Remove a listener for this cursor
345     */
346    public void removeListener(ConversationListener listener) {
347        synchronized(sListeners) {
348            sListeners.remove(listener);
349        }
350    }
351
352    /**
353     * Generate a forwarding Uri to ConversationProvider from an original Uri.  We do this by
354     * changing the authority to ours, but otherwise leaving the Uri intact.
355     * NOTE: This won't handle query parameters, so the functionality will need to be added if
356     * parameters are used in the future
357     * @param uri the uri
358     * @return a forwarding uri to ConversationProvider
359     */
360    private static String uriToCachingUriString (Uri uri) {
361        String provider = uri.getAuthority();
362        return uri.getScheme() + "://" + ConversationProvider.AUTHORITY
363                + "/" + provider + uri.getPath();
364    }
365
366    /**
367     * Regenerate the original Uri from a forwarding (ConversationProvider) Uri
368     * NOTE: See note above for uriToCachingUri
369     * @param uri the forwarding Uri
370     * @return the original Uri
371     */
372    private static Uri uriFromCachingUri(Uri uri) {
373        String authority = uri.getAuthority();
374        // Don't modify uri's that aren't ours
375        if (!authority.equals(ConversationProvider.AUTHORITY)) {
376            return uri;
377        }
378        List<String> path = uri.getPathSegments();
379        Uri.Builder builder = new Uri.Builder().scheme(uri.getScheme()).authority(path.get(0));
380        for (int i = 1; i < path.size(); i++) {
381            builder.appendPath(path.get(i));
382        }
383        return builder.build();
384    }
385
386    public static void setConversationColumn(String uriString, String columnName, Object value) {
387        synchronized (sCacheMapLock) {
388            if (sConversationCursor != null) {
389                cacheValue(uriString, columnName, value);
390            }
391        }
392    }
393
394    /**
395     * Cache a column name/value pair for a given Uri
396     * @param uriString the Uri for which the column name/value pair applies
397     * @param columnName the column name
398     * @param value the value to be cached
399     */
400    private static void cacheValue(String uriString, String columnName, Object value) {
401        // Calling this method off the UI thread will mess with ListView's reading of the cursor's
402        // count
403        if (offUiThread()) {
404            LogUtils.e(TAG, new Error(), "cacheValue incorrectly being called from non-UI thread");
405        }
406
407        synchronized (sCacheMapLock) {
408            try {
409                // Get the map for our uri
410                ContentValues map = sCacheMap.get(uriString);
411                // Create one if necessary
412                if (map == null) {
413                    map = new ContentValues();
414                    sCacheMap.put(uriString, map);
415                }
416                // If we're caching a deletion, add to our count
417                if (columnName == DELETED_COLUMN) {
418                    final boolean state = (Boolean)value;
419                    final boolean hasValue = map.get(columnName) != null;
420                    if (state && !hasValue) {
421                        sDeletedCount++;
422                        if (DEBUG) {
423                            LogUtils.i(TAG, "Deleted %s, incremented deleted count=%d", uriString,
424                                    sDeletedCount);
425                        }
426                    } else if (!state && hasValue) {
427                        sDeletedCount--;
428                        map.remove(columnName);
429                        if (DEBUG) {
430                            LogUtils.i(TAG, "Undeleted %s, decremented deleted count=%d", uriString,
431                                    sDeletedCount);
432                        }
433                        return;
434                    }
435                }
436                // ContentValues has no generic "put", so we must test.  For now, the only classes
437                // of values implemented are Boolean/Integer/String, though others are trivially
438                // added
439                if (value instanceof Boolean) {
440                    map.put(columnName, ((Boolean) value).booleanValue() ? 1 : 0);
441                } else if (value instanceof Integer) {
442                    map.put(columnName, (Integer) value);
443                } else if (value instanceof String) {
444                    map.put(columnName, (String) value);
445                } else {
446                    final String cname = value.getClass().getName();
447                    throw new IllegalArgumentException("Value class not compatible with cache: "
448                            + cname);
449                }
450                if (sRefreshInProgress) {
451                    map.put(REQUERY_COLUMN, 1);
452                }
453                if (DEBUG && (columnName != DELETED_COLUMN)) {
454                    LogUtils.i(TAG, "Caching value for " + uriString + ": " + columnName);
455                }
456            } finally {
457                synchronized(sListeners) {
458                    for (ConversationListener listener: sListeners) {
459                        listener.onDataSetChanged();
460                    }
461                }
462            }
463        }
464    }
465
466    /**
467     * Get the cached value for the provided column; we special case -1 as the "deleted" column
468     * @param columnIndex the index of the column whose cached value we want to retrieve
469     * @return the cached value for this column, or null if there is none
470     */
471    private Object getCachedValue(int columnIndex) {
472        String uri = sUnderlyingCursor.getString(sUriColumnIndex);
473        ContentValues uriMap = sCacheMap.get(uri);
474        if (uriMap != null) {
475            String columnName;
476            if (columnIndex == DELETED_COLUMN_INDEX) {
477                columnName = DELETED_COLUMN;
478            } else {
479                columnName = mColumnNames[columnIndex];
480            }
481            return uriMap.get(columnName);
482        }
483        return null;
484    }
485
486    /**
487     * When the underlying cursor changes, we want to alert the listener
488     */
489    private void underlyingChanged() {
490        if (mCursorObserverRegistered) {
491            try {
492                sUnderlyingCursor.unregisterContentObserver(mCursorObserver);
493            } catch (IllegalStateException e) {
494                // Maybe the cursor was GC'd?
495            }
496            mCursorObserverRegistered = false;
497        }
498        if (DEBUG) {
499            LogUtils.i(TAG, "[Notify: onRefreshRequired()]");
500        }
501        synchronized(sListeners) {
502            for (ConversationListener listener: sListeners) {
503                listener.onRefreshRequired();
504            }
505        }
506        sRefreshRequired = true;
507    }
508
509    /**
510     * Put the refreshed cursor in place (called by the UI)
511     */
512    public void sync() {
513        if (sRequeryCursor == null) {
514            // This can happen during an animated deletion, if the UI isn't keeping track, or
515            // if a new query intervened (i.e. user changed folders)
516            if (DEBUG) {
517                LogUtils.i(TAG, "[sync() called; no requery cursor]");
518            }
519            return;
520        }
521        synchronized(sCacheMapLock) {
522            if (DEBUG) {
523                LogUtils.i(TAG, "[sync()]");
524            }
525            resetCursor(sRequeryCursor);
526            sRequeryCursor = null;
527            sRefreshInProgress = false;
528            sRefreshReady = false;
529        }
530    }
531
532    public boolean isRefreshRequired() {
533        return sRefreshRequired;
534    }
535
536    public boolean isRefreshReady() {
537        return sRefreshReady;
538    }
539
540    /**
541     * Cancel a refresh in progress
542     */
543    public static void cancelRefresh() {
544        if (DEBUG) {
545            LogUtils.i(TAG, "[cancelRefresh() called]");
546        }
547        synchronized(sCacheMapLock) {
548            // Mark the requery closed
549            sRefreshInProgress = false;
550            sRefreshReady = false;
551            // If we have the cursor, close it; otherwise, it will get closed when the query
552            // finishes (it checks sRefreshInProgress)
553            if (sRequeryCursor != null) {
554                sRequeryCursor.close();
555                sRequeryCursor = null;
556            }
557        }
558    }
559
560    /**
561     * Get a list of deletions from ConversationCursor to the refreshed cursor that hasn't yet
562     * been swapped into place; this allows the UI to animate these away if desired
563     * @return a list of positions deleted in ConversationCursor
564     */
565    public ArrayList<Integer> getRefreshDeletions () {
566        // It's possible that the requery cursor is null in the case that loadInBackground() causes
567        // ConversationCursor.create to do a sync() between the time that refreshReady() is called
568        // and the subsequent call to getRefreshDeletions().  This is harmless, and an empty
569        // result list is correct.
570        return EMPTY_DELETION_LIST;
571//        if (sRequeryCursor == null) {
572//            if (DEBUG) {
573//                LogUtils.i(TAG, "[getRefreshDeletions() called; no cursor]");
574//            }
575//            return EMPTY_DELETION_LIST;
576//        } else if (!sRequeryCursor.getUri().equals(sUnderlyingCursor.getUri())) {
577//            if (DEBUG) {
578//                LogUtils.i(TAG, "[getRefreshDeletions(); cursors differ]");
579//            }
580//            return EMPTY_DELETION_LIST;
581//        }
582//        Cursor deviceCursor = sConversationCursor;
583//        Cursor serverCursor = sRequeryCursor;
584//        ArrayList<Integer> deleteList = new ArrayList<Integer>();
585//        int serverCount = serverCursor.getCount();
586//        int deviceCount = deviceCursor.getCount();
587//        deviceCursor.moveToFirst();
588//        serverCursor.moveToFirst();
589//        while (serverCount > 0 || deviceCount > 0) {
590//            if (serverCount == 0) {
591//                for (; deviceCount > 0; deviceCount--, deviceCursor.moveToPrevious()) {
592//                    deleteList.add(deviceCursor.getPosition());
593//                    if (deleteList.size() > 6) {
594//                        if (DEBUG) {
595//                            LogUtils.i(TAG, "[getRefreshDeletions(); mega changes]");
596//                        }
597//                        return EMPTY_DELETION_LIST;
598//                    }
599//                }
600//                break;
601//            } else if (deviceCount == 0) {
602//                break;
603//            }
604//            long deviceMs = deviceCursor.getLong(UIProvider.CONVERSATION_DATE_RECEIVED_MS_COLUMN);
605//            long serverMs = serverCursor.getLong(UIProvider.CONVERSATION_DATE_RECEIVED_MS_COLUMN);
606//            String deviceUri = deviceCursor.getString(UIProvider.CONVERSATION_URI_COLUMN);
607//            String serverUri = serverCursor.getString(UIProvider.CONVERSATION_URI_COLUMN);
608//            deviceCursor.moveToNext();
609//            serverCursor.moveToNext();
610//            serverCount--;
611//            deviceCount--;
612//            if (serverMs == deviceMs) {
613//                // Check for duplicates here; if our identical dates refer to different messages,
614//                // we'll just quit here for now (at worst, this will cause a non-animating delete)
615//                // My guess is that this happens VERY rarely, if at all
616//                if (!deviceUri.equals(serverUri)) {
617//                    // To do this right, we'd find all of the rows with the same ms (date), etc...
618//                    //return deleteList;
619//                }
620//                continue;
621//            } else if (deviceMs > serverMs) {
622//                deleteList.add(deviceCursor.getPosition() - 1);
623//                if (deleteList.size() > 6) {
624//                    if (DEBUG) {
625//                        LogUtils.i(TAG, "[getRefreshDeletions(); mega changes]");
626//                    }
627//                    return EMPTY_DELETION_LIST;
628//                }
629//                // Move back because we've already advanced cursor (that's why we subtract 1 above)
630//                serverCount++;
631//                serverCursor.moveToPrevious();
632//            } else if (serverMs > deviceMs) {
633//                // If we wanted to track insertions, we'd so so here
634//                // Move back because we've already advanced cursor
635//                deviceCount++;
636//                deviceCursor.moveToPrevious();
637//            }
638//        }
639//        if (DEBUG) {
640//            LogUtils.i(TAG, "getRefreshDeletions(): " + deleteList);
641//        }
642//        return deleteList;
643    }
644
645    /**
646     * When we get a requery from the UI, we'll do it, but also clear the cache. The listener is
647     * notified when the requery is complete
648     * NOTE: This will have to change, of course, when we start using loaders...
649     */
650    public boolean refresh() {
651        if (DEBUG) {
652            LogUtils.i(TAG, "[refresh() called]");
653        }
654        if (sRefreshInProgress) {
655            return false;
656        }
657        // Say we're starting a requery
658        sRefreshInProgress = true;
659        new Thread(new Runnable() {
660            @Override
661            public void run() {
662                // Get new data
663                sRequeryCursor = doQuery(qUri, qProjection, false);
664                // Make sure window is full
665                synchronized(sCacheMapLock) {
666                    if (sRefreshInProgress) {
667                        sRequeryCursor.getCount();
668                        sRefreshReady = true;
669                        sActivity.runOnUiThread(new Runnable() {
670                            @Override
671                            public void run() {
672                                if (DEBUG) {
673                                    LogUtils.i(TAG, "[Notify: onRefreshReady()]");
674                                }
675                                if (sRequeryCursor != null && !sRequeryCursor.isClosed()) {
676                                    synchronized (sListeners) {
677                                        for (ConversationListener listener : sListeners) {
678                                            listener.onRefreshReady();
679                                        }
680                                    }
681                                }
682                            }});
683                    } else {
684                        cancelRefresh();
685                    }
686                }
687            }
688        }).start();
689        return true;
690    }
691
692    @Override
693    public void close() {
694        if (!sUnderlyingCursor.isClosed()) {
695            // Unregister our observer on the underlying cursor and close as usual
696            if (mCursorObserverRegistered) {
697                try {
698                    sUnderlyingCursor.unregisterContentObserver(mCursorObserver);
699                } catch (IllegalStateException e) {
700                    // Maybe the cursor got GC'd?
701                }
702                mCursorObserverRegistered = false;
703            }
704            sUnderlyingCursor.close();
705        }
706    }
707
708    /**
709     * Move to the next not-deleted item in the conversation
710     */
711    @Override
712    public boolean moveToNext() {
713        while (true) {
714            boolean ret = sUnderlyingCursor.moveToNext();
715            if (!ret) return false;
716            if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue;
717            mPosition++;
718            return true;
719        }
720    }
721
722    /**
723     * Move to the previous not-deleted item in the conversation
724     */
725    @Override
726    public boolean moveToPrevious() {
727        while (true) {
728            boolean ret = sUnderlyingCursor.moveToPrevious();
729            if (!ret) return false;
730            if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue;
731            mPosition--;
732            // STOPSHIP: Remove this if statement
733            if (mPosition < 0) {
734                mStackTrace = new Throwable().getStackTrace();
735            }
736            return true;
737        }
738    }
739
740    @Override
741    public int getPosition() {
742        return mPosition;
743    }
744
745    /**
746     * The actual cursor's count must be decremented by the number we've deleted from the UI
747     */
748    @Override
749    public int getCount() {
750        return sUnderlyingCursor.getCount() - sDeletedCount;
751    }
752
753    @Override
754    public boolean moveToFirst() {
755        sUnderlyingCursor.moveToPosition(-1);
756        mPosition = -1;
757        return moveToNext();
758    }
759
760    // STOPSHIP: Remove this
761    private StackTraceElement[] mStackTrace = null;
762
763    @Override
764    public boolean moveToPosition(int pos) {
765        // STOPSHIP: Remove this if statement
766        if (pos == -1) {
767            mStackTrace = new Throwable().getStackTrace();
768        }
769        // STOPSHIP: Remove this check
770        if (offUiThread()) {
771            LogUtils.w(TAG, new Throwable(), "********** moveToPosition OFF UI THREAD: %d", pos);
772        }
773        if (pos < -1 || pos >= getCount()) {
774            // STOPSHIP: Remove this logging
775            LogUtils.w(TAG, new Throwable(), "********** moveToPosition OUT OF RANGE: %d", pos);
776            return false;
777        }
778        if (pos == mPosition) return true;
779        if (pos > mPosition) {
780            while (pos > mPosition) {
781                if (!moveToNext()) {
782                    return false;
783                }
784            }
785            return true;
786        } else if (pos == 0) {
787            return moveToFirst();
788        } else {
789            while (pos < mPosition) {
790                if (!moveToPrevious()) {
791                    return false;
792                }
793            }
794            return true;
795        }
796    }
797
798    @Override
799    public boolean moveToLast() {
800        throw new UnsupportedOperationException("moveToLast unsupported!");
801    }
802
803    @Override
804    public boolean move(int offset) {
805        throw new UnsupportedOperationException("move unsupported!");
806    }
807
808    /**
809     * We need to override all of the getters to make sure they look at cached values before using
810     * the values in the underlying cursor
811     */
812    @Override
813    public double getDouble(int columnIndex) {
814        Object obj = getCachedValue(columnIndex);
815        if (obj != null) return (Double)obj;
816        return sUnderlyingCursor.getDouble(columnIndex);
817    }
818
819    @Override
820    public float getFloat(int columnIndex) {
821        Object obj = getCachedValue(columnIndex);
822        if (obj != null) return (Float)obj;
823        return sUnderlyingCursor.getFloat(columnIndex);
824    }
825
826    @Override
827    public int getInt(int columnIndex) {
828        Object obj = getCachedValue(columnIndex);
829        if (obj != null) return (Integer)obj;
830        return sUnderlyingCursor.getInt(columnIndex);
831    }
832
833    @Override
834    public long getLong(int columnIndex) {
835        // STOPSHIP: Remove try/catch
836        try {
837            Object obj = getCachedValue(columnIndex);
838            if (obj != null) return (Long)obj;
839            return sUnderlyingCursor.getLong(columnIndex);
840        } catch (CursorIndexOutOfBoundsException e) {
841            if (mStackTrace != null) {
842                Log.e(TAG, "Stack trace at last moveToPosition(-1)");
843                Throwable t = new Throwable();
844                t.setStackTrace(mStackTrace);
845                t.printStackTrace();
846            }
847            throw e;
848        }
849    }
850
851    @Override
852    public short getShort(int columnIndex) {
853        Object obj = getCachedValue(columnIndex);
854        if (obj != null) return (Short)obj;
855        return sUnderlyingCursor.getShort(columnIndex);
856    }
857
858    @Override
859    public String getString(int columnIndex) {
860        // If we're asking for the Uri for the conversation list, we return a forwarding URI
861        // so that we can intercept update/delete and handle it ourselves
862        if (columnIndex == sUriColumnIndex) {
863            Uri uri = Uri.parse(sUnderlyingCursor.getString(columnIndex));
864            return uriToCachingUriString(uri);
865        }
866        Object obj = getCachedValue(columnIndex);
867        if (obj != null) return (String)obj;
868        return sUnderlyingCursor.getString(columnIndex);
869    }
870
871    @Override
872    public byte[] getBlob(int columnIndex) {
873        Object obj = getCachedValue(columnIndex);
874        if (obj != null) return (byte[])obj;
875        return sUnderlyingCursor.getBlob(columnIndex);
876    }
877
878    /**
879     * Observer of changes to underlying data
880     */
881    private class CursorObserver extends ContentObserver {
882        public CursorObserver() {
883            super(null);
884        }
885
886        @Override
887        public void onChange(boolean selfChange) {
888            // If we're here, then something outside of the UI has changed the data, and we
889            // must query the underlying provider for that data
890            ConversationCursor.this.underlyingChanged();
891        }
892    }
893
894    /**
895     * ConversationProvider is the ContentProvider for our forwarding Uri's; it passes queries
896     * and inserts directly, and caches updates/deletes before passing them through.  The caching
897     * will cause a redraw of the list with updated values.
898     */
899    public abstract static class ConversationProvider extends ContentProvider {
900        public static String AUTHORITY;
901
902        /**
903         * Allows the implmenting provider to specify the authority that should be used.
904         */
905        protected abstract String getAuthority();
906
907        @Override
908        public boolean onCreate() {
909            sProvider = this;
910            AUTHORITY = getAuthority();
911            return true;
912        }
913
914        @Override
915        public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
916                String sortOrder) {
917            return mResolver.query(
918                    uriFromCachingUri(uri), projection, selection, selectionArgs, sortOrder);
919        }
920
921        @Override
922        public Uri insert(Uri uri, ContentValues values) {
923            insertLocal(uri, values);
924            return ProviderExecute.opInsert(uri, values);
925        }
926
927        @Override
928        public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
929            updateLocal(uri, values);
930            return ProviderExecute.opUpdate(uri, values);
931        }
932
933        @Override
934        public int delete(Uri uri, String selection, String[] selectionArgs) {
935            deleteLocal(uri);
936            return ProviderExecute.opDelete(uri);
937        }
938
939        @Override
940        public String getType(Uri uri) {
941            return null;
942        }
943
944        /**
945         * Quick and dirty class that executes underlying provider CRUD operations on a background
946         * thread.
947         */
948        static class ProviderExecute implements Runnable {
949            static final int DELETE = 0;
950            static final int INSERT = 1;
951            static final int UPDATE = 2;
952
953            final int mCode;
954            final Uri mUri;
955            final ContentValues mValues; //HEHEH
956
957            ProviderExecute(int code, Uri uri, ContentValues values) {
958                mCode = code;
959                mUri = uriFromCachingUri(uri);
960                mValues = values;
961            }
962
963            ProviderExecute(int code, Uri uri) {
964                this(code, uri, null);
965            }
966
967            static Uri opInsert(Uri uri, ContentValues values) {
968                ProviderExecute e = new ProviderExecute(INSERT, uri, values);
969                if (offUiThread()) return (Uri)e.go();
970                new Thread(e).start();
971                return null;
972            }
973
974            static int opDelete(Uri uri) {
975                ProviderExecute e = new ProviderExecute(DELETE, uri);
976                if (offUiThread()) return (Integer)e.go();
977                new Thread(new ProviderExecute(DELETE, uri)).start();
978                return 0;
979            }
980
981            static int opUpdate(Uri uri, ContentValues values) {
982                ProviderExecute e = new ProviderExecute(UPDATE, uri, values);
983                if (offUiThread()) return (Integer)e.go();
984                new Thread(e).start();
985                return 0;
986            }
987
988            @Override
989            public void run() {
990                go();
991            }
992
993            public Object go() {
994                switch(mCode) {
995                    case DELETE:
996                        return mResolver.delete(mUri, null, null);
997                    case INSERT:
998                        return mResolver.insert(mUri, mValues);
999                    case UPDATE:
1000                        return mResolver.update(mUri,  mValues, null, null);
1001                    default:
1002                        return null;
1003                }
1004            }
1005        }
1006
1007        private void insertLocal(Uri uri, ContentValues values) {
1008            // Placeholder for now; there's no local insert
1009        }
1010
1011        private int mUndoSequence = 0;
1012        private ArrayList<Uri> mUndoDeleteUris = new ArrayList<Uri>();
1013
1014        @VisibleForTesting
1015        void deleteLocal(Uri uri) {
1016            Uri underlyingUri = uriFromCachingUri(uri);
1017            // Remember to decode the underlying Uri as it might be encoded (as w/ Gmail)
1018            String uriString =  Uri.decode(underlyingUri.toString());
1019            cacheValue(uriString, DELETED_COLUMN, true);
1020            if (sSequence != mUndoSequence) {
1021                mUndoSequence = sSequence;
1022                mUndoDeleteUris.clear();
1023            }
1024            mUndoDeleteUris.add(uri);
1025        }
1026
1027        @VisibleForTesting
1028        void undeleteLocal(Uri uri) {
1029            Uri underlyingUri = uriFromCachingUri(uri);
1030            // Remember to decode the underlying Uri as it might be encoded (as w/ Gmail)
1031            String uriString =  Uri.decode(underlyingUri.toString());
1032            cacheValue(uriString, DELETED_COLUMN, false);
1033        }
1034
1035        public void undo() {
1036            if (sSequence == mUndoSequence) {
1037                for (Uri uri: mUndoDeleteUris) {
1038                    undeleteLocal(uri);
1039                }
1040                mUndoSequence = 0;
1041            }
1042        }
1043
1044        @VisibleForTesting
1045        void updateLocal(Uri uri, ContentValues values) {
1046            if (values == null) {
1047                return;
1048            }
1049            Uri underlyingUri = uriFromCachingUri(uri);
1050            // Remember to decode the underlying Uri as it might be encoded (as w/ Gmail)
1051            String uriString =  Uri.decode(underlyingUri.toString());
1052            for (String columnName: values.keySet()) {
1053                cacheValue(uriString, columnName, values.get(columnName));
1054            }
1055        }
1056
1057        public int apply(ArrayList<ConversationOperation> ops) {
1058            final HashMap<String, ArrayList<ContentProviderOperation>> batchMap =
1059                    new HashMap<String, ArrayList<ContentProviderOperation>>();
1060            // Increment sequence count
1061            sSequence++;
1062            // Execute locally and build CPO's for underlying provider
1063            for (ConversationOperation op: ops) {
1064                Uri underlyingUri = uriFromCachingUri(op.mUri);
1065                String authority = underlyingUri.getAuthority();
1066                ArrayList<ContentProviderOperation> authOps = batchMap.get(authority);
1067                if (authOps == null) {
1068                    authOps = new ArrayList<ContentProviderOperation>();
1069                    batchMap.put(authority, authOps);
1070                }
1071                authOps.add(op.execute(underlyingUri));
1072            }
1073
1074            // Send changes to underlying provider
1075            for (String authority: batchMap.keySet()) {
1076                try {
1077                    if (offUiThread()) {
1078                        mResolver.applyBatch(authority, batchMap.get(authority));
1079                    } else {
1080                        final String auth = authority;
1081                        new Thread(new Runnable() {
1082                            @Override
1083                            public void run() {
1084                                try {
1085                                    mResolver.applyBatch(auth, batchMap.get(auth));
1086                                } catch (RemoteException e) {
1087                                } catch (OperationApplicationException e) {
1088                                }
1089                           }
1090                        }).start();
1091                    }
1092                } catch (RemoteException e) {
1093                } catch (OperationApplicationException e) {
1094                }
1095            }
1096            return sSequence;
1097        }
1098    }
1099
1100    /**
1101     * ConversationOperation is the encapsulation of a ContentProvider operation to be performed
1102     * atomically as part of a "batch" operation.
1103     */
1104    public static class ConversationOperation {
1105        public static final int DELETE = 0;
1106        public static final int INSERT = 1;
1107        public static final int UPDATE = 2;
1108        public static final int ARCHIVE = 3;
1109        public static final int MUTE = 4;
1110        public static final int REPORT_SPAM = 5;
1111
1112        private final int mType;
1113        private final Uri mUri;
1114        private final ContentValues mValues;
1115        // True if an updated item should be removed locally (from ConversationCursor)
1116        // This would be the case for a folder change in which the conversation is no longer
1117        // in the folder represented by the ConversationCursor
1118        private final boolean mLocalDeleteOnUpdate;
1119
1120        /**
1121         * Set to true to immediately notify any {@link DataSetObserver}s watching the global
1122         * {@link ConversationCursor} upon applying the change to the data cache. You would not
1123         * want to do this if a change you make is being handled specially, like an animated delete.
1124         *
1125         * TODO: move this to the application Controller, or whoever has a canonical reference
1126         * to a {@link ConversationCursor} to notify on.
1127         */
1128        private final boolean mAutoNotify;
1129
1130        public ConversationOperation(int type, Conversation conv) {
1131            this(type, conv, null, false /* autoNotify */);
1132        }
1133
1134        public ConversationOperation(int type, Conversation conv, ContentValues values,
1135                boolean autoNotify) {
1136            mType = type;
1137            mUri = conv.uri;
1138            mValues = values;
1139            mLocalDeleteOnUpdate = conv.localDeleteOnUpdate;
1140            mAutoNotify = autoNotify;
1141        }
1142
1143        private ContentProviderOperation execute(Uri underlyingUri) {
1144            Uri uri = underlyingUri.buildUpon()
1145                    .appendQueryParameter(UIProvider.SEQUENCE_QUERY_PARAMETER,
1146                            Integer.toString(sSequence))
1147                    .build();
1148            ContentProviderOperation op;
1149            switch(mType) {
1150                case DELETE:
1151                    sProvider.deleteLocal(mUri);
1152                    op = ContentProviderOperation.newDelete(uri).build();
1153                    break;
1154                case UPDATE:
1155                    if (mLocalDeleteOnUpdate) {
1156                        sProvider.deleteLocal(mUri);
1157                    } else {
1158                        sProvider.updateLocal(mUri, mValues);
1159                    }
1160                    op = ContentProviderOperation.newUpdate(uri)
1161                            .withValues(mValues)
1162                            .build();
1163                    break;
1164                case INSERT:
1165                    sProvider.insertLocal(mUri, mValues);
1166                    op = ContentProviderOperation.newInsert(uri)
1167                            .withValues(mValues).build();
1168                    break;
1169                case ARCHIVE:
1170                    sProvider.deleteLocal(mUri);
1171
1172                    // Create an update operation that represents archive
1173                    op = ContentProviderOperation.newUpdate(uri).withValue(
1174                            ConversationOperations.OPERATION_KEY, ConversationOperations.ARCHIVE)
1175                            .build();
1176                    break;
1177                case MUTE:
1178                    if (mLocalDeleteOnUpdate) {
1179                        sProvider.deleteLocal(mUri);
1180                    }
1181
1182                    // Create an update operation that represents mute
1183                    op = ContentProviderOperation.newUpdate(uri).withValue(
1184                            ConversationOperations.OPERATION_KEY, ConversationOperations.MUTE)
1185                            .build();
1186                    break;
1187                case REPORT_SPAM:
1188                    sProvider.deleteLocal(mUri);
1189
1190                    // Create an update operation that represents report spam
1191                    op = ContentProviderOperation.newUpdate(uri).withValue(
1192                            ConversationOperations.OPERATION_KEY,
1193                            ConversationOperations.REPORT_SPAM).build();
1194                    break;
1195                default:
1196                    throw new UnsupportedOperationException(
1197                            "No such ConversationOperation type: " + mType);
1198            }
1199
1200            // FIXME: this is a hack to notify conversation list of changes from conversation view.
1201            // The proper way to do this is to have the Controller handle the 'mark read' action.
1202            // It has a reference to this ConversationCursor so it can notify without using global
1203            // magic.
1204            if (mAutoNotify) {
1205                if (sConversationCursor != null) {
1206                    sConversationCursor.notifyDataSetChanged();
1207                } else {
1208                    LogUtils.i(TAG, "Unable to auto-notify because there is no existing" +
1209                            " conversation cursor");
1210                }
1211            }
1212
1213            return op;
1214        }
1215    }
1216
1217    /**
1218     * For now, a single listener can be associated with the cursor, and for now we'll just
1219     * notify on deletions
1220     */
1221    public interface ConversationListener {
1222        /**
1223         * Data in the underlying provider has changed; a refresh is required to sync up
1224         */
1225        public void onRefreshRequired();
1226        /**
1227         * We've completed a requested refresh of the underlying cursor
1228         */
1229        public void onRefreshReady();
1230        /**
1231         * The data underlying the cursor has changed; the UI should redraw the list
1232         */
1233        public void onDataSetChanged();
1234    }
1235
1236    @Override
1237    public boolean isFirst() {
1238        throw new UnsupportedOperationException();
1239    }
1240
1241    @Override
1242    public boolean isLast() {
1243        throw new UnsupportedOperationException();
1244    }
1245
1246    @Override
1247    public boolean isBeforeFirst() {
1248        throw new UnsupportedOperationException();
1249    }
1250
1251    @Override
1252    public boolean isAfterLast() {
1253        throw new UnsupportedOperationException();
1254    }
1255
1256    @Override
1257    public int getColumnIndex(String columnName) {
1258        return sUnderlyingCursor.getColumnIndex(columnName);
1259    }
1260
1261    @Override
1262    public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException {
1263        return sUnderlyingCursor.getColumnIndexOrThrow(columnName);
1264    }
1265
1266    @Override
1267    public String getColumnName(int columnIndex) {
1268        return sUnderlyingCursor.getColumnName(columnIndex);
1269    }
1270
1271    @Override
1272    public String[] getColumnNames() {
1273        return sUnderlyingCursor.getColumnNames();
1274    }
1275
1276    @Override
1277    public int getColumnCount() {
1278        return sUnderlyingCursor.getColumnCount();
1279    }
1280
1281    @Override
1282    public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) {
1283        throw new UnsupportedOperationException();
1284    }
1285
1286    @Override
1287    public int getType(int columnIndex) {
1288        return sUnderlyingCursor.getType(columnIndex);
1289    }
1290
1291    @Override
1292    public boolean isNull(int columnIndex) {
1293        throw new UnsupportedOperationException();
1294    }
1295
1296    @Override
1297    public void deactivate() {
1298        throw new UnsupportedOperationException();
1299    }
1300
1301    @Override
1302    public boolean isClosed() {
1303        return sUnderlyingCursor.isClosed();
1304    }
1305
1306    @Override
1307    public void registerContentObserver(ContentObserver observer) {
1308        // Nope. We never notify of underlying changes on this channel, since the cursor watches
1309        // internally and offers onRefreshRequired/onRefreshReady to accomplish the same thing.
1310    }
1311
1312    @Override
1313    public void unregisterContentObserver(ContentObserver observer) {
1314        // See above.
1315    }
1316
1317    @Override
1318    public void registerDataSetObserver(DataSetObserver observer) {
1319        mDataSetObservable.registerObserver(observer);
1320    }
1321
1322    @Override
1323    public void unregisterDataSetObserver(DataSetObserver observer) {
1324        mDataSetObservable.unregisterObserver(observer);
1325    }
1326
1327    public void notifyDataSetChanged() {
1328        mDataSetObservable.notifyChanged();
1329    }
1330
1331    @Override
1332    public void setNotificationUri(ContentResolver cr, Uri uri) {
1333        throw new UnsupportedOperationException();
1334    }
1335
1336    @Override
1337    public boolean getWantsAllOnMoveCalls() {
1338        throw new UnsupportedOperationException();
1339    }
1340
1341    @Override
1342    public Bundle getExtras() {
1343        throw new UnsupportedOperationException();
1344    }
1345
1346    @Override
1347    public Bundle respond(Bundle extras) {
1348        throw new UnsupportedOperationException();
1349    }
1350
1351    @Override
1352    public boolean requery() {
1353        return true;
1354    }
1355}
1356