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