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