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