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