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