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