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