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