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