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