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