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