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