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