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