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