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