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