ConversationCursor.java revision 3c439763dc2904602e96db82139f2f310cfea9ee
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.content.ContentProvider;
21import android.content.ContentResolver;
22import android.content.ContentValues;
23import android.content.Context;
24import android.database.ContentObserver;
25import android.database.Cursor;
26import android.database.CursorWrapper;
27import android.net.Uri;
28import android.os.Handler;
29import android.util.Log;
30
31import java.util.ArrayList;
32import java.util.HashMap;
33import java.util.List;
34
35/**
36 * ConversationCursor is a wrapper around a conversation list cursor that provides update/delete
37 * caching for quick UI response. This is effectively a singleton class, as the cache is
38 * implemented as a static HashMap.
39 */
40public class ConversationCursor extends CursorWrapper {
41    private static final String TAG = "ConversationCursor";
42    private static final boolean DEBUG = true;  // STOPSHIP Set to false before shipping
43
44    // The authority of our conversation provider (a forwarding provider)
45    // This string must match the declaration in AndroidManifest.xml
46    private static final String sAuthority = "com.android.mail.conversation.provider";
47
48    // A mapping from Uri to updated ContentValues
49    private static HashMap<String, ContentValues> sCacheMap = new HashMap<String, ContentValues>();
50    // A deleted row is indicated by the presence of DELETED_COLUMN in the cache map
51    private static final String DELETED_COLUMN = "__deleted__";
52    // A sentinel value for the "index" of the deleted column; it's an int that is otherwise invalid
53    private static final int DELETED_COLUMN_INDEX = -1;
54    // The current conversation cursor
55    private static ConversationCursor sConversationCursor;
56    // The index of the Uri whose data is reflected in the cached row
57    // Updates/Deletes to this Uri are cached
58    private static int sUriColumnIndex;
59    // The listener registered for this cursor
60    private static ConversationListener sListener;
61
62    // The cursor underlying the caching cursor
63    private final Cursor mUnderlying;
64    // Column names for this cursor
65    private final String[] mColumnNames;
66    // The resolver for the cursor instantiator's context
67    private static ContentResolver mResolver;
68    // An observer on the underlying cursor (so we can detect changes from outside the UI)
69    private final CursorObserver mCursorObserver;
70    // Whether our observer is currently registered with the underlying cursor
71    private boolean mCursorObserverRegistered = false;
72
73    // The current position of the cursor
74    private int mPosition = -1;
75    // The number of cached deletions from this cursor (used to quickly generate an accurate count)
76    private static int sDeletedCount = 0;
77
78    public ConversationCursor(Cursor cursor, Context context, String messageListColumn) {
79        super(cursor);
80        sConversationCursor = this;
81        mUnderlying = cursor;
82        mCursorObserver = new CursorObserver();
83        // New cursor -> clear the cache
84        resetCache();
85        mColumnNames = cursor.getColumnNames();
86        sUriColumnIndex = getColumnIndex(messageListColumn);
87        if (sUriColumnIndex < 0) {
88            throw new IllegalArgumentException("Cursor must include a message list column");
89        }
90        mResolver = context.getContentResolver();
91    }
92
93    /**
94     * Reset the cache; this involves clearing out our cache map and resetting our various counts
95     * The cache should be reset whenever we get fresh data from the underlying cursor
96     */
97    private void resetCache() {
98        sCacheMap.clear();
99        sDeletedCount = 0;
100        mPosition = -1;
101        if (!mCursorObserverRegistered) {
102            mUnderlying.registerContentObserver(mCursorObserver);
103            mCursorObserverRegistered = true;
104        }
105    }
106
107    /**
108     * Set the listener for this cursor; we'll notify it when our data changes
109     */
110    public void setListener(ConversationListener listener) {
111        sListener = listener;
112    }
113
114    /**
115     * Generate a forwarding Uri to ConversationProvider from an original Uri.  We do this by
116     * changing the authority to ours, but otherwise leaving the Uri intact.
117     * NOTE: This won't handle query parameters, so the functionality will need to be added if
118     * parameters are used in the future
119     * @param uri the uri
120     * @return a forwarding uri to ConversationProvider
121     */
122    private static String uriToCachingUriString (Uri uri) {
123        String provider = uri.getAuthority();
124        return uri.getScheme() + "://" + sAuthority + "/" + provider + uri.getPath();
125    }
126
127    /**
128     * Regenerate the original Uri from a forwarding (ConversationProvider) Uri
129     * NOTE: See note above for uriToCachingUri
130     * @param uri the forwarding Uri
131     * @return the original Uri
132     */
133    private static Uri uriFromCachingUri(Uri uri) {
134        List<String> path = uri.getPathSegments();
135        Uri.Builder builder = new Uri.Builder().scheme(uri.getScheme()).authority(path.get(0));
136        for (int i = 1; i < path.size(); i++) {
137            builder.appendPath(path.get(i));
138        }
139        return builder.build();
140    }
141
142    /**
143     * Given a uri string (for the conversation), return its position in the cursor (0 based)
144     * @param uriString the uri string to locate
145     * @return the position of the row holding uriString, or -1 if not found
146     */
147    private static int getPositionFromUriString(String uriString) {
148        sConversationCursor.moveToFirst();
149        int pos = 0;
150        while (sConversationCursor.moveToNext()) {
151            if (sConversationCursor.getUriString().equals(uriString)) {
152                return pos;
153            }
154            pos++;
155        }
156        return -1;
157    }
158
159    /**
160     * Cache a column name/value pair for a given Uri
161     * @param uriString the Uri for which the column name/value pair applies
162     * @param columnName the column name
163     * @param value the value to be cached
164     */
165    private static void cacheValue(String uriString, String columnName, Object value) {
166        // Get the map for our uri
167        ContentValues map = sCacheMap.get(uriString);
168        // Create one if necessary
169        if (map == null) {
170            map = new ContentValues();
171            sCacheMap.put(uriString, map);
172        }
173        // If we're caching a deletion, add to our count
174        if ((columnName == DELETED_COLUMN) && (map.get(columnName) == null)) {
175           sDeletedCount++;
176            if (DEBUG) {
177                Log.d(TAG, "Deleted " + uriString);
178            }
179            // Tell the listener what we deleted
180            if (sListener != null) {
181                int pos = getPositionFromUriString(uriString);
182                if (pos >= 0) {
183                    ArrayList<Integer> positions = new ArrayList<Integer>();
184                    positions.add(pos);
185                    sListener.onDeletedItems(positions);
186                }
187            }
188         }
189        // ContentValues has no generic "put", so we must test.  For now, the only classes of
190        // values implemented are Boolean/Integer/String, though others are trivially added
191        if (value instanceof Boolean) {
192            map.put(columnName, ((Boolean)value).booleanValue() ? 1 : 0);
193        } else if (value instanceof Integer) {
194            map.put(columnName, (Integer)value);
195        } else if (value instanceof String) {
196            map.put(columnName, (String)value);
197        } else {
198            String cname = value.getClass().getName();
199            throw new IllegalArgumentException("Value class not compatible with cache: " + cname);
200        }
201
202        if (DEBUG && (columnName != DELETED_COLUMN)) {
203            Log.d(TAG, "Caching value for " + uriString + ": " + columnName);
204        }
205    }
206
207    private String getUriString() {
208        return super.getString(sUriColumnIndex);
209    }
210
211    /**
212     * Get the cached value for the provided column; we special case -1 as the "deleted" column
213     * @param columnIndex the index of the column whose cached value we want to retrieve
214     * @return the cached value for this column, or null if there is none
215     */
216    private Object getCachedValue(int columnIndex) {
217        String uri = super.getString(sUriColumnIndex);
218        ContentValues uriMap = sCacheMap.get(uri);
219        if (uriMap != null) {
220            String columnName;
221            if (columnIndex == DELETED_COLUMN_INDEX) {
222                columnName = DELETED_COLUMN;
223            } else {
224                columnName = mColumnNames[columnIndex];
225            }
226            return uriMap.get(columnName);
227        }
228        return null;
229    }
230
231    /**
232     * When the underlying cursor changes, we want to alert the listener
233     */
234    private void underlyingChanged() {
235        if (sListener != null) {
236            if (mCursorObserverRegistered) {
237                mUnderlying.unregisterContentObserver(mCursorObserver);
238                mCursorObserverRegistered = false;
239            }
240            sListener.onNewSyncData();
241        }
242    }
243
244    /**
245     * When we get a requery from the UI, we'll do it, but also clear the cache
246     * NOTE: This will have to change, of course, when we start using loaders...
247     */
248    public boolean requery() {
249        super.requery();
250        resetCache();
251        return true;
252    }
253
254    public void close() {
255        // Unregister our observer on the underlying cursor and close as usual
256        mUnderlying.unregisterContentObserver(mCursorObserver);
257        super.close();
258    }
259
260    /**
261     * Move to the next not-deleted item in the conversation
262     */
263    public boolean moveToNext() {
264        while (true) {
265            boolean ret = super.moveToNext();
266            if (!ret) return false;
267            if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue;
268            mPosition++;
269            return true;
270        }
271    }
272
273    /**
274     * Move to the previous not-deleted item in the conversation
275     */
276    public boolean moveToPrevious() {
277        while (true) {
278            boolean ret = super.moveToPrevious();
279            if (!ret) return false;
280            if (getCachedValue(-1) instanceof Integer) continue;
281            mPosition--;
282            return true;
283        }
284    }
285
286    public int getPosition() {
287        return mPosition;
288    }
289
290    /**
291     * The actual cursor's count must be decremented by the number we've deleted from the UI
292     */
293    public int getCount() {
294        return super.getCount() - sDeletedCount;
295    }
296
297    public boolean moveToFirst() {
298        super.moveToPosition(-1);
299        mPosition = -1;
300        return moveToNext();
301    }
302
303    public boolean moveToPosition(int pos) {
304        if (pos == mPosition) return true;
305        if (pos > mPosition) {
306            while (pos > mPosition) {
307                if (!moveToNext()) {
308                    return false;
309                }
310            }
311            return true;
312        } else if (pos == 0) {
313            return moveToFirst();
314        } else {
315            while (pos < mPosition) {
316                if (!moveToPrevious()) {
317                    return false;
318                }
319            }
320            return true;
321        }
322    }
323
324    public boolean moveToLast() {
325        throw new UnsupportedOperationException("moveToLast unsupported!");
326    }
327
328    public boolean move(int offset) {
329        throw new UnsupportedOperationException("move unsupported!");
330    }
331
332    /**
333     * We need to override all of the getters to make sure they look at cached values before using
334     * the values in the underlying cursor
335     */
336    @Override
337    public double getDouble(int columnIndex) {
338        Object obj = getCachedValue(columnIndex);
339        if (obj != null) return (Double)obj;
340        return super.getDouble(columnIndex);
341    }
342
343    @Override
344    public float getFloat(int columnIndex) {
345        Object obj = getCachedValue(columnIndex);
346        if (obj != null) return (Float)obj;
347        return super.getFloat(columnIndex);
348    }
349
350    @Override
351    public int getInt(int columnIndex) {
352        Object obj = getCachedValue(columnIndex);
353        if (obj != null) return (Integer)obj;
354        return super.getInt(columnIndex);
355    }
356
357    @Override
358    public long getLong(int columnIndex) {
359        Object obj = getCachedValue(columnIndex);
360        if (obj != null) return (Long)obj;
361        return super.getLong(columnIndex);
362    }
363
364    @Override
365    public short getShort(int columnIndex) {
366        Object obj = getCachedValue(columnIndex);
367        if (obj != null) return (Short)obj;
368        return super.getShort(columnIndex);
369    }
370
371    @Override
372    public String getString(int columnIndex) {
373        // If we're asking for the Uri for the conversation list, we return a forwarding URI
374        // so that we can intercept update/delete and handle it ourselves
375        if (columnIndex == sUriColumnIndex) {
376            Uri uri = Uri.parse(super.getString(columnIndex));
377            return uriToCachingUriString(uri);
378        }
379        Object obj = getCachedValue(columnIndex);
380        if (obj != null) return (String)obj;
381        return super.getString(columnIndex);
382    }
383
384    @Override
385    public byte[] getBlob(int columnIndex) {
386        Object obj = getCachedValue(columnIndex);
387        if (obj != null) return (byte[])obj;
388        return super.getBlob(columnIndex);
389    }
390
391    /**
392     * Observer of changes to underlying data
393     */
394    private class CursorObserver extends ContentObserver {
395        public CursorObserver() {
396            super(new Handler());
397        }
398
399        @Override
400        public void onChange(boolean selfChange) {
401            // If we're here, then something outside of the UI has changed the data, and we
402            // must requery to get that data from the underlying provider
403            if (DEBUG) {
404                Log.d(TAG, "Underlying conversation cursor changed; requerying");
405            }
406            // It's not at all obvious to me why we must unregister/re-register after the requery
407            // However, if we don't we'll only get one notification and no more...
408            ConversationCursor.this.underlyingChanged();
409        }
410    }
411
412    /**
413     * ConversationProvider is the ContentProvider for our forwarding Uri's; it passes queries
414     * and inserts directly, and caches updates/deletes before passing them through.  The caching
415     * will cause a redraw of the list with updated values.
416     */
417    public static class ConversationProvider extends ContentProvider {
418        @Override
419        public boolean onCreate() {
420            return false;
421        }
422
423        @Override
424        public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
425                String sortOrder) {
426            return mResolver.query(
427                    uriFromCachingUri(uri), projection, selection, selectionArgs, sortOrder);
428        }
429
430        @Override
431        public String getType(Uri uri) {
432            return null;
433        }
434
435        /**
436         * Quick and dirty class that executes underlying provider CRUD operations on a background
437         * thread.
438         */
439        static class ProviderExecute implements Runnable {
440            static final int DELETE = 0;
441            static final int INSERT = 1;
442            static final int UPDATE = 2;
443
444            final int mCode;
445            final Uri mUri;
446            final ContentValues mValues; //HEHEH
447
448            ProviderExecute(int code, Uri uri, ContentValues values) {
449                mCode = code;
450                mUri = uriFromCachingUri(uri);
451                mValues = values;
452            }
453
454            ProviderExecute(int code, Uri uri) {
455                this(code, uri, null);
456            }
457
458            static void opDelete(Uri uri) {
459                new Thread(new ProviderExecute(DELETE, uri)).start();
460            }
461
462            static void opUpdate(Uri uri, ContentValues values) {
463                new Thread(new ProviderExecute(UPDATE, uri, values)).start();
464            }
465
466            @Override
467            public void run() {
468                switch(mCode) {
469                    case DELETE:
470                        mResolver.delete(mUri, null, null);
471                        break;
472                    case INSERT:
473                        mResolver.insert(mUri, mValues);
474                        break;
475                    case UPDATE:
476                        mResolver.update(mUri,  mValues, null, null);
477                        break;
478                }
479            }
480        }
481
482        // Synchronous for now; we'll revisit all this in a later design review
483        @Override
484        public Uri insert(Uri uri, ContentValues values) {
485            return mResolver.insert(uri, values);
486        }
487
488        @Override
489        public int delete(Uri uri, String selection, String[] selectionArgs) {
490            Uri underlyingUri = uriFromCachingUri(uri);
491            String uriString = underlyingUri.toString();
492            cacheValue(uriString, DELETED_COLUMN, true);
493            ProviderExecute.opDelete(uri);
494            return 0;
495        }
496
497        @Override
498        public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
499            Uri underlyingUri = uriFromCachingUri(uri);
500            // Remember to decode the underlying Uri as it might be encoded (as w/ Gmail)
501            String uriString =  Uri.decode(underlyingUri.toString());
502            for (String columnName: values.keySet()) {
503                cacheValue(uriString, columnName, values.get(columnName));
504            }
505            ProviderExecute.opUpdate(uri, values);
506            return 0;
507        }
508    }
509
510    /**
511     * For now, a single listener can be associated with the cursor, and for now we'll just
512     * notify on deletions
513     */
514    public interface ConversationListener {
515        // The UI has deleted items at the positions referenced in the array
516        public void onDeletedItems(ArrayList<Integer> positions);
517        // We've received new data from a sync (i.e. outside the UI)
518        public void onNewSyncData();
519    }
520}
521