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