ConversationCursor.java revision cdeeb2b718561e4715387439d2a145a48633a789
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 Object val = getCachedValue(uriString, 1663 UIProvider.CONVERSATION_FLAGS_COLUMN); 1664 if (val != null) { 1665 int flags = ((Integer)val).intValue(); 1666 if ((flags & Conversation.FLAG_MOSTLY_DEAD) != 0) { 1667 cacheValue(uriString, UIProvider.ConversationColumns.FLAGS, 1668 flags &= ~Conversation.FLAG_MOSTLY_DEAD); 1669 return true; 1670 } 1671 } 1672 return false; 1673 } 1674 1675 1676 1677 1678 /** 1679 * ConversationOperation is the encapsulation of a ContentProvider operation to be performed 1680 * atomically as part of a "batch" operation. 1681 */ 1682 public class ConversationOperation { 1683 private static final int MOSTLY = 0x80; 1684 public static final int DELETE = 0; 1685 public static final int INSERT = 1; 1686 public static final int UPDATE = 2; 1687 public static final int ARCHIVE = 3; 1688 public static final int MUTE = 4; 1689 public static final int REPORT_SPAM = 5; 1690 public static final int REPORT_NOT_SPAM = 6; 1691 public static final int REPORT_PHISHING = 7; 1692 public static final int DISCARD_DRAFTS = 8; 1693 public static final int MOSTLY_ARCHIVE = MOSTLY | ARCHIVE; 1694 public static final int MOSTLY_DELETE = MOSTLY | DELETE; 1695 public static final int MOSTLY_DESTRUCTIVE_UPDATE = MOSTLY | UPDATE; 1696 1697 private final int mType; 1698 private final Uri mUri; 1699 private final Conversation mConversation; 1700 private final ContentValues mValues; 1701 // True if an updated item should be removed locally (from ConversationCursor) 1702 // This would be the case for a folder change in which the conversation is no longer 1703 // in the folder represented by the ConversationCursor 1704 private final boolean mLocalDeleteOnUpdate; 1705 // After execution, this indicates whether or not the operation requires recalibration of 1706 // the current cursor position (i.e. it removed or added items locally) 1707 private boolean mRecalibrateRequired = true; 1708 // Whether this item is already mostly dead 1709 private final boolean mMostlyDead; 1710 1711 public ConversationOperation(int type, Conversation conv) { 1712 this(type, conv, null); 1713 } 1714 1715 public ConversationOperation(int type, Conversation conv, ContentValues values) { 1716 mType = type; 1717 mUri = conv.uri; 1718 mConversation = conv; 1719 mValues = values; 1720 mLocalDeleteOnUpdate = conv.localDeleteOnUpdate; 1721 mMostlyDead = conv.isMostlyDead(); 1722 } 1723 1724 private ContentProviderOperation execute(Uri underlyingUri) { 1725 Uri uri = underlyingUri.buildUpon() 1726 .appendQueryParameter(UIProvider.SEQUENCE_QUERY_PARAMETER, 1727 Integer.toString(sSequence)) 1728 .build(); 1729 ContentProviderOperation op = null; 1730 switch(mType) { 1731 case UPDATE: 1732 if (mLocalDeleteOnUpdate) { 1733 sProvider.deleteLocal(mUri, ConversationCursor.this); 1734 } else { 1735 sProvider.updateLocal(mUri, mValues, ConversationCursor.this); 1736 mRecalibrateRequired = false; 1737 } 1738 if (!mMostlyDead) { 1739 op = ContentProviderOperation.newUpdate(uri) 1740 .withValues(mValues) 1741 .build(); 1742 } else { 1743 sProvider.commitMostlyDead(mConversation, ConversationCursor.this); 1744 } 1745 break; 1746 case MOSTLY_DESTRUCTIVE_UPDATE: 1747 sProvider.setMostlyDead(mConversation, ConversationCursor.this); 1748 op = ContentProviderOperation.newUpdate(uri).withValues(mValues).build(); 1749 break; 1750 case INSERT: 1751 sProvider.insertLocal(mUri, mValues); 1752 op = ContentProviderOperation.newInsert(uri) 1753 .withValues(mValues).build(); 1754 break; 1755 // Destructive actions below! 1756 // "Mostly" operations are reflected globally, but not locally, except to set 1757 // FLAG_MOSTLY_DEAD in the conversation itself 1758 case DELETE: 1759 sProvider.deleteLocal(mUri, ConversationCursor.this); 1760 if (!mMostlyDead) { 1761 op = ContentProviderOperation.newDelete(uri).build(); 1762 } else { 1763 sProvider.commitMostlyDead(mConversation, ConversationCursor.this); 1764 } 1765 break; 1766 case MOSTLY_DELETE: 1767 sProvider.setMostlyDead(mConversation,ConversationCursor.this); 1768 op = ContentProviderOperation.newDelete(uri).build(); 1769 break; 1770 case ARCHIVE: 1771 sProvider.deleteLocal(mUri, ConversationCursor.this); 1772 if (!mMostlyDead) { 1773 // Create an update operation that represents archive 1774 op = ContentProviderOperation.newUpdate(uri).withValue( 1775 ConversationOperations.OPERATION_KEY, 1776 ConversationOperations.ARCHIVE) 1777 .build(); 1778 } else { 1779 sProvider.commitMostlyDead(mConversation, ConversationCursor.this); 1780 } 1781 break; 1782 case MOSTLY_ARCHIVE: 1783 sProvider.setMostlyDead(mConversation, ConversationCursor.this); 1784 // Create an update operation that represents archive 1785 op = ContentProviderOperation.newUpdate(uri).withValue( 1786 ConversationOperations.OPERATION_KEY, ConversationOperations.ARCHIVE) 1787 .build(); 1788 break; 1789 case MUTE: 1790 if (mLocalDeleteOnUpdate) { 1791 sProvider.deleteLocal(mUri, ConversationCursor.this); 1792 } 1793 1794 // Create an update operation that represents mute 1795 op = ContentProviderOperation.newUpdate(uri).withValue( 1796 ConversationOperations.OPERATION_KEY, ConversationOperations.MUTE) 1797 .build(); 1798 break; 1799 case REPORT_SPAM: 1800 case REPORT_NOT_SPAM: 1801 sProvider.deleteLocal(mUri, ConversationCursor.this); 1802 1803 final String operation = mType == REPORT_SPAM ? 1804 ConversationOperations.REPORT_SPAM : 1805 ConversationOperations.REPORT_NOT_SPAM; 1806 1807 // Create an update operation that represents report spam 1808 op = ContentProviderOperation.newUpdate(uri).withValue( 1809 ConversationOperations.OPERATION_KEY, operation).build(); 1810 break; 1811 case REPORT_PHISHING: 1812 sProvider.deleteLocal(mUri, ConversationCursor.this); 1813 1814 // Create an update operation that represents report phishing 1815 op = ContentProviderOperation.newUpdate(uri).withValue( 1816 ConversationOperations.OPERATION_KEY, 1817 ConversationOperations.REPORT_PHISHING).build(); 1818 break; 1819 case DISCARD_DRAFTS: 1820 sProvider.deleteLocal(mUri, ConversationCursor.this); 1821 1822 // Create an update operation that represents discarding drafts 1823 op = ContentProviderOperation.newUpdate(uri).withValue( 1824 ConversationOperations.OPERATION_KEY, 1825 ConversationOperations.DISCARD_DRAFTS).build(); 1826 break; 1827 default: 1828 throw new UnsupportedOperationException( 1829 "No such ConversationOperation type: " + mType); 1830 } 1831 1832 return op; 1833 } 1834 } 1835 1836 /** 1837 * For now, a single listener can be associated with the cursor, and for now we'll just 1838 * notify on deletions 1839 */ 1840 public interface ConversationListener { 1841 /** 1842 * Data in the underlying provider has changed; a refresh is required to sync up 1843 */ 1844 public void onRefreshRequired(); 1845 /** 1846 * We've completed a requested refresh of the underlying cursor 1847 */ 1848 public void onRefreshReady(); 1849 /** 1850 * The data underlying the cursor has changed; the UI should redraw the list 1851 */ 1852 public void onDataSetChanged(); 1853 } 1854 1855 @Override 1856 public boolean isFirst() { 1857 throw new UnsupportedOperationException(); 1858 } 1859 1860 @Override 1861 public boolean isLast() { 1862 throw new UnsupportedOperationException(); 1863 } 1864 1865 @Override 1866 public boolean isBeforeFirst() { 1867 throw new UnsupportedOperationException(); 1868 } 1869 1870 @Override 1871 public boolean isAfterLast() { 1872 throw new UnsupportedOperationException(); 1873 } 1874 1875 @Override 1876 public int getColumnIndex(String columnName) { 1877 return mUnderlyingCursor.getColumnIndex(columnName); 1878 } 1879 1880 @Override 1881 public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException { 1882 return mUnderlyingCursor.getColumnIndexOrThrow(columnName); 1883 } 1884 1885 @Override 1886 public String getColumnName(int columnIndex) { 1887 return mUnderlyingCursor.getColumnName(columnIndex); 1888 } 1889 1890 @Override 1891 public String[] getColumnNames() { 1892 return mUnderlyingCursor.getColumnNames(); 1893 } 1894 1895 @Override 1896 public int getColumnCount() { 1897 return mUnderlyingCursor.getColumnCount(); 1898 } 1899 1900 @Override 1901 public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) { 1902 throw new UnsupportedOperationException(); 1903 } 1904 1905 @Override 1906 public int getType(int columnIndex) { 1907 return mUnderlyingCursor.getType(columnIndex); 1908 } 1909 1910 @Override 1911 public boolean isNull(int columnIndex) { 1912 throw new UnsupportedOperationException(); 1913 } 1914 1915 @Override 1916 public void deactivate() { 1917 throw new UnsupportedOperationException(); 1918 } 1919 1920 @Override 1921 public boolean isClosed() { 1922 return mUnderlyingCursor == null || mUnderlyingCursor.isClosed(); 1923 } 1924 1925 @Override 1926 public void registerContentObserver(ContentObserver observer) { 1927 // Nope. We never notify of underlying changes on this channel, since the cursor watches 1928 // internally and offers onRefreshRequired/onRefreshReady to accomplish the same thing. 1929 } 1930 1931 @Override 1932 public void unregisterContentObserver(ContentObserver observer) { 1933 // See above. 1934 } 1935 1936 @Override 1937 public void registerDataSetObserver(DataSetObserver observer) { 1938 // Nope. We use ConversationListener to accomplish this. 1939 } 1940 1941 @Override 1942 public void unregisterDataSetObserver(DataSetObserver observer) { 1943 // See above. 1944 } 1945 1946 @Override 1947 public void setNotificationUri(ContentResolver cr, Uri uri) { 1948 throw new UnsupportedOperationException(); 1949 } 1950 1951 public Uri getNotificationUri() { 1952 throw new UnsupportedOperationException(); 1953 } 1954 1955 @Override 1956 public boolean getWantsAllOnMoveCalls() { 1957 throw new UnsupportedOperationException(); 1958 } 1959 1960 @Override 1961 public Bundle getExtras() { 1962 return mUnderlyingCursor != null ? mUnderlyingCursor.getExtras() : Bundle.EMPTY; 1963 } 1964 1965 @Override 1966 public Bundle respond(Bundle extras) { 1967 if (mUnderlyingCursor != null) { 1968 return mUnderlyingCursor.respond(extras); 1969 } 1970 return Bundle.EMPTY; 1971 } 1972 1973 @Override 1974 public boolean requery() { 1975 return true; 1976 } 1977 1978 // Below are methods that update Conversation data (update/delete) 1979 1980 public int updateBoolean(Conversation conversation, String columnName, boolean value) { 1981 return updateBoolean(Arrays.asList(conversation), columnName, value); 1982 } 1983 1984 /** 1985 * Update an integer column for a group of conversations (see updateValues below) 1986 */ 1987 public int updateInt(Collection<Conversation> conversations, String columnName, 1988 int value) { 1989 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) { 1990 LogUtils.d(LOG_TAG, "ConversationCursor.updateInt(conversations=%s, columnName=%s)", 1991 conversations.toArray(), columnName); 1992 } 1993 ContentValues cv = new ContentValues(); 1994 cv.put(columnName, value); 1995 return updateValues(conversations, cv); 1996 } 1997 1998 /** 1999 * Update a string column for a group of conversations (see updateValues below) 2000 */ 2001 public int updateBoolean(Collection<Conversation> conversations, String columnName, 2002 boolean value) { 2003 ContentValues cv = new ContentValues(); 2004 cv.put(columnName, value); 2005 return updateValues(conversations, cv); 2006 } 2007 2008 /** 2009 * Update a string column for a group of conversations (see updateValues below) 2010 */ 2011 public int updateString(Collection<Conversation> conversations, String columnName, 2012 String value) { 2013 return updateStrings(conversations, new String[] { 2014 columnName 2015 }, new String[] { 2016 value 2017 }); 2018 } 2019 2020 /** 2021 * Update a string columns for a group of conversations (see updateValues below) 2022 */ 2023 public int updateStrings(Collection<Conversation> conversations, 2024 String[] columnNames, String[] values) { 2025 ContentValues cv = new ContentValues(); 2026 for (int i = 0; i < columnNames.length; i++) { 2027 cv.put(columnNames[i], values[i]); 2028 } 2029 return updateValues(conversations, cv); 2030 } 2031 2032 /** 2033 * Update a boolean column for a group of conversations, immediately in the UI and in a single 2034 * transaction in the underlying provider 2035 * @param conversations a collection of conversations 2036 * @param values the data to update 2037 * @return the sequence number of the operation (for undo) 2038 */ 2039 public int updateValues(Collection<Conversation> conversations, ContentValues values) { 2040 return apply( 2041 getOperationsForConversations(conversations, ConversationOperation.UPDATE, values)); 2042 } 2043 2044 /** 2045 * Apply many operations in a single batch transaction. 2046 * @param op the collection of operations obtained through successive calls to 2047 * {@link #getOperationForConversation(Conversation, int, ContentValues)}. 2048 * @return the sequence number of the operation (for undo) 2049 */ 2050 public int updateBulkValues(Collection<ConversationOperation> op) { 2051 return apply(op); 2052 } 2053 2054 private ArrayList<ConversationOperation> getOperationsForConversations( 2055 Collection<Conversation> conversations, int type, ContentValues values) { 2056 final ArrayList<ConversationOperation> ops = Lists.newArrayList(); 2057 for (Conversation conv: conversations) { 2058 ops.add(getOperationForConversation(conv, type, values)); 2059 } 2060 return ops; 2061 } 2062 2063 public ConversationOperation getOperationForConversation(Conversation conv, int type, 2064 ContentValues values) { 2065 return new ConversationOperation(type, conv, values); 2066 } 2067 2068 public static void addFolderUpdates(ArrayList<Uri> folderUris, ArrayList<Boolean> add, 2069 ContentValues values) { 2070 ArrayList<String> folders = new ArrayList<String>(); 2071 for (int i = 0; i < folderUris.size(); i++) { 2072 folders.add(folderUris.get(i).buildUpon().appendPath(add.get(i) + "").toString()); 2073 } 2074 values.put(ConversationOperations.FOLDERS_UPDATED, 2075 TextUtils.join(ConversationOperations.FOLDERS_UPDATED_SPLIT_PATTERN, folders)); 2076 } 2077 2078 public static void addTargetFolders(Collection<Folder> targetFolders, ContentValues values) { 2079 values.put(Conversation.UPDATE_FOLDER_COLUMN, FolderList.copyOf(targetFolders).toBlob()); 2080 } 2081 2082 public ConversationOperation getConversationFolderOperation(Conversation conv, 2083 ArrayList<Uri> folderUris, ArrayList<Boolean> add, Collection<Folder> targetFolders) { 2084 return getConversationFolderOperation(conv, folderUris, add, targetFolders, 2085 new ContentValues()); 2086 } 2087 2088 public ConversationOperation getConversationFolderOperation(Conversation conv, 2089 ArrayList<Uri> folderUris, ArrayList<Boolean> add, Collection<Folder> targetFolders, 2090 ContentValues values) { 2091 addFolderUpdates(folderUris, add, values); 2092 addTargetFolders(targetFolders, values); 2093 return getOperationForConversation(conv, ConversationOperation.UPDATE, values); 2094 } 2095 2096 // Convenience methods 2097 private int apply(Collection<ConversationOperation> operations) { 2098 return sProvider.apply(operations, this); 2099 } 2100 2101 private void undoLocal() { 2102 sProvider.undo(this); 2103 } 2104 2105 public void undo(final Context context, final Uri undoUri) { 2106 new Thread(new Runnable() { 2107 @Override 2108 public void run() { 2109 Cursor c = context.getContentResolver().query(undoUri, UIProvider.UNDO_PROJECTION, 2110 null, null, null); 2111 if (c != null) { 2112 c.close(); 2113 } 2114 } 2115 }).start(); 2116 undoLocal(); 2117 } 2118 2119 /** 2120 * Delete a group of conversations immediately in the UI and in a single transaction in the 2121 * underlying provider. See applyAction for argument descriptions 2122 */ 2123 public int delete(Collection<Conversation> conversations) { 2124 return applyAction(conversations, ConversationOperation.DELETE); 2125 } 2126 2127 /** 2128 * As above, for archive 2129 */ 2130 public int archive(Collection<Conversation> conversations) { 2131 return applyAction(conversations, ConversationOperation.ARCHIVE); 2132 } 2133 2134 /** 2135 * As above, for mute 2136 */ 2137 public int mute(Collection<Conversation> conversations) { 2138 return applyAction(conversations, ConversationOperation.MUTE); 2139 } 2140 2141 /** 2142 * As above, for report spam 2143 */ 2144 public int reportSpam(Collection<Conversation> conversations) { 2145 return applyAction(conversations, ConversationOperation.REPORT_SPAM); 2146 } 2147 2148 /** 2149 * As above, for report not spam 2150 */ 2151 public int reportNotSpam(Collection<Conversation> conversations) { 2152 return applyAction(conversations, ConversationOperation.REPORT_NOT_SPAM); 2153 } 2154 2155 /** 2156 * As above, for report phishing 2157 */ 2158 public int reportPhishing(Collection<Conversation> conversations) { 2159 return applyAction(conversations, ConversationOperation.REPORT_PHISHING); 2160 } 2161 2162 /** 2163 * Discard the drafts in the specified conversations 2164 */ 2165 public int discardDrafts(Collection<Conversation> conversations) { 2166 return applyAction(conversations, ConversationOperation.DISCARD_DRAFTS); 2167 } 2168 2169 /** 2170 * As above, for mostly archive 2171 */ 2172 public int mostlyArchive(Collection<Conversation> conversations) { 2173 return applyAction(conversations, ConversationOperation.MOSTLY_ARCHIVE); 2174 } 2175 2176 /** 2177 * As above, for mostly delete 2178 */ 2179 public int mostlyDelete(Collection<Conversation> conversations) { 2180 return applyAction(conversations, ConversationOperation.MOSTLY_DELETE); 2181 } 2182 2183 /** 2184 * As above, for mostly destructive updates. 2185 */ 2186 public int mostlyDestructiveUpdate(Collection<Conversation> conversations, 2187 ContentValues values) { 2188 return apply( 2189 getOperationsForConversations(conversations, 2190 ConversationOperation.MOSTLY_DESTRUCTIVE_UPDATE, values)); 2191 } 2192 2193 /** 2194 * Convenience method for performing an operation on a group of conversations 2195 * @param conversations the conversations to be affected 2196 * @param opAction the action to take 2197 * @return the sequence number of the operation applied in CC 2198 */ 2199 private int applyAction(Collection<Conversation> conversations, int opAction) { 2200 ArrayList<ConversationOperation> ops = Lists.newArrayList(); 2201 for (Conversation conv: conversations) { 2202 ConversationOperation op = 2203 new ConversationOperation(opAction, conv); 2204 ops.add(op); 2205 } 2206 return apply(ops); 2207 } 2208 2209 /** 2210 * Do not make this method dependent on the internal mechanism of the cursor. 2211 * Currently just calls the parent implementation. If this is ever overriden, take care to 2212 * ensure that two references map to the same hashcode. If 2213 * ConversationCursor first == ConversationCursor second, 2214 * then 2215 * first.hashCode() == second.hashCode(). 2216 * The {@link ConversationListFragment} relies on this behavior of 2217 * {@link ConversationCursor#hashCode()} to avoid storing dangerous references to the cursor. 2218 * {@inheritDoc} 2219 */ 2220 @Override 2221 public int hashCode() { 2222 return super.hashCode(); 2223 } 2224 2225 @Override 2226 public String toString() { 2227 final StringBuilder sb = new StringBuilder("{"); 2228 sb.append(super.toString()); 2229 sb.append(" mName="); 2230 sb.append(mName); 2231 sb.append(" mDeferSync="); 2232 sb.append(mDeferSync); 2233 sb.append(" mRefreshRequired="); 2234 sb.append(mRefreshRequired); 2235 sb.append(" mRefreshReady="); 2236 sb.append(mRefreshReady); 2237 sb.append(" mRefreshTask="); 2238 sb.append(mRefreshTask); 2239 sb.append(" mPaused="); 2240 sb.append(mPaused); 2241 sb.append(" mDeletedCount="); 2242 sb.append(mDeletedCount); 2243 sb.append(" mUnderlying="); 2244 sb.append(mUnderlyingCursor); 2245 sb.append("}"); 2246 return sb.toString(); 2247 } 2248 2249 private void resetNotificationActions() { 2250 // Needs to be on the UI thread because it updates the ConversationCursor's internal 2251 // state which violates assumptions about how the ListView works and how 2252 // the ConversationViewPager works if performed off of the UI thread. 2253 // Also, prevents ConcurrentModificationExceptions on mNotificationTempDeleted. 2254 mMainThreadHandler.post(new Runnable() { 2255 @Override 2256 public void run() { 2257 final boolean changed = !mNotificationTempDeleted.isEmpty(); 2258 2259 for (final Conversation conversation : mNotificationTempDeleted) { 2260 sProvider.undeleteLocal(conversation.uri, ConversationCursor.this); 2261 } 2262 2263 mNotificationTempDeleted.clear(); 2264 2265 if (changed) { 2266 notifyDataChanged(); 2267 } 2268 } 2269 }); 2270 } 2271 2272 /** 2273 * If a destructive notification action was triggered, but has not yet been processed because an 2274 * "Undo" action is available, we do not want to show the conversation in the list. 2275 */ 2276 public void handleNotificationActions() { 2277 // Needs to be on the UI thread because it updates the ConversationCursor's internal 2278 // state which violates assumptions about how the ListView works and how 2279 // the ConversationViewPager works if performed off of the UI thread. 2280 // Also, prevents ConcurrentModificationExceptions on mNotificationTempDeleted. 2281 mMainThreadHandler.post(new Runnable() { 2282 @Override 2283 public void run() { 2284 final SparseArrayCompat<NotificationAction> undoNotifications = 2285 NotificationActionUtils.sUndoNotifications; 2286 final Set<Conversation> undoneConversations = 2287 NotificationActionUtils.sUndoneConversations; 2288 2289 final Set<Conversation> undoConversations = 2290 Sets.newHashSetWithExpectedSize(undoNotifications.size()); 2291 2292 boolean changed = false; 2293 2294 for (int i = 0; i < undoNotifications.size(); i++) { 2295 final NotificationAction notificationAction = 2296 undoNotifications.get(undoNotifications.keyAt(i)); 2297 2298 // We only care about notifications that were for this folder 2299 // or if the action was delete 2300 final Folder folder = notificationAction.getFolder(); 2301 final boolean deleteAction = notificationAction.getNotificationActionType() 2302 == NotificationActionType.DELETE; 2303 2304 if (folder.conversationListUri.equals(qUri) || deleteAction) { 2305 // We only care about destructive actions 2306 if (notificationAction.getNotificationActionType().getIsDestructive()) { 2307 final Conversation conversation = notificationAction.getConversation(); 2308 2309 undoConversations.add(conversation); 2310 2311 if (!mNotificationTempDeleted.contains(conversation)) { 2312 sProvider.deleteLocal(conversation.uri, ConversationCursor.this); 2313 mNotificationTempDeleted.add(conversation); 2314 2315 changed = true; 2316 } 2317 } 2318 } 2319 } 2320 2321 // Remove any conversations from the temporary deleted state 2322 // if they no longer have an undo notification 2323 final Iterator<Conversation> iterator = mNotificationTempDeleted.iterator(); 2324 while (iterator.hasNext()) { 2325 final Conversation conversation = iterator.next(); 2326 2327 if (!undoConversations.contains(conversation)) { 2328 // We should only be un-deleting local cursor edits 2329 // if the notification was undone rather than just 2330 // disappearing because the internal cursor 2331 // gets updated when the undo goes away via timeout which 2332 // will update everything properly. 2333 if (undoneConversations.contains(conversation)) { 2334 sProvider.undeleteLocal(conversation.uri, ConversationCursor.this); 2335 undoneConversations.remove(conversation); 2336 } 2337 iterator.remove(); 2338 2339 changed = true; 2340 } 2341 } 2342 2343 if (changed) { 2344 notifyDataChanged(); 2345 } 2346 } 2347 }); 2348 } 2349 2350 @Override 2351 public void markContentsSeen() { 2352 ConversationCursorOperationListener.OperationHelper.markContentsSeen(mUnderlyingCursor); 2353 } 2354 2355 @Override 2356 public void emptyFolder() { 2357 ConversationCursorOperationListener.OperationHelper.emptyFolder(mUnderlyingCursor); 2358 } 2359} 2360