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