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