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