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