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