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