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