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