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