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