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