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