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