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