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