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