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