ConversationCursor.java revision 958bf4d37a70f456dcda1530f9bb357d88c79300
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 %d]", 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 %d]", 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 %d]", 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                    LogUtils.i(TAG, new Error(),
353                            "IN resetCursor, remove requery column from %s", entry.getKey());
354                } else {
355                    // Keep the deleted count up-to-date; remove the cache entry
356                    if (values.containsKey(DELETED_COLUMN)) {
357                        sDeletedCount--;
358                        LogUtils.i(TAG, new Error(),
359                                "IN resetCursor, sDeletedCount decremented to: %d by %s",
360                                sDeletedCount, entry.getKey());
361                    }
362                    // Remove the entry
363                    iter.remove();
364                }
365            }
366
367            // Swap cursor
368            if (newCursor != null) {
369                close();
370                sUnderlyingCursor = newCursor;
371            }
372
373            mPosition = -1;
374            sUnderlyingCursor.moveToPosition(mPosition);
375            if (!mCursorObserverRegistered) {
376                sUnderlyingCursor.registerContentObserver(mCursorObserver);
377                mCursorObserverRegistered = true;
378            }
379            sRefreshRequired = false;
380        }
381    }
382
383    /**
384     * Add a listener for this cursor; we'll notify it when our data changes
385     */
386    public void addListener(ConversationListener listener) {
387        synchronized (sListeners) {
388            if (!sListeners.contains(listener)) {
389                sListeners.add(listener);
390            } else {
391                LogUtils.i(TAG, "Ignoring duplicate add of listener");
392            }
393        }
394    }
395
396    /**
397     * Remove a listener for this cursor
398     */
399    public void removeListener(ConversationListener listener) {
400        synchronized(sListeners) {
401            sListeners.remove(listener);
402        }
403    }
404
405    /**
406     * Generate a forwarding Uri to ConversationProvider from an original Uri.  We do this by
407     * changing the authority to ours, but otherwise leaving the Uri intact.
408     * NOTE: This won't handle query parameters, so the functionality will need to be added if
409     * parameters are used in the future
410     * @param uri the uri
411     * @return a forwarding uri to ConversationProvider
412     */
413    private static String uriToCachingUriString (Uri uri) {
414        String provider = uri.getAuthority();
415        return uri.getScheme() + "://" + ConversationProvider.AUTHORITY
416                + "/" + provider + uri.getPath();
417    }
418
419    /**
420     * Regenerate the original Uri from a forwarding (ConversationProvider) Uri
421     * NOTE: See note above for uriToCachingUri
422     * @param uri the forwarding Uri
423     * @return the original Uri
424     */
425    private static Uri uriFromCachingUri(Uri uri) {
426        String authority = uri.getAuthority();
427        // Don't modify uri's that aren't ours
428        if (!authority.equals(ConversationProvider.AUTHORITY)) {
429            return uri;
430        }
431        List<String> path = uri.getPathSegments();
432        Uri.Builder builder = new Uri.Builder().scheme(uri.getScheme()).authority(path.get(0));
433        for (int i = 1; i < path.size(); i++) {
434            builder.appendPath(path.get(i));
435        }
436        return builder.build();
437    }
438
439    public static void setConversationColumn(String uriString, String columnName, Object value) {
440        synchronized (sCacheMapLock) {
441            if (sConversationCursor != null) {
442                cacheValue(uriString, columnName, value);
443            }
444        }
445        sConversationCursor.notifyDataChanged();
446    }
447
448    /**
449     * Must be called on UI thread; notify listeners that data has changed
450     */
451    private void notifyDataChanged() {
452        synchronized(sListeners) {
453            for (ConversationListener listener: sListeners) {
454                listener.onDataSetChanged();
455            }
456        }
457    }
458
459    /**
460     * Cache a column name/value pair for a given Uri
461     * @param uriString the Uri for which the column name/value pair applies
462     * @param columnName the column name
463     * @param value the value to be cached
464     */
465    private static void cacheValue(String uriString, String columnName, Object value) {
466        // Calling this method off the UI thread will mess with ListView's reading of the cursor's
467        // count
468        if (offUiThread()) {
469            LogUtils.e(TAG, new Error(), "cacheValue incorrectly being called from non-UI thread");
470        }
471
472        synchronized (sCacheMapLock) {
473            // Get the map for our uri
474            ContentValues map = sCacheMap.get(uriString);
475            // Create one if necessary
476            if (map == null) {
477                map = new ContentValues();
478                sCacheMap.put(uriString, map);
479            }
480            // If we're caching a deletion, add to our count
481            if (columnName == DELETED_COLUMN) {
482                final boolean state = (Boolean)value;
483                final boolean hasValue = map.get(columnName) != null;
484                if (state && !hasValue) {
485                    sDeletedCount++;
486                    if (DEBUG) {
487                        LogUtils.i(TAG, "Deleted %s, incremented deleted count=%d", uriString,
488                                sDeletedCount);
489                    }
490                } else if (!state && hasValue) {
491                    sDeletedCount--;
492                    map.remove(columnName);
493                    if (DEBUG) {
494                        LogUtils.i(TAG, "Undeleted %s, decremented deleted count=%d", uriString,
495                                sDeletedCount);
496                    }
497                    return;
498                } else if (!state) {
499                    // Trying to undelete, but it's not deleted; just return
500                    if (DEBUG) {
501                        LogUtils.i(TAG, "Undeleted %s, IGNORING, deleted count=%d", uriString,
502                                sDeletedCount);
503                    }
504                    return;
505                }
506            }
507            // ContentValues has no generic "put", so we must test.  For now, the only classes
508            // of values implemented are Boolean/Integer/String, though others are trivially
509            // added
510            if (value instanceof Boolean) {
511                map.put(columnName, ((Boolean) value).booleanValue() ? 1 : 0);
512            } else if (value instanceof Integer) {
513                map.put(columnName, (Integer) value);
514            } else if (value instanceof String) {
515                map.put(columnName, (String) value);
516            } else {
517                final String cname = value.getClass().getName();
518                throw new IllegalArgumentException("Value class not compatible with cache: "
519                        + cname);
520            }
521            if (sRefreshTask != null) {
522                map.put(REQUERY_COLUMN, 1);
523            }
524            if (DEBUG && (columnName != DELETED_COLUMN)) {
525                LogUtils.i(TAG, "Caching value for %s: %s", uriString, columnName);
526            }
527        }
528    }
529
530    /**
531     * Get the cached value for the provided column; we special case -1 as the "deleted" column
532     * @param columnIndex the index of the column whose cached value we want to retrieve
533     * @return the cached value for this column, or null if there is none
534     */
535    private Object getCachedValue(int columnIndex) {
536        String uri = sUnderlyingCursor.getString(sUriColumnIndex);
537        ContentValues uriMap = sCacheMap.get(uri);
538        if (uriMap != null) {
539            String columnName;
540            if (columnIndex == DELETED_COLUMN_INDEX) {
541                columnName = DELETED_COLUMN;
542            } else {
543                columnName = mColumnNames[columnIndex];
544            }
545            return uriMap.get(columnName);
546        }
547        return null;
548    }
549
550    /**
551     * When the underlying cursor changes, we want to alert the listener
552     */
553    private void underlyingChanged() {
554        if (mCursorObserverRegistered) {
555            try {
556                sUnderlyingCursor.unregisterContentObserver(mCursorObserver);
557            } catch (IllegalStateException e) {
558                // Maybe the cursor was GC'd?
559            }
560            mCursorObserverRegistered = false;
561        }
562        if (DEBUG) {
563            LogUtils.i(TAG, "[Notify: onRefreshRequired()]");
564        }
565        synchronized(sListeners) {
566            for (ConversationListener listener: sListeners) {
567                listener.onRefreshRequired();
568            }
569        }
570        sRefreshRequired = true;
571    }
572
573    /**
574     * Put the refreshed cursor in place (called by the UI)
575     */
576    public void sync() {
577        if (sRequeryCursor == null) {
578            // This can happen during an animated deletion, if the UI isn't keeping track, or
579            // if a new query intervened (i.e. user changed folders)
580            if (DEBUG) {
581                LogUtils.i(TAG, "[sync() called; no requery cursor]");
582            }
583            return;
584        }
585        synchronized(sCacheMapLock) {
586            if (DEBUG) {
587                LogUtils.i(TAG, "[sync()]");
588            }
589            resetCursor(sRequeryCursor);
590            sRequeryCursor = null;
591            sRefreshTask = null;
592            sRefreshReady = false;
593        }
594    }
595
596    public boolean isRefreshRequired() {
597        return sRefreshRequired;
598    }
599
600    public boolean isRefreshReady() {
601        return sRefreshReady;
602    }
603
604    /**
605     * Cancel a refresh in progress
606     */
607    public static void cancelRefresh() {
608        if (DEBUG) {
609            LogUtils.i(TAG, "[cancelRefresh() called]");
610        }
611        synchronized(sCacheMapLock) {
612            if (sRefreshTask != null) {
613                sRefreshTask.cancel(true);
614                sRefreshTask = null;
615            }
616            sRefreshReady = false;
617            // If we have the cursor, close it; otherwise, it will get closed when the query
618            // finishes (it checks sRefreshInProgress)
619            if (sRequeryCursor != null) {
620                sRequeryCursor.close();
621                sRequeryCursor = null;
622            }
623        }
624    }
625
626    /**
627     * Get a list of deletions from ConversationCursor to the refreshed cursor that hasn't yet
628     * been swapped into place; this allows the UI to animate these away if desired
629     * @return a list of positions deleted in ConversationCursor
630     */
631    public ArrayList<Integer> getRefreshDeletions () {
632        // It's possible that the requery cursor is null in the case that loadInBackground() causes
633        // ConversationCursor.create to do a sync() between the time that refreshReady() is called
634        // and the subsequent call to getRefreshDeletions().  This is harmless, and an empty
635        // result list is correct.
636        return EMPTY_DELETION_LIST;
637//        if (sRequeryCursor == null) {
638//            if (DEBUG) {
639//                LogUtils.i(TAG, "[getRefreshDeletions() called; no cursor]");
640//            }
641//            return EMPTY_DELETION_LIST;
642//        } else if (!sRequeryCursor.getUri().equals(sUnderlyingCursor.getUri())) {
643//            if (DEBUG) {
644//                LogUtils.i(TAG, "[getRefreshDeletions(); cursors differ]");
645//            }
646//            return EMPTY_DELETION_LIST;
647//        }
648//        Cursor deviceCursor = sConversationCursor;
649//        Cursor serverCursor = sRequeryCursor;
650//        ArrayList<Integer> deleteList = new ArrayList<Integer>();
651//        int serverCount = serverCursor.getCount();
652//        int deviceCount = deviceCursor.getCount();
653//        deviceCursor.moveToFirst();
654//        serverCursor.moveToFirst();
655//        while (serverCount > 0 || deviceCount > 0) {
656//            if (serverCount == 0) {
657//                for (; deviceCount > 0; deviceCount--, deviceCursor.moveToPrevious()) {
658//                    deleteList.add(deviceCursor.getPosition());
659//                    if (deleteList.size() > 6) {
660//                        if (DEBUG) {
661//                            LogUtils.i(TAG, "[getRefreshDeletions(); mega changes]");
662//                        }
663//                        return EMPTY_DELETION_LIST;
664//                    }
665//                }
666//                break;
667//            } else if (deviceCount == 0) {
668//                break;
669//            }
670//            long deviceMs = deviceCursor.getLong(UIProvider.CONVERSATION_DATE_RECEIVED_MS_COLUMN);
671//            long serverMs = serverCursor.getLong(UIProvider.CONVERSATION_DATE_RECEIVED_MS_COLUMN);
672//            String deviceUri = deviceCursor.getString(UIProvider.CONVERSATION_URI_COLUMN);
673//            String serverUri = serverCursor.getString(UIProvider.CONVERSATION_URI_COLUMN);
674//            deviceCursor.moveToNext();
675//            serverCursor.moveToNext();
676//            serverCount--;
677//            deviceCount--;
678//            if (serverMs == deviceMs) {
679//                // Check for duplicates here; if our identical dates refer to different messages,
680//                // we'll just quit here for now (at worst, this will cause a non-animating delete)
681//                // My guess is that this happens VERY rarely, if at all
682//                if (!deviceUri.equals(serverUri)) {
683//                    // To do this right, we'd find all of the rows with the same ms (date), etc...
684//                    //return deleteList;
685//                }
686//                continue;
687//            } else if (deviceMs > serverMs) {
688//                deleteList.add(deviceCursor.getPosition() - 1);
689//                if (deleteList.size() > 6) {
690//                    if (DEBUG) {
691//                        LogUtils.i(TAG, "[getRefreshDeletions(); mega changes]");
692//                    }
693//                    return EMPTY_DELETION_LIST;
694//                }
695//                // Move back because we've already advanced cursor (that's why we subtract 1 above)
696//                serverCount++;
697//                serverCursor.moveToPrevious();
698//            } else if (serverMs > deviceMs) {
699//                // If we wanted to track insertions, we'd so so here
700//                // Move back because we've already advanced cursor
701//                deviceCount++;
702//                deviceCursor.moveToPrevious();
703//            }
704//        }
705//        if (DEBUG) {
706//            LogUtils.i(TAG, "getRefreshDeletions(): " + deleteList);
707//        }
708//        return deleteList;
709    }
710
711    /**
712     * When we get a requery from the UI, we'll do it, but also clear the cache. The listener is
713     * notified when the requery is complete
714     * NOTE: This will have to change, of course, when we start using loaders...
715     */
716    public boolean refresh() {
717        if (DEBUG) {
718            LogUtils.i(TAG, "[refresh() called]");
719        }
720        synchronized(sCacheMapLock) {
721            if (sRefreshTask != null) {
722                if (DEBUG) {
723                    LogUtils.i(TAG, "[refresh() returning; already running %d]",
724                            sRefreshTask.hashCode());
725                }
726                return false;
727            }
728            sRefreshTask = new RefreshTask(qUri, qProjection);
729            sRefreshTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
730        }
731        return true;
732    }
733
734    @Override
735    public void close() {
736        if (!sUnderlyingCursor.isClosed()) {
737            // Unregister our observer on the underlying cursor and close as usual
738            if (mCursorObserverRegistered) {
739                try {
740                    sUnderlyingCursor.unregisterContentObserver(mCursorObserver);
741                } catch (IllegalStateException e) {
742                    // Maybe the cursor got GC'd?
743                }
744                mCursorObserverRegistered = false;
745            }
746            sUnderlyingCursor.close();
747        }
748    }
749
750    /**
751     * Move to the next not-deleted item in the conversation
752     */
753    @Override
754    public boolean moveToNext() {
755        while (true) {
756            boolean ret = sUnderlyingCursor.moveToNext();
757            if (!ret) return false;
758            if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue;
759            mPosition++;
760            return true;
761        }
762    }
763
764    /**
765     * Move to the previous not-deleted item in the conversation
766     */
767    @Override
768    public boolean moveToPrevious() {
769        while (true) {
770            boolean ret = sUnderlyingCursor.moveToPrevious();
771            if (!ret) return false;
772            if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue;
773            mPosition--;
774            // STOPSHIP: Remove this if statement
775            if (mPosition < 0) {
776                mStackTrace = new Throwable().getStackTrace();
777            }
778            return true;
779        }
780    }
781
782    @Override
783    public int getPosition() {
784        return mPosition;
785    }
786
787    /**
788     * The actual cursor's count must be decremented by the number we've deleted from the UI
789     */
790    @Override
791    public int getCount() {
792        return sUnderlyingCursor.getCount() - sDeletedCount;
793    }
794
795    @Override
796    public boolean moveToFirst() {
797        sUnderlyingCursor.moveToPosition(-1);
798        mPosition = -1;
799        return moveToNext();
800    }
801
802    // STOPSHIP: Remove this
803    private StackTraceElement[] mStackTrace = null;
804
805    @Override
806    public boolean moveToPosition(int pos) {
807        // STOPSHIP: Remove this if statement
808        if (pos == -1) {
809            mStackTrace = new Throwable().getStackTrace();
810        }
811        // STOPSHIP: Remove this check
812        if (offUiThread()) {
813            LogUtils.w(TAG, new Throwable(), "********** moveToPosition OFF UI THREAD: %d", pos);
814        }
815        if (pos < -1 || pos >= getCount()) {
816            // STOPSHIP: Remove this logging
817            LogUtils.w(TAG, new Throwable(), "********** moveToPosition OUT OF RANGE: %d", pos);
818            return false;
819        }
820        if (pos == mPosition) return true;
821        if (pos > mPosition) {
822            while (pos > mPosition) {
823                if (!moveToNext()) {
824                    return false;
825                }
826            }
827            return true;
828        } else if (pos == 0) {
829            return moveToFirst();
830        } else {
831            while (pos < mPosition) {
832                if (!moveToPrevious()) {
833                    return false;
834                }
835            }
836            return true;
837        }
838    }
839
840    @Override
841    public boolean moveToLast() {
842        throw new UnsupportedOperationException("moveToLast unsupported!");
843    }
844
845    @Override
846    public boolean move(int offset) {
847        throw new UnsupportedOperationException("move unsupported!");
848    }
849
850    /**
851     * We need to override all of the getters to make sure they look at cached values before using
852     * the values in the underlying cursor
853     */
854    @Override
855    public double getDouble(int columnIndex) {
856        Object obj = getCachedValue(columnIndex);
857        if (obj != null) return (Double)obj;
858        return sUnderlyingCursor.getDouble(columnIndex);
859    }
860
861    @Override
862    public float getFloat(int columnIndex) {
863        Object obj = getCachedValue(columnIndex);
864        if (obj != null) return (Float)obj;
865        return sUnderlyingCursor.getFloat(columnIndex);
866    }
867
868    @Override
869    public int getInt(int columnIndex) {
870        Object obj = getCachedValue(columnIndex);
871        if (obj != null) return (Integer)obj;
872        return sUnderlyingCursor.getInt(columnIndex);
873    }
874
875    @Override
876    public long getLong(int columnIndex) {
877        // STOPSHIP: Remove try/catch
878        try {
879            Object obj = getCachedValue(columnIndex);
880            if (obj != null) return (Long)obj;
881            return sUnderlyingCursor.getLong(columnIndex);
882        } catch (CursorIndexOutOfBoundsException e) {
883            if (mStackTrace != null) {
884                Log.e(TAG, "Stack trace at last moveToPosition(-1)");
885                Throwable t = new Throwable();
886                t.setStackTrace(mStackTrace);
887                t.printStackTrace();
888            }
889            throw e;
890        }
891    }
892
893    @Override
894    public short getShort(int columnIndex) {
895        Object obj = getCachedValue(columnIndex);
896        if (obj != null) return (Short)obj;
897        return sUnderlyingCursor.getShort(columnIndex);
898    }
899
900    @Override
901    public String getString(int columnIndex) {
902        // If we're asking for the Uri for the conversation list, we return a forwarding URI
903        // so that we can intercept update/delete and handle it ourselves
904        if (columnIndex == sUriColumnIndex) {
905            Uri uri = Uri.parse(sUnderlyingCursor.getString(columnIndex));
906            return uriToCachingUriString(uri);
907        }
908        Object obj = getCachedValue(columnIndex);
909        if (obj != null) return (String)obj;
910        return sUnderlyingCursor.getString(columnIndex);
911    }
912
913    @Override
914    public byte[] getBlob(int columnIndex) {
915        Object obj = getCachedValue(columnIndex);
916        if (obj != null) return (byte[])obj;
917        return sUnderlyingCursor.getBlob(columnIndex);
918    }
919
920    /**
921     * Observer of changes to underlying data
922     */
923    private class CursorObserver extends ContentObserver {
924        public CursorObserver() {
925            super(null);
926        }
927
928        @Override
929        public void onChange(boolean selfChange) {
930            // If we're here, then something outside of the UI has changed the data, and we
931            // must query the underlying provider for that data
932            ConversationCursor.this.underlyingChanged();
933        }
934    }
935
936    /**
937     * ConversationProvider is the ContentProvider for our forwarding Uri's; it passes queries
938     * and inserts directly, and caches updates/deletes before passing them through.  The caching
939     * will cause a redraw of the list with updated values.
940     */
941    public abstract static class ConversationProvider extends ContentProvider {
942        public static String AUTHORITY;
943
944        /**
945         * Allows the implementing provider to specify the authority that should be used.
946         */
947        protected abstract String getAuthority();
948
949        @Override
950        public boolean onCreate() {
951            sProvider = this;
952            AUTHORITY = getAuthority();
953            return true;
954        }
955
956        @Override
957        public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
958                String sortOrder) {
959            return mResolver.query(
960                    uriFromCachingUri(uri), projection, selection, selectionArgs, sortOrder);
961        }
962
963        @Override
964        public Uri insert(Uri uri, ContentValues values) {
965            insertLocal(uri, values);
966            return ProviderExecute.opInsert(uri, values);
967        }
968
969        @Override
970        public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
971            updateLocal(uri, values);
972            return ProviderExecute.opUpdate(uri, values);
973        }
974
975        @Override
976        public int delete(Uri uri, String selection, String[] selectionArgs) {
977            deleteLocal(uri);
978            return ProviderExecute.opDelete(uri);
979        }
980
981        @Override
982        public String getType(Uri uri) {
983            return null;
984        }
985
986        /**
987         * Quick and dirty class that executes underlying provider CRUD operations on a background
988         * thread.
989         */
990        static class ProviderExecute implements Runnable {
991            static final int DELETE = 0;
992            static final int INSERT = 1;
993            static final int UPDATE = 2;
994
995            final int mCode;
996            final Uri mUri;
997            final ContentValues mValues; //HEHEH
998
999            ProviderExecute(int code, Uri uri, ContentValues values) {
1000                mCode = code;
1001                mUri = uriFromCachingUri(uri);
1002                mValues = values;
1003            }
1004
1005            ProviderExecute(int code, Uri uri) {
1006                this(code, uri, null);
1007            }
1008
1009            static Uri opInsert(Uri uri, ContentValues values) {
1010                ProviderExecute e = new ProviderExecute(INSERT, uri, values);
1011                if (offUiThread()) return (Uri)e.go();
1012                new Thread(e).start();
1013                return null;
1014            }
1015
1016            static int opDelete(Uri uri) {
1017                ProviderExecute e = new ProviderExecute(DELETE, uri);
1018                if (offUiThread()) return (Integer)e.go();
1019                new Thread(new ProviderExecute(DELETE, uri)).start();
1020                return 0;
1021            }
1022
1023            static int opUpdate(Uri uri, ContentValues values) {
1024                ProviderExecute e = new ProviderExecute(UPDATE, uri, values);
1025                if (offUiThread()) return (Integer)e.go();
1026                new Thread(e).start();
1027                return 0;
1028            }
1029
1030            @Override
1031            public void run() {
1032                go();
1033            }
1034
1035            public Object go() {
1036                switch(mCode) {
1037                    case DELETE:
1038                        return mResolver.delete(mUri, null, null);
1039                    case INSERT:
1040                        return mResolver.insert(mUri, mValues);
1041                    case UPDATE:
1042                        return mResolver.update(mUri,  mValues, null, null);
1043                    default:
1044                        return null;
1045                }
1046            }
1047        }
1048
1049        private void insertLocal(Uri uri, ContentValues values) {
1050            // Placeholder for now; there's no local insert
1051        }
1052
1053        private int mUndoSequence = 0;
1054        private ArrayList<Uri> mUndoDeleteUris = new ArrayList<Uri>();
1055
1056        @VisibleForTesting
1057        void deleteLocal(Uri uri) {
1058            Uri underlyingUri = uriFromCachingUri(uri);
1059            // Remember to decode the underlying Uri as it might be encoded (as w/ Gmail)
1060            String uriString =  Uri.decode(underlyingUri.toString());
1061            cacheValue(uriString, DELETED_COLUMN, true);
1062            if (sSequence != mUndoSequence) {
1063                mUndoSequence = sSequence;
1064                mUndoDeleteUris.clear();
1065            }
1066            mUndoDeleteUris.add(uri);
1067        }
1068
1069        @VisibleForTesting
1070        void undeleteLocal(Uri uri) {
1071            Uri underlyingUri = uriFromCachingUri(uri);
1072            // Remember to decode the underlying Uri as it might be encoded (as w/ Gmail)
1073            String uriString =  Uri.decode(underlyingUri.toString());
1074            cacheValue(uriString, DELETED_COLUMN, false);
1075        }
1076
1077        public void undo() {
1078            if (sSequence == mUndoSequence) {
1079                for (Uri uri: mUndoDeleteUris) {
1080                    undeleteLocal(uri);
1081                }
1082                mUndoSequence = 0;
1083            }
1084        }
1085
1086        @VisibleForTesting
1087        void updateLocal(Uri uri, ContentValues values) {
1088            if (values == null) {
1089                return;
1090            }
1091            Uri underlyingUri = uriFromCachingUri(uri);
1092            // Remember to decode the underlying Uri as it might be encoded (as w/ Gmail)
1093            String uriString =  Uri.decode(underlyingUri.toString());
1094            for (String columnName: values.keySet()) {
1095                cacheValue(uriString, columnName, values.get(columnName));
1096            }
1097        }
1098
1099        public int apply(ArrayList<ConversationOperation> ops) {
1100            final HashMap<String, ArrayList<ContentProviderOperation>> batchMap =
1101                    new HashMap<String, ArrayList<ContentProviderOperation>>();
1102            // STOPSHIP: Remove this test
1103            if (offUiThread()) {
1104                Log.w(TAG, "apply() called off of UI thread", new Throwable());
1105            }
1106            // Increment sequence count
1107            sSequence++;
1108            // Execute locally and build CPO's for underlying provider
1109            for (ConversationOperation op: ops) {
1110                Uri underlyingUri = uriFromCachingUri(op.mUri);
1111                String authority = underlyingUri.getAuthority();
1112                ArrayList<ContentProviderOperation> authOps = batchMap.get(authority);
1113                if (authOps == null) {
1114                    authOps = new ArrayList<ContentProviderOperation>();
1115                    batchMap.put(authority, authOps);
1116                }
1117                authOps.add(op.execute(underlyingUri));
1118            }
1119
1120            // Notify listeners that data has changed
1121            sConversationCursor.notifyDataChanged();
1122
1123            // Send changes to underlying provider
1124            for (String authority: batchMap.keySet()) {
1125                try {
1126                    if (offUiThread()) {
1127                        mResolver.applyBatch(authority, batchMap.get(authority));
1128                    } else {
1129                        final String auth = authority;
1130                        new Thread(new Runnable() {
1131                            @Override
1132                            public void run() {
1133                                try {
1134                                    mResolver.applyBatch(auth, batchMap.get(auth));
1135                                } catch (RemoteException e) {
1136                                } catch (OperationApplicationException e) {
1137                                }
1138                           }
1139                        }).start();
1140                    }
1141                } catch (RemoteException e) {
1142                } catch (OperationApplicationException e) {
1143                }
1144            }
1145            return sSequence;
1146        }
1147    }
1148
1149    /**
1150     * ConversationOperation is the encapsulation of a ContentProvider operation to be performed
1151     * atomically as part of a "batch" operation.
1152     */
1153    public static class ConversationOperation {
1154        public static final int DELETE = 0;
1155        public static final int INSERT = 1;
1156        public static final int UPDATE = 2;
1157        public static final int ARCHIVE = 3;
1158        public static final int MUTE = 4;
1159        public static final int REPORT_SPAM = 5;
1160
1161        private final int mType;
1162        private final Uri mUri;
1163        private final ContentValues mValues;
1164        // True if an updated item should be removed locally (from ConversationCursor)
1165        // This would be the case for a folder change in which the conversation is no longer
1166        // in the folder represented by the ConversationCursor
1167        private final boolean mLocalDeleteOnUpdate;
1168
1169        /**
1170         * Set to true to immediately notify any {@link DataSetObserver}s watching the global
1171         * {@link ConversationCursor} upon applying the change to the data cache. You would not
1172         * want to do this if a change you make is being handled specially, like an animated delete.
1173         *
1174         * TODO: move this to the application Controller, or whoever has a canonical reference
1175         * to a {@link ConversationCursor} to notify on.
1176         */
1177        private final boolean mAutoNotify;
1178
1179        public ConversationOperation(int type, Conversation conv) {
1180            this(type, conv, null, false /* autoNotify */);
1181        }
1182
1183        public ConversationOperation(int type, Conversation conv, ContentValues values,
1184                boolean autoNotify) {
1185            mType = type;
1186            mUri = conv.uri;
1187            mValues = values;
1188            mLocalDeleteOnUpdate = conv.localDeleteOnUpdate;
1189            mAutoNotify = autoNotify;
1190        }
1191
1192        private ContentProviderOperation execute(Uri underlyingUri) {
1193            Uri uri = underlyingUri.buildUpon()
1194                    .appendQueryParameter(UIProvider.SEQUENCE_QUERY_PARAMETER,
1195                            Integer.toString(sSequence))
1196                    .build();
1197            ContentProviderOperation op;
1198            switch(mType) {
1199                case DELETE:
1200                    sProvider.deleteLocal(mUri);
1201                    op = ContentProviderOperation.newDelete(uri).build();
1202                    break;
1203                case UPDATE:
1204                    if (mLocalDeleteOnUpdate) {
1205                        sProvider.deleteLocal(mUri);
1206                    } else {
1207                        sProvider.updateLocal(mUri, mValues);
1208                    }
1209                    op = ContentProviderOperation.newUpdate(uri)
1210                            .withValues(mValues)
1211                            .build();
1212                    break;
1213                case INSERT:
1214                    sProvider.insertLocal(mUri, mValues);
1215                    op = ContentProviderOperation.newInsert(uri)
1216                            .withValues(mValues).build();
1217                    break;
1218                case ARCHIVE:
1219                    sProvider.deleteLocal(mUri);
1220
1221                    // Create an update operation that represents archive
1222                    op = ContentProviderOperation.newUpdate(uri).withValue(
1223                            ConversationOperations.OPERATION_KEY, ConversationOperations.ARCHIVE)
1224                            .build();
1225                    break;
1226                case MUTE:
1227                    if (mLocalDeleteOnUpdate) {
1228                        sProvider.deleteLocal(mUri);
1229                    }
1230
1231                    // Create an update operation that represents mute
1232                    op = ContentProviderOperation.newUpdate(uri).withValue(
1233                            ConversationOperations.OPERATION_KEY, ConversationOperations.MUTE)
1234                            .build();
1235                    break;
1236                case REPORT_SPAM:
1237                    sProvider.deleteLocal(mUri);
1238
1239                    // Create an update operation that represents report spam
1240                    op = ContentProviderOperation.newUpdate(uri).withValue(
1241                            ConversationOperations.OPERATION_KEY,
1242                            ConversationOperations.REPORT_SPAM).build();
1243                    break;
1244                default:
1245                    throw new UnsupportedOperationException(
1246                            "No such ConversationOperation type: " + mType);
1247            }
1248
1249            // FIXME: this is a hack to notify conversation list of changes from conversation view.
1250            // The proper way to do this is to have the Controller handle the 'mark read' action.
1251            // It has a reference to this ConversationCursor so it can notify without using global
1252            // magic.
1253            if (mAutoNotify) {
1254                if (sConversationCursor != null) {
1255                    sConversationCursor.notifyDataSetChanged();
1256                } else {
1257                    LogUtils.i(TAG, "Unable to auto-notify because there is no existing" +
1258                            " conversation cursor");
1259                }
1260            }
1261
1262            return op;
1263        }
1264    }
1265
1266    /**
1267     * For now, a single listener can be associated with the cursor, and for now we'll just
1268     * notify on deletions
1269     */
1270    public interface ConversationListener {
1271        /**
1272         * Data in the underlying provider has changed; a refresh is required to sync up
1273         */
1274        public void onRefreshRequired();
1275        /**
1276         * We've completed a requested refresh of the underlying cursor
1277         */
1278        public void onRefreshReady();
1279        /**
1280         * The data underlying the cursor has changed; the UI should redraw the list
1281         */
1282        public void onDataSetChanged();
1283    }
1284
1285    @Override
1286    public boolean isFirst() {
1287        throw new UnsupportedOperationException();
1288    }
1289
1290    @Override
1291    public boolean isLast() {
1292        throw new UnsupportedOperationException();
1293    }
1294
1295    @Override
1296    public boolean isBeforeFirst() {
1297        throw new UnsupportedOperationException();
1298    }
1299
1300    @Override
1301    public boolean isAfterLast() {
1302        throw new UnsupportedOperationException();
1303    }
1304
1305    @Override
1306    public int getColumnIndex(String columnName) {
1307        return sUnderlyingCursor.getColumnIndex(columnName);
1308    }
1309
1310    @Override
1311    public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException {
1312        return sUnderlyingCursor.getColumnIndexOrThrow(columnName);
1313    }
1314
1315    @Override
1316    public String getColumnName(int columnIndex) {
1317        return sUnderlyingCursor.getColumnName(columnIndex);
1318    }
1319
1320    @Override
1321    public String[] getColumnNames() {
1322        return sUnderlyingCursor.getColumnNames();
1323    }
1324
1325    @Override
1326    public int getColumnCount() {
1327        return sUnderlyingCursor.getColumnCount();
1328    }
1329
1330    @Override
1331    public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) {
1332        throw new UnsupportedOperationException();
1333    }
1334
1335    @Override
1336    public int getType(int columnIndex) {
1337        return sUnderlyingCursor.getType(columnIndex);
1338    }
1339
1340    @Override
1341    public boolean isNull(int columnIndex) {
1342        throw new UnsupportedOperationException();
1343    }
1344
1345    @Override
1346    public void deactivate() {
1347        throw new UnsupportedOperationException();
1348    }
1349
1350    @Override
1351    public boolean isClosed() {
1352        return sUnderlyingCursor.isClosed();
1353    }
1354
1355    @Override
1356    public void registerContentObserver(ContentObserver observer) {
1357        // Nope. We never notify of underlying changes on this channel, since the cursor watches
1358        // internally and offers onRefreshRequired/onRefreshReady to accomplish the same thing.
1359    }
1360
1361    @Override
1362    public void unregisterContentObserver(ContentObserver observer) {
1363        // See above.
1364    }
1365
1366    @Override
1367    public void registerDataSetObserver(DataSetObserver observer) {
1368        mDataSetObservable.registerObserver(observer);
1369    }
1370
1371    @Override
1372    public void unregisterDataSetObserver(DataSetObserver observer) {
1373        mDataSetObservable.unregisterObserver(observer);
1374    }
1375
1376    public void notifyDataSetChanged() {
1377        mDataSetObservable.notifyChanged();
1378    }
1379
1380    @Override
1381    public void setNotificationUri(ContentResolver cr, Uri uri) {
1382        throw new UnsupportedOperationException();
1383    }
1384
1385    @Override
1386    public boolean getWantsAllOnMoveCalls() {
1387        throw new UnsupportedOperationException();
1388    }
1389
1390    @Override
1391    public Bundle getExtras() {
1392        throw new UnsupportedOperationException();
1393    }
1394
1395    @Override
1396    public Bundle respond(Bundle extras) {
1397        throw new UnsupportedOperationException();
1398    }
1399
1400    @Override
1401    public boolean requery() {
1402        return true;
1403    }
1404}
1405