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