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