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