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