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