blob: 6638185f30e034eb9994db93b1875d00fb17d938 [file] [log] [blame]
/*******************************************************************************
* 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.app.Activity;
import android.content.ContentProvider;
import android.content.ContentProviderOperation;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.OperationApplicationException;
import android.database.CharArrayBuffer;
import android.database.ContentObserver;
import android.database.Cursor;
import android.database.DataSetObserver;
import android.net.Uri;
import android.os.Bundle;
import android.os.Looper;
import android.os.RemoteException;
import android.util.Log;
import com.android.mail.providers.Conversation;
import com.android.mail.providers.UIProvider;
import com.android.mail.providers.UIProvider.ConversationOperations;
import com.google.common.annotations.VisibleForTesting;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
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 final class ConversationCursor implements Cursor {
private static final String TAG = "ConversationCursor";
private static final boolean DEBUG = true; // STOPSHIP Set to false before shipping
// The cursor instantiator's activity
private static Activity sActivity;
// The cursor underlying the caching cursor
@VisibleForTesting
static Cursor sUnderlyingCursor;
// The new cursor obtained via a requery
private static Cursor sRequeryCursor;
// A mapping from Uri to updated ContentValues
private static HashMap<String, ContentValues> sCacheMap = new HashMap<String, ContentValues>();
// Cache map lock (will be used only very briefly - few ms at most)
private static Object sCacheMapLock = new Object();
// A deleted row is indicated by the presence of DELETED_COLUMN in the cache map
private static final String DELETED_COLUMN = "__deleted__";
// An row cached during a requery is indicated by the presence of REQUERY_COLUMN in the map
private static final String REQUERY_COLUMN = "__requery__";
// 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 current conversation cursor
private static ConversationCursor sConversationCursor;
// The index of the Uri whose data is reflected in the cached row
// Updates/Deletes to this Uri are cached
private static int sUriColumnIndex;
// The listeners registered for this cursor
private static ArrayList<ConversationListener> sListeners =
new ArrayList<ConversationListener>();
// The ConversationProvider instance
@VisibleForTesting
static ConversationProvider sProvider;
// Set when we're in the middle of a refresh of the underlying cursor
private static boolean sRefreshInProgress = false;
// Set when we've sent refreshReady() to listeners
private static boolean sRefreshReady = false;
// Set when we've sent refreshRequired() to listeners
private static boolean sRefreshRequired = false;
// Our sequence count (for changes sent to underlying provider)
private static int sSequence = 0;
// Column names for this cursor
private final String[] mColumnNames;
// 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;
// Whether our observer is currently registered with the underlying cursor
private boolean mCursorObserverRegistered = false;
// 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;
// Parameters passed to the underlying query
private static Uri qUri;
private static String[] qProjection;
private static String qSelection;
private static String[] qSelectionArgs;
private static String qSortOrder;
private ConversationCursor(Cursor cursor, Activity activity, String messageListColumn) {
sActivity = activity;
mResolver = activity.getContentResolver();
sConversationCursor = this;
sUnderlyingCursor = cursor;
sListeners.clear();
mCursorObserver = new CursorObserver();
resetCursor(null);
mColumnNames = cursor.getColumnNames();
sUriColumnIndex = cursor.getColumnIndex(messageListColumn);
if (sUriColumnIndex < 0) {
throw new IllegalArgumentException("Cursor must include a message list column");
}
}
/**
* Create a ConversationCursor; this should be called by the ListActivity using that cursor
* @param activity the activity creating the cursor
* @param messageListColumn the column used for individual cursor items
* @param uri the query uri
* @param projection the query projecion
* @param selection the query selection
* @param selectionArgs the query selection args
* @param sortOrder the query sort order
* @return a ConversationCursor
*/
public static ConversationCursor create(Activity activity, String messageListColumn, Uri uri,
String[] projection, String selection, String[] selectionArgs, String sortOrder) {
qUri = uri;
qProjection = projection;
qSelection = selection;
qSelectionArgs = selectionArgs;
qSortOrder = sortOrder;
Cursor cursor = activity.getContentResolver().query(uri, projection, selection,
selectionArgs, sortOrder);
return new ConversationCursor(cursor, activity, messageListColumn);
}
/**
* Return whether the uri string (message list uri) is in the underlying cursor
* @param uriString the uri string we're looking for
* @return true if the uri string is in the cursor; false otherwise
*/
private boolean isInUnderlyingCursor(String uriString) {
sUnderlyingCursor.moveToPosition(-1);
while (sUnderlyingCursor.moveToNext()) {
if (uriString.equals(sUnderlyingCursor.getString(sUriColumnIndex))) {
return true;
}
}
return false;
}
/**
* Reset the cursor; this involves clearing out our cache map and resetting our various counts
* The cursor should be reset whenever we get fresh data from the underlying cursor. The cache
* is locked during the reset, which will block the UI, but for only a very short time
* (estimated at a few ms, but we can profile this; remember that the cache will usually
* be empty or have a few entries)
*/
private void resetCursor(Cursor newCursor) {
// Temporary, log time for reset
long startTime = System.currentTimeMillis();
if (DEBUG) {
Log.d(TAG, "[--resetCursor--]");
}
synchronized (sCacheMapLock) {
// Walk through the cache. Here are the cases:
// 1) The entry isn't marked with REQUERY - remove it from the cache. If DELETED is
// set, decrement the deleted count
// 2) The REQUERY entry is still in the UP
// 2a) The REQUERY entry isn't DELETED; we're good, and the client change will remain
// (i.e. client wins, it's on its way to the UP)
// 2b) The REQUERY entry is DELETED; we're good (client change remains, it's on
// its way to the UP)
// 3) the REQUERY was deleted on the server (sheesh; this would be bizarre timing!) -
// we need to throw the item out of the cache
// So ... the only interesting case is #3, we need to look for remaining deleted items
// and see if they're still in the UP
Iterator<HashMap.Entry<String, ContentValues>> iter = sCacheMap.entrySet().iterator();
while (iter.hasNext()) {
HashMap.Entry<String, ContentValues> entry = iter.next();
ContentValues values = entry.getValue();
if (values.containsKey(REQUERY_COLUMN) && isInUnderlyingCursor(entry.getKey())) {
// If we're in a requery and we're still around, remove the requery key
// We're good here, the cached change (delete/update) is on its way to UP
values.remove(REQUERY_COLUMN);
} else {
// Keep the deleted count up-to-date; remove the cache entry
if (values.containsKey(DELETED_COLUMN)) {
sDeletedCount--;
}
// Remove the entry
iter.remove();
}
}
// Swap cursor
if (newCursor != null) {
close();
sUnderlyingCursor = newCursor;
}
mPosition = -1;
sUnderlyingCursor.moveToPosition(mPosition);
if (!mCursorObserverRegistered) {
sUnderlyingCursor.registerContentObserver(mCursorObserver);
mCursorObserverRegistered = true;
}
sRefreshRequired = false;
}
Log.d(TAG, "resetCache time: " + ((System.currentTimeMillis() - startTime)) + "ms");
}
/**
* Add a listener for this cursor; we'll notify it when our data changes
*/
public void addListener(ConversationListener listener) {
synchronized (sListeners) {
sListeners.add(listener);
}
}
/**
* Remove a listener for this cursor
*/
public void removeListener(ConversationListener listener) {
synchronized(sListeners) {
sListeners.remove(listener);
}
}
/**
* 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() + "://" + ConversationProvider.AUTHORITY
+ "/" + 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) {
synchronized (sCacheMapLock) {
// 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);
}
if (sRefreshInProgress) {
map.put(REQUERY_COLUMN, 1);
}
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 = sUnderlyingCursor.getString(sUriColumnIndex);
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 alert the listener
*/
private void underlyingChanged() {
if (mCursorObserverRegistered) {
try {
sUnderlyingCursor.unregisterContentObserver(mCursorObserver);
} catch (IllegalStateException e) {
// Maybe the cursor was GC'd?
}
mCursorObserverRegistered = false;
}
if (DEBUG) {
Log.d(TAG, "[Notify: onRefreshRequired()]");
}
synchronized(sListeners) {
for (ConversationListener listener: sListeners) {
listener.onRefreshRequired();
}
}
sRefreshRequired = true;
}
/**
* Put the refreshed cursor in place (called by the UI)
*/
// NOTE: We don't like the name (it implies syncing with the server); suggestions gladly
// taken - reset? syncToUnderlying? completeRefresh? align?
public void sync() {
if (DEBUG) {
Log.d(TAG, "[sync() called]");
}
if (sRequeryCursor == null) {
throw new IllegalStateException("Can't swap cursors; no requery done");
}
resetCursor(sRequeryCursor);
sRequeryCursor = null;
sRefreshInProgress = false;
sRefreshReady = false;
}
public boolean isRefreshRequired() {
return sRefreshRequired;
}
public boolean isRefreshReady() {
return sRefreshReady;
}
/**
* Cancel a refresh in progress
*/
public void cancelRefresh() {
if (DEBUG) {
Log.d(TAG, "[cancelRefresh() called]");
}
synchronized(sCacheMapLock) {
// Mark the requery closed
sRefreshInProgress = false;
// If we have the cursor, close it; otherwise, it will get closed when the query
// finishes (it checks sRequeryInProgress)
if (sRequeryCursor != null) {
sRequeryCursor.close();
sRequeryCursor = null;
}
}
}
/**
* Get a list of deletions from ConversationCursor to the refreshed cursor that hasn't yet
* been swapped into place; this allows the UI to animate these away if desired
* @return a list of positions deleted in ConversationCursor
*/
public ArrayList<Integer> getRefreshDeletions () {
if (DEBUG) {
Log.d(TAG, "[getRefreshDeletions() called]");
}
Cursor deviceCursor = sConversationCursor;
Cursor serverCursor = sRequeryCursor;
// TODO: (mindyp) saw some instability here. Adding an assert to try to
// catch it.
assert(sRequeryCursor != null);
ArrayList<Integer> deleteList = new ArrayList<Integer>();
int serverCount = serverCursor.getCount();
int deviceCount = deviceCursor.getCount();
deviceCursor.moveToFirst();
serverCursor.moveToFirst();
while (serverCount > 0 || deviceCount > 0) {
if (serverCount == 0) {
for (; deviceCount > 0; deviceCount--)
deleteList.add(deviceCursor.getPosition());
break;
} else if (deviceCount == 0) {
break;
}
long deviceMs = deviceCursor.getLong(UIProvider.CONVERSATION_DATE_RECEIVED_MS_COLUMN);
long serverMs = serverCursor.getLong(UIProvider.CONVERSATION_DATE_RECEIVED_MS_COLUMN);
String deviceUri = deviceCursor.getString(UIProvider.CONVERSATION_URI_COLUMN);
String serverUri = serverCursor.getString(UIProvider.CONVERSATION_URI_COLUMN);
deviceCursor.moveToNext();
serverCursor.moveToNext();
serverCount--;
deviceCount--;
if (serverMs == deviceMs) {
// Check for duplicates here; if our identical dates refer to different messages,
// we'll just quit here for now (at worst, this will cause a non-animating delete)
// My guess is that this happens VERY rarely, if at all
if (!deviceUri.equals(serverUri)) {
// To do this right, we'd find all of the rows with the same ms (date), etc...
//return deleteList;
}
continue;
} else if (deviceMs > serverMs) {
deleteList.add(deviceCursor.getPosition() - 1);
// Move back because we've already advanced cursor (that's why we subtract 1 above)
serverCount++;
serverCursor.moveToPrevious();
} else if (serverMs > deviceMs) {
// If we wanted to track insertions, we'd so so here
// Move back because we've already advanced cursor
deviceCount++;
deviceCursor.moveToPrevious();
}
}
Log.d(TAG, "Deletions: " + deleteList);
return deleteList;
}
/**
* When we get a requery from the UI, we'll do it, but also clear the cache. The listener is
* notified when the requery is complete
* NOTE: This will have to change, of course, when we start using loaders...
*/
public boolean refresh() {
if (DEBUG) {
Log.d(TAG, "[refresh() called]");
}
if (sRefreshInProgress) {
return false;
}
// Say we're starting a requery
sRefreshInProgress = true;
new Thread(new Runnable() {
@Override
public void run() {
// Get new data
sRequeryCursor =
mResolver.query(qUri, qProjection, qSelection, qSelectionArgs, qSortOrder);
// Make sure window is full
synchronized(sCacheMapLock) {
if (sRefreshInProgress) {
sRequeryCursor.getCount();
sActivity.runOnUiThread(new Runnable() {
@Override
public void run() {
if (DEBUG) {
Log.d(TAG, "[Notify: onRefreshReady()]");
}
synchronized (sListeners) {
for (ConversationListener listener : sListeners) {
listener.onRefreshReady();
}
}
sRefreshReady = true;
}});
} else {
cancelRefresh();
}
}
}
}).start();
return true;
}
@Override
public void close() {
if (!sUnderlyingCursor.isClosed()) {
// Unregister our observer on the underlying cursor and close as usual
if (mCursorObserverRegistered) {
try {
sUnderlyingCursor.unregisterContentObserver(mCursorObserver);
} catch (IllegalStateException e) {
// Maybe the cursor got GC'd?
}
mCursorObserverRegistered = false;
}
sUnderlyingCursor.close();
}
}
/**
* Move to the next not-deleted item in the conversation
*/
@Override
public boolean moveToNext() {
while (true) {
boolean ret = sUnderlyingCursor.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
*/
@Override
public boolean moveToPrevious() {
while (true) {
boolean ret = sUnderlyingCursor.moveToPrevious();
if (!ret) return false;
if (getCachedValue(-1) instanceof Integer) continue;
mPosition--;
return true;
}
}
@Override
public int getPosition() {
return mPosition;
}
/**
* The actual cursor's count must be decremented by the number we've deleted from the UI
*/
@Override
public int getCount() {
return sUnderlyingCursor.getCount() - sDeletedCount;
}
@Override
public boolean moveToFirst() {
sUnderlyingCursor.moveToPosition(-1);
mPosition = -1;
return moveToNext();
}
@Override
public boolean moveToPosition(int pos) {
if (pos < -1 || pos >= getCount()) return false;
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;
}
}
@Override
public boolean moveToLast() {
throw new UnsupportedOperationException("moveToLast unsupported!");
}
@Override
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 sUnderlyingCursor.getDouble(columnIndex);
}
@Override
public float getFloat(int columnIndex) {
Object obj = getCachedValue(columnIndex);
if (obj != null) return (Float)obj;
return sUnderlyingCursor.getFloat(columnIndex);
}
@Override
public int getInt(int columnIndex) {
Object obj = getCachedValue(columnIndex);
if (obj != null) return (Integer)obj;
return sUnderlyingCursor.getInt(columnIndex);
}
@Override
public long getLong(int columnIndex) {
Object obj = getCachedValue(columnIndex);
if (obj != null) return (Long)obj;
return sUnderlyingCursor.getLong(columnIndex);
}
@Override
public short getShort(int columnIndex) {
Object obj = getCachedValue(columnIndex);
if (obj != null) return (Short)obj;
return sUnderlyingCursor.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 == sUriColumnIndex) {
Uri uri = Uri.parse(sUnderlyingCursor.getString(columnIndex));
return uriToCachingUriString(uri);
}
Object obj = getCachedValue(columnIndex);
if (obj != null) return (String)obj;
return sUnderlyingCursor.getString(columnIndex);
}
@Override
public byte[] getBlob(int columnIndex) {
Object obj = getCachedValue(columnIndex);
if (obj != null) return (byte[])obj;
return sUnderlyingCursor.getBlob(columnIndex);
}
/**
* Observer of changes to underlying data
*/
private class CursorObserver extends ContentObserver {
public CursorObserver() {
super(null);
}
@Override
public void onChange(boolean selfChange) {
// If we're here, then something outside of the UI has changed the data, and we
// must query the underlying provider for that data
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...
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 abstract static class ConversationProvider extends ContentProvider {
public static String AUTHORITY;
/**
* Allows the implmenting provider to specify the authority that should be used.
*/
protected abstract String getAuthority();
@Override
public boolean onCreate() {
sProvider = this;
AUTHORITY = getAuthority();
return true;
}
@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 Uri insert(Uri uri, ContentValues values) {
insertLocal(uri, values);
return ProviderExecute.opInsert(uri, values);
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
updateLocal(uri, values);
return ProviderExecute.opUpdate(uri, values);
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
deleteLocal(uri);
return ProviderExecute.opDelete(uri);
}
@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 Uri opInsert(Uri uri, ContentValues values) {
ProviderExecute e = new ProviderExecute(INSERT, uri, values);
if (offUiThread()) return (Uri)e.go();
new Thread(e).start();
return null;
}
static int opDelete(Uri uri) {
ProviderExecute e = new ProviderExecute(DELETE, uri);
if (offUiThread()) return (Integer)e.go();
new Thread(new ProviderExecute(DELETE, uri)).start();
return 0;
}
static int opUpdate(Uri uri, ContentValues values) {
ProviderExecute e = new ProviderExecute(UPDATE, uri, values);
if (offUiThread()) return (Integer)e.go();
new Thread(e).start();
return 0;
}
@Override
public void run() {
go();
}
public Object go() {
switch(mCode) {
case DELETE:
return mResolver.delete(mUri, null, null);
case INSERT:
return mResolver.insert(mUri, mValues);
case UPDATE:
return mResolver.update(mUri, mValues, null, null);
default:
return null;
}
}
}
private void insertLocal(Uri uri, ContentValues values) {
// Placeholder for now; there's no local insert
}
@VisibleForTesting
void deleteLocal(Uri uri) {
Uri underlyingUri = uriFromCachingUri(uri);
// Remember to decode the underlying Uri as it might be encoded (as w/ Gmail)
String uriString = Uri.decode(underlyingUri.toString());
cacheValue(uriString, DELETED_COLUMN, true);
}
@VisibleForTesting
void updateLocal(Uri uri, ContentValues values) {
if (values == null) {
return;
}
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));
}
}
static boolean offUiThread() {
return Looper.getMainLooper().getThread() != Thread.currentThread();
}
public int apply(ArrayList<ConversationOperation> ops) {
final HashMap<String, ArrayList<ContentProviderOperation>> batchMap =
new HashMap<String, ArrayList<ContentProviderOperation>>();
// Increment sequence count
sSequence++;
// Execute locally and build CPO's for underlying provider
for (ConversationOperation op: ops) {
Uri underlyingUri = uriFromCachingUri(op.mUri);
String authority = underlyingUri.getAuthority();
ArrayList<ContentProviderOperation> authOps = batchMap.get(authority);
if (authOps == null) {
authOps = new ArrayList<ContentProviderOperation>();
batchMap.put(authority, authOps);
}
authOps.add(op.execute(underlyingUri));
}
// Send changes to underlying provider
for (String authority: batchMap.keySet()) {
try {
if (offUiThread()) {
mResolver.applyBatch(authority, batchMap.get(authority));
} else {
final String auth = authority;
new Thread(new Runnable() {
@Override
public void run() {
try {
mResolver.applyBatch(auth, batchMap.get(auth));
} catch (RemoteException e) {
} catch (OperationApplicationException e) {
}
}
}).start();
}
} catch (RemoteException e) {
} catch (OperationApplicationException e) {
}
}
return sSequence;
}
}
/**
* ConversationOperation is the encapsulation of a ContentProvider operation to be performed
* atomically as part of a "batch" operation.
*/
public static class ConversationOperation {
public static final int DELETE = 0;
public static final int INSERT = 1;
public static final int UPDATE = 2;
public static final int ARCHIVE = 3;
public static final int MUTE = 4;
public static final int REPORT_SPAM = 5;
private final int mType;
private final Uri mUri;
private final ContentValues mValues;
// True if an updated item should be removed locally (from ConversationCursor)
// This would be the case for a folder/label change in which the conversation is no longer
// in the folder represented by the ConversationCursor
private final boolean mLocalDeleteOnUpdate;
public ConversationOperation(int type, Conversation conv) {
this(type, conv, null);
}
public ConversationOperation(int type, Conversation conv, ContentValues values) {
mType = type;
mUri = conv.uri;
mValues = values;
mLocalDeleteOnUpdate = conv.localDeleteOnUpdate;
}
private ContentProviderOperation execute(Uri underlyingUri) {
Uri uri = underlyingUri.buildUpon()
.appendQueryParameter(UIProvider.SEQUENCE_QUERY_PARAMETER,
Integer.toString(sSequence))
.build();
switch(mType) {
case DELETE:
sProvider.deleteLocal(mUri);
return ContentProviderOperation.newDelete(uri).build();
case UPDATE:
if (mLocalDeleteOnUpdate) {
sProvider.deleteLocal(mUri);
} else {
sProvider.updateLocal(mUri, mValues);
}
return ContentProviderOperation.newUpdate(uri)
.withValues(mValues)
.build();
case INSERT:
sProvider.insertLocal(mUri, mValues);
return ContentProviderOperation.newInsert(uri)
.withValues(mValues).build();
case ARCHIVE:
sProvider.deleteLocal(mUri);
// Create an update operation that represents archive
return ContentProviderOperation.newUpdate(uri).withValue(
ConversationOperations.OPERATION_KEY, ConversationOperations.ARCHIVE)
.build();
case MUTE:
if (mLocalDeleteOnUpdate) {
sProvider.deleteLocal(mUri);
}
// Create an update operation that represents mute
return ContentProviderOperation.newUpdate(uri).withValue(
ConversationOperations.OPERATION_KEY, ConversationOperations.MUTE)
.build();
case REPORT_SPAM:
sProvider.deleteLocal(mUri);
// Create an update operation that represents report spam
return ContentProviderOperation.newUpdate(uri).withValue(
ConversationOperations.OPERATION_KEY,
ConversationOperations.REPORT_SPAM).build();
default:
throw new UnsupportedOperationException(
"No such ConversationOperation type: " + mType);
}
}
}
/**
* For now, a single listener can be associated with the cursor, and for now we'll just
* notify on deletions
*/
public interface ConversationListener {
// Data in the underlying provider has changed; a refresh is required to sync up
public void onRefreshRequired();
// We've completed a requested refresh of the underlying cursor
public void onRefreshReady();
}
@Override
public boolean isFirst() {
throw new UnsupportedOperationException();
}
@Override
public boolean isLast() {
throw new UnsupportedOperationException();
}
@Override
public boolean isBeforeFirst() {
throw new UnsupportedOperationException();
}
@Override
public boolean isAfterLast() {
throw new UnsupportedOperationException();
}
@Override
public int getColumnIndex(String columnName) {
return sUnderlyingCursor.getColumnIndex(columnName);
}
@Override
public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException {
return sUnderlyingCursor.getColumnIndexOrThrow(columnName);
}
@Override
public String getColumnName(int columnIndex) {
return sUnderlyingCursor.getColumnName(columnIndex);
}
@Override
public String[] getColumnNames() {
return sUnderlyingCursor.getColumnNames();
}
@Override
public int getColumnCount() {
return sUnderlyingCursor.getColumnCount();
}
@Override
public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) {
throw new UnsupportedOperationException();
}
@Override
public int getType(int columnIndex) {
return sUnderlyingCursor.getType(columnIndex);
}
@Override
public boolean isNull(int columnIndex) {
throw new UnsupportedOperationException();
}
@Override
public void deactivate() {
throw new UnsupportedOperationException();
}
@Override
public boolean isClosed() {
return sUnderlyingCursor.isClosed();
}
@Override
public void registerContentObserver(ContentObserver observer) {
sUnderlyingCursor.registerContentObserver(observer);
}
@Override
public void unregisterContentObserver(ContentObserver observer) {
sUnderlyingCursor.unregisterContentObserver(observer);
}
@Override
public void registerDataSetObserver(DataSetObserver observer) {
sUnderlyingCursor.registerDataSetObserver(observer);
}
@Override
public void unregisterDataSetObserver(DataSetObserver observer) {
sUnderlyingCursor.unregisterDataSetObserver(observer);
}
@Override
public void setNotificationUri(ContentResolver cr, Uri uri) {
throw new UnsupportedOperationException();
}
@Override
public boolean getWantsAllOnMoveCalls() {
throw new UnsupportedOperationException();
}
@Override
public Bundle getExtras() {
throw new UnsupportedOperationException();
}
@Override
public Bundle respond(Bundle extras) {
throw new UnsupportedOperationException();
}
@Override
public boolean requery() {
return true;
}
}