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