ConversationCursor.java revision 40ada66d5dc6a0d5ad02f4a319acc03d126e9fc9
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.Context; 26import android.content.OperationApplicationException; 27import android.database.CharArrayBuffer; 28import android.database.ContentObserver; 29import android.database.Cursor; 30import android.database.CursorWrapper; 31import android.database.DataSetObserver; 32import android.net.Uri; 33import android.os.AsyncTask; 34import android.os.Bundle; 35import android.os.Handler; 36import android.os.Looper; 37import android.os.RemoteException; 38import android.os.SystemClock; 39import android.support.v4.util.SparseArrayCompat; 40import android.text.TextUtils; 41import android.util.Log; 42 43import com.android.mail.providers.Conversation; 44import com.android.mail.providers.Folder; 45import com.android.mail.providers.FolderList; 46import com.android.mail.providers.UIProvider; 47import com.android.mail.providers.UIProvider.ConversationListQueryParameters; 48import com.android.mail.providers.UIProvider.ConversationOperations; 49import com.android.mail.ui.ConversationListFragment; 50import com.android.mail.utils.LogTag; 51import com.android.mail.utils.LogUtils; 52import com.android.mail.utils.NotificationActionUtils; 53import com.android.mail.utils.NotificationActionUtils.NotificationAction; 54import com.android.mail.utils.NotificationActionUtils.NotificationActionType; 55import com.android.mail.utils.Utils; 56import com.google.common.annotations.VisibleForTesting; 57import com.google.common.collect.ImmutableMap; 58import com.google.common.collect.ImmutableSet; 59import com.google.common.collect.Lists; 60import com.google.common.collect.Sets; 61 62import java.util.ArrayList; 63import java.util.Arrays; 64import java.util.Collection; 65import java.util.Collections; 66import java.util.HashMap; 67import java.util.Iterator; 68import java.util.List; 69import java.util.Map; 70import java.util.Set; 71 72/** 73 * ConversationCursor is a wrapper around a conversation list cursor that provides update/delete 74 * caching for quick UI response. This is effectively a singleton class, as the cache is 75 * implemented as a static HashMap. 76 */ 77public final class ConversationCursor implements Cursor, ConversationCursorMarkSeenListener { 78 79 private static final boolean ENABLE_CONVERSATION_PRECACHING = true; 80 81 private static final String LOG_TAG = LogTag.getLogTag(); 82 /** Turn to true for debugging. */ 83 private static final boolean DEBUG = false; 84 /** A deleted row is indicated by the presence of DELETED_COLUMN in the cache map */ 85 private static final String DELETED_COLUMN = "__deleted__"; 86 /** An row cached during a requery is indicated by the presence of REQUERY_COLUMN in the map */ 87 private static final String UPDATE_TIME_COLUMN = "__updatetime__"; 88 /** 89 * A sentinel value for the "index" of the deleted column; it's an int that is otherwise invalid 90 */ 91 private static final int DELETED_COLUMN_INDEX = -1; 92 /** 93 * If a cached value within 10 seconds of a refresh(), preserve it. This time has been 94 * chosen empirically (long enough for UI changes to propagate in any reasonable case) 95 */ 96 private static final long REQUERY_ALLOWANCE_TIME = 10000L; 97 98 /** 99 * The index of the Uri whose data is reflected in the cached row. Updates/Deletes to this Uri 100 * are cached 101 */ 102 private static final int URI_COLUMN_INDEX = UIProvider.CONVERSATION_URI_COLUMN; 103 104 /** The resolver for the cursor instantiator's context */ 105 private final ContentResolver mResolver; 106 107 /** Our sequence count (for changes sent to underlying provider) */ 108 private static int sSequence = 0; 109 @VisibleForTesting 110 static ConversationProvider sProvider; 111 112 /** The cursor underlying the caching cursor */ 113 @VisibleForTesting 114 UnderlyingCursorWrapper mUnderlyingCursor; 115 /** The new cursor obtained via a requery */ 116 private volatile UnderlyingCursorWrapper mRequeryCursor; 117 /** A mapping from Uri to updated ContentValues */ 118 private final HashMap<String, ContentValues> mCacheMap = new HashMap<String, ContentValues>(); 119 /** Cache map lock (will be used only very briefly - few ms at most) */ 120 private final Object mCacheMapLock = new Object(); 121 /** The listeners registered for this cursor */ 122 private final List<ConversationListener> mListeners = Lists.newArrayList(); 123 /** 124 * The ConversationProvider instance // The runnable executing a refresh (query of underlying 125 * provider) 126 */ 127 private RefreshTask mRefreshTask; 128 /** Set when we've sent refreshReady() to listeners */ 129 private boolean mRefreshReady = false; 130 /** Set when we've sent refreshRequired() to listeners */ 131 private boolean mRefreshRequired = false; 132 /** Whether our first query on this cursor should include a limit */ 133 private boolean mInitialConversationLimit = false; 134 /** A list of mostly-dead items */ 135 private final List<Conversation> mMostlyDead = Lists.newArrayList(); 136 /** A list of items pending removal from a notification action. These may be undone later. */ 137 private final Set<Conversation> mNotificationTempDeleted = Sets.newHashSet(); 138 /** The name of the loader */ 139 private final String mName; 140 /** Column names for this cursor */ 141 private String[] mColumnNames; 142 // Column names as above, as a Set for quick membership checking 143 private Set<String> mColumnNameSet; 144 /** An observer on the underlying cursor (so we can detect changes from outside the UI) */ 145 private final CursorObserver mCursorObserver; 146 /** Whether our observer is currently registered with the underlying cursor */ 147 private boolean mCursorObserverRegistered = false; 148 /** Whether our loader is paused */ 149 private boolean mPaused = false; 150 /** Whether or not sync from underlying provider should be deferred */ 151 private boolean mDeferSync = false; 152 153 /** The current position of the cursor */ 154 private int mPosition = -1; 155 156 /** 157 * The number of cached deletions from this cursor (used to quickly generate an accurate count) 158 */ 159 private int mDeletedCount = 0; 160 161 /** Parameters passed to the underlying query */ 162 private Uri qUri; 163 private String[] qProjection; 164 165 private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper()); 166 167 private void setCursor(UnderlyingCursorWrapper cursor) { 168 // If we have an existing underlying cursor, make sure it's closed 169 if (mUnderlyingCursor != null) { 170 close(); 171 } 172 mColumnNames = cursor.getColumnNames(); 173 ImmutableSet.Builder<String> builder = ImmutableSet.builder(); 174 for (String name : mColumnNames) { 175 builder.add(name); 176 } 177 mColumnNameSet = builder.build(); 178 mRefreshRequired = false; 179 mRefreshReady = false; 180 mRefreshTask = null; 181 resetCursor(cursor); 182 183 resetNotificationActions(); 184 handleNotificationActions(); 185 } 186 187 public ConversationCursor(Activity activity, Uri uri, boolean initialConversationLimit, 188 String name) { 189 mInitialConversationLimit = initialConversationLimit; 190 mResolver = activity.getApplicationContext().getContentResolver(); 191 qUri = uri; 192 mName = name; 193 qProjection = UIProvider.CONVERSATION_PROJECTION; 194 mCursorObserver = new CursorObserver(new Handler(Looper.getMainLooper())); 195 } 196 197 /** 198 * Create a ConversationCursor; this should be called by the ListActivity using that cursor 199 */ 200 public void load() { 201 synchronized (mCacheMapLock) { 202 try { 203 // Create new ConversationCursor 204 LogUtils.d(LOG_TAG, "Create: initial creation"); 205 setCursor(doQuery(mInitialConversationLimit)); 206 } finally { 207 // If we used a limit, queue up a query without limit 208 if (mInitialConversationLimit) { 209 mInitialConversationLimit = false; 210 refresh(); 211 } 212 } 213 } 214 } 215 216 /** 217 * Pause notifications to UI 218 */ 219 public void pause() { 220 if (DEBUG) { 221 LogUtils.i(LOG_TAG, "[Paused: %s]", mName); 222 } 223 mPaused = true; 224 } 225 226 /** 227 * Resume notifications to UI; if any are pending, send them 228 */ 229 public void resume() { 230 if (DEBUG) { 231 LogUtils.i(LOG_TAG, "[Resumed: %s]", mName); 232 } 233 mPaused = false; 234 checkNotifyUI(); 235 } 236 237 private void checkNotifyUI() { 238 LogUtils.d( 239 LOG_TAG, 240 "Received notify ui callback and sending a notification is enabled?" + 241 " %s and refresh ready ? %s", 242 (!mPaused && !mDeferSync), 243 (mRefreshReady || (mRefreshRequired && mRefreshTask == null))); 244 if (!mPaused && !mDeferSync) { 245 if (mRefreshRequired && (mRefreshTask == null)) { 246 notifyRefreshRequired(); 247 } else if (mRefreshReady) { 248 notifyRefreshReady(); 249 } 250 } else { 251 LogUtils.d(LOG_TAG, "[checkNotifyUI: %s%s", 252 (mPaused ? "Paused " : ""), (mDeferSync ? "Defer" : "")); 253 } 254 } 255 256 public Set<Long> getConversationIds() { 257 return mUnderlyingCursor != null ? mUnderlyingCursor.conversationIds() : null; 258 } 259 260 private static class UnderlyingRowData { 261 public final String wrappedUri; 262 public final String innerUri; 263 public final Conversation conversation; 264 265 public UnderlyingRowData(String wrappedUri, String innerUri, Conversation conversation) { 266 this.wrappedUri = wrappedUri; 267 this.innerUri = innerUri; 268 this.conversation = conversation; 269 } 270 } 271 272 /** 273 * Simple wrapper for a cursor that provides methods for quickly determining 274 * the existence of a row. 275 */ 276 private static class UnderlyingCursorWrapper extends CursorWrapper { 277 // Ideally these two objects could be combined into a Map from 278 // conversationId -> position, but the cached values uses the conversation 279 // uri as a key. 280 private final Map<String, Integer> mConversationUriPositionMap; 281 private final Map<Long, Integer> mConversationIdPositionMap; 282 private final List<UnderlyingRowData> mRowCache; 283 284 public UnderlyingCursorWrapper(Cursor result) { 285 super(result); 286 long start = SystemClock.uptimeMillis(); 287 final ImmutableMap.Builder<String, Integer> conversationUriPositionMapBuilder = 288 new ImmutableMap.Builder<String, Integer>(); 289 final ImmutableMap.Builder<Long, Integer> conversationIdPositionMapBuilder = 290 new ImmutableMap.Builder<Long, Integer>(); 291 final UnderlyingRowData[] cache; 292 final int count; 293 if (result != null && result.moveToFirst()) { 294 count = result.getCount(); 295 cache = new UnderlyingRowData[count]; 296 // We don't want iterating over this cursor to trigger a network 297 // request 298 final boolean networkWasEnabled = 299 Utils.disableConversationCursorNetworkAccess(result); 300 int i = 0; 301 do { 302 final Conversation c; 303 final String innerUriString; 304 final String wrappedUriString; 305 final long convId; 306 307 if (ENABLE_CONVERSATION_PRECACHING) { 308 c = new Conversation(this); 309 innerUriString = c.uri.toString(); 310 wrappedUriString = uriToCachingUriString(c.uri); 311 convId = c.id; 312 } else { 313 c = null; 314 innerUriString = result.getString(URI_COLUMN_INDEX); 315 wrappedUriString = uriToCachingUriString(Uri.parse(innerUriString)); 316 convId = result.getLong(UIProvider.CONVERSATION_ID_COLUMN); 317 } 318 conversationUriPositionMapBuilder.put(innerUriString, i); 319 conversationIdPositionMapBuilder.put(convId, i); 320 cache[i] = new UnderlyingRowData( 321 wrappedUriString, 322 innerUriString, 323 c); 324 325 } while (result.moveToPosition(++i)); 326 327 if (networkWasEnabled) { 328 Utils.enableConversationCursorNetworkAccess(result); 329 } 330 } else { 331 count = 0; 332 cache = new UnderlyingRowData[0]; 333 } 334 mConversationUriPositionMap = conversationUriPositionMapBuilder.build(); 335 mConversationIdPositionMap = conversationIdPositionMapBuilder.build(); 336 mRowCache = Collections.unmodifiableList(Arrays.asList(cache)); 337 long end = SystemClock.uptimeMillis(); 338 LogUtils.i(LOG_TAG, "*** ConversationCursor pre-loading took" + 339 " %sms n=%s CONV_PRECACHING=%s", 340 (end-start), count, ENABLE_CONVERSATION_PRECACHING); 341 } 342 343 public boolean contains(String uri) { 344 return mConversationUriPositionMap.containsKey(uri); 345 } 346 347 public Set<Long> conversationIds() { 348 return mConversationIdPositionMap.keySet(); 349 } 350 351 public int getPosition(long conversationId) { 352 final Integer position = mConversationIdPositionMap.get(conversationId); 353 return position != null ? position.intValue() : -1; 354 } 355 356 public int getPosition(String conversationUri) { 357 final Integer position = mConversationUriPositionMap.get(conversationUri); 358 return position != null ? position.intValue() : -1; 359 } 360 361 public String getWrappedUri() { 362 return mRowCache.get(getPosition()).wrappedUri; 363 } 364 365 public String getInnerUri() { 366 return mRowCache.get(getPosition()).innerUri; 367 } 368 369 public Conversation getConversation() { 370 return mRowCache.get(getPosition()).conversation; 371 } 372 } 373 374 /** 375 * Runnable that performs the query on the underlying provider 376 */ 377 private class RefreshTask extends AsyncTask<Void, Void, Void> { 378 private UnderlyingCursorWrapper mCursor = null; 379 380 private RefreshTask() { 381 } 382 383 @Override 384 protected Void doInBackground(Void... params) { 385 if (DEBUG) { 386 LogUtils.i(LOG_TAG, "[Start refresh of %s: %d]", mName, hashCode()); 387 } 388 // Get new data 389 mCursor = doQuery(false); 390 // Make sure window is full 391 mCursor.getCount(); 392 return null; 393 } 394 395 @Override 396 protected void onPostExecute(Void param) { 397 synchronized(mCacheMapLock) { 398 LogUtils.d( 399 LOG_TAG, 400 "Received notify ui callback and sending a notification is enabled? %s", 401 (!mPaused && !mDeferSync)); 402 // If cursor got closed (e.g. reset loader) in the meantime, cancel the refresh 403 if (isClosed()) { 404 onCancelled(); 405 return; 406 } 407 mRequeryCursor = mCursor; 408 mRefreshReady = true; 409 if (DEBUG) { 410 LogUtils.i(LOG_TAG, "[Query done %s: %d]", mName, hashCode()); 411 } 412 if (!mDeferSync && !mPaused) { 413 notifyRefreshReady(); 414 } 415 } 416 } 417 418 @Override 419 protected void onCancelled() { 420 if (DEBUG) { 421 LogUtils.i(LOG_TAG, "[Ignoring refresh result: %d]", hashCode()); 422 } 423 if (mCursor != null) { 424 mCursor.close(); 425 } 426 } 427 } 428 429 private UnderlyingCursorWrapper doQuery(boolean withLimit) { 430 Uri uri = qUri; 431 if (withLimit) { 432 uri = uri.buildUpon().appendQueryParameter(ConversationListQueryParameters.LIMIT, 433 ConversationListQueryParameters.DEFAULT_LIMIT).build(); 434 } 435 long time = System.currentTimeMillis(); 436 437 final Cursor result = mResolver.query(uri, qProjection, null, null, null); 438 if (result == null) { 439 Log.w(LOG_TAG, "doQuery returning null cursor, uri: " + uri); 440 } else if (DEBUG) { 441 time = System.currentTimeMillis() - time; 442 LogUtils.i(LOG_TAG, "ConversationCursor query: %s, %dms, %d results", 443 uri, time, result.getCount()); 444 } 445 return new UnderlyingCursorWrapper(result); 446 } 447 448 static boolean offUiThread() { 449 return Looper.getMainLooper().getThread() != Thread.currentThread(); 450 } 451 452 /** 453 * Reset the cursor; this involves clearing out our cache map and resetting our various counts 454 * The cursor should be reset whenever we get fresh data from the underlying cursor. The cache 455 * is locked during the reset, which will block the UI, but for only a very short time 456 * (estimated at a few ms, but we can profile this; remember that the cache will usually 457 * be empty or have a few entries) 458 */ 459 private void resetCursor(UnderlyingCursorWrapper newCursorWrapper) { 460 synchronized (mCacheMapLock) { 461 // Walk through the cache 462 final Iterator<Map.Entry<String, ContentValues>> iter = 463 mCacheMap.entrySet().iterator(); 464 final long now = System.currentTimeMillis(); 465 while (iter.hasNext()) { 466 Map.Entry<String, ContentValues> entry = iter.next(); 467 final ContentValues values = entry.getValue(); 468 final String key = entry.getKey(); 469 boolean withinTimeWindow = false; 470 boolean removed = false; 471 if (values != null) { 472 Long updateTime = values.getAsLong(UPDATE_TIME_COLUMN); 473 if (updateTime != null && ((now - updateTime) < REQUERY_ALLOWANCE_TIME)) { 474 LogUtils.d(LOG_TAG, "IN resetCursor, keep recent changes to %s", key); 475 withinTimeWindow = true; 476 } else if (updateTime == null) { 477 LogUtils.e(LOG_TAG, "null updateTime from mCacheMap for key: %s", key); 478 } 479 if (values.containsKey(DELETED_COLUMN)) { 480 // Item is deleted locally AND deleted in the new cursor. 481 if (!newCursorWrapper.contains(key)) { 482 // Keep the deleted count up-to-date; remove the 483 // cache entry 484 mDeletedCount--; 485 removed = true; 486 LogUtils.d(LOG_TAG, 487 "IN resetCursor, sDeletedCount decremented to: %d by %s", 488 mDeletedCount, key); 489 } 490 } 491 } else { 492 LogUtils.e(LOG_TAG, "null ContentValues from mCacheMap for key: %s", key); 493 } 494 // Remove the entry if it was time for an update or the item was deleted by the user. 495 if (!withinTimeWindow || removed) { 496 iter.remove(); 497 } 498 } 499 500 // Swap cursor 501 if (mUnderlyingCursor != null) { 502 close(); 503 } 504 mUnderlyingCursor = newCursorWrapper; 505 506 mPosition = -1; 507 mUnderlyingCursor.moveToPosition(mPosition); 508 if (!mCursorObserverRegistered) { 509 mUnderlyingCursor.registerContentObserver(mCursorObserver); 510 mCursorObserverRegistered = true; 511 } 512 mRefreshRequired = false; 513 } 514 } 515 516 /** 517 * Returns the conversation uris for the Conversations that the ConversationCursor is treating 518 * as deleted. This is an optimization to allow clients to determine if an item has been 519 * removed, without having to iterate through the whole cursor 520 */ 521 public Set<String> getDeletedItems() { 522 synchronized (mCacheMapLock) { 523 // Walk through the cache and return the list of uris that have been deleted 524 final Set<String> deletedItems = Sets.newHashSet(); 525 final Iterator<Map.Entry<String, ContentValues>> iter = 526 mCacheMap.entrySet().iterator(); 527 while (iter.hasNext()) { 528 final Map.Entry<String, ContentValues> entry = iter.next(); 529 final ContentValues values = entry.getValue(); 530 if (values.containsKey(DELETED_COLUMN)) { 531 // Since clients of the conversation cursor see conversation ConversationCursor 532 // provider uris, we need to make sure that this also returns these uris 533 final Uri conversationUri = Uri.parse(entry.getKey()); 534 deletedItems.add(uriToCachingUriString(conversationUri)) ; 535 } 536 } 537 return deletedItems; 538 } 539 } 540 541 /** 542 * Returns the position, in the ConversationCursor, of the Conversation with the specified id. 543 * The returned posision will take into account any items that have been deleted. 544 */ 545 public int getConversationPosition(long conversationId) { 546 final int underlyingPosition = mUnderlyingCursor.getPosition(conversationId); 547 if (underlyingPosition < 0) { 548 // The conversation wasn't found in the underlying cursor, return the underlying result. 549 return underlyingPosition; 550 } 551 552 // Walk through each of the deleted items. If the deleted item is before the underlying 553 // position, decrement the position 554 synchronized (mCacheMapLock) { 555 int updatedPosition = underlyingPosition; 556 final Iterator<Map.Entry<String, ContentValues>> iter = 557 mCacheMap.entrySet().iterator(); 558 while (iter.hasNext()) { 559 final Map.Entry<String, ContentValues> entry = iter.next(); 560 final ContentValues values = entry.getValue(); 561 if (values.containsKey(DELETED_COLUMN)) { 562 // Since clients of the conversation cursor see conversation ConversationCursor 563 // provider uris, we need to make sure that this also returns these uris 564 final String conversationUri = entry.getKey(); 565 final int deletedItemPosition = mUnderlyingCursor.getPosition(conversationUri); 566 if (deletedItemPosition == underlyingPosition) { 567 // The requested items has been deleted. 568 return -1; 569 } 570 571 if (deletedItemPosition >= 0 && deletedItemPosition < underlyingPosition) { 572 // This item has been deleted, but is still in the underlying cursor, at 573 // a position before the requested item. Decrement the position of the 574 // requested item. 575 updatedPosition--; 576 } 577 } 578 } 579 return updatedPosition; 580 } 581 } 582 583 /** 584 * Add a listener for this cursor; we'll notify it when our data changes 585 */ 586 public void addListener(ConversationListener listener) { 587 synchronized (mListeners) { 588 if (!mListeners.contains(listener)) { 589 mListeners.add(listener); 590 } else { 591 LogUtils.d(LOG_TAG, "Ignoring duplicate add of listener"); 592 } 593 } 594 } 595 596 /** 597 * Remove a listener for this cursor 598 */ 599 public void removeListener(ConversationListener listener) { 600 synchronized(mListeners) { 601 mListeners.remove(listener); 602 } 603 } 604 605 /** 606 * Generate a forwarding Uri to ConversationProvider from an original Uri. We do this by 607 * changing the authority to ours, but otherwise leaving the Uri intact. 608 * NOTE: This won't handle query parameters, so the functionality will need to be added if 609 * parameters are used in the future 610 * @param uri the uri 611 * @return a forwarding uri to ConversationProvider 612 */ 613 private static String uriToCachingUriString (Uri uri) { 614 final String provider = uri.getAuthority(); 615 return uri.getScheme() + "://" + ConversationProvider.AUTHORITY 616 + "/" + provider + uri.getPath(); 617 } 618 619 /** 620 * Regenerate the original Uri from a forwarding (ConversationProvider) Uri 621 * NOTE: See note above for uriToCachingUri 622 * @param uri the forwarding Uri 623 * @return the original Uri 624 */ 625 private static Uri uriFromCachingUri(Uri uri) { 626 String authority = uri.getAuthority(); 627 // Don't modify uri's that aren't ours 628 if (!authority.equals(ConversationProvider.AUTHORITY)) { 629 return uri; 630 } 631 List<String> path = uri.getPathSegments(); 632 Uri.Builder builder = new Uri.Builder().scheme(uri.getScheme()).authority(path.get(0)); 633 for (int i = 1; i < path.size(); i++) { 634 builder.appendPath(path.get(i)); 635 } 636 return builder.build(); 637 } 638 639 private static String uriStringFromCachingUri(Uri uri) { 640 Uri underlyingUri = uriFromCachingUri(uri); 641 // Remember to decode the underlying Uri as it might be encoded (as w/ Gmail) 642 return Uri.decode(underlyingUri.toString()); 643 } 644 645 public void setConversationColumn(Uri conversationUri, String columnName, Object value) { 646 final String uriStr = uriStringFromCachingUri(conversationUri); 647 synchronized (mCacheMapLock) { 648 cacheValue(uriStr, columnName, value); 649 } 650 notifyDataChanged(); 651 } 652 653 /** 654 * Cache a column name/value pair for a given Uri 655 * @param uriString the Uri for which the column name/value pair applies 656 * @param columnName the column name 657 * @param value the value to be cached 658 */ 659 private void cacheValue(String uriString, String columnName, Object value) { 660 // Calling this method off the UI thread will mess with ListView's reading of the cursor's 661 // count 662 if (offUiThread()) { 663 LogUtils.e(LOG_TAG, new Error(), 664 "cacheValue incorrectly being called from non-UI thread"); 665 } 666 667 synchronized (mCacheMapLock) { 668 // Get the map for our uri 669 ContentValues map = mCacheMap.get(uriString); 670 // Create one if necessary 671 if (map == null) { 672 map = new ContentValues(); 673 mCacheMap.put(uriString, map); 674 } 675 // If we're caching a deletion, add to our count 676 if (columnName == DELETED_COLUMN) { 677 final boolean state = (Boolean)value; 678 final boolean hasValue = map.get(columnName) != null; 679 if (state && !hasValue) { 680 mDeletedCount++; 681 if (DEBUG) { 682 LogUtils.i(LOG_TAG, "Deleted %s, incremented deleted count=%d", uriString, 683 mDeletedCount); 684 } 685 } else if (!state && hasValue) { 686 mDeletedCount--; 687 map.remove(columnName); 688 if (DEBUG) { 689 LogUtils.i(LOG_TAG, "Undeleted %s, decremented deleted count=%d", uriString, 690 mDeletedCount); 691 } 692 return; 693 } else if (!state) { 694 // Trying to undelete, but it's not deleted; just return 695 if (DEBUG) { 696 LogUtils.i(LOG_TAG, "Undeleted %s, IGNORING, deleted count=%d", uriString, 697 mDeletedCount); 698 } 699 return; 700 } 701 } 702 putInValues(map, columnName, value); 703 map.put(UPDATE_TIME_COLUMN, System.currentTimeMillis()); 704 if (DEBUG && (columnName != DELETED_COLUMN)) { 705 LogUtils.i(LOG_TAG, "Caching value for %s: %s", uriString, columnName); 706 } 707 } 708 } 709 710 /** 711 * Get the cached value for the provided column; we special case -1 as the "deleted" column 712 * @param columnIndex the index of the column whose cached value we want to retrieve 713 * @return the cached value for this column, or null if there is none 714 */ 715 private Object getCachedValue(int columnIndex) { 716 final String uri = mUnderlyingCursor.getInnerUri(); 717 return getCachedValue(uri, columnIndex); 718 } 719 720 private Object getCachedValue(String uri, int columnIndex) { 721 ContentValues uriMap = mCacheMap.get(uri); 722 if (uriMap != null) { 723 String columnName; 724 if (columnIndex == DELETED_COLUMN_INDEX) { 725 columnName = DELETED_COLUMN; 726 } else { 727 columnName = mColumnNames[columnIndex]; 728 } 729 return uriMap.get(columnName); 730 } 731 return null; 732 } 733 734 /** 735 * When the underlying cursor changes, we want to alert the listener 736 */ 737 private void underlyingChanged() { 738 synchronized(mCacheMapLock) { 739 if (mCursorObserverRegistered) { 740 try { 741 mUnderlyingCursor.unregisterContentObserver(mCursorObserver); 742 } catch (IllegalStateException e) { 743 // Maybe the cursor was GC'd? 744 } 745 mCursorObserverRegistered = false; 746 } 747 mRefreshRequired = true; 748 if (!mPaused) { 749 notifyRefreshRequired(); 750 } 751 } 752 } 753 754 /** 755 * Must be called on UI thread; notify listeners that a refresh is required 756 */ 757 private void notifyRefreshRequired() { 758 if (DEBUG) { 759 LogUtils.i(LOG_TAG, "[Notify %s: onRefreshRequired()]", mName); 760 } 761 if (!mDeferSync) { 762 synchronized(mListeners) { 763 for (ConversationListener listener: mListeners) { 764 listener.onRefreshRequired(); 765 } 766 } 767 } 768 } 769 770 /** 771 * Must be called on UI thread; notify listeners that a new cursor is ready 772 */ 773 private void notifyRefreshReady() { 774 if (DEBUG) { 775 LogUtils.i(LOG_TAG, "[Notify %s: onRefreshReady(), %d listeners]", 776 mName, mListeners.size()); 777 } 778 synchronized(mListeners) { 779 for (ConversationListener listener: mListeners) { 780 listener.onRefreshReady(); 781 } 782 } 783 } 784 785 /** 786 * Must be called on UI thread; notify listeners that data has changed 787 */ 788 private void notifyDataChanged() { 789 if (DEBUG) { 790 LogUtils.i(LOG_TAG, "[Notify %s: onDataSetChanged()]", mName); 791 } 792 synchronized(mListeners) { 793 for (ConversationListener listener: mListeners) { 794 listener.onDataSetChanged(); 795 } 796 } 797 798 handleNotificationActions(); 799 } 800 801 /** 802 * Put the refreshed cursor in place (called by the UI) 803 */ 804 public void sync() { 805 if (mRequeryCursor == null) { 806 // This can happen during an animated deletion, if the UI isn't keeping track, or 807 // if a new query intervened (i.e. user changed folders) 808 if (DEBUG) { 809 LogUtils.i(LOG_TAG, "[sync() %s; no requery cursor]", mName); 810 } 811 return; 812 } 813 synchronized(mCacheMapLock) { 814 if (DEBUG) { 815 LogUtils.i(LOG_TAG, "[sync() %s]", mName); 816 } 817 resetCursor(mRequeryCursor); 818 mRequeryCursor = null; 819 mRefreshTask = null; 820 mRefreshReady = false; 821 } 822 notifyDataChanged(); 823 } 824 825 public boolean isRefreshRequired() { 826 return mRefreshRequired; 827 } 828 829 public boolean isRefreshReady() { 830 return mRefreshReady; 831 } 832 833 /** 834 * When we get a requery from the UI, we'll do it, but also clear the cache. The listener is 835 * notified when the requery is complete 836 * NOTE: This will have to change, of course, when we start using loaders... 837 */ 838 public boolean refresh() { 839 if (DEBUG) { 840 LogUtils.i(LOG_TAG, "[refresh() %s]", mName); 841 } 842 synchronized(mCacheMapLock) { 843 if (mRefreshTask != null) { 844 if (DEBUG) { 845 LogUtils.i(LOG_TAG, "[refresh() %s returning; already running %d]", 846 mName, mRefreshTask.hashCode()); 847 } 848 return false; 849 } 850 mRefreshTask = new RefreshTask(); 851 mRefreshTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); 852 } 853 return true; 854 } 855 856 public void disable() { 857 close(); 858 mCacheMap.clear(); 859 mListeners.clear(); 860 mUnderlyingCursor = null; 861 } 862 863 @Override 864 public void close() { 865 if (mUnderlyingCursor != null && !mUnderlyingCursor.isClosed()) { 866 // Unregister our observer on the underlying cursor and close as usual 867 if (mCursorObserverRegistered) { 868 try { 869 mUnderlyingCursor.unregisterContentObserver(mCursorObserver); 870 } catch (IllegalStateException e) { 871 // Maybe the cursor got GC'd? 872 } 873 mCursorObserverRegistered = false; 874 } 875 mUnderlyingCursor.close(); 876 } 877 } 878 879 /** 880 * Move to the next not-deleted item in the conversation 881 */ 882 @Override 883 public boolean moveToNext() { 884 while (true) { 885 boolean ret = mUnderlyingCursor.moveToNext(); 886 if (!ret) { 887 mPosition = getCount(); 888 if (DEBUG) { 889 LogUtils.i(LOG_TAG, "*** moveToNext returns false: pos = %d, und = %d" + 890 ", del = %d", mPosition, mUnderlyingCursor.getPosition(), 891 mDeletedCount); 892 } 893 return false; 894 } 895 if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue; 896 mPosition++; 897 return true; 898 } 899 } 900 901 /** 902 * Move to the previous not-deleted item in the conversation 903 */ 904 @Override 905 public boolean moveToPrevious() { 906 while (true) { 907 boolean ret = mUnderlyingCursor.moveToPrevious(); 908 if (!ret) { 909 // Make sure we're before the first position 910 mPosition = -1; 911 return false; 912 } 913 if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue; 914 mPosition--; 915 return true; 916 } 917 } 918 919 @Override 920 public int getPosition() { 921 return mPosition; 922 } 923 924 /** 925 * The actual cursor's count must be decremented by the number we've deleted from the UI 926 */ 927 @Override 928 public int getCount() { 929 if (mUnderlyingCursor == null) { 930 throw new IllegalStateException( 931 "getCount() on disabled cursor: " + mName + "(" + qUri + ")"); 932 } 933 return mUnderlyingCursor.getCount() - mDeletedCount; 934 } 935 936 @Override 937 public boolean moveToFirst() { 938 if (mUnderlyingCursor == null) { 939 throw new IllegalStateException( 940 "moveToFirst() on disabled cursor: " + mName + "(" + qUri + ")"); 941 } 942 mUnderlyingCursor.moveToPosition(-1); 943 mPosition = -1; 944 return moveToNext(); 945 } 946 947 @Override 948 public boolean moveToPosition(int pos) { 949 if (mUnderlyingCursor == null) { 950 throw new IllegalStateException( 951 "moveToPosition() on disabled cursor: " + mName + "(" + qUri + ")"); 952 } 953 // Handle the "move to first" case before anything else; moveToPosition(0) in an empty 954 // SQLiteCursor moves the position to 0 when returning false, which we will mirror. 955 // But we don't want to return true on a subsequent "move to first", which we would if we 956 // check pos vs mPosition first 957 if (mUnderlyingCursor.getPosition() == -1) { 958 LogUtils.d(LOG_TAG, "*** Underlying cursor position is -1 asking to move from %d to %d", 959 mPosition, pos); 960 } 961 if (pos == 0) { 962 return moveToFirst(); 963 } else if (pos < 0) { 964 mPosition = -1; 965 mUnderlyingCursor.moveToPosition(mPosition); 966 return false; 967 } else if (pos == mPosition) { 968 // Return false if we're past the end of the cursor 969 return pos < getCount(); 970 } else if (pos > mPosition) { 971 while (pos > mPosition) { 972 if (!moveToNext()) { 973 return false; 974 } 975 } 976 return true; 977 } else if ((pos >= 0) && (mPosition - pos) > pos) { 978 // Optimization if it's easier to move forward to position instead of backward 979 if (DEBUG) { 980 LogUtils.i(LOG_TAG, "*** Move from %d to %d, starting from first", mPosition, pos); 981 } 982 moveToFirst(); 983 return moveToPosition(pos); 984 } else { 985 while (pos < mPosition) { 986 if (!moveToPrevious()) { 987 return false; 988 } 989 } 990 return true; 991 } 992 } 993 994 /** 995 * Make sure mPosition is correct after locally deleting/undeleting items 996 */ 997 private void recalibratePosition() { 998 final int pos = mPosition; 999 moveToFirst(); 1000 moveToPosition(pos); 1001 } 1002 1003 @Override 1004 public boolean moveToLast() { 1005 throw new UnsupportedOperationException("moveToLast unsupported!"); 1006 } 1007 1008 @Override 1009 public boolean move(int offset) { 1010 throw new UnsupportedOperationException("move unsupported!"); 1011 } 1012 1013 /** 1014 * We need to override all of the getters to make sure they look at cached values before using 1015 * the values in the underlying cursor 1016 */ 1017 @Override 1018 public double getDouble(int columnIndex) { 1019 Object obj = getCachedValue(columnIndex); 1020 if (obj != null) return (Double)obj; 1021 return mUnderlyingCursor.getDouble(columnIndex); 1022 } 1023 1024 @Override 1025 public float getFloat(int columnIndex) { 1026 Object obj = getCachedValue(columnIndex); 1027 if (obj != null) return (Float)obj; 1028 return mUnderlyingCursor.getFloat(columnIndex); 1029 } 1030 1031 @Override 1032 public int getInt(int columnIndex) { 1033 Object obj = getCachedValue(columnIndex); 1034 if (obj != null) return (Integer)obj; 1035 return mUnderlyingCursor.getInt(columnIndex); 1036 } 1037 1038 @Override 1039 public long getLong(int columnIndex) { 1040 Object obj = getCachedValue(columnIndex); 1041 if (obj != null) return (Long)obj; 1042 return mUnderlyingCursor.getLong(columnIndex); 1043 } 1044 1045 @Override 1046 public short getShort(int columnIndex) { 1047 Object obj = getCachedValue(columnIndex); 1048 if (obj != null) return (Short)obj; 1049 return mUnderlyingCursor.getShort(columnIndex); 1050 } 1051 1052 @Override 1053 public String getString(int columnIndex) { 1054 // If we're asking for the Uri for the conversation list, we return a forwarding URI 1055 // so that we can intercept update/delete and handle it ourselves 1056 if (columnIndex == URI_COLUMN_INDEX) { 1057 return mUnderlyingCursor.getWrappedUri(); 1058 } 1059 Object obj = getCachedValue(columnIndex); 1060 if (obj != null) return (String)obj; 1061 return mUnderlyingCursor.getString(columnIndex); 1062 } 1063 1064 @Override 1065 public byte[] getBlob(int columnIndex) { 1066 Object obj = getCachedValue(columnIndex); 1067 if (obj != null) return (byte[])obj; 1068 return mUnderlyingCursor.getBlob(columnIndex); 1069 } 1070 1071 public Conversation getConversation() { 1072 Conversation c = mUnderlyingCursor.getConversation(); 1073 1074 if (c == null) { 1075 // not pre-cached. fall back to just-in-time construction. 1076 c = new Conversation(this); 1077 } else { 1078 // apply any cached values 1079 // but skip over any cached values that aren't part of the cursor projection 1080 final ContentValues values = mCacheMap.get(mUnderlyingCursor.getInnerUri()); 1081 if (values != null) { 1082 final ContentValues queryableValues = new ContentValues(); 1083 for (String key : values.keySet()) { 1084 if (!mColumnNameSet.contains(key)) { 1085 continue; 1086 } 1087 putInValues(queryableValues, key, values.get(key)); 1088 } 1089 if (queryableValues.size() > 0) { 1090 // copy-on-write to help ensure the underlying cached Conversation is immutable 1091 // of course, any callers this method should also try not to modify them 1092 // overmuch... 1093 c = new Conversation(c); 1094 c.applyCachedValues(queryableValues); 1095 } 1096 } 1097 } 1098 1099 return c; 1100 } 1101 1102 private static void putInValues(ContentValues dest, String key, Object value) { 1103 // ContentValues has no generic "put", so we must test. For now, the only classes 1104 // of values implemented are Boolean/Integer/String/Blob, though others are trivially 1105 // added 1106 if (value instanceof Boolean) { 1107 dest.put(key, ((Boolean) value).booleanValue() ? 1 : 0); 1108 } else if (value instanceof Integer) { 1109 dest.put(key, (Integer) value); 1110 } else if (value instanceof String) { 1111 dest.put(key, (String) value); 1112 } else if (value instanceof byte[]) { 1113 dest.put(key, (byte[])value); 1114 } else { 1115 final String cname = value.getClass().getName(); 1116 throw new IllegalArgumentException("Value class not compatible with cache: " 1117 + cname); 1118 } 1119 } 1120 1121 /** 1122 * Observer of changes to underlying data 1123 */ 1124 private class CursorObserver extends ContentObserver { 1125 public CursorObserver(Handler handler) { 1126 super(handler); 1127 } 1128 1129 @Override 1130 public void onChange(boolean selfChange) { 1131 // If we're here, then something outside of the UI has changed the data, and we 1132 // must query the underlying provider for that data; 1133 ConversationCursor.this.underlyingChanged(); 1134 } 1135 } 1136 1137 /** 1138 * ConversationProvider is the ContentProvider for our forwarding Uri's; it passes queries 1139 * and inserts directly, and caches updates/deletes before passing them through. The caching 1140 * will cause a redraw of the list with updated values. 1141 */ 1142 public abstract static class ConversationProvider extends ContentProvider { 1143 public static String AUTHORITY; 1144 private ContentResolver mResolver; 1145 1146 /** 1147 * Allows the implementing provider to specify the authority that should be used. 1148 */ 1149 protected abstract String getAuthority(); 1150 1151 @Override 1152 public boolean onCreate() { 1153 sProvider = this; 1154 AUTHORITY = getAuthority(); 1155 mResolver = getContext().getContentResolver(); 1156 return true; 1157 } 1158 1159 @Override 1160 public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, 1161 String sortOrder) { 1162 return mResolver.query( 1163 uriFromCachingUri(uri), projection, selection, selectionArgs, sortOrder); 1164 } 1165 1166 @Override 1167 public Uri insert(Uri uri, ContentValues values) { 1168 insertLocal(uri, values); 1169 return ProviderExecute.opInsert(mResolver, uri, values); 1170 } 1171 1172 @Override 1173 public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { 1174 throw new IllegalStateException("Unexpected call to ConversationProvider.update"); 1175 } 1176 1177 @Override 1178 public int delete(Uri uri, String selection, String[] selectionArgs) { 1179 throw new IllegalStateException("Unexpected call to ConversationProvider.delete"); 1180 } 1181 1182 @Override 1183 public String getType(Uri uri) { 1184 return null; 1185 } 1186 1187 /** 1188 * Quick and dirty class that executes underlying provider CRUD operations on a background 1189 * thread. 1190 */ 1191 static class ProviderExecute implements Runnable { 1192 static final int DELETE = 0; 1193 static final int INSERT = 1; 1194 static final int UPDATE = 2; 1195 1196 final int mCode; 1197 final Uri mUri; 1198 final ContentValues mValues; //HEHEH 1199 final ContentResolver mResolver; 1200 1201 ProviderExecute(int code, ContentResolver resolver, Uri uri, ContentValues values) { 1202 mCode = code; 1203 mUri = uriFromCachingUri(uri); 1204 mValues = values; 1205 mResolver = resolver; 1206 } 1207 1208 static Uri opInsert(ContentResolver resolver, Uri uri, ContentValues values) { 1209 ProviderExecute e = new ProviderExecute(INSERT, resolver, uri, values); 1210 if (offUiThread()) return (Uri)e.go(); 1211 new Thread(e).start(); 1212 return null; 1213 } 1214 1215 @Override 1216 public void run() { 1217 go(); 1218 } 1219 1220 public Object go() { 1221 switch(mCode) { 1222 case DELETE: 1223 return mResolver.delete(mUri, null, null); 1224 case INSERT: 1225 return mResolver.insert(mUri, mValues); 1226 case UPDATE: 1227 return mResolver.update(mUri, mValues, null, null); 1228 default: 1229 return null; 1230 } 1231 } 1232 } 1233 1234 private void insertLocal(Uri uri, ContentValues values) { 1235 // Placeholder for now; there's no local insert 1236 } 1237 1238 private int mUndoSequence = 0; 1239 private ArrayList<Uri> mUndoDeleteUris = new ArrayList<Uri>(); 1240 1241 void addToUndoSequence(Uri uri) { 1242 if (sSequence != mUndoSequence) { 1243 mUndoSequence = sSequence; 1244 mUndoDeleteUris.clear(); 1245 } 1246 mUndoDeleteUris.add(uri); 1247 } 1248 1249 @VisibleForTesting 1250 void deleteLocal(Uri uri, ConversationCursor conversationCursor) { 1251 String uriString = uriStringFromCachingUri(uri); 1252 conversationCursor.cacheValue(uriString, DELETED_COLUMN, true); 1253 addToUndoSequence(uri); 1254 } 1255 1256 @VisibleForTesting 1257 void undeleteLocal(Uri uri, ConversationCursor conversationCursor) { 1258 String uriString = uriStringFromCachingUri(uri); 1259 conversationCursor.cacheValue(uriString, DELETED_COLUMN, false); 1260 } 1261 1262 void setMostlyDead(Conversation conv, ConversationCursor conversationCursor) { 1263 Uri uri = conv.uri; 1264 String uriString = uriStringFromCachingUri(uri); 1265 conversationCursor.setMostlyDead(uriString, conv); 1266 addToUndoSequence(uri); 1267 } 1268 1269 void commitMostlyDead(Conversation conv, ConversationCursor conversationCursor) { 1270 conversationCursor.commitMostlyDead(conv); 1271 } 1272 1273 boolean clearMostlyDead(Uri uri, ConversationCursor conversationCursor) { 1274 String uriString = uriStringFromCachingUri(uri); 1275 return conversationCursor.clearMostlyDead(uriString); 1276 } 1277 1278 public void undo(ConversationCursor conversationCursor) { 1279 if (sSequence == mUndoSequence) { 1280 for (Uri uri: mUndoDeleteUris) { 1281 if (!clearMostlyDead(uri, conversationCursor)) { 1282 undeleteLocal(uri, conversationCursor); 1283 } 1284 } 1285 mUndoSequence = 0; 1286 conversationCursor.recalibratePosition(); 1287 // Notify listeners that there was a change to the underlying 1288 // cursor to add back in some items. 1289 conversationCursor.notifyDataChanged(); 1290 } 1291 } 1292 1293 @VisibleForTesting 1294 void updateLocal(Uri uri, ContentValues values, ConversationCursor conversationCursor) { 1295 if (values == null) { 1296 return; 1297 } 1298 String uriString = uriStringFromCachingUri(uri); 1299 for (String columnName: values.keySet()) { 1300 conversationCursor.cacheValue(uriString, columnName, values.get(columnName)); 1301 } 1302 } 1303 1304 public int apply(Collection<ConversationOperation> ops, 1305 ConversationCursor conversationCursor) { 1306 final HashMap<String, ArrayList<ContentProviderOperation>> batchMap = 1307 new HashMap<String, ArrayList<ContentProviderOperation>>(); 1308 // Increment sequence count 1309 sSequence++; 1310 1311 // Execute locally and build CPO's for underlying provider 1312 boolean recalibrateRequired = false; 1313 for (ConversationOperation op: ops) { 1314 Uri underlyingUri = uriFromCachingUri(op.mUri); 1315 String authority = underlyingUri.getAuthority(); 1316 ArrayList<ContentProviderOperation> authOps = batchMap.get(authority); 1317 if (authOps == null) { 1318 authOps = new ArrayList<ContentProviderOperation>(); 1319 batchMap.put(authority, authOps); 1320 } 1321 ContentProviderOperation cpo = op.execute(underlyingUri); 1322 if (cpo != null) { 1323 authOps.add(cpo); 1324 } 1325 // Keep track of whether our operations require recalibrating the cursor position 1326 if (op.mRecalibrateRequired) { 1327 recalibrateRequired = true; 1328 } 1329 } 1330 1331 // Recalibrate cursor position if required 1332 if (recalibrateRequired) { 1333 conversationCursor.recalibratePosition(); 1334 } 1335 1336 // Notify listeners that data has changed 1337 conversationCursor.notifyDataChanged(); 1338 1339 // Send changes to underlying provider 1340 final boolean notUiThread = offUiThread(); 1341 for (final String authority: batchMap.keySet()) { 1342 final ArrayList<ContentProviderOperation> opList = batchMap.get(authority); 1343 if (notUiThread) { 1344 try { 1345 mResolver.applyBatch(authority, opList); 1346 } catch (RemoteException e) { 1347 } catch (OperationApplicationException e) { 1348 } 1349 } else { 1350 new Thread(new Runnable() { 1351 @Override 1352 public void run() { 1353 try { 1354 mResolver.applyBatch(authority, opList); 1355 } catch (RemoteException e) { 1356 } catch (OperationApplicationException e) { 1357 } 1358 } 1359 }).start(); 1360 } 1361 } 1362 return sSequence; 1363 } 1364 } 1365 1366 void setMostlyDead(String uriString, Conversation conv) { 1367 LogUtils.d(LOG_TAG, "[Mostly dead, deferring: %s] ", uriString); 1368 cacheValue(uriString, 1369 UIProvider.ConversationColumns.FLAGS, Conversation.FLAG_MOSTLY_DEAD); 1370 conv.convFlags |= Conversation.FLAG_MOSTLY_DEAD; 1371 mMostlyDead.add(conv); 1372 mDeferSync = true; 1373 } 1374 1375 void commitMostlyDead(Conversation conv) { 1376 conv.convFlags &= ~Conversation.FLAG_MOSTLY_DEAD; 1377 mMostlyDead.remove(conv); 1378 LogUtils.d(LOG_TAG, "[All dead: %s]", conv.uri); 1379 if (mMostlyDead.isEmpty()) { 1380 mDeferSync = false; 1381 checkNotifyUI(); 1382 } 1383 } 1384 1385 boolean clearMostlyDead(String uriString) { 1386 Object val = getCachedValue(uriString, 1387 UIProvider.CONVERSATION_FLAGS_COLUMN); 1388 if (val != null) { 1389 int flags = ((Integer)val).intValue(); 1390 if ((flags & Conversation.FLAG_MOSTLY_DEAD) != 0) { 1391 cacheValue(uriString, UIProvider.ConversationColumns.FLAGS, 1392 flags &= ~Conversation.FLAG_MOSTLY_DEAD); 1393 return true; 1394 } 1395 } 1396 return false; 1397 } 1398 1399 1400 1401 1402 /** 1403 * ConversationOperation is the encapsulation of a ContentProvider operation to be performed 1404 * atomically as part of a "batch" operation. 1405 */ 1406 public class ConversationOperation { 1407 private static final int MOSTLY = 0x80; 1408 public static final int DELETE = 0; 1409 public static final int INSERT = 1; 1410 public static final int UPDATE = 2; 1411 public static final int ARCHIVE = 3; 1412 public static final int MUTE = 4; 1413 public static final int REPORT_SPAM = 5; 1414 public static final int REPORT_NOT_SPAM = 6; 1415 public static final int REPORT_PHISHING = 7; 1416 public static final int DISCARD_DRAFTS = 8; 1417 public static final int MOSTLY_ARCHIVE = MOSTLY | ARCHIVE; 1418 public static final int MOSTLY_DELETE = MOSTLY | DELETE; 1419 public static final int MOSTLY_DESTRUCTIVE_UPDATE = MOSTLY | UPDATE; 1420 1421 private final int mType; 1422 private final Uri mUri; 1423 private final Conversation mConversation; 1424 private final ContentValues mValues; 1425 // True if an updated item should be removed locally (from ConversationCursor) 1426 // This would be the case for a folder change in which the conversation is no longer 1427 // in the folder represented by the ConversationCursor 1428 private final boolean mLocalDeleteOnUpdate; 1429 // After execution, this indicates whether or not the operation requires recalibration of 1430 // the current cursor position (i.e. it removed or added items locally) 1431 private boolean mRecalibrateRequired = true; 1432 // Whether this item is already mostly dead 1433 private final boolean mMostlyDead; 1434 1435 public ConversationOperation(int type, Conversation conv) { 1436 this(type, conv, null); 1437 } 1438 1439 public ConversationOperation(int type, Conversation conv, ContentValues values) { 1440 mType = type; 1441 mUri = conv.uri; 1442 mConversation = conv; 1443 mValues = values; 1444 mLocalDeleteOnUpdate = conv.localDeleteOnUpdate; 1445 mMostlyDead = conv.isMostlyDead(); 1446 } 1447 1448 private ContentProviderOperation execute(Uri underlyingUri) { 1449 Uri uri = underlyingUri.buildUpon() 1450 .appendQueryParameter(UIProvider.SEQUENCE_QUERY_PARAMETER, 1451 Integer.toString(sSequence)) 1452 .build(); 1453 ContentProviderOperation op = null; 1454 switch(mType) { 1455 case UPDATE: 1456 if (mLocalDeleteOnUpdate) { 1457 sProvider.deleteLocal(mUri, ConversationCursor.this); 1458 } else { 1459 sProvider.updateLocal(mUri, mValues, ConversationCursor.this); 1460 mRecalibrateRequired = false; 1461 } 1462 if (!mMostlyDead) { 1463 op = ContentProviderOperation.newUpdate(uri) 1464 .withValues(mValues) 1465 .build(); 1466 } else { 1467 sProvider.commitMostlyDead(mConversation, ConversationCursor.this); 1468 } 1469 break; 1470 case MOSTLY_DESTRUCTIVE_UPDATE: 1471 sProvider.setMostlyDead(mConversation, ConversationCursor.this); 1472 op = ContentProviderOperation.newUpdate(uri).withValues(mValues).build(); 1473 break; 1474 case INSERT: 1475 sProvider.insertLocal(mUri, mValues); 1476 op = ContentProviderOperation.newInsert(uri) 1477 .withValues(mValues).build(); 1478 break; 1479 // Destructive actions below! 1480 // "Mostly" operations are reflected globally, but not locally, except to set 1481 // FLAG_MOSTLY_DEAD in the conversation itself 1482 case DELETE: 1483 sProvider.deleteLocal(mUri, ConversationCursor.this); 1484 if (!mMostlyDead) { 1485 op = ContentProviderOperation.newDelete(uri).build(); 1486 } else { 1487 sProvider.commitMostlyDead(mConversation, ConversationCursor.this); 1488 } 1489 break; 1490 case MOSTLY_DELETE: 1491 sProvider.setMostlyDead(mConversation,ConversationCursor.this); 1492 op = ContentProviderOperation.newDelete(uri).build(); 1493 break; 1494 case ARCHIVE: 1495 sProvider.deleteLocal(mUri, ConversationCursor.this); 1496 if (!mMostlyDead) { 1497 // Create an update operation that represents archive 1498 op = ContentProviderOperation.newUpdate(uri).withValue( 1499 ConversationOperations.OPERATION_KEY, 1500 ConversationOperations.ARCHIVE) 1501 .build(); 1502 } else { 1503 sProvider.commitMostlyDead(mConversation, ConversationCursor.this); 1504 } 1505 break; 1506 case MOSTLY_ARCHIVE: 1507 sProvider.setMostlyDead(mConversation, ConversationCursor.this); 1508 // Create an update operation that represents archive 1509 op = ContentProviderOperation.newUpdate(uri).withValue( 1510 ConversationOperations.OPERATION_KEY, ConversationOperations.ARCHIVE) 1511 .build(); 1512 break; 1513 case MUTE: 1514 if (mLocalDeleteOnUpdate) { 1515 sProvider.deleteLocal(mUri, ConversationCursor.this); 1516 } 1517 1518 // Create an update operation that represents mute 1519 op = ContentProviderOperation.newUpdate(uri).withValue( 1520 ConversationOperations.OPERATION_KEY, ConversationOperations.MUTE) 1521 .build(); 1522 break; 1523 case REPORT_SPAM: 1524 case REPORT_NOT_SPAM: 1525 sProvider.deleteLocal(mUri, ConversationCursor.this); 1526 1527 final String operation = mType == REPORT_SPAM ? 1528 ConversationOperations.REPORT_SPAM : 1529 ConversationOperations.REPORT_NOT_SPAM; 1530 1531 // Create an update operation that represents report spam 1532 op = ContentProviderOperation.newUpdate(uri).withValue( 1533 ConversationOperations.OPERATION_KEY, operation).build(); 1534 break; 1535 case REPORT_PHISHING: 1536 sProvider.deleteLocal(mUri, ConversationCursor.this); 1537 1538 // Create an update operation that represents report phishing 1539 op = ContentProviderOperation.newUpdate(uri).withValue( 1540 ConversationOperations.OPERATION_KEY, 1541 ConversationOperations.REPORT_PHISHING).build(); 1542 break; 1543 case DISCARD_DRAFTS: 1544 sProvider.deleteLocal(mUri, ConversationCursor.this); 1545 1546 // Create an update operation that represents discarding drafts 1547 op = ContentProviderOperation.newUpdate(uri).withValue( 1548 ConversationOperations.OPERATION_KEY, 1549 ConversationOperations.DISCARD_DRAFTS).build(); 1550 break; 1551 default: 1552 throw new UnsupportedOperationException( 1553 "No such ConversationOperation type: " + mType); 1554 } 1555 1556 return op; 1557 } 1558 } 1559 1560 /** 1561 * For now, a single listener can be associated with the cursor, and for now we'll just 1562 * notify on deletions 1563 */ 1564 public interface ConversationListener { 1565 /** 1566 * Data in the underlying provider has changed; a refresh is required to sync up 1567 */ 1568 public void onRefreshRequired(); 1569 /** 1570 * We've completed a requested refresh of the underlying cursor 1571 */ 1572 public void onRefreshReady(); 1573 /** 1574 * The data underlying the cursor has changed; the UI should redraw the list 1575 */ 1576 public void onDataSetChanged(); 1577 } 1578 1579 @Override 1580 public boolean isFirst() { 1581 throw new UnsupportedOperationException(); 1582 } 1583 1584 @Override 1585 public boolean isLast() { 1586 throw new UnsupportedOperationException(); 1587 } 1588 1589 @Override 1590 public boolean isBeforeFirst() { 1591 throw new UnsupportedOperationException(); 1592 } 1593 1594 @Override 1595 public boolean isAfterLast() { 1596 throw new UnsupportedOperationException(); 1597 } 1598 1599 @Override 1600 public int getColumnIndex(String columnName) { 1601 return mUnderlyingCursor.getColumnIndex(columnName); 1602 } 1603 1604 @Override 1605 public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException { 1606 return mUnderlyingCursor.getColumnIndexOrThrow(columnName); 1607 } 1608 1609 @Override 1610 public String getColumnName(int columnIndex) { 1611 return mUnderlyingCursor.getColumnName(columnIndex); 1612 } 1613 1614 @Override 1615 public String[] getColumnNames() { 1616 return mUnderlyingCursor.getColumnNames(); 1617 } 1618 1619 @Override 1620 public int getColumnCount() { 1621 return mUnderlyingCursor.getColumnCount(); 1622 } 1623 1624 @Override 1625 public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) { 1626 throw new UnsupportedOperationException(); 1627 } 1628 1629 @Override 1630 public int getType(int columnIndex) { 1631 return mUnderlyingCursor.getType(columnIndex); 1632 } 1633 1634 @Override 1635 public boolean isNull(int columnIndex) { 1636 throw new UnsupportedOperationException(); 1637 } 1638 1639 @Override 1640 public void deactivate() { 1641 throw new UnsupportedOperationException(); 1642 } 1643 1644 @Override 1645 public boolean isClosed() { 1646 return mUnderlyingCursor == null || mUnderlyingCursor.isClosed(); 1647 } 1648 1649 @Override 1650 public void registerContentObserver(ContentObserver observer) { 1651 // Nope. We never notify of underlying changes on this channel, since the cursor watches 1652 // internally and offers onRefreshRequired/onRefreshReady to accomplish the same thing. 1653 } 1654 1655 @Override 1656 public void unregisterContentObserver(ContentObserver observer) { 1657 // See above. 1658 } 1659 1660 @Override 1661 public void registerDataSetObserver(DataSetObserver observer) { 1662 // Nope. We use ConversationListener to accomplish this. 1663 } 1664 1665 @Override 1666 public void unregisterDataSetObserver(DataSetObserver observer) { 1667 // See above. 1668 } 1669 1670 @Override 1671 public void setNotificationUri(ContentResolver cr, Uri uri) { 1672 throw new UnsupportedOperationException(); 1673 } 1674 1675 @Override 1676 public boolean getWantsAllOnMoveCalls() { 1677 throw new UnsupportedOperationException(); 1678 } 1679 1680 @Override 1681 public Bundle getExtras() { 1682 return mUnderlyingCursor != null ? mUnderlyingCursor.getExtras() : Bundle.EMPTY; 1683 } 1684 1685 @Override 1686 public Bundle respond(Bundle extras) { 1687 if (mUnderlyingCursor != null) { 1688 return mUnderlyingCursor.respond(extras); 1689 } 1690 return Bundle.EMPTY; 1691 } 1692 1693 @Override 1694 public boolean requery() { 1695 return true; 1696 } 1697 1698 // Below are methods that update Conversation data (update/delete) 1699 1700 public int updateBoolean(Conversation conversation, String columnName, boolean value) { 1701 return updateBoolean(Arrays.asList(conversation), columnName, value); 1702 } 1703 1704 /** 1705 * Update an integer column for a group of conversations (see updateValues below) 1706 */ 1707 public int updateInt(Collection<Conversation> conversations, String columnName, 1708 int value) { 1709 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) { 1710 LogUtils.d(LOG_TAG, "ConversationCursor.updateInt(conversations=%s, columnName=%s)", 1711 conversations.toArray(), columnName); 1712 } 1713 ContentValues cv = new ContentValues(); 1714 cv.put(columnName, value); 1715 return updateValues(conversations, cv); 1716 } 1717 1718 /** 1719 * Update a string column for a group of conversations (see updateValues below) 1720 */ 1721 public int updateBoolean(Collection<Conversation> conversations, String columnName, 1722 boolean value) { 1723 ContentValues cv = new ContentValues(); 1724 cv.put(columnName, value); 1725 return updateValues(conversations, cv); 1726 } 1727 1728 /** 1729 * Update a string column for a group of conversations (see updateValues below) 1730 */ 1731 public int updateString(Collection<Conversation> conversations, String columnName, 1732 String value) { 1733 return updateStrings(conversations, new String[] { 1734 columnName 1735 }, new String[] { 1736 value 1737 }); 1738 } 1739 1740 /** 1741 * Update a string columns for a group of conversations (see updateValues below) 1742 */ 1743 public int updateStrings(Collection<Conversation> conversations, 1744 String[] columnNames, String[] values) { 1745 ContentValues cv = new ContentValues(); 1746 for (int i = 0; i < columnNames.length; i++) { 1747 cv.put(columnNames[i], values[i]); 1748 } 1749 return updateValues(conversations, cv); 1750 } 1751 1752 /** 1753 * Update a boolean column for a group of conversations, immediately in the UI and in a single 1754 * transaction in the underlying provider 1755 * @param conversations a collection of conversations 1756 * @param values the data to update 1757 * @return the sequence number of the operation (for undo) 1758 */ 1759 public int updateValues(Collection<Conversation> conversations, ContentValues values) { 1760 return apply( 1761 getOperationsForConversations(conversations, ConversationOperation.UPDATE, values)); 1762 } 1763 1764 /** 1765 * Apply many operations in a single batch transaction. 1766 * @param op the collection of operations obtained through successive calls to 1767 * {@link #getOperationForConversation(Conversation, int, ContentValues)}. 1768 * @return the sequence number of the operation (for undo) 1769 */ 1770 public int updateBulkValues(Collection<ConversationOperation> op) { 1771 return apply(op); 1772 } 1773 1774 private ArrayList<ConversationOperation> getOperationsForConversations( 1775 Collection<Conversation> conversations, int type, ContentValues values) { 1776 final ArrayList<ConversationOperation> ops = Lists.newArrayList(); 1777 for (Conversation conv: conversations) { 1778 ops.add(getOperationForConversation(conv, type, values)); 1779 } 1780 return ops; 1781 } 1782 1783 public ConversationOperation getOperationForConversation(Conversation conv, int type, 1784 ContentValues values) { 1785 return new ConversationOperation(type, conv, values); 1786 } 1787 1788 public static void addFolderUpdates(ArrayList<Uri> folderUris, ArrayList<Boolean> add, 1789 ContentValues values) { 1790 ArrayList<String> folders = new ArrayList<String>(); 1791 for (int i = 0; i < folderUris.size(); i++) { 1792 folders.add(folderUris.get(i).buildUpon().appendPath(add.get(i) + "").toString()); 1793 } 1794 values.put(ConversationOperations.FOLDERS_UPDATED, 1795 TextUtils.join(ConversationOperations.FOLDERS_UPDATED_SPLIT_PATTERN, folders)); 1796 } 1797 1798 public static void addTargetFolders(Collection<Folder> targetFolders, ContentValues values) { 1799 values.put(Conversation.UPDATE_FOLDER_COLUMN, FolderList.copyOf(targetFolders).toBlob()); 1800 } 1801 1802 public ConversationOperation getConversationFolderOperation(Conversation conv, 1803 ArrayList<Uri> folderUris, ArrayList<Boolean> add, Collection<Folder> targetFolders) { 1804 return getConversationFolderOperation(conv, folderUris, add, targetFolders, 1805 new ContentValues()); 1806 } 1807 1808 public ConversationOperation getConversationFolderOperation(Conversation conv, 1809 ArrayList<Uri> folderUris, ArrayList<Boolean> add, Collection<Folder> targetFolders, 1810 ContentValues values) { 1811 addFolderUpdates(folderUris, add, values); 1812 addTargetFolders(targetFolders, values); 1813 return getOperationForConversation(conv, ConversationOperation.UPDATE, values); 1814 } 1815 1816 // Convenience methods 1817 private int apply(Collection<ConversationOperation> operations) { 1818 return sProvider.apply(operations, this); 1819 } 1820 1821 private void undoLocal() { 1822 sProvider.undo(this); 1823 } 1824 1825 public void undo(final Context context, final Uri undoUri) { 1826 new Thread(new Runnable() { 1827 @Override 1828 public void run() { 1829 Cursor c = context.getContentResolver().query(undoUri, UIProvider.UNDO_PROJECTION, 1830 null, null, null); 1831 if (c != null) { 1832 c.close(); 1833 } 1834 } 1835 }).start(); 1836 undoLocal(); 1837 } 1838 1839 /** 1840 * Delete a group of conversations immediately in the UI and in a single transaction in the 1841 * underlying provider. See applyAction for argument descriptions 1842 */ 1843 public int delete(Collection<Conversation> conversations) { 1844 return applyAction(conversations, ConversationOperation.DELETE); 1845 } 1846 1847 /** 1848 * As above, for archive 1849 */ 1850 public int archive(Collection<Conversation> conversations) { 1851 return applyAction(conversations, ConversationOperation.ARCHIVE); 1852 } 1853 1854 /** 1855 * As above, for mute 1856 */ 1857 public int mute(Collection<Conversation> conversations) { 1858 return applyAction(conversations, ConversationOperation.MUTE); 1859 } 1860 1861 /** 1862 * As above, for report spam 1863 */ 1864 public int reportSpam(Collection<Conversation> conversations) { 1865 return applyAction(conversations, ConversationOperation.REPORT_SPAM); 1866 } 1867 1868 /** 1869 * As above, for report not spam 1870 */ 1871 public int reportNotSpam(Collection<Conversation> conversations) { 1872 return applyAction(conversations, ConversationOperation.REPORT_NOT_SPAM); 1873 } 1874 1875 /** 1876 * As above, for report phishing 1877 */ 1878 public int reportPhishing(Collection<Conversation> conversations) { 1879 return applyAction(conversations, ConversationOperation.REPORT_PHISHING); 1880 } 1881 1882 /** 1883 * Discard the drafts in the specified conversations 1884 */ 1885 public int discardDrafts(Collection<Conversation> conversations) { 1886 return applyAction(conversations, ConversationOperation.DISCARD_DRAFTS); 1887 } 1888 1889 /** 1890 * As above, for mostly archive 1891 */ 1892 public int mostlyArchive(Collection<Conversation> conversations) { 1893 return applyAction(conversations, ConversationOperation.MOSTLY_ARCHIVE); 1894 } 1895 1896 /** 1897 * As above, for mostly delete 1898 */ 1899 public int mostlyDelete(Collection<Conversation> conversations) { 1900 return applyAction(conversations, ConversationOperation.MOSTLY_DELETE); 1901 } 1902 1903 /** 1904 * As above, for mostly destructive updates. 1905 */ 1906 public int mostlyDestructiveUpdate(Collection<Conversation> conversations, 1907 ContentValues values) { 1908 return apply( 1909 getOperationsForConversations(conversations, 1910 ConversationOperation.MOSTLY_DESTRUCTIVE_UPDATE, values)); 1911 } 1912 1913 /** 1914 * Convenience method for performing an operation on a group of conversations 1915 * @param conversations the conversations to be affected 1916 * @param opAction the action to take 1917 * @return the sequence number of the operation applied in CC 1918 */ 1919 private int applyAction(Collection<Conversation> conversations, int opAction) { 1920 ArrayList<ConversationOperation> ops = Lists.newArrayList(); 1921 for (Conversation conv: conversations) { 1922 ConversationOperation op = 1923 new ConversationOperation(opAction, conv); 1924 ops.add(op); 1925 } 1926 return apply(ops); 1927 } 1928 1929 /** 1930 * Do not make this method dependent on the internal mechanism of the cursor. 1931 * Currently just calls the parent implementation. If this is ever overriden, take care to 1932 * ensure that two references map to the same hashcode. If 1933 * ConversationCursor first == ConversationCursor second, 1934 * then 1935 * first.hashCode() == second.hashCode(). 1936 * The {@link ConversationListFragment} relies on this behavior of 1937 * {@link ConversationCursor#hashCode()} to avoid storing dangerous references to the cursor. 1938 * {@inheritDoc} 1939 */ 1940 @Override 1941 public int hashCode() { 1942 return super.hashCode(); 1943 } 1944 1945 private void resetNotificationActions() { 1946 final boolean changed = !mNotificationTempDeleted.isEmpty(); 1947 1948 for (final Conversation conversation : mNotificationTempDeleted) { 1949 sProvider.undeleteLocal(conversation.uri, this); 1950 } 1951 1952 mNotificationTempDeleted.clear(); 1953 1954 if (changed) { 1955 mMainThreadHandler.post(new Runnable() { 1956 @Override 1957 public void run() { 1958 notifyDataChanged(); 1959 } 1960 }); 1961 } 1962 } 1963 1964 /** 1965 * If a destructive notification action was triggered, but has not yet been processed because an 1966 * "Undo" action is available, we do not want to show the conversation in the list. 1967 */ 1968 public void handleNotificationActions() { 1969 final SparseArrayCompat<NotificationAction> undoNotifications = 1970 NotificationActionUtils.sUndoNotifications; 1971 1972 final Set<Conversation> undoConversations = 1973 Sets.newHashSetWithExpectedSize(undoNotifications.size()); 1974 1975 boolean changed = false; 1976 1977 for (int i = 0; i < undoNotifications.size(); i++) { 1978 final NotificationAction notificationAction = 1979 undoNotifications.get(undoNotifications.keyAt(i)); 1980 1981 // We only care about notifications that were for this folder or if the action was 1982 // delete 1983 final Folder folder = notificationAction.getFolder(); 1984 final boolean deleteAction = 1985 notificationAction.getNotificationActionType() == NotificationActionType.DELETE; 1986 1987 if (folder.conversationListUri.equals(qUri) || deleteAction) { 1988 // We only care about destructive actions 1989 if (notificationAction.getNotificationActionType().getIsDestructive()) { 1990 final Conversation conversation = notificationAction.getConversation(); 1991 1992 undoConversations.add(conversation); 1993 1994 if (!mNotificationTempDeleted.contains(conversation)) { 1995 sProvider.deleteLocal(conversation.uri, this); 1996 mNotificationTempDeleted.add(conversation); 1997 1998 changed = true; 1999 } 2000 } 2001 } 2002 } 2003 2004 // Remove any conversations from the temporary deleted state if they no longer have an undo 2005 // notification 2006 final Iterator<Conversation> iterator = mNotificationTempDeleted.iterator(); 2007 while (iterator.hasNext()) { 2008 final Conversation conversation = iterator.next(); 2009 2010 if (!undoConversations.contains(conversation)) { 2011 sProvider.undeleteLocal(conversation.uri, this); 2012 iterator.remove(); 2013 2014 changed = true; 2015 } 2016 } 2017 2018 if (changed) { 2019 mMainThreadHandler.post(new Runnable() { 2020 @Override 2021 public void run() { 2022 notifyDataChanged(); 2023 } 2024 }); 2025 } 2026 } 2027 2028 /** 2029 * Marks all contents of this cursor as seen. This may have no effect with certain providers. 2030 */ 2031 @Override 2032 public void markContentsSeen() { 2033 ConversationCursorMarkSeenListener.MarkSeenHelper.markContentsSeen(mUnderlyingCursor); 2034 } 2035} 2036