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