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