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