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