ConversationCursor.java revision 609480e14d4cb999d9b6bb1c224b3584d3f367f0
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.app.Activity; 21import android.content.ContentProvider; 22import android.content.ContentProviderOperation; 23import android.content.ContentResolver; 24import android.content.ContentValues; 25import android.content.OperationApplicationException; 26import android.database.CharArrayBuffer; 27import android.database.ContentObserver; 28import android.database.Cursor; 29import android.database.DataSetObserver; 30import android.net.Uri; 31import android.os.Bundle; 32import android.os.Looper; 33import android.os.RemoteException; 34import android.util.Log; 35 36import com.android.mail.providers.Conversation; 37import com.android.mail.providers.UIProvider; 38import com.google.common.annotations.VisibleForTesting; 39 40import java.util.ArrayList; 41import java.util.HashMap; 42import java.util.Iterator; 43import java.util.List; 44 45/** 46 * ConversationCursor is a wrapper around a conversation list cursor that provides update/delete 47 * caching for quick UI response. This is effectively a singleton class, as the cache is 48 * implemented as a static HashMap. 49 */ 50public final class ConversationCursor implements Cursor { 51 private static final String TAG = "ConversationCursor"; 52 private static final boolean DEBUG = true; // STOPSHIP Set to false before shipping 53 54 // The cursor instantiator's activity 55 private static Activity sActivity; 56 // The cursor underlying the caching cursor 57 @VisibleForTesting 58 static Cursor sUnderlyingCursor; 59 // The new cursor obtained via a requery 60 private static Cursor sRequeryCursor; 61 // A mapping from Uri to updated ContentValues 62 private static HashMap<String, ContentValues> sCacheMap = new HashMap<String, ContentValues>(); 63 // Cache map lock (will be used only very briefly - few ms at most) 64 private static Object sCacheMapLock = new Object(); 65 // A deleted row is indicated by the presence of DELETED_COLUMN in the cache map 66 private static final String DELETED_COLUMN = "__deleted__"; 67 // An row cached during a requery is indicated by the presence of REQUERY_COLUMN in the map 68 private static final String REQUERY_COLUMN = "__requery__"; 69 // A sentinel value for the "index" of the deleted column; it's an int that is otherwise invalid 70 private static final int DELETED_COLUMN_INDEX = -1; 71 // The current conversation cursor 72 private static ConversationCursor sConversationCursor; 73 // The index of the Uri whose data is reflected in the cached row 74 // Updates/Deletes to this Uri are cached 75 private static int sUriColumnIndex; 76 // The listeners registered for this cursor 77 private static ArrayList<ConversationListener> sListeners = 78 new ArrayList<ConversationListener>(); 79 // The ConversationProvider instance 80 @VisibleForTesting 81 static ConversationProvider sProvider; 82 // Set when we're in the middle of a refresh of the underlying cursor 83 private static boolean sRefreshInProgress = false; 84 // Set when we've sent refreshReady() to listeners 85 private static boolean sRefreshReady = false; 86 // Set when we've sent refreshRequired() to listeners 87 private static boolean sRefreshRequired = false; 88 // Our sequence count (for changes sent to underlying provider) 89 private static int sSequence = 0; 90 91 // Column names for this cursor 92 private final String[] mColumnNames; 93 // The resolver for the cursor instantiator's context 94 private static ContentResolver mResolver; 95 // An observer on the underlying cursor (so we can detect changes from outside the UI) 96 private final CursorObserver mCursorObserver; 97 // Whether our observer is currently registered with the underlying cursor 98 private boolean mCursorObserverRegistered = false; 99 100 // The current position of the cursor 101 private int mPosition = -1; 102 // The number of cached deletions from this cursor (used to quickly generate an accurate count) 103 private static int sDeletedCount = 0; 104 105 // Parameters passed to the underlying query 106 private static Uri qUri; 107 private static String[] qProjection; 108 private static String qSelection; 109 private static String[] qSelectionArgs; 110 private static String qSortOrder; 111 112 private ConversationCursor(Cursor cursor, Activity activity, String messageListColumn) { 113 sActivity = activity; 114 mResolver = activity.getContentResolver(); 115 sConversationCursor = this; 116 sUnderlyingCursor = cursor; 117 sListeners.clear(); 118 mCursorObserver = new CursorObserver(); 119 resetCursor(null); 120 mColumnNames = cursor.getColumnNames(); 121 sUriColumnIndex = cursor.getColumnIndex(messageListColumn); 122 if (sUriColumnIndex < 0) { 123 throw new IllegalArgumentException("Cursor must include a message list column"); 124 } 125 } 126 127 /** 128 * Create a ConversationCursor; this should be called by the ListActivity using that cursor 129 * @param activity the activity creating the cursor 130 * @param messageListColumn the column used for individual cursor items 131 * @param uri the query uri 132 * @param projection the query projecion 133 * @param selection the query selection 134 * @param selectionArgs the query selection args 135 * @param sortOrder the query sort order 136 * @return a ConversationCursor 137 */ 138 public static ConversationCursor create(Activity activity, String messageListColumn, Uri uri, 139 String[] projection, String selection, String[] selectionArgs, String sortOrder) { 140 qUri = uri; 141 qProjection = projection; 142 qSelection = selection; 143 qSelectionArgs = selectionArgs; 144 qSortOrder = sortOrder; 145 Cursor cursor = activity.getContentResolver().query(uri, projection, selection, 146 selectionArgs, sortOrder); 147 return new ConversationCursor(cursor, activity, messageListColumn); 148 } 149 150 /** 151 * Return whether the uri string (message list uri) is in the underlying cursor 152 * @param uriString the uri string we're looking for 153 * @return true if the uri string is in the cursor; false otherwise 154 */ 155 private boolean isInUnderlyingCursor(String uriString) { 156 sUnderlyingCursor.moveToPosition(-1); 157 while (sUnderlyingCursor.moveToNext()) { 158 if (uriString.equals(sUnderlyingCursor.getString(sUriColumnIndex))) { 159 return true; 160 } 161 } 162 return false; 163 } 164 165 /** 166 * Reset the cursor; this involves clearing out our cache map and resetting our various counts 167 * The cursor should be reset whenever we get fresh data from the underlying cursor. The cache 168 * is locked during the reset, which will block the UI, but for only a very short time 169 * (estimated at a few ms, but we can profile this; remember that the cache will usually 170 * be empty or have a few entries) 171 */ 172 private void resetCursor(Cursor newCursor) { 173 // Temporary, log time for reset 174 long startTime = System.currentTimeMillis(); 175 if (DEBUG) { 176 Log.d(TAG, "[--resetCursor--]"); 177 } 178 synchronized (sCacheMapLock) { 179 // Walk through the cache. Here are the cases: 180 // 1) The entry isn't marked with REQUERY - remove it from the cache. If DELETED is 181 // set, decrement the deleted count 182 // 2) The REQUERY entry is still in the UP 183 // 2a) The REQUERY entry isn't DELETED; we're good, and the client change will remain 184 // (i.e. client wins, it's on its way to the UP) 185 // 2b) The REQUERY entry is DELETED; we're good (client change remains, it's on 186 // its way to the UP) 187 // 3) the REQUERY was deleted on the server (sheesh; this would be bizarre timing!) - 188 // we need to throw the item out of the cache 189 // So ... the only interesting case is #3, we need to look for remaining deleted items 190 // and see if they're still in the UP 191 Iterator<HashMap.Entry<String, ContentValues>> iter = sCacheMap.entrySet().iterator(); 192 while (iter.hasNext()) { 193 HashMap.Entry<String, ContentValues> entry = iter.next(); 194 ContentValues values = entry.getValue(); 195 if (values.containsKey(REQUERY_COLUMN) && isInUnderlyingCursor(entry.getKey())) { 196 // If we're in a requery and we're still around, remove the requery key 197 // We're good here, the cached change (delete/update) is on its way to UP 198 values.remove(REQUERY_COLUMN); 199 } else { 200 // Keep the deleted count up-to-date; remove the cache entry 201 if (values.containsKey(DELETED_COLUMN)) { 202 sDeletedCount--; 203 } 204 // Remove the entry 205 iter.remove(); 206 } 207 } 208 209 // Swap cursor 210 if (newCursor != null) { 211 close(); 212 sUnderlyingCursor = newCursor; 213 } 214 215 mPosition = -1; 216 sUnderlyingCursor.moveToPosition(mPosition); 217 if (!mCursorObserverRegistered) { 218 sUnderlyingCursor.registerContentObserver(mCursorObserver); 219 mCursorObserverRegistered = true; 220 } 221 sRefreshRequired = false; 222 } 223 Log.d(TAG, "resetCache time: " + ((System.currentTimeMillis() - startTime)) + "ms"); 224 } 225 226 /** 227 * Add a listener for this cursor; we'll notify it when our data changes 228 */ 229 public void addListener(ConversationListener listener) { 230 sListeners.add(listener); 231 } 232 233 /** 234 * Remove a listener for this cursor 235 */ 236 public void removeListener(ConversationListener listener) { 237 sListeners.remove(listener); 238 } 239 240 /** 241 * Generate a forwarding Uri to ConversationProvider from an original Uri. We do this by 242 * changing the authority to ours, but otherwise leaving the Uri intact. 243 * NOTE: This won't handle query parameters, so the functionality will need to be added if 244 * parameters are used in the future 245 * @param uri the uri 246 * @return a forwarding uri to ConversationProvider 247 */ 248 private static String uriToCachingUriString (Uri uri) { 249 String provider = uri.getAuthority(); 250 return uri.getScheme() + "://" + ConversationProvider.AUTHORITY 251 + "/" + provider + uri.getPath(); 252 } 253 254 /** 255 * Regenerate the original Uri from a forwarding (ConversationProvider) Uri 256 * NOTE: See note above for uriToCachingUri 257 * @param uri the forwarding Uri 258 * @return the original Uri 259 */ 260 private static Uri uriFromCachingUri(Uri uri) { 261 List<String> path = uri.getPathSegments(); 262 Uri.Builder builder = new Uri.Builder().scheme(uri.getScheme()).authority(path.get(0)); 263 for (int i = 1; i < path.size(); i++) { 264 builder.appendPath(path.get(i)); 265 } 266 return builder.build(); 267 } 268 269 /** 270 * Cache a column name/value pair for a given Uri 271 * @param uriString the Uri for which the column name/value pair applies 272 * @param columnName the column name 273 * @param value the value to be cached 274 */ 275 private static void cacheValue(String uriString, String columnName, Object value) { 276 synchronized (sCacheMapLock) { 277 // Get the map for our uri 278 ContentValues map = sCacheMap.get(uriString); 279 // Create one if necessary 280 if (map == null) { 281 map = new ContentValues(); 282 sCacheMap.put(uriString, map); 283 } 284 // If we're caching a deletion, add to our count 285 if ((columnName == DELETED_COLUMN) && (map.get(columnName) == null)) { 286 sDeletedCount++; 287 if (DEBUG) { 288 Log.d(TAG, "Deleted " + uriString); 289 } 290 } 291 // ContentValues has no generic "put", so we must test. For now, the only classes of 292 // values implemented are Boolean/Integer/String, though others are trivially added 293 if (value instanceof Boolean) { 294 map.put(columnName, ((Boolean) value).booleanValue() ? 1 : 0); 295 } else if (value instanceof Integer) { 296 map.put(columnName, (Integer) value); 297 } else if (value instanceof String) { 298 map.put(columnName, (String) value); 299 } else { 300 String cname = value.getClass().getName(); 301 throw new IllegalArgumentException("Value class not compatible with cache: " 302 + cname); 303 } 304 if (sRefreshInProgress) { 305 map.put(REQUERY_COLUMN, 1); 306 } 307 if (DEBUG && (columnName != DELETED_COLUMN)) { 308 Log.d(TAG, "Caching value for " + uriString + ": " + columnName); 309 } 310 } 311 } 312 313 /** 314 * Get the cached value for the provided column; we special case -1 as the "deleted" column 315 * @param columnIndex the index of the column whose cached value we want to retrieve 316 * @return the cached value for this column, or null if there is none 317 */ 318 private Object getCachedValue(int columnIndex) { 319 String uri = sUnderlyingCursor.getString(sUriColumnIndex); 320 ContentValues uriMap = sCacheMap.get(uri); 321 if (uriMap != null) { 322 String columnName; 323 if (columnIndex == DELETED_COLUMN_INDEX) { 324 columnName = DELETED_COLUMN; 325 } else { 326 columnName = mColumnNames[columnIndex]; 327 } 328 return uriMap.get(columnName); 329 } 330 return null; 331 } 332 333 /** 334 * When the underlying cursor changes, we want to alert the listener 335 */ 336 private void underlyingChanged() { 337 if (mCursorObserverRegistered) { 338 try { 339 sUnderlyingCursor.unregisterContentObserver(mCursorObserver); 340 } catch (IllegalStateException e) { 341 // Maybe the cursor was GC'd? 342 } 343 mCursorObserverRegistered = false; 344 } 345 if (DEBUG) { 346 Log.d(TAG, "[Notify: onRefreshRequired()]"); 347 } 348 for (ConversationListener listener: sListeners) { 349 listener.onRefreshRequired(); 350 } 351 sRefreshRequired = true; 352 } 353 354 /** 355 * Put the refreshed cursor in place (called by the UI) 356 */ 357 // NOTE: We don't like the name (it implies syncing with the server); suggestions gladly 358 // taken - reset? syncToUnderlying? completeRefresh? align? 359 public void sync() { 360 if (DEBUG) { 361 Log.d(TAG, "[sync() called]"); 362 } 363 if (sRequeryCursor == null) { 364 throw new IllegalStateException("Can't swap cursors; no requery done"); 365 } 366 resetCursor(sRequeryCursor); 367 sRequeryCursor = null; 368 sRefreshInProgress = false; 369 sRefreshReady = false; 370 } 371 372 public boolean isRefreshRequired() { 373 return sRefreshRequired; 374 } 375 376 public boolean isRefreshReady() { 377 return sRefreshReady; 378 } 379 380 /** 381 * Cancel a refresh in progress 382 */ 383 public void cancelRefresh() { 384 if (DEBUG) { 385 Log.d(TAG, "[cancelRefresh() called]"); 386 } 387 synchronized(sCacheMapLock) { 388 // Mark the requery closed 389 sRefreshInProgress = false; 390 // If we have the cursor, close it; otherwise, it will get closed when the query 391 // finishes (it checks sRequeryInProgress) 392 if (sRequeryCursor != null) { 393 sRequeryCursor.close(); 394 sRequeryCursor = null; 395 } 396 } 397 } 398 399 /** 400 * Get a list of deletions from ConversationCursor to the refreshed cursor that hasn't yet 401 * been swapped into place; this allows the UI to animate these away if desired 402 * @return a list of positions deleted in ConversationCursor 403 */ 404 public ArrayList<Integer> getRefreshDeletions () { 405 if (DEBUG) { 406 Log.d(TAG, "[getRefreshDeletions() called]"); 407 } 408 Cursor deviceCursor = sConversationCursor; 409 Cursor serverCursor = sRequeryCursor; 410 ArrayList<Integer> deleteList = new ArrayList<Integer>(); 411 int serverCount = serverCursor.getCount(); 412 int deviceCount = deviceCursor.getCount(); 413 deviceCursor.moveToFirst(); 414 serverCursor.moveToFirst(); 415 while (serverCount > 0 || deviceCount > 0) { 416 if (serverCount == 0) { 417 for (; deviceCount > 0; deviceCount--) 418 deleteList.add(deviceCursor.getPosition()); 419 break; 420 } else if (deviceCount == 0) { 421 break; 422 } 423 long deviceMs = deviceCursor.getLong(UIProvider.CONVERSATION_DATE_RECEIVED_MS_COLUMN); 424 long serverMs = serverCursor.getLong(UIProvider.CONVERSATION_DATE_RECEIVED_MS_COLUMN); 425 String deviceUri = deviceCursor.getString(UIProvider.CONVERSATION_URI_COLUMN); 426 String serverUri = serverCursor.getString(UIProvider.CONVERSATION_URI_COLUMN); 427 deviceCursor.moveToNext(); 428 serverCursor.moveToNext(); 429 serverCount--; 430 deviceCount--; 431 if (serverMs == deviceMs) { 432 // Check for duplicates here; if our identical dates refer to different messages, 433 // we'll just quit here for now (at worst, this will cause a non-animating delete) 434 // My guess is that this happens VERY rarely, if at all 435 if (!deviceUri.equals(serverUri)) { 436 // To do this right, we'd find all of the rows with the same ms (date), etc... 437 //return deleteList; 438 } 439 continue; 440 } else if (deviceMs > serverMs) { 441 deleteList.add(deviceCursor.getPosition() - 1); 442 // Move back because we've already advanced cursor (that's why we subtract 1 above) 443 serverCount++; 444 serverCursor.moveToPrevious(); 445 } else if (serverMs > deviceMs) { 446 // If we wanted to track insertions, we'd so so here 447 // Move back because we've already advanced cursor 448 deviceCount++; 449 deviceCursor.moveToPrevious(); 450 } 451 } 452 Log.d(TAG, "Deletions: " + deleteList); 453 return deleteList; 454 } 455 456 /** 457 * When we get a requery from the UI, we'll do it, but also clear the cache. The listener is 458 * notified when the requery is complete 459 * NOTE: This will have to change, of course, when we start using loaders... 460 */ 461 public boolean refresh() { 462 if (DEBUG) { 463 Log.d(TAG, "[refresh() called]"); 464 } 465 if (sRefreshInProgress) { 466 return false; 467 } 468 // Say we're starting a requery 469 sRefreshInProgress = true; 470 new Thread(new Runnable() { 471 @Override 472 public void run() { 473 // Get new data 474 sRequeryCursor = 475 mResolver.query(qUri, qProjection, qSelection, qSelectionArgs, qSortOrder); 476 // Make sure window is full 477 synchronized(sCacheMapLock) { 478 if (sRefreshInProgress) { 479 sRequeryCursor.getCount(); 480 sActivity.runOnUiThread(new Runnable() { 481 @Override 482 public void run() { 483 if (DEBUG) { 484 Log.d(TAG, "[Notify: onRefreshReady()]"); 485 } 486 for (ConversationListener listener: sListeners) { 487 listener.onRefreshReady(); 488 } 489 sRefreshReady = true; 490 }}); 491 } else { 492 cancelRefresh(); 493 } 494 } 495 } 496 }).start(); 497 return true; 498 } 499 500 @Override 501 public void close() { 502 if (!sUnderlyingCursor.isClosed()) { 503 // Unregister our observer on the underlying cursor and close as usual 504 if (mCursorObserverRegistered) { 505 try { 506 sUnderlyingCursor.unregisterContentObserver(mCursorObserver); 507 } catch (IllegalStateException e) { 508 // Maybe the cursor got GC'd? 509 } 510 mCursorObserverRegistered = false; 511 } 512 sUnderlyingCursor.close(); 513 } 514 } 515 516 /** 517 * Move to the next not-deleted item in the conversation 518 */ 519 @Override 520 public boolean moveToNext() { 521 while (true) { 522 boolean ret = sUnderlyingCursor.moveToNext(); 523 if (!ret) return false; 524 if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue; 525 mPosition++; 526 return true; 527 } 528 } 529 530 /** 531 * Move to the previous not-deleted item in the conversation 532 */ 533 @Override 534 public boolean moveToPrevious() { 535 while (true) { 536 boolean ret = sUnderlyingCursor.moveToPrevious(); 537 if (!ret) return false; 538 if (getCachedValue(-1) instanceof Integer) continue; 539 mPosition--; 540 return true; 541 } 542 } 543 544 @Override 545 public int getPosition() { 546 return mPosition; 547 } 548 549 /** 550 * The actual cursor's count must be decremented by the number we've deleted from the UI 551 */ 552 @Override 553 public int getCount() { 554 return sUnderlyingCursor.getCount() - sDeletedCount; 555 } 556 557 @Override 558 public boolean moveToFirst() { 559 sUnderlyingCursor.moveToPosition(-1); 560 mPosition = -1; 561 return moveToNext(); 562 } 563 564 @Override 565 public boolean moveToPosition(int pos) { 566 if (pos < -1 || pos >= getCount()) return false; 567 if (pos == mPosition) return true; 568 if (pos > mPosition) { 569 while (pos > mPosition) { 570 if (!moveToNext()) { 571 return false; 572 } 573 } 574 return true; 575 } else if (pos == 0) { 576 return moveToFirst(); 577 } else { 578 while (pos < mPosition) { 579 if (!moveToPrevious()) { 580 return false; 581 } 582 } 583 return true; 584 } 585 } 586 587 @Override 588 public boolean moveToLast() { 589 throw new UnsupportedOperationException("moveToLast unsupported!"); 590 } 591 592 @Override 593 public boolean move(int offset) { 594 throw new UnsupportedOperationException("move unsupported!"); 595 } 596 597 /** 598 * We need to override all of the getters to make sure they look at cached values before using 599 * the values in the underlying cursor 600 */ 601 @Override 602 public double getDouble(int columnIndex) { 603 Object obj = getCachedValue(columnIndex); 604 if (obj != null) return (Double)obj; 605 return sUnderlyingCursor.getDouble(columnIndex); 606 } 607 608 @Override 609 public float getFloat(int columnIndex) { 610 Object obj = getCachedValue(columnIndex); 611 if (obj != null) return (Float)obj; 612 return sUnderlyingCursor.getFloat(columnIndex); 613 } 614 615 @Override 616 public int getInt(int columnIndex) { 617 Object obj = getCachedValue(columnIndex); 618 if (obj != null) return (Integer)obj; 619 return sUnderlyingCursor.getInt(columnIndex); 620 } 621 622 @Override 623 public long getLong(int columnIndex) { 624 Object obj = getCachedValue(columnIndex); 625 if (obj != null) return (Long)obj; 626 return sUnderlyingCursor.getLong(columnIndex); 627 } 628 629 @Override 630 public short getShort(int columnIndex) { 631 Object obj = getCachedValue(columnIndex); 632 if (obj != null) return (Short)obj; 633 return sUnderlyingCursor.getShort(columnIndex); 634 } 635 636 @Override 637 public String getString(int columnIndex) { 638 // If we're asking for the Uri for the conversation list, we return a forwarding URI 639 // so that we can intercept update/delete and handle it ourselves 640 if (columnIndex == sUriColumnIndex) { 641 Uri uri = Uri.parse(sUnderlyingCursor.getString(columnIndex)); 642 return uriToCachingUriString(uri); 643 } 644 Object obj = getCachedValue(columnIndex); 645 if (obj != null) return (String)obj; 646 return sUnderlyingCursor.getString(columnIndex); 647 } 648 649 @Override 650 public byte[] getBlob(int columnIndex) { 651 Object obj = getCachedValue(columnIndex); 652 if (obj != null) return (byte[])obj; 653 return sUnderlyingCursor.getBlob(columnIndex); 654 } 655 656 /** 657 * Observer of changes to underlying data 658 */ 659 private class CursorObserver extends ContentObserver { 660 public CursorObserver() { 661 super(null); 662 } 663 664 @Override 665 public void onChange(boolean selfChange) { 666 // If we're here, then something outside of the UI has changed the data, and we 667 // must query the underlying provider for that data 668 if (DEBUG) { 669 Log.d(TAG, "Underlying conversation cursor changed; requerying"); 670 } 671 // It's not at all obvious to me why we must unregister/re-register after the requery 672 // However, if we don't we'll only get one notification and no more... 673 ConversationCursor.this.underlyingChanged(); 674 } 675 } 676 677 /** 678 * ConversationProvider is the ContentProvider for our forwarding Uri's; it passes queries 679 * and inserts directly, and caches updates/deletes before passing them through. The caching 680 * will cause a redraw of the list with updated values. 681 */ 682 public abstract static class ConversationProvider extends ContentProvider { 683 public static String AUTHORITY; 684 685 /** 686 * Allows the implmenting provider to specify the authority that should be used. 687 */ 688 protected abstract String getAuthority(); 689 690 @Override 691 public boolean onCreate() { 692 sProvider = this; 693 AUTHORITY = getAuthority(); 694 return true; 695 } 696 697 @Override 698 public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, 699 String sortOrder) { 700 return mResolver.query( 701 uriFromCachingUri(uri), projection, selection, selectionArgs, sortOrder); 702 } 703 704 @Override 705 public Uri insert(Uri uri, ContentValues values) { 706 insertLocal(uri, values); 707 return ProviderExecute.opInsert(uri, values); 708 } 709 710 @Override 711 public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { 712 updateLocal(uri, values); 713 return ProviderExecute.opUpdate(uri, values); 714 } 715 716 @Override 717 public int delete(Uri uri, String selection, String[] selectionArgs) { 718 deleteLocal(uri); 719 return ProviderExecute.opDelete(uri); 720 } 721 722 @Override 723 public String getType(Uri uri) { 724 return null; 725 } 726 727 /** 728 * Quick and dirty class that executes underlying provider CRUD operations on a background 729 * thread. 730 */ 731 static class ProviderExecute implements Runnable { 732 static final int DELETE = 0; 733 static final int INSERT = 1; 734 static final int UPDATE = 2; 735 736 final int mCode; 737 final Uri mUri; 738 final ContentValues mValues; //HEHEH 739 740 ProviderExecute(int code, Uri uri, ContentValues values) { 741 mCode = code; 742 mUri = uriFromCachingUri(uri); 743 mValues = values; 744 } 745 746 ProviderExecute(int code, Uri uri) { 747 this(code, uri, null); 748 } 749 750 static Uri opInsert(Uri uri, ContentValues values) { 751 ProviderExecute e = new ProviderExecute(INSERT, uri, values); 752 if (offUiThread()) return (Uri)e.go(); 753 new Thread(e).start(); 754 return null; 755 } 756 757 static int opDelete(Uri uri) { 758 ProviderExecute e = new ProviderExecute(DELETE, uri); 759 if (offUiThread()) return (Integer)e.go(); 760 new Thread(new ProviderExecute(DELETE, uri)).start(); 761 return 0; 762 } 763 764 static int opUpdate(Uri uri, ContentValues values) { 765 ProviderExecute e = new ProviderExecute(UPDATE, uri, values); 766 if (offUiThread()) return (Integer)e.go(); 767 new Thread(e).start(); 768 return 0; 769 } 770 771 @Override 772 public void run() { 773 go(); 774 } 775 776 public Object go() { 777 switch(mCode) { 778 case DELETE: 779 return mResolver.delete(mUri, null, null); 780 case INSERT: 781 return mResolver.insert(mUri, mValues); 782 case UPDATE: 783 return mResolver.update(mUri, mValues, null, null); 784 default: 785 return null; 786 } 787 } 788 } 789 790 private void insertLocal(Uri uri, ContentValues values) { 791 // Placeholder for now; there's no local insert 792 } 793 794 @VisibleForTesting 795 void deleteLocal(Uri uri) { 796 Uri underlyingUri = uriFromCachingUri(uri); 797 // Remember to decode the underlying Uri as it might be encoded (as w/ Gmail) 798 String uriString = Uri.decode(underlyingUri.toString()); 799 cacheValue(uriString, DELETED_COLUMN, true); 800 } 801 802 @VisibleForTesting 803 void updateLocal(Uri uri, ContentValues values) { 804 Uri underlyingUri = uriFromCachingUri(uri); 805 // Remember to decode the underlying Uri as it might be encoded (as w/ Gmail) 806 String uriString = Uri.decode(underlyingUri.toString()); 807 for (String columnName: values.keySet()) { 808 cacheValue(uriString, columnName, values.get(columnName)); 809 } 810 } 811 812 static boolean offUiThread() { 813 return Looper.getMainLooper().getThread() != Thread.currentThread(); 814 } 815 816 public int apply(ArrayList<ConversationOperation> ops) { 817 final HashMap<String, ArrayList<ContentProviderOperation>> batchMap = 818 new HashMap<String, ArrayList<ContentProviderOperation>>(); 819 // Increment sequence count 820 sSequence++; 821 // Execute locally and build CPO's for underlying provider 822 for (ConversationOperation op: ops) { 823 Uri underlyingUri = uriFromCachingUri(op.mUri); 824 String authority = underlyingUri.getAuthority(); 825 ArrayList<ContentProviderOperation> authOps = batchMap.get(authority); 826 if (authOps == null) { 827 authOps = new ArrayList<ContentProviderOperation>(); 828 batchMap.put(authority, authOps); 829 } 830 authOps.add(op.execute(underlyingUri)); 831 } 832 833 // Send changes to underlying provider 834 for (String authority: batchMap.keySet()) { 835 try { 836 if (offUiThread()) { 837 mResolver.applyBatch(authority, batchMap.get(authority)); 838 } else { 839 final String auth = authority; 840 new Thread(new Runnable() { 841 @Override 842 public void run() { 843 try { 844 mResolver.applyBatch(auth, batchMap.get(auth)); 845 } catch (RemoteException e) { 846 } catch (OperationApplicationException e) { 847 } 848 } 849 }).start(); 850 } 851 } catch (RemoteException e) { 852 } catch (OperationApplicationException e) { 853 } 854 } 855 return sSequence; 856 } 857 } 858 859 /** 860 * ConversationOperation is the encapsulation of a ContentProvider operation to be performed 861 * atomically as part of a "batch" operation. 862 */ 863 public static class ConversationOperation { 864 public static final int DELETE = 0; 865 public static final int INSERT = 1; 866 public static final int UPDATE = 2; 867 868 private final int mType; 869 private final Uri mUri; 870 private final ContentValues mValues; 871 // True if an updated item should be removed locally (from ConversationCursor) 872 // This would be the case for a folder/label change in which the conversation is no longer 873 // in the folder represented by the ConversationCursor 874 private final boolean mLocalDeleteOnUpdate; 875 876 public ConversationOperation(int type, Conversation conv) { 877 this(type, conv, null); 878 } 879 880 public ConversationOperation(int type, Conversation conv, ContentValues values) { 881 mType = type; 882 mUri = conv.uri; 883 mValues = values; 884 mLocalDeleteOnUpdate = conv.localDeleteOnUpdate; 885 } 886 887 private ContentProviderOperation execute(Uri underlyingUri) { 888 Uri uri = underlyingUri.buildUpon() 889 .appendQueryParameter(UIProvider.SEQUENCE_QUERY_PARAMETER, 890 Integer.toString(sSequence)) 891 .build(); 892 switch(mType) { 893 case DELETE: 894 sProvider.deleteLocal(mUri); 895 return ContentProviderOperation.newDelete(uri).build(); 896 case UPDATE: 897 if (mLocalDeleteOnUpdate) { 898 sProvider.deleteLocal(mUri); 899 } else { 900 sProvider.updateLocal(mUri, mValues); 901 } 902 return ContentProviderOperation.newUpdate(uri) 903 .withValues(mValues) 904 .build(); 905 case INSERT: 906 sProvider.insertLocal(mUri, mValues); 907 return ContentProviderOperation.newInsert(uri) 908 .withValues(mValues).build(); 909 default: 910 throw new UnsupportedOperationException( 911 "No such ConversationOperation type: " + mType); 912 } 913 } 914 } 915 916 /** 917 * For now, a single listener can be associated with the cursor, and for now we'll just 918 * notify on deletions 919 */ 920 public interface ConversationListener { 921 // Data in the underlying provider has changed; a refresh is required to sync up 922 public void onRefreshRequired(); 923 // We've completed a requested refresh of the underlying cursor 924 public void onRefreshReady(); 925 } 926 927 @Override 928 public boolean isFirst() { 929 throw new UnsupportedOperationException(); 930 } 931 932 @Override 933 public boolean isLast() { 934 throw new UnsupportedOperationException(); 935 } 936 937 @Override 938 public boolean isBeforeFirst() { 939 throw new UnsupportedOperationException(); 940 } 941 942 @Override 943 public boolean isAfterLast() { 944 throw new UnsupportedOperationException(); 945 } 946 947 @Override 948 public int getColumnIndex(String columnName) { 949 return sUnderlyingCursor.getColumnIndex(columnName); 950 } 951 952 @Override 953 public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException { 954 return sUnderlyingCursor.getColumnIndexOrThrow(columnName); 955 } 956 957 @Override 958 public String getColumnName(int columnIndex) { 959 return sUnderlyingCursor.getColumnName(columnIndex); 960 } 961 962 @Override 963 public String[] getColumnNames() { 964 return sUnderlyingCursor.getColumnNames(); 965 } 966 967 @Override 968 public int getColumnCount() { 969 return sUnderlyingCursor.getColumnCount(); 970 } 971 972 @Override 973 public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) { 974 throw new UnsupportedOperationException(); 975 } 976 977 @Override 978 public int getType(int columnIndex) { 979 return sUnderlyingCursor.getType(columnIndex); 980 } 981 982 @Override 983 public boolean isNull(int columnIndex) { 984 throw new UnsupportedOperationException(); 985 } 986 987 @Override 988 public void deactivate() { 989 throw new UnsupportedOperationException(); 990 } 991 992 @Override 993 public boolean isClosed() { 994 return sUnderlyingCursor.isClosed(); 995 } 996 997 @Override 998 public void registerContentObserver(ContentObserver observer) { 999 sUnderlyingCursor.registerContentObserver(observer); 1000 } 1001 1002 @Override 1003 public void unregisterContentObserver(ContentObserver observer) { 1004 sUnderlyingCursor.unregisterContentObserver(observer); 1005 } 1006 1007 @Override 1008 public void registerDataSetObserver(DataSetObserver observer) { 1009 sUnderlyingCursor.registerDataSetObserver(observer); 1010 } 1011 1012 @Override 1013 public void unregisterDataSetObserver(DataSetObserver observer) { 1014 sUnderlyingCursor.unregisterDataSetObserver(observer); 1015 } 1016 1017 @Override 1018 public void setNotificationUri(ContentResolver cr, Uri uri) { 1019 throw new UnsupportedOperationException(); 1020 } 1021 1022 @Override 1023 public boolean getWantsAllOnMoveCalls() { 1024 throw new UnsupportedOperationException(); 1025 } 1026 1027 @Override 1028 public Bundle getExtras() { 1029 throw new UnsupportedOperationException(); 1030 } 1031 1032 @Override 1033 public Bundle respond(Bundle extras) { 1034 throw new UnsupportedOperationException(); 1035 } 1036 1037 @Override 1038 public boolean requery() { 1039 return true; 1040 } 1041} 1042