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