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