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