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