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