1 /* 2 * Copyright (C) 2006 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package android.content; 18 19 import android.database.Cursor; 20 import android.database.DatabaseUtils; 21 import android.database.sqlite.SQLiteDatabase; 22 import android.net.Uri; 23 import android.os.Debug; 24 import android.provider.BaseColumns; 25 import static android.provider.SyncConstValue.*; 26 import android.text.TextUtils; 27 import android.util.Log; 28 import android.accounts.Account; 29 30 /** 31 * @hide 32 */ 33 public abstract class AbstractTableMerger 34 { 35 private ContentValues mValues; 36 37 protected SQLiteDatabase mDb; 38 protected String mTable; 39 protected Uri mTableURL; 40 protected String mDeletedTable; 41 protected Uri mDeletedTableURL; 42 static protected ContentValues mSyncMarkValues; 43 static private boolean TRACE; 44 45 static { 46 mSyncMarkValues = new ContentValues(); mSyncMarkValues.put(_SYNC_MARK, 1)47 mSyncMarkValues.put(_SYNC_MARK, 1); 48 TRACE = false; 49 } 50 51 private static final String TAG = "AbstractTableMerger"; 52 private static final String[] syncDirtyProjection = 53 new String[] {_SYNC_DIRTY, BaseColumns._ID, _SYNC_ID, _SYNC_VERSION}; 54 private static final String[] syncIdAndVersionProjection = 55 new String[] {_SYNC_ID, _SYNC_VERSION}; 56 57 private volatile boolean mIsMergeCancelled; 58 59 private static final String SELECT_MARKED = _SYNC_MARK + "> 0 and " 60 + _SYNC_ACCOUNT + "=? and " + _SYNC_ACCOUNT_TYPE + "=?"; 61 62 private static final String SELECT_BY_SYNC_ID_AND_ACCOUNT = 63 _SYNC_ID +"=? and " + _SYNC_ACCOUNT + "=? and " + _SYNC_ACCOUNT_TYPE + "=?"; 64 private static final String SELECT_BY_ID = BaseColumns._ID +"=?"; 65 66 private static final String SELECT_UNSYNCED = 67 "(" + _SYNC_ACCOUNT + " IS NULL OR (" 68 + _SYNC_ACCOUNT + "=? and " + _SYNC_ACCOUNT_TYPE + "=?)) and " 69 + "(" + _SYNC_ID + " IS NULL OR (" + _SYNC_DIRTY + " > 0 and " 70 + _SYNC_VERSION + " IS NOT NULL))"; 71 AbstractTableMerger(SQLiteDatabase database, String table, Uri tableURL, String deletedTable, Uri deletedTableURL)72 public AbstractTableMerger(SQLiteDatabase database, 73 String table, Uri tableURL, String deletedTable, 74 Uri deletedTableURL) 75 { 76 mDb = database; 77 mTable = table; 78 mTableURL = tableURL; 79 mDeletedTable = deletedTable; 80 mDeletedTableURL = deletedTableURL; 81 mValues = new ContentValues(); 82 } 83 insertRow(ContentProvider diffs, Cursor diffsCursor)84 public abstract void insertRow(ContentProvider diffs, 85 Cursor diffsCursor); updateRow(long localPersonID, ContentProvider diffs, Cursor diffsCursor)86 public abstract void updateRow(long localPersonID, 87 ContentProvider diffs, Cursor diffsCursor); resolveRow(long localPersonID, String syncID, ContentProvider diffs, Cursor diffsCursor)88 public abstract void resolveRow(long localPersonID, 89 String syncID, ContentProvider diffs, Cursor diffsCursor); 90 91 /** 92 * This is called when it is determined that a row should be deleted from the 93 * ContentProvider. The localCursor is on a table from the local ContentProvider 94 * and its current position is of the row that should be deleted. The localCursor 95 * is only guaranteed to contain the BaseColumns.ID column so the implementation 96 * of deleteRow() must query the database directly if other columns are needed. 97 * <p> 98 * It is the responsibility of the implementation of this method to ensure that the cursor 99 * points to the next row when this method returns, either by calling Cursor.deleteRow() or 100 * Cursor.next(). 101 * 102 * @param localCursor The Cursor into the local table, which points to the row that 103 * is to be deleted. 104 */ deleteRow(Cursor localCursor)105 public void deleteRow(Cursor localCursor) { 106 localCursor.deleteRow(); 107 } 108 109 /** 110 * After {@link #merge} has completed, this method is called to send 111 * notifications to {@link android.database.ContentObserver}s of changes 112 * to the containing {@link ContentProvider}. These notifications likely 113 * do not want to request a sync back to the network. 114 */ notifyChanges()115 protected abstract void notifyChanges(); 116 findInCursor(Cursor cursor, int column, String id)117 private static boolean findInCursor(Cursor cursor, int column, String id) { 118 while (!cursor.isAfterLast() && !cursor.isNull(column)) { 119 int comp = id.compareTo(cursor.getString(column)); 120 if (comp > 0) { 121 cursor.moveToNext(); 122 continue; 123 } 124 return comp == 0; 125 } 126 return false; 127 } 128 onMergeCancelled()129 public void onMergeCancelled() { 130 mIsMergeCancelled = true; 131 } 132 133 /** 134 * Carry out a merge of the given diffs, and add the results to 135 * the given MergeResult. If we are the first merge to find 136 * client-side diffs, we'll use the given ContentProvider to 137 * construct a temporary instance to hold them. 138 */ merge(final SyncContext context, final Account account, final SyncableContentProvider serverDiffs, TempProviderSyncResult result, SyncResult syncResult, SyncableContentProvider temporaryInstanceFactory)139 public void merge(final SyncContext context, 140 final Account account, 141 final SyncableContentProvider serverDiffs, 142 TempProviderSyncResult result, 143 SyncResult syncResult, SyncableContentProvider temporaryInstanceFactory) { 144 mIsMergeCancelled = false; 145 if (serverDiffs != null) { 146 if (!mDb.isDbLockedByCurrentThread()) { 147 throw new IllegalStateException("this must be called from within a DB transaction"); 148 } 149 mergeServerDiffs(context, account, serverDiffs, syncResult); 150 notifyChanges(); 151 } 152 153 if (result != null) { 154 findLocalChanges(result, temporaryInstanceFactory, account, syncResult); 155 } 156 if (Log.isLoggable(TAG, Log.VERBOSE)) Log.v(TAG, "merge complete"); 157 } 158 159 /** 160 * @hide this is public for testing purposes only 161 */ mergeServerDiffs(SyncContext context, Account account, SyncableContentProvider serverDiffs, SyncResult syncResult)162 public void mergeServerDiffs(SyncContext context, 163 Account account, SyncableContentProvider serverDiffs, SyncResult syncResult) { 164 boolean diffsArePartial = serverDiffs.getContainsDiffs(); 165 // mark the current rows so that we can distinguish these from new 166 // inserts that occur during the merge 167 mDb.update(mTable, mSyncMarkValues, null, null); 168 if (mDeletedTable != null) { 169 mDb.update(mDeletedTable, mSyncMarkValues, null, null); 170 } 171 172 Cursor localCursor = null; 173 Cursor deletedCursor = null; 174 Cursor diffsCursor = null; 175 try { 176 // load the local database entries, so we can merge them with the server 177 final String[] accountSelectionArgs = new String[]{account.name, account.type}; 178 localCursor = mDb.query(mTable, syncDirtyProjection, 179 SELECT_MARKED, accountSelectionArgs, null, null, 180 mTable + "." + _SYNC_ID); 181 if (mDeletedTable != null) { 182 deletedCursor = mDb.query(mDeletedTable, syncIdAndVersionProjection, 183 SELECT_MARKED, accountSelectionArgs, null, null, 184 mDeletedTable + "." + _SYNC_ID); 185 } else { 186 deletedCursor = 187 mDb.rawQuery("select 'a' as _sync_id, 'b' as _sync_version limit 0", null); 188 } 189 190 // Apply updates and insertions from the server 191 diffsCursor = serverDiffs.query(mTableURL, 192 null, null, null, mTable + "." + _SYNC_ID); 193 int deletedSyncIDColumn = deletedCursor.getColumnIndexOrThrow(_SYNC_ID); 194 int deletedSyncVersionColumn = deletedCursor.getColumnIndexOrThrow(_SYNC_VERSION); 195 int serverSyncIDColumn = diffsCursor.getColumnIndexOrThrow(_SYNC_ID); 196 int serverSyncVersionColumn = diffsCursor.getColumnIndexOrThrow(_SYNC_VERSION); 197 int serverSyncLocalIdColumn = diffsCursor.getColumnIndexOrThrow(_SYNC_LOCAL_ID); 198 199 String lastSyncId = null; 200 int diffsCount = 0; 201 int localCount = 0; 202 localCursor.moveToFirst(); 203 deletedCursor.moveToFirst(); 204 while (diffsCursor.moveToNext()) { 205 if (mIsMergeCancelled) { 206 return; 207 } 208 mDb.yieldIfContended(); 209 String serverSyncId = diffsCursor.getString(serverSyncIDColumn); 210 String serverSyncVersion = diffsCursor.getString(serverSyncVersionColumn); 211 long localRowId = 0; 212 String localSyncVersion = null; 213 214 diffsCount++; 215 context.setStatusText("Processing " + diffsCount + "/" 216 + diffsCursor.getCount()); 217 if (Log.isLoggable(TAG, Log.VERBOSE)) Log.v(TAG, "processing server entry " + 218 diffsCount + ", " + serverSyncId); 219 220 if (TRACE) { 221 if (diffsCount == 10) { 222 Debug.startMethodTracing("atmtrace"); 223 } 224 if (diffsCount == 20) { 225 Debug.stopMethodTracing(); 226 } 227 } 228 229 boolean conflict = false; 230 boolean update = false; 231 boolean insert = false; 232 233 if (Log.isLoggable(TAG, Log.VERBOSE)) { 234 Log.v(TAG, "found event with serverSyncID " + serverSyncId); 235 } 236 if (TextUtils.isEmpty(serverSyncId)) { 237 if (Log.isLoggable(TAG, Log.VERBOSE)) { 238 Log.e(TAG, "server entry doesn't have a serverSyncID"); 239 } 240 continue; 241 } 242 243 // It is possible that the sync adapter wrote the same record multiple times, 244 // e.g. if the same record came via multiple feeds. If this happens just ignore 245 // the duplicate records. 246 if (serverSyncId.equals(lastSyncId)) { 247 if (Log.isLoggable(TAG, Log.VERBOSE)) { 248 Log.v(TAG, "skipping record with duplicate remote server id " + lastSyncId); 249 } 250 continue; 251 } 252 lastSyncId = serverSyncId; 253 254 String localSyncID = null; 255 boolean localSyncDirty = false; 256 257 while (!localCursor.isAfterLast()) { 258 if (mIsMergeCancelled) { 259 return; 260 } 261 localCount++; 262 localSyncID = localCursor.getString(2); 263 264 // If the local record doesn't have a _sync_id then 265 // it is new. Ignore it for now, we will send an insert 266 // the the server later. 267 if (TextUtils.isEmpty(localSyncID)) { 268 if (Log.isLoggable(TAG, Log.VERBOSE)) { 269 Log.v(TAG, "local record " + 270 localCursor.getLong(1) + 271 " has no _sync_id, ignoring"); 272 } 273 localCursor.moveToNext(); 274 localSyncID = null; 275 continue; 276 } 277 278 int comp = serverSyncId.compareTo(localSyncID); 279 280 // the local DB has a record that the server doesn't have 281 if (comp > 0) { 282 if (Log.isLoggable(TAG, Log.VERBOSE)) { 283 Log.v(TAG, "local record " + 284 localCursor.getLong(1) + 285 " has _sync_id " + localSyncID + 286 " that is < server _sync_id " + serverSyncId); 287 } 288 if (diffsArePartial) { 289 localCursor.moveToNext(); 290 } else { 291 deleteRow(localCursor); 292 if (mDeletedTable != null) { 293 mDb.delete(mDeletedTable, _SYNC_ID +"=?", new String[] {localSyncID}); 294 } 295 syncResult.stats.numDeletes++; 296 mDb.yieldIfContended(); 297 } 298 localSyncID = null; 299 continue; 300 } 301 302 // the server has a record that the local DB doesn't have 303 if (comp < 0) { 304 if (Log.isLoggable(TAG, Log.VERBOSE)) { 305 Log.v(TAG, "local record " + 306 localCursor.getLong(1) + 307 " has _sync_id " + localSyncID + 308 " that is > server _sync_id " + serverSyncId); 309 } 310 localSyncID = null; 311 } 312 313 // the server and the local DB both have this record 314 if (comp == 0) { 315 if (Log.isLoggable(TAG, Log.VERBOSE)) { 316 Log.v(TAG, "local record " + 317 localCursor.getLong(1) + 318 " has _sync_id " + localSyncID + 319 " that matches the server _sync_id"); 320 } 321 localSyncDirty = localCursor.getInt(0) != 0; 322 localRowId = localCursor.getLong(1); 323 localSyncVersion = localCursor.getString(3); 324 localCursor.moveToNext(); 325 } 326 327 break; 328 } 329 330 // If this record is in the deleted table then update the server version 331 // in the deleted table, if necessary, and then ignore it here. 332 // We will send a deletion indication to the server down a 333 // little further. 334 if (findInCursor(deletedCursor, deletedSyncIDColumn, serverSyncId)) { 335 if (Log.isLoggable(TAG, Log.VERBOSE)) { 336 Log.v(TAG, "remote record " + serverSyncId + " is in the deleted table"); 337 } 338 final String deletedSyncVersion = deletedCursor.getString(deletedSyncVersionColumn); 339 if (!TextUtils.equals(deletedSyncVersion, serverSyncVersion)) { 340 if (Log.isLoggable(TAG, Log.VERBOSE)) { 341 Log.v(TAG, "setting version of deleted record " + serverSyncId + " to " 342 + serverSyncVersion); 343 } 344 ContentValues values = new ContentValues(); 345 values.put(_SYNC_VERSION, serverSyncVersion); 346 mDb.update(mDeletedTable, values, "_sync_id=?", new String[]{serverSyncId}); 347 } 348 continue; 349 } 350 351 // If the _sync_local_id is present in the diffsCursor 352 // then this record corresponds to a local record that was just 353 // inserted into the server and the _sync_local_id is the row id 354 // of the local record. Set these fields so that the next check 355 // treats this record as an update, which will allow the 356 // merger to update the record with the server's sync id 357 if (!diffsCursor.isNull(serverSyncLocalIdColumn)) { 358 localRowId = diffsCursor.getLong(serverSyncLocalIdColumn); 359 if (Log.isLoggable(TAG, Log.VERBOSE)) { 360 Log.v(TAG, "the remote record with sync id " + serverSyncId 361 + " has a local sync id, " + localRowId); 362 } 363 localSyncID = serverSyncId; 364 localSyncDirty = false; 365 localSyncVersion = null; 366 } 367 368 if (!TextUtils.isEmpty(localSyncID)) { 369 // An existing server item has changed 370 // If serverSyncVersion is null, there is no edit URL; 371 // server won't let this change be written. 372 boolean recordChanged = (localSyncVersion == null) || 373 (serverSyncVersion == null) || 374 !serverSyncVersion.equals(localSyncVersion); 375 if (recordChanged) { 376 if (localSyncDirty) { 377 if (Log.isLoggable(TAG, Log.VERBOSE)) { 378 Log.v(TAG, "remote record " + serverSyncId 379 + " conflicts with local _sync_id " + localSyncID 380 + ", local _id " + localRowId); 381 } 382 conflict = true; 383 } else { 384 if (Log.isLoggable(TAG, Log.VERBOSE)) { 385 Log.v(TAG, 386 "remote record " + 387 serverSyncId + 388 " updates local _sync_id " + 389 localSyncID + ", local _id " + 390 localRowId); 391 } 392 update = true; 393 } 394 } else { 395 if (Log.isLoggable(TAG, Log.VERBOSE)) { 396 Log.v(TAG, 397 "Skipping update: localSyncVersion: " + localSyncVersion + 398 ", serverSyncVersion: " + serverSyncVersion); 399 } 400 } 401 } else { 402 // the local db doesn't know about this record so add it 403 if (Log.isLoggable(TAG, Log.VERBOSE)) { 404 Log.v(TAG, "remote record " + serverSyncId + " is new, inserting"); 405 } 406 insert = true; 407 } 408 409 if (update) { 410 updateRow(localRowId, serverDiffs, diffsCursor); 411 syncResult.stats.numUpdates++; 412 } else if (conflict) { 413 resolveRow(localRowId, serverSyncId, serverDiffs, diffsCursor); 414 syncResult.stats.numUpdates++; 415 } else if (insert) { 416 insertRow(serverDiffs, diffsCursor); 417 syncResult.stats.numInserts++; 418 } 419 } 420 421 if (Log.isLoggable(TAG, Log.VERBOSE)) { 422 Log.v(TAG, "processed " + diffsCount + " server entries"); 423 } 424 425 // If tombstones aren't in use delete any remaining local rows that 426 // don't have corresponding server rows. Keep the rows that don't 427 // have a sync id since those were created locally and haven't been 428 // synced to the server yet. 429 if (!diffsArePartial) { 430 while (!localCursor.isAfterLast() && !TextUtils.isEmpty(localCursor.getString(2))) { 431 if (mIsMergeCancelled) { 432 return; 433 } 434 localCount++; 435 final String localSyncId = localCursor.getString(2); 436 if (Log.isLoggable(TAG, Log.VERBOSE)) { 437 Log.v(TAG, 438 "deleting local record " + 439 localCursor.getLong(1) + 440 " _sync_id " + localSyncId); 441 } 442 deleteRow(localCursor); 443 if (mDeletedTable != null) { 444 mDb.delete(mDeletedTable, _SYNC_ID + "=?", new String[] {localSyncId}); 445 } 446 syncResult.stats.numDeletes++; 447 mDb.yieldIfContended(); 448 } 449 } 450 if (Log.isLoggable(TAG, Log.VERBOSE)) Log.v(TAG, "checked " + localCount + 451 " local entries"); 452 } finally { 453 if (diffsCursor != null) diffsCursor.close(); 454 if (localCursor != null) localCursor.close(); 455 if (deletedCursor != null) deletedCursor.close(); 456 } 457 458 459 if (Log.isLoggable(TAG, Log.VERBOSE)) Log.v(TAG, "applying deletions from the server"); 460 461 // Apply deletions from the server 462 if (mDeletedTableURL != null) { 463 diffsCursor = serverDiffs.query(mDeletedTableURL, null, null, null, null); 464 try { 465 while (diffsCursor.moveToNext()) { 466 if (mIsMergeCancelled) { 467 return; 468 } 469 // delete all rows that match each element in the diffsCursor 470 fullyDeleteMatchingRows(diffsCursor, account, syncResult); 471 mDb.yieldIfContended(); 472 } 473 } finally { 474 diffsCursor.close(); 475 } 476 } 477 } 478 fullyDeleteMatchingRows(Cursor diffsCursor, Account account, SyncResult syncResult)479 private void fullyDeleteMatchingRows(Cursor diffsCursor, Account account, 480 SyncResult syncResult) { 481 int serverSyncIdColumn = diffsCursor.getColumnIndexOrThrow(_SYNC_ID); 482 final boolean deleteBySyncId = !diffsCursor.isNull(serverSyncIdColumn); 483 484 // delete the rows explicitly so that the delete operation can be overridden 485 final String[] selectionArgs; 486 Cursor c = null; 487 try { 488 if (deleteBySyncId) { 489 selectionArgs = new String[]{diffsCursor.getString(serverSyncIdColumn), 490 account.name, account.type}; 491 c = mDb.query(mTable, new String[]{BaseColumns._ID}, SELECT_BY_SYNC_ID_AND_ACCOUNT, 492 selectionArgs, null, null, null); 493 } else { 494 int serverSyncLocalIdColumn = diffsCursor.getColumnIndexOrThrow(_SYNC_LOCAL_ID); 495 selectionArgs = new String[]{diffsCursor.getString(serverSyncLocalIdColumn)}; 496 c = mDb.query(mTable, new String[]{BaseColumns._ID}, SELECT_BY_ID, selectionArgs, 497 null, null, null); 498 } 499 c.moveToFirst(); 500 while (!c.isAfterLast()) { 501 deleteRow(c); // advances the cursor 502 syncResult.stats.numDeletes++; 503 } 504 } finally { 505 if (c != null) c.close(); 506 } 507 if (deleteBySyncId && mDeletedTable != null) { 508 mDb.delete(mDeletedTable, SELECT_BY_SYNC_ID_AND_ACCOUNT, selectionArgs); 509 } 510 } 511 512 /** 513 * Converts cursor into a Map, using the correct types for the values. 514 */ cursorRowToContentValues(Cursor cursor, ContentValues map)515 protected void cursorRowToContentValues(Cursor cursor, ContentValues map) { 516 DatabaseUtils.cursorRowToContentValues(cursor, map); 517 } 518 519 /** 520 * Finds local changes, placing the results in the given result object. 521 * @param temporaryInstanceFactory As an optimization for the case 522 * where there are no client-side diffs, mergeResult may initially 523 * have no {@link TempProviderSyncResult#tempContentProvider}. If this is 524 * the first in the sequence of AbstractTableMergers to find 525 * client-side diffs, it will use the given ContentProvider to 526 * create a temporary instance and store its {@link 527 * android.content.ContentProvider} in the mergeResult. 528 * @param account 529 * @param syncResult 530 */ findLocalChanges(TempProviderSyncResult mergeResult, SyncableContentProvider temporaryInstanceFactory, Account account, SyncResult syncResult)531 private void findLocalChanges(TempProviderSyncResult mergeResult, 532 SyncableContentProvider temporaryInstanceFactory, Account account, 533 SyncResult syncResult) { 534 SyncableContentProvider clientDiffs = mergeResult.tempContentProvider; 535 if (Log.isLoggable(TAG, Log.VERBOSE)) Log.v(TAG, "generating client updates"); 536 537 final String[] accountSelectionArgs = new String[]{account.name, account.type}; 538 539 // Generate the client updates and insertions 540 // Create a cursor for dirty records 541 long numInsertsOrUpdates = 0; 542 Cursor localChangesCursor = mDb.query(mTable, null, SELECT_UNSYNCED, accountSelectionArgs, 543 null, null, null); 544 try { 545 numInsertsOrUpdates = localChangesCursor.getCount(); 546 while (localChangesCursor.moveToNext()) { 547 if (mIsMergeCancelled) { 548 return; 549 } 550 if (clientDiffs == null) { 551 clientDiffs = temporaryInstanceFactory.getTemporaryInstance(); 552 } 553 mValues.clear(); 554 cursorRowToContentValues(localChangesCursor, mValues); 555 mValues.remove("_id"); 556 DatabaseUtils.cursorLongToContentValues(localChangesCursor, "_id", mValues, 557 _SYNC_LOCAL_ID); 558 clientDiffs.insert(mTableURL, mValues); 559 } 560 } finally { 561 localChangesCursor.close(); 562 } 563 564 // Generate the client deletions 565 if (Log.isLoggable(TAG, Log.VERBOSE)) Log.v(TAG, "generating client deletions"); 566 long numEntries = DatabaseUtils.queryNumEntries(mDb, mTable); 567 long numDeletedEntries = 0; 568 if (mDeletedTable != null) { 569 Cursor deletedCursor = mDb.query(mDeletedTable, 570 syncIdAndVersionProjection, 571 _SYNC_ACCOUNT + "=? AND " + _SYNC_ACCOUNT_TYPE + "=? AND " 572 + _SYNC_ID + " IS NOT NULL", accountSelectionArgs, 573 null, null, mDeletedTable + "." + _SYNC_ID); 574 try { 575 numDeletedEntries = deletedCursor.getCount(); 576 while (deletedCursor.moveToNext()) { 577 if (mIsMergeCancelled) { 578 return; 579 } 580 if (clientDiffs == null) { 581 clientDiffs = temporaryInstanceFactory.getTemporaryInstance(); 582 } 583 mValues.clear(); 584 DatabaseUtils.cursorRowToContentValues(deletedCursor, mValues); 585 clientDiffs.insert(mDeletedTableURL, mValues); 586 } 587 } finally { 588 deletedCursor.close(); 589 } 590 } 591 592 if (clientDiffs != null) { 593 mergeResult.tempContentProvider = clientDiffs; 594 } 595 syncResult.stats.numDeletes += numDeletedEntries; 596 syncResult.stats.numUpdates += numInsertsOrUpdates; 597 syncResult.stats.numEntries += numEntries; 598 } 599 } 600