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