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