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