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