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