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