ConversationCursor.java revision c43bc0a606e41144a780c4f873b5450e0ede0c91
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.DataSetObserver; 30import android.net.Uri; 31import android.os.Bundle; 32import android.os.Handler; 33import android.os.Looper; 34import android.os.RemoteException; 35import android.util.Log; 36 37import com.android.mail.providers.Conversation; 38import com.android.mail.providers.UIProvider; 39 40import java.util.ArrayList; 41import java.util.HashMap; 42import java.util.Iterator; 43import java.util.List; 44 45/** 46 * ConversationCursor is a wrapper around a conversation list cursor that provides update/delete 47 * caching for quick UI response. This is effectively a singleton class, as the cache is 48 * implemented as a static HashMap. 49 */ 50public final class ConversationCursor implements Cursor { 51 private static final String TAG = "ConversationCursor"; 52 private static final boolean DEBUG = true; // STOPSHIP Set to false before shipping 53 54 // The authority of our conversation provider (a forwarding provider) 55 // This string must match the declaration in AndroidManifest.xml 56 private static final String sAuthority = "com.android.mail.conversation.provider"; 57 58 // The cursor instantiator's activity 59 private static Activity sActivity; 60 // The cursor underlying the caching cursor 61 private static Cursor sUnderlyingCursor; 62 // The new cursor obtained via a requery 63 private static Cursor sRequeryCursor; 64 // A mapping from Uri to updated ContentValues 65 private static HashMap<String, ContentValues> sCacheMap = new HashMap<String, ContentValues>(); 66 // Cache map lock (will be used only very briefly - few ms at most) 67 private static Object sCacheMapLock = new Object(); 68 // A deleted row is indicated by the presence of DELETED_COLUMN in the cache map 69 private static final String DELETED_COLUMN = "__deleted__"; 70 // An row cached during a requery is indicated by the presence of REQUERY_COLUMN in the map 71 private static final String REQUERY_COLUMN = "__requery__"; 72 // A sentinel value for the "index" of the deleted column; it's an int that is otherwise invalid 73 private static final int DELETED_COLUMN_INDEX = -1; 74 // The current conversation cursor 75 private static ConversationCursor sConversationCursor; 76 // The index of the Uri whose data is reflected in the cached row 77 // Updates/Deletes to this Uri are cached 78 private static int sUriColumnIndex; 79 // The listener registered for this cursor 80 private static ConversationListener sListener; 81 // The ConversationProvider instance 82 private static ConversationProvider sProvider; 83 // Set when we're in the middle of a requery of the underlying cursor 84 private static boolean sRequeryInProgress = false; 85 // Our sequence count (for changes sent to underlying provider) 86 private static int sSequence = 0; 87 88 // Column names for this cursor 89 private final String[] mColumnNames; 90 // The resolver for the cursor instantiator's context 91 private static ContentResolver mResolver; 92 // An observer on the underlying cursor (so we can detect changes from outside the UI) 93 private final CursorObserver mCursorObserver; 94 // Whether our observer is currently registered with the underlying cursor 95 private boolean mCursorObserverRegistered = false; 96 97 // The current position of the cursor 98 private int mPosition = -1; 99 // The number of cached deletions from this cursor (used to quickly generate an accurate count) 100 private static int sDeletedCount = 0; 101 102 // Parameters passed to the underlying query 103 private static Uri qUri; 104 private static String[] qProjection; 105 private static String qSelection; 106 private static String[] qSelectionArgs; 107 private static String qSortOrder; 108 109 private ConversationCursor(Cursor cursor, Activity activity, String messageListColumn) { 110 sActivity = activity; 111 mResolver = activity.getContentResolver(); 112 sConversationCursor = this; 113 sUnderlyingCursor = cursor; 114 mCursorObserver = new CursorObserver(); 115 resetCursor(null); 116 mColumnNames = cursor.getColumnNames(); 117 sUriColumnIndex = cursor.getColumnIndex(messageListColumn); 118 if (sUriColumnIndex < 0) { 119 throw new IllegalArgumentException("Cursor must include a message list column"); 120 } 121 } 122 123 /** 124 * Create a ConversationCursor; this should be called by the ListActivity using that cursor 125 * @param activity the activity creating the cursor 126 * @param messageListColumn the column used for individual cursor items 127 * @param uri the query uri 128 * @param projection the query projecion 129 * @param selection the query selection 130 * @param selectionArgs the query selection args 131 * @param sortOrder the query sort order 132 * @return a ConversationCursor 133 */ 134 public static ConversationCursor create(Activity activity, String messageListColumn, Uri uri, 135 String[] projection, String selection, String[] selectionArgs, String sortOrder) { 136 qUri = uri; 137 qProjection = projection; 138 qSelection = selection; 139 qSelectionArgs = selectionArgs; 140 qSortOrder = sortOrder; 141 Cursor cursor = activity.getContentResolver().query(uri, projection, selection, 142 selectionArgs, sortOrder); 143 return new ConversationCursor(cursor, activity, messageListColumn); 144 } 145 146 /** 147 * Return whether the uri string (message list uri) is in the underlying cursor 148 * @param uriString the uri string we're looking for 149 * @return true if the uri string is in the cursor; false otherwise 150 */ 151 private boolean isInUnderlyingCursor(String uriString) { 152 sUnderlyingCursor.moveToPosition(-1); 153 while (sUnderlyingCursor.moveToNext()) { 154 if (uriString.equals(sUnderlyingCursor.getString(sUriColumnIndex))) { 155 return true; 156 } 157 } 158 return false; 159 } 160 161 /** 162 * Reset the cursor; this involves clearing out our cache map and resetting our various counts 163 * The cursor should be reset whenever we get fresh data from the underlying cursor. The cache 164 * is locked during the reset, which will block the UI, but for only a very short time 165 * (estimated at a few ms, but we can profile this; remember that the cache will usually 166 * be empty or have a few entries) 167 */ 168 private void resetCursor(Cursor newCursor) { 169 // Temporary, log time for reset 170 long startTime = System.currentTimeMillis(); 171 synchronized (sCacheMapLock) { 172 // Walk through the cache. Here are the cases: 173 // 1) The entry isn't marked with REQUERY - remove it from the cache. If DELETED is 174 // set, decrement the deleted count 175 // 2) The REQUERY entry is still in the UP 176 // 2a) The REQUERY entry isn't DELETED; we're good, and the client change will remain 177 // (i.e. client wins, it's on its way to the UP) 178 // 2b) The REQUERY entry is DELETED; we're good (client change remains, it's on 179 // its way to the UP) 180 // 3) the REQUERY was deleted on the server (sheesh; this would be bizarre timing!) - 181 // we need to throw the item out of the cache 182 // So ... the only interesting case is #3, we need to look for remaining deleted items 183 // and see if they're still in the UP 184 Iterator<HashMap.Entry<String, ContentValues>> iter = sCacheMap.entrySet().iterator(); 185 while (iter.hasNext()) { 186 HashMap.Entry<String, ContentValues> entry = iter.next(); 187 ContentValues values = entry.getValue(); 188 if (values.containsKey(REQUERY_COLUMN) && isInUnderlyingCursor(entry.getKey())) { 189 // If we're in a requery and we're still around, remove the requery key 190 // We're good here, the cached change (delete/update) is on its way to UP 191 values.remove(REQUERY_COLUMN); 192 } else { 193 // Keep the deleted count up-to-date; remove the cache entry 194 if (values.containsKey(DELETED_COLUMN)) { 195 sDeletedCount--; 196 } 197 // Remove the entry 198 iter.remove(); 199 } 200 } 201 202 // Swap cursor 203 if (newCursor != null) { 204 close(); 205 sUnderlyingCursor = newCursor; 206 } 207 208 mPosition = -1; 209 sUnderlyingCursor.moveToPosition(mPosition); 210 if (!mCursorObserverRegistered) { 211 sUnderlyingCursor.registerContentObserver(mCursorObserver); 212 mCursorObserverRegistered = true; 213 } 214 } 215 Log.d(TAG, "resetCache time: " + ((System.currentTimeMillis() - startTime)) + "ms"); 216 } 217 218 /** 219 * Set the listener for this cursor; we'll notify it when our data changes 220 */ 221 public void setListener(ConversationListener listener) { 222 sListener = listener; 223 } 224 225 /** 226 * Generate a forwarding Uri to ConversationProvider from an original Uri. We do this by 227 * changing the authority to ours, but otherwise leaving the Uri intact. 228 * NOTE: This won't handle query parameters, so the functionality will need to be added if 229 * parameters are used in the future 230 * @param uri the uri 231 * @return a forwarding uri to ConversationProvider 232 */ 233 private static String uriToCachingUriString (Uri uri) { 234 String provider = uri.getAuthority(); 235 return uri.getScheme() + "://" + sAuthority + "/" + provider + uri.getPath(); 236 } 237 238 /** 239 * Regenerate the original Uri from a forwarding (ConversationProvider) Uri 240 * NOTE: See note above for uriToCachingUri 241 * @param uri the forwarding Uri 242 * @return the original Uri 243 */ 244 private static Uri uriFromCachingUri(Uri uri) { 245 List<String> path = uri.getPathSegments(); 246 Uri.Builder builder = new Uri.Builder().scheme(uri.getScheme()).authority(path.get(0)); 247 for (int i = 1; i < path.size(); i++) { 248 builder.appendPath(path.get(i)); 249 } 250 return builder.build(); 251 } 252 253 /** 254 * Cache a column name/value pair for a given Uri 255 * @param uriString the Uri for which the column name/value pair applies 256 * @param columnName the column name 257 * @param value the value to be cached 258 */ 259 private static void cacheValue(String uriString, String columnName, Object value) { 260 synchronized (sCacheMapLock) { 261 // Get the map for our uri 262 ContentValues map = sCacheMap.get(uriString); 263 // Create one if necessary 264 if (map == null) { 265 map = new ContentValues(); 266 sCacheMap.put(uriString, map); 267 } 268 // If we're caching a deletion, add to our count 269 if ((columnName == DELETED_COLUMN) && (map.get(columnName) == null)) { 270 sDeletedCount++; 271 if (DEBUG) { 272 Log.d(TAG, "Deleted " + uriString); 273 } 274 } 275 // ContentValues has no generic "put", so we must test. For now, the only classes of 276 // values implemented are Boolean/Integer/String, though others are trivially added 277 if (value instanceof Boolean) { 278 map.put(columnName, ((Boolean) value).booleanValue() ? 1 : 0); 279 } else if (value instanceof Integer) { 280 map.put(columnName, (Integer) value); 281 } else if (value instanceof String) { 282 map.put(columnName, (String) value); 283 } else { 284 String cname = value.getClass().getName(); 285 throw new IllegalArgumentException("Value class not compatible with cache: " 286 + cname); 287 } 288 if (sRequeryInProgress) { 289 map.put(REQUERY_COLUMN, 1); 290 } 291 if (DEBUG && (columnName != DELETED_COLUMN)) { 292 Log.d(TAG, "Caching value for " + uriString + ": " + columnName); 293 } 294 } 295 } 296 297 /** 298 * Get the cached value for the provided column; we special case -1 as the "deleted" column 299 * @param columnIndex the index of the column whose cached value we want to retrieve 300 * @return the cached value for this column, or null if there is none 301 */ 302 private Object getCachedValue(int columnIndex) { 303 String uri = sUnderlyingCursor.getString(sUriColumnIndex); 304 ContentValues uriMap = sCacheMap.get(uri); 305 if (uriMap != null) { 306 String columnName; 307 if (columnIndex == DELETED_COLUMN_INDEX) { 308 columnName = DELETED_COLUMN; 309 } else { 310 columnName = mColumnNames[columnIndex]; 311 } 312 return uriMap.get(columnName); 313 } 314 return null; 315 } 316 317 /** 318 * When the underlying cursor changes, we want to alert the listener 319 */ 320 private void underlyingChanged() { 321 if (sListener != null) { 322 if (mCursorObserverRegistered) { 323 sUnderlyingCursor.unregisterContentObserver(mCursorObserver); 324 mCursorObserverRegistered = false; 325 } 326 sListener.onRefreshRequired(); 327 } 328 } 329 330 /** 331 * Put the refreshed cursor in place (called by the UI) 332 */ 333 public void swapCursors() { 334 if (sRequeryCursor == null) { 335 throw new IllegalStateException("Can't swap cursors; no requery done"); 336 } 337 resetCursor(sRequeryCursor); 338 sRequeryCursor = null; 339 sRequeryInProgress = false; 340 } 341 342 /** 343 * Cancel a refresh in progress 344 */ 345 public void cancelRefresh() { 346 synchronized(sCacheMapLock) { 347 // Mark the requery closed 348 sRequeryInProgress = false; 349 // If we have the cursor, close it; otherwise, it will get closed when the query 350 // finishes (it checks sRequeryInProgress) 351 if (sRequeryCursor != null) { 352 sRequeryCursor.close(); 353 sRequeryCursor = null; 354 } 355 } 356 } 357 358 /** 359 * Get a list of deletions from ConversationCursor to the refreshed cursor that hasn't yet 360 * been swapped into place; this allows the UI to animate these away if desired 361 * @return a list of positions deleted in ConversationCursor 362 */ 363 public ArrayList<Integer> getRefreshDeletions () { 364 Cursor deviceCursor = sConversationCursor; 365 Cursor serverCursor = sRequeryCursor; 366 ArrayList<Integer> deleteList = new ArrayList<Integer>(); 367 int serverCount = serverCursor.getCount(); 368 int deviceCount = deviceCursor.getCount(); 369 deviceCursor.moveToFirst(); 370 serverCursor.moveToFirst(); 371 while (serverCount > 0 || deviceCount > 0) { 372 if (serverCount == 0) { 373 for (; deviceCount > 0; deviceCount--) 374 deleteList.add(deviceCursor.getPosition()); 375 break; 376 } else if (deviceCount == 0) { 377 break; 378 } 379 long deviceMs = deviceCursor.getLong(UIProvider.CONVERSATION_DATE_RECEIVED_MS_COLUMN); 380 long serverMs = serverCursor.getLong(UIProvider.CONVERSATION_DATE_RECEIVED_MS_COLUMN); 381 String deviceUri = deviceCursor.getString(UIProvider.CONVERSATION_URI_COLUMN); 382 String serverUri = serverCursor.getString(UIProvider.CONVERSATION_URI_COLUMN); 383 deviceCursor.moveToNext(); 384 serverCursor.moveToNext(); 385 serverCount--; 386 deviceCount--; 387 if (serverMs == deviceMs) { 388 // Check for duplicates here; if our identical dates refer to different messages, 389 // we'll just quit here for now (at worst, this will cause a non-animating delete) 390 // My guess is that this happens VERY rarely, if at all 391 if (!deviceUri.equals(serverUri)) { 392 // To do this right, we'd find all of the rows with the same ms (date), etc... 393 //return deleteList; 394 } 395 continue; 396 } else if (deviceMs > serverMs) { 397 deleteList.add(deviceCursor.getPosition() - 1); 398 // Move back because we've already advanced cursor (that's why we subtract 1 above) 399 serverCount++; 400 serverCursor.moveToPrevious(); 401 } else if (serverMs > deviceMs) { 402 // If we wanted to track insertions, we'd so so here 403 // Move back because we've already advanced cursor 404 deviceCount++; 405 deviceCursor.moveToPrevious(); 406 } 407 } 408 Log.d(TAG, "Deletions: " + deleteList); 409 return deleteList; 410 } 411 412 /** 413 * When we get a requery from the UI, we'll do it, but also clear the cache. The listener is 414 * notified when the requery is complete 415 * NOTE: This will have to change, of course, when we start using loaders... 416 */ 417 public boolean refresh() { 418 if (sRequeryInProgress) { 419 return false; 420 } 421 // Say we're starting a requery 422 sRequeryInProgress = true; 423 new Thread(new Runnable() { 424 @Override 425 public void run() { 426 // Get new data 427 sRequeryCursor = 428 mResolver.query(qUri, qProjection, qSelection, qSelectionArgs, qSortOrder); 429 // Make sure window is full 430 synchronized(sCacheMapLock) { 431 if (sRequeryInProgress) { 432 sRequeryCursor.getCount(); 433 sActivity.runOnUiThread(new Runnable() { 434 @Override 435 public void run() { 436 sListener.onRefreshReady(); 437 }}); 438 } else { 439 cancelRefresh(); 440 } 441 } 442 } 443 }).start(); 444 return true; 445 } 446 447 public void close() { 448 // Unregister our observer on the underlying cursor and close as usual 449 if (mCursorObserverRegistered) { 450 sUnderlyingCursor.unregisterContentObserver(mCursorObserver); 451 mCursorObserverRegistered = false; 452 } 453 sUnderlyingCursor.close(); 454 } 455 456 /** 457 * Move to the next not-deleted item in the conversation 458 */ 459 public boolean moveToNext() { 460 while (true) { 461 boolean ret = sUnderlyingCursor.moveToNext(); 462 if (!ret) return false; 463 if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue; 464 mPosition++; 465 return true; 466 } 467 } 468 469 /** 470 * Move to the previous not-deleted item in the conversation 471 */ 472 public boolean moveToPrevious() { 473 while (true) { 474 boolean ret = sUnderlyingCursor.moveToPrevious(); 475 if (!ret) return false; 476 if (getCachedValue(-1) instanceof Integer) continue; 477 mPosition--; 478 return true; 479 } 480 } 481 482 public int getPosition() { 483 return mPosition; 484 } 485 486 /** 487 * The actual cursor's count must be decremented by the number we've deleted from the UI 488 */ 489 public int getCount() { 490 return sUnderlyingCursor.getCount() - sDeletedCount; 491 } 492 493 public boolean moveToFirst() { 494 sUnderlyingCursor.moveToPosition(-1); 495 mPosition = -1; 496 return moveToNext(); 497 } 498 499 public boolean moveToPosition(int pos) { 500 if (pos < -1 || pos >= getCount()) return false; 501 if (pos == mPosition) return true; 502 if (pos > mPosition) { 503 while (pos > mPosition) { 504 if (!moveToNext()) { 505 return false; 506 } 507 } 508 return true; 509 } else if (pos == 0) { 510 return moveToFirst(); 511 } else { 512 while (pos < mPosition) { 513 if (!moveToPrevious()) { 514 return false; 515 } 516 } 517 return true; 518 } 519 } 520 521 public boolean moveToLast() { 522 throw new UnsupportedOperationException("moveToLast unsupported!"); 523 } 524 525 public boolean move(int offset) { 526 throw new UnsupportedOperationException("move unsupported!"); 527 } 528 529 /** 530 * We need to override all of the getters to make sure they look at cached values before using 531 * the values in the underlying cursor 532 */ 533 @Override 534 public double getDouble(int columnIndex) { 535 Object obj = getCachedValue(columnIndex); 536 if (obj != null) return (Double)obj; 537 return sUnderlyingCursor.getDouble(columnIndex); 538 } 539 540 @Override 541 public float getFloat(int columnIndex) { 542 Object obj = getCachedValue(columnIndex); 543 if (obj != null) return (Float)obj; 544 return sUnderlyingCursor.getFloat(columnIndex); 545 } 546 547 @Override 548 public int getInt(int columnIndex) { 549 Object obj = getCachedValue(columnIndex); 550 if (obj != null) return (Integer)obj; 551 return sUnderlyingCursor.getInt(columnIndex); 552 } 553 554 @Override 555 public long getLong(int columnIndex) { 556 Object obj = getCachedValue(columnIndex); 557 if (obj != null) return (Long)obj; 558 return sUnderlyingCursor.getLong(columnIndex); 559 } 560 561 @Override 562 public short getShort(int columnIndex) { 563 Object obj = getCachedValue(columnIndex); 564 if (obj != null) return (Short)obj; 565 return sUnderlyingCursor.getShort(columnIndex); 566 } 567 568 @Override 569 public String getString(int columnIndex) { 570 // If we're asking for the Uri for the conversation list, we return a forwarding URI 571 // so that we can intercept update/delete and handle it ourselves 572 if (columnIndex == sUriColumnIndex) { 573 Uri uri = Uri.parse(sUnderlyingCursor.getString(columnIndex)); 574 return uriToCachingUriString(uri); 575 } 576 Object obj = getCachedValue(columnIndex); 577 if (obj != null) return (String)obj; 578 return sUnderlyingCursor.getString(columnIndex); 579 } 580 581 @Override 582 public byte[] getBlob(int columnIndex) { 583 Object obj = getCachedValue(columnIndex); 584 if (obj != null) return (byte[])obj; 585 return sUnderlyingCursor.getBlob(columnIndex); 586 } 587 588 /** 589 * Observer of changes to underlying data 590 */ 591 private class CursorObserver extends ContentObserver { 592 public CursorObserver() { 593 super(new Handler()); 594 } 595 596 @Override 597 public void onChange(boolean selfChange) { 598 // If we're here, then something outside of the UI has changed the data, and we 599 // must query the underlying provider for that data 600 if (DEBUG) { 601 Log.d(TAG, "Underlying conversation cursor changed; requerying"); 602 } 603 // It's not at all obvious to me why we must unregister/re-register after the requery 604 // However, if we don't we'll only get one notification and no more... 605 ConversationCursor.this.underlyingChanged(); 606 } 607 } 608 609 /** 610 * ConversationProvider is the ContentProvider for our forwarding Uri's; it passes queries 611 * and inserts directly, and caches updates/deletes before passing them through. The caching 612 * will cause a redraw of the list with updated values. 613 */ 614 public static class ConversationProvider extends ContentProvider { 615 public static final String AUTHORITY = sAuthority; 616 617 @Override 618 public boolean onCreate() { 619 sProvider = this; 620 return true; 621 } 622 623 @Override 624 public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, 625 String sortOrder) { 626 return mResolver.query( 627 uriFromCachingUri(uri), projection, selection, selectionArgs, sortOrder); 628 } 629 630 @Override 631 public Uri insert(Uri uri, ContentValues values) { 632 insertLocal(uri, values); 633 return ProviderExecute.opInsert(uri, values); 634 } 635 636 @Override 637 public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { 638 updateLocal(uri, values); 639 return ProviderExecute.opUpdate(uri, values); 640 } 641 642 @Override 643 public int delete(Uri uri, String selection, String[] selectionArgs) { 644 deleteLocal(uri); 645 return ProviderExecute.opDelete(uri); 646 } 647 648 @Override 649 public String getType(Uri uri) { 650 return null; 651 } 652 653 /** 654 * Quick and dirty class that executes underlying provider CRUD operations on a background 655 * thread. 656 */ 657 static class ProviderExecute implements Runnable { 658 static final int DELETE = 0; 659 static final int INSERT = 1; 660 static final int UPDATE = 2; 661 662 final int mCode; 663 final Uri mUri; 664 final ContentValues mValues; //HEHEH 665 666 ProviderExecute(int code, Uri uri, ContentValues values) { 667 mCode = code; 668 mUri = uriFromCachingUri(uri); 669 mValues = values; 670 } 671 672 ProviderExecute(int code, Uri uri) { 673 this(code, uri, null); 674 } 675 676 static Uri opInsert(Uri uri, ContentValues values) { 677 ProviderExecute e = new ProviderExecute(INSERT, uri, values); 678 if (offUiThread()) return (Uri)e.go(); 679 new Thread(e).start(); 680 return null; 681 } 682 683 static int opDelete(Uri uri) { 684 ProviderExecute e = new ProviderExecute(DELETE, uri); 685 if (offUiThread()) return (Integer)e.go(); 686 new Thread(new ProviderExecute(DELETE, uri)).start(); 687 return 0; 688 } 689 690 static int opUpdate(Uri uri, ContentValues values) { 691 ProviderExecute e = new ProviderExecute(UPDATE, uri, values); 692 if (offUiThread()) return (Integer)e.go(); 693 new Thread(e).start(); 694 return 0; 695 } 696 697 @Override 698 public void run() { 699 go(); 700 } 701 702 public Object go() { 703 switch(mCode) { 704 case DELETE: 705 return mResolver.delete(mUri, null, null); 706 case INSERT: 707 return mResolver.insert(mUri, mValues); 708 case UPDATE: 709 return mResolver.update(mUri, mValues, null, null); 710 default: 711 return null; 712 } 713 } 714 } 715 716 private void insertLocal(Uri uri, ContentValues values) { 717 // Placeholder for now; there's no local insert 718 } 719 720 private void deleteLocal(Uri uri) { 721 Uri underlyingUri = uriFromCachingUri(uri); 722 String uriString = underlyingUri.toString(); 723 cacheValue(uriString, DELETED_COLUMN, true); 724 } 725 726 private void updateLocal(Uri uri, ContentValues values) { 727 Uri underlyingUri = uriFromCachingUri(uri); 728 // Remember to decode the underlying Uri as it might be encoded (as w/ Gmail) 729 String uriString = Uri.decode(underlyingUri.toString()); 730 for (String columnName: values.keySet()) { 731 cacheValue(uriString, columnName, values.get(columnName)); 732 } 733 } 734 735 static boolean offUiThread() { 736 return Looper.getMainLooper().getThread() != Thread.currentThread(); 737 } 738 739 public int apply(ArrayList<ConversationOperation> ops) { 740 final HashMap<String, ArrayList<ContentProviderOperation>> batchMap = 741 new HashMap<String, ArrayList<ContentProviderOperation>>(); 742 // Increment sequence count 743 sSequence++; 744 // Execute locally and build CPO's for underlying provider 745 for (ConversationOperation op: ops) { 746 Uri underlyingUri = uriFromCachingUri(op.mUri); 747 String authority = underlyingUri.getAuthority(); 748 ArrayList<ContentProviderOperation> authOps = batchMap.get(authority); 749 if (authOps == null) { 750 authOps = new ArrayList<ContentProviderOperation>(); 751 batchMap.put(authority, authOps); 752 } 753 authOps.add(op.execute(underlyingUri)); 754 } 755 756 // Send changes to underlying provider 757 for (String authority: batchMap.keySet()) { 758 try { 759 if (offUiThread()) { 760 mResolver.applyBatch(authority, batchMap.get(authority)); 761 } else { 762 final String auth = authority; 763 new Thread(new Runnable() { 764 @Override 765 public void run() { 766 try { 767 mResolver.applyBatch(auth, batchMap.get(auth)); 768 } catch (RemoteException e) { 769 } catch (OperationApplicationException e) { 770 } 771 } 772 }).start(); 773 } 774 } catch (RemoteException e) { 775 } catch (OperationApplicationException e) { 776 } 777 } 778 return sSequence; 779 } 780 } 781 782 /** 783 * ConversationOperation is the encapsulation of a ContentProvider operation to be performed 784 * atomically as part of a "batch" operation. 785 */ 786 public static class ConversationOperation { 787 public static final int DELETE = 0; 788 public static final int INSERT = 1; 789 public static final int UPDATE = 2; 790 791 private final int mType; 792 private final Uri mUri; 793 private final ContentValues mValues; 794 795 public ConversationOperation(int type, Conversation conv) { 796 this(type, conv, null); 797 } 798 799 public ConversationOperation(int type, Conversation conv, ContentValues values) { 800 mType = type; 801 mUri = conv.uri; 802 mValues = values; 803 } 804 805 private ContentProviderOperation execute(Uri underlyingUri) { 806 Uri uri = underlyingUri.buildUpon() 807 .appendQueryParameter(UIProvider.SEQUENCE_QUERY_PARAMETER, 808 Integer.toString(sSequence)) 809 .build(); 810 switch(mType) { 811 case DELETE: 812 sProvider.deleteLocal(mUri); 813 return ContentProviderOperation.newDelete(uri).build(); 814 case UPDATE: 815 sProvider.updateLocal(mUri, mValues); 816 return ContentProviderOperation.newUpdate(uri) 817 .withValues(mValues) 818 .build(); 819 case INSERT: 820 sProvider.insertLocal(mUri, mValues); 821 return ContentProviderOperation.newInsert(uri) 822 .withValues(mValues).build(); 823 default: 824 throw new UnsupportedOperationException( 825 "No such ConversationOperation type: " + mType); 826 } 827 } 828 } 829 830 /** 831 * For now, a single listener can be associated with the cursor, and for now we'll just 832 * notify on deletions 833 */ 834 public interface ConversationListener { 835 // Data in the underlying provider has changed; a refresh is required to sync up 836 public void onRefreshRequired(); 837 // We've completed a requested refresh of the underlying cursor 838 public void onRefreshReady(); 839 } 840 841 @Override 842 public boolean isFirst() { 843 throw new UnsupportedOperationException(); 844 } 845 846 @Override 847 public boolean isLast() { 848 throw new UnsupportedOperationException(); 849 } 850 851 @Override 852 public boolean isBeforeFirst() { 853 throw new UnsupportedOperationException(); 854 } 855 856 @Override 857 public boolean isAfterLast() { 858 throw new UnsupportedOperationException(); 859 } 860 861 @Override 862 public int getColumnIndex(String columnName) { 863 return sUnderlyingCursor.getColumnIndex(columnName); 864 } 865 866 @Override 867 public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException { 868 return sUnderlyingCursor.getColumnIndexOrThrow(columnName); 869 } 870 871 @Override 872 public String getColumnName(int columnIndex) { 873 return sUnderlyingCursor.getColumnName(columnIndex); 874 } 875 876 @Override 877 public String[] getColumnNames() { 878 return sUnderlyingCursor.getColumnNames(); 879 } 880 881 @Override 882 public int getColumnCount() { 883 return sUnderlyingCursor.getColumnCount(); 884 } 885 886 @Override 887 public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) { 888 throw new UnsupportedOperationException(); 889 } 890 891 @Override 892 public int getType(int columnIndex) { 893 return sUnderlyingCursor.getType(columnIndex); 894 } 895 896 @Override 897 public boolean isNull(int columnIndex) { 898 throw new UnsupportedOperationException(); 899 } 900 901 @Override 902 public void deactivate() { 903 throw new UnsupportedOperationException(); 904 } 905 906 @Override 907 public boolean isClosed() { 908 return sUnderlyingCursor.isClosed(); 909 } 910 911 @Override 912 public void registerContentObserver(ContentObserver observer) { 913 sUnderlyingCursor.registerContentObserver(observer); 914 } 915 916 @Override 917 public void unregisterContentObserver(ContentObserver observer) { 918 sUnderlyingCursor.unregisterContentObserver(observer); 919 } 920 921 @Override 922 public void registerDataSetObserver(DataSetObserver observer) { 923 sUnderlyingCursor.registerDataSetObserver(observer); 924 } 925 926 @Override 927 public void unregisterDataSetObserver(DataSetObserver observer) { 928 sUnderlyingCursor.unregisterDataSetObserver(observer); 929 } 930 931 @Override 932 public void setNotificationUri(ContentResolver cr, Uri uri) { 933 throw new UnsupportedOperationException(); 934 } 935 936 @Override 937 public boolean getWantsAllOnMoveCalls() { 938 throw new UnsupportedOperationException(); 939 } 940 941 @Override 942 public Bundle getExtras() { 943 throw new UnsupportedOperationException(); 944 } 945 946 @Override 947 public Bundle respond(Bundle extras) { 948 throw new UnsupportedOperationException(); 949 } 950 951 @Override 952 public boolean requery() { 953 return true; 954 } 955} 956