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