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