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