Prototype ConversationCursor/ConversationProvider

* Handles updates/deletes from the list instantly, then forwards the request
  to the underlying provider
* Only starred/delete are implemented currently for Email types; the Gmail
  provider doesn't yet support updates to UIProvider uri's
  Note: Email types support read there's no read/unread support in the prototype
  UI
* Updated UIProvider/MockUiProvider with latest adds to Conversation class
* Underlying provider must notify ConversationCursor of changes via newly
  defined notifier URI

TODO: ConversationCursor wants unit tests

Change-Id: I91babcd5c27109acaa1f7479d584524e8a508a56
diff --git a/src/com/android/mail/browse/ConversationCursor.java b/src/com/android/mail/browse/ConversationCursor.java
new file mode 100644
index 0000000..f793b26
--- /dev/null
+++ b/src/com/android/mail/browse/ConversationCursor.java
@@ -0,0 +1,472 @@
+/*******************************************************************************
+ *      Copyright (C) 2012 Google Inc.
+ *      Licensed to The Android Open Source Project.
+ *
+ *      Licensed under the Apache License, Version 2.0 (the "License");
+ *      you may not use this file except in compliance with the License.
+ *      You may obtain a copy of the License at
+ *
+ *           http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *      Unless required by applicable law or agreed to in writing, software
+ *      distributed under the License is distributed on an "AS IS" BASIS,
+ *      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *      See the License for the specific language governing permissions and
+ *      limitations under the License.
+ *******************************************************************************/
+
+package com.android.mail.browse;
+
+import android.content.ContentProvider;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.CursorWrapper;
+import android.net.Uri;
+import android.os.Handler;
+import android.util.Log;
+import android.widget.CursorAdapter;
+
+import java.util.HashMap;
+import java.util.List;
+
+/**
+ * ConversationCursor is a wrapper around a conversation list cursor that provides update/delete
+ * caching for quick UI response. This is effectively a singleton class, as the cache is
+ * implemented as a static HashMap.
+ */
+public class ConversationCursor extends CursorWrapper {
+    private static final String TAG = "ConversationCursor";
+    private static final boolean DEBUG = true;  // STOPSHIP Set to false before shipping
+
+    // The authority of our conversation provider (a forwarding provider)
+    // This string must match the declaration in AndroidManifest.xml
+    private static final String sAuthority = "com.android.mail.conversation.provider";
+
+    // A mapping from Uri to updated ContentValues
+    private static HashMap<String, ContentValues> sCacheMap = new HashMap<String, ContentValues>();
+    // A deleted row is indicated by the presence of DELETED_COLUMN in the cache map
+    private static final String DELETED_COLUMN = "__deleted__";
+    // A sentinel value for the "index" of the deleted column; it's an int that is otherwise invalid
+    private static final int DELETED_COLUMN_INDEX = -1;
+
+    // The cursor underlying the caching cursor
+    private final Cursor mUnderlying;
+    // Column names for this cursor
+    private final String[] mColumnNames;
+    // The index of the Uri whose data is reflected in the cached row
+    // Updates/Deletes to this Uri are cached
+    private final int mUriColumnIndex;
+    // The resolver for the cursor instantiator's context
+    private static ContentResolver mResolver;
+    // An observer on the underlying cursor (so we can detect changes from outside the UI)
+    private final CursorObserver mCursorObserver;
+    // The adapter using this cursor (which needs to refresh when data changes)
+    private static CursorAdapter mAdapter;
+
+    // The current position of the cursor
+    private int mPosition = -1;
+    // The number of cached deletions from this cursor (used to quickly generate an accurate count)
+    private static int sDeletedCount = 0;
+
+    public ConversationCursor(Cursor cursor, Context context, String messageListColumn) {
+        super(cursor);
+        mUnderlying = cursor;
+        mCursorObserver = new CursorObserver();
+        // New cursor -> clear the cache
+        resetCache();
+        mColumnNames = cursor.getColumnNames();
+        mUriColumnIndex = getColumnIndex(messageListColumn);
+        if (mUriColumnIndex < 0) {
+            throw new IllegalArgumentException("Cursor must include a message list column");
+        }
+        mResolver = context.getContentResolver();
+        // We'll observe the underlying cursor and act when it changes
+        //cursor.registerContentObserver(mCursorObserver);
+    }
+
+    /**
+     * Reset the cache; this involves clearing out our cache map and resetting our various counts
+     * The cache should be reset whenever we get fresh data from the underlying cursor
+     */
+    private void resetCache() {
+        sCacheMap.clear();
+        sDeletedCount = 0;
+        mPosition = -1;
+        mUnderlying.registerContentObserver(mCursorObserver);
+    }
+
+    /**
+     * Set the adapter for this cursor; we'll notify it when our data changes
+     */
+    public void setAdapter(CursorAdapter adapter) {
+        mAdapter = adapter;
+    }
+
+    /**
+     * Generate a forwarding Uri to ConversationProvider from an original Uri.  We do this by
+     * changing the authority to ours, but otherwise leaving the Uri intact.
+     * NOTE: This won't handle query parameters, so the functionality will need to be added if
+     * parameters are used in the future
+     * @param uri the uri
+     * @return a forwarding uri to ConversationProvider
+     */
+    private static String uriToCachingUriString (Uri uri) {
+        String provider = uri.getAuthority();
+        return uri.getScheme() + "://" + sAuthority + "/" + provider + uri.getPath();
+    }
+
+    /**
+     * Regenerate the original Uri from a forwarding (ConversationProvider) Uri
+     * NOTE: See note above for uriToCachingUri
+     * @param uri the forwarding Uri
+     * @return the original Uri
+     */
+    private static Uri uriFromCachingUri(Uri uri) {
+        List<String> path = uri.getPathSegments();
+        Uri.Builder builder = new Uri.Builder().scheme(uri.getScheme()).authority(path.get(0));
+        for (int i = 1; i < path.size(); i++) {
+            builder.appendPath(path.get(i));
+        }
+        return builder.build();
+    }
+
+    /**
+     * Cache a column name/value pair for a given Uri
+     * @param uriString the Uri for which the column name/value pair applies
+     * @param columnName the column name
+     * @param value the value to be cached
+     */
+    private static void cacheValue(String uriString, String columnName, Object value) {
+        // Get the map for our uri
+        ContentValues map = sCacheMap.get(uriString);
+        // Create one if necessary
+        if (map == null) {
+            map = new ContentValues();
+            sCacheMap.put(uriString, map);
+        }
+        // If we're caching a deletion, add to our count
+        if ((columnName == DELETED_COLUMN) && (map.get(columnName) == null)) {
+            sDeletedCount++;
+            if (DEBUG) {
+                Log.d(TAG, "Deleted " + uriString);
+            }
+        }
+        // ContentValues has no generic "put", so we must test.  For now, the only classes of
+        // values implemented are Boolean/Integer/String, though others are trivially added
+        if (value instanceof Boolean) {
+            map.put(columnName, ((Boolean)value).booleanValue() ? 1 : 0);
+        } else if (value instanceof Integer) {
+            map.put(columnName, (Integer)value);
+        } else if (value instanceof String) {
+            map.put(columnName, (String)value);
+        } else {
+            String cname = value.getClass().getName();
+            throw new IllegalArgumentException("Value class not compatible with cache: " + cname);
+        }
+
+        // Since we've changed the data, alert the adapter to redraw
+        mAdapter.notifyDataSetChanged();
+        if (DEBUG && (columnName != DELETED_COLUMN)) {
+            Log.d(TAG, "Caching value for " + uriString + ": " + columnName);
+        }
+    }
+
+    /**
+     * Get the cached value for the provided column; we special case -1 as the "deleted" column
+     * @param columnIndex the index of the column whose cached value we want to retrieve
+     * @return the cached value for this column, or null if there is none
+     */
+    private Object getCachedValue(int columnIndex) {
+        String uri = super.getString(mUriColumnIndex);
+        ContentValues uriMap = sCacheMap.get(uri);
+        if (uriMap != null) {
+            String columnName;
+            if (columnIndex == DELETED_COLUMN_INDEX) {
+                columnName = DELETED_COLUMN;
+            } else {
+                columnName = mColumnNames[columnIndex];
+            }
+            return uriMap.get(columnName);
+        }
+        return null;
+    }
+
+    /**
+     * When the underlying cursor changes, we want to force a requery to get the new provider data;
+     * the cache must also be reset here since it's no longer fresh
+     */
+    private void underlyingChanged() {
+        super.requery();
+        resetCache();
+    }
+
+    // We don't want to do anything when we get a requery, as our data is updated immediately from
+    // the UI and we detect changes on the underlying provider above
+    public boolean requery() {
+        return true;
+    }
+
+    public void close() {
+        // Unregister our observer on the underlying cursor and close as usual
+        mUnderlying.unregisterContentObserver(mCursorObserver);
+        super.close();
+    }
+
+    /**
+     * Move to the next not-deleted item in the conversation
+     */
+    public boolean moveToNext() {
+        while (true) {
+            boolean ret = super.moveToNext();
+            if (!ret) return false;
+            if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue;
+            mPosition++;
+            return true;
+        }
+    }
+
+    /**
+     * Move to the previous not-deleted item in the conversation
+     */
+    public boolean moveToPrevious() {
+        while (true) {
+            boolean ret = super.moveToPrevious();
+            if (!ret) return false;
+            if (getCachedValue(-1) instanceof Integer) continue;
+            mPosition--;
+            return true;
+        }
+    }
+
+    public int getPosition() {
+        return mPosition;
+    }
+
+    /**
+     * The actual cursor's count must be decremented by the number we've deleted from the UI
+     */
+    public int getCount() {
+        return super.getCount() - sDeletedCount;
+    }
+
+    public boolean moveToFirst() {
+        super.moveToPosition(-1);
+        mPosition = -1;
+        return moveToNext();
+    }
+
+    public boolean moveToPosition(int pos) {
+        if (pos == mPosition) return true;
+        if (pos > mPosition) {
+            while (pos > mPosition) {
+                if (!moveToNext()) {
+                    return false;
+                }
+            }
+            return true;
+        } else if (pos == 0) {
+            return moveToFirst();
+        } else {
+            while (pos < mPosition) {
+                if (!moveToPrevious()) {
+                    return false;
+                }
+            }
+            return true;
+        }
+    }
+
+    public boolean moveToLast() {
+        throw new UnsupportedOperationException("moveToLast unsupported!");
+    }
+
+    public boolean move(int offset) {
+        throw new UnsupportedOperationException("move unsupported!");
+    }
+
+    /**
+     * We need to override all of the getters to make sure they look at cached values before using
+     * the values in the underlying cursor
+     */
+    @Override
+    public double getDouble(int columnIndex) {
+        Object obj = getCachedValue(columnIndex);
+        if (obj != null) return (Double)obj;
+        return super.getDouble(columnIndex);
+    }
+
+    @Override
+    public float getFloat(int columnIndex) {
+        Object obj = getCachedValue(columnIndex);
+        if (obj != null) return (Float)obj;
+        return super.getFloat(columnIndex);
+    }
+
+    @Override
+    public int getInt(int columnIndex) {
+        Object obj = getCachedValue(columnIndex);
+        if (obj != null) return (Integer)obj;
+        return super.getInt(columnIndex);
+    }
+
+    @Override
+    public long getLong(int columnIndex) {
+        Object obj = getCachedValue(columnIndex);
+        if (obj != null) return (Long)obj;
+        return super.getLong(columnIndex);
+    }
+
+    @Override
+    public short getShort(int columnIndex) {
+        Object obj = getCachedValue(columnIndex);
+        if (obj != null) return (Short)obj;
+        return super.getShort(columnIndex);
+    }
+
+    @Override
+    public String getString(int columnIndex) {
+        // If we're asking for the Uri for the conversation list, we return a forwarding URI
+        // so that we can intercept update/delete and handle it ourselves
+        if (columnIndex == mUriColumnIndex) {
+            Uri uri = Uri.parse(super.getString(columnIndex));
+            return uriToCachingUriString(uri);
+        }
+        Object obj = getCachedValue(columnIndex);
+        if (obj != null) return (String)obj;
+        return super.getString(columnIndex);
+    }
+
+    @Override
+    public byte[] getBlob(int columnIndex) {
+        Object obj = getCachedValue(columnIndex);
+        if (obj != null) return (byte[])obj;
+        return super.getBlob(columnIndex);
+    }
+
+    /**
+     * Observer of changes to underlying data
+     */
+    private class CursorObserver extends ContentObserver {
+        public CursorObserver() {
+            super(new Handler());
+        }
+
+        @Override
+        public void onChange(boolean selfChange) {
+            // If we're here, then something outside of the UI has changed the data, and we
+            // must requery to get that data from the underlying provider
+            if (DEBUG) {
+                Log.d(TAG, "Underlying conversation cursor changed; requerying");
+            }
+            // It's not at all obvious to me why we must unregister/re-register after the requery
+            // However, if we don't we'll only get one notification and no more...
+            mUnderlying.unregisterContentObserver(mCursorObserver);
+            ConversationCursor.this.underlyingChanged();
+        }
+    }
+
+    /**
+     * ConversationProvider is the ContentProvider for our forwarding Uri's; it passes queries
+     * and inserts directly, and caches updates/deletes before passing them through.  The caching
+     * will cause a redraw of the list with updated values.
+     */
+    public static class ConversationProvider extends ContentProvider {
+        @Override
+        public boolean onCreate() {
+            return false;
+        }
+
+        @Override
+        public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+                String sortOrder) {
+            return mResolver.query(
+                    uriFromCachingUri(uri), projection, selection, selectionArgs, sortOrder);
+        }
+
+        @Override
+        public String getType(Uri uri) {
+            return null;
+        }
+
+        /**
+         * Quick and dirty class that executes underlying provider CRUD operations on a background
+         * thread.
+         */
+        static class ProviderExecute implements Runnable {
+            static final int DELETE = 0;
+            static final int INSERT = 1;
+            static final int UPDATE = 2;
+
+            final int mCode;
+            final Uri mUri;
+            final ContentValues mValues; //HEHEH
+
+            ProviderExecute(int code, Uri uri, ContentValues values) {
+                mCode = code;
+                mUri = uriFromCachingUri(uri);
+                mValues = values;
+            }
+
+            ProviderExecute(int code, Uri uri) {
+                this(code, uri, null);
+            }
+
+            static void opDelete(Uri uri) {
+                new Thread(new ProviderExecute(DELETE, uri)).start();
+            }
+
+            static void opInsert(Uri uri, ContentValues values) {
+                new Thread(new ProviderExecute(INSERT, uri, values)).start();
+            }
+
+            static void opUpdate(Uri uri, ContentValues values) {
+                new Thread(new ProviderExecute(UPDATE, uri, values)).start();
+            }
+
+            @Override
+            public void run() {
+                switch(mCode) {
+                    case DELETE:
+                        mResolver.delete(mUri, null, null);
+                        break;
+                    case INSERT:
+                        mResolver.insert(mUri, mValues);
+                        break;
+                    case UPDATE:
+                        mResolver.update(mUri,  mValues, null, null);
+                        break;
+                }
+            }
+        }
+
+        @Override
+        public Uri insert(Uri uri, ContentValues values) {
+            ProviderExecute.opInsert(uri, values);
+            return null;
+        }
+
+        @Override
+        public int delete(Uri uri, String selection, String[] selectionArgs) {
+            Uri underlyingUri = uriFromCachingUri(uri);
+            String uriString = underlyingUri.toString();
+            cacheValue(uriString, DELETED_COLUMN, true);
+            ProviderExecute.opDelete(uri);
+            return 0;
+        }
+
+        @Override
+        public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+            Uri underlyingUri = uriFromCachingUri(uri);
+            // Remember to decode the underlying Uri as it might be encoded (as w/ Gmail)
+            String uriString =  Uri.decode(underlyingUri.toString());
+            for (String columnName: values.keySet()) {
+                cacheValue(uriString, columnName, values.get(columnName));
+            }
+            ProviderExecute.opUpdate(uri, values);
+            return 0;
+        }
+    }
+}