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