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