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