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