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