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