/* * Copyright (C) 2006 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.content; import android.database.Cursor; import android.database.DatabaseUtils; import android.database.sqlite.SQLiteDatabase; import android.net.Uri; import android.os.Debug; import android.provider.BaseColumns; import static android.provider.SyncConstValue.*; import android.text.TextUtils; import android.util.Log; /** * @hide */ public abstract class AbstractTableMerger { private ContentValues mValues; protected SQLiteDatabase mDb; protected String mTable; protected Uri mTableURL; protected String mDeletedTable; protected Uri mDeletedTableURL; static protected ContentValues mSyncMarkValues; static private boolean TRACE; static { mSyncMarkValues = new ContentValues(); mSyncMarkValues.put(_SYNC_MARK, 1); TRACE = false; } private static final String TAG = "AbstractTableMerger"; private static final String[] syncDirtyProjection = new String[] {_SYNC_DIRTY, BaseColumns._ID, _SYNC_ID, _SYNC_VERSION}; private static final String[] syncIdAndVersionProjection = new String[] {_SYNC_ID, _SYNC_VERSION}; private volatile boolean mIsMergeCancelled; private static final String SELECT_MARKED = _SYNC_MARK + "> 0 and " + _SYNC_ACCOUNT + "=?"; private static final String SELECT_BY_SYNC_ID_AND_ACCOUNT = _SYNC_ID +"=? and " + _SYNC_ACCOUNT + "=?"; private static final String SELECT_BY_ID = BaseColumns._ID +"=?"; private static final String SELECT_UNSYNCED = "" + _SYNC_DIRTY + " > 0 and (" + _SYNC_ACCOUNT + "=? or " + _SYNC_ACCOUNT + " is null)"; public AbstractTableMerger(SQLiteDatabase database, String table, Uri tableURL, String deletedTable, Uri deletedTableURL) { mDb = database; mTable = table; mTableURL = tableURL; mDeletedTable = deletedTable; mDeletedTableURL = deletedTableURL; mValues = new ContentValues(); } public abstract void insertRow(ContentProvider diffs, Cursor diffsCursor); public abstract void updateRow(long localPersonID, ContentProvider diffs, Cursor diffsCursor); public abstract void resolveRow(long localPersonID, String syncID, ContentProvider diffs, Cursor diffsCursor); /** * This is called when it is determined that a row should be deleted from the * ContentProvider. The localCursor is on a table from the local ContentProvider * and its current position is of the row that should be deleted. The localCursor * is only guaranteed to contain the BaseColumns.ID column so the implementation * of deleteRow() must query the database directly if other columns are needed. * <p> * It is the responsibility of the implementation of this method to ensure that the cursor * points to the next row when this method returns, either by calling Cursor.deleteRow() or * Cursor.next(). * * @param localCursor The Cursor into the local table, which points to the row that * is to be deleted. */ public void deleteRow(Cursor localCursor) { localCursor.deleteRow(); } /** * After {@link #merge} has completed, this method is called to send * notifications to {@link android.database.ContentObserver}s of changes * to the containing {@link ContentProvider}. These notifications likely * do not want to request a sync back to the network. */ protected abstract void notifyChanges(); private static boolean findInCursor(Cursor cursor, int column, String id) { while (!cursor.isAfterLast() && !cursor.isNull(column)) { int comp = id.compareTo(cursor.getString(column)); if (comp > 0) { cursor.moveToNext(); continue; } return comp == 0; } return false; } public void onMergeCancelled() { mIsMergeCancelled = true; } /** * Carry out a merge of the given diffs, and add the results to * the given MergeResult. If we are the first merge to find * client-side diffs, we'll use the given ContentProvider to * construct a temporary instance to hold them. */ public void merge(final SyncContext context, final String account, final SyncableContentProvider serverDiffs, TempProviderSyncResult result, SyncResult syncResult, SyncableContentProvider temporaryInstanceFactory) { mIsMergeCancelled = false; if (serverDiffs != null) { if (!mDb.isDbLockedByCurrentThread()) { throw new IllegalStateException("this must be called from within a DB transaction"); } mergeServerDiffs(context, account, serverDiffs, syncResult); notifyChanges(); } if (result != null) { findLocalChanges(result, temporaryInstanceFactory, account, syncResult); } if (Log.isLoggable(TAG, Log.VERBOSE)) Log.v(TAG, "merge complete"); } /** * @hide this is public for testing purposes only */ public void mergeServerDiffs(SyncContext context, String account, SyncableContentProvider serverDiffs, SyncResult syncResult) { boolean diffsArePartial = serverDiffs.getContainsDiffs(); // mark the current rows so that we can distinguish these from new // inserts that occur during the merge mDb.update(mTable, mSyncMarkValues, null, null); if (mDeletedTable != null) { mDb.update(mDeletedTable, mSyncMarkValues, null, null); } // load the local database entries, so we can merge them with the server final String[] accountSelectionArgs = new String[]{account}; Cursor localCursor = mDb.query(mTable, syncDirtyProjection, SELECT_MARKED, accountSelectionArgs, null, null, mTable + "." + _SYNC_ID); Cursor deletedCursor; if (mDeletedTable != null) { deletedCursor = mDb.query(mDeletedTable, syncIdAndVersionProjection, SELECT_MARKED, accountSelectionArgs, null, null, mDeletedTable + "." + _SYNC_ID); } else { deletedCursor = mDb.rawQuery("select 'a' as _sync_id, 'b' as _sync_version limit 0", null); } // Apply updates and insertions from the server Cursor diffsCursor = serverDiffs.query(mTableURL, null, null, null, mTable + "." + _SYNC_ID); int deletedSyncIDColumn = deletedCursor.getColumnIndexOrThrow(_SYNC_ID); int deletedSyncVersionColumn = deletedCursor.getColumnIndexOrThrow(_SYNC_VERSION); int serverSyncIDColumn = diffsCursor.getColumnIndexOrThrow(_SYNC_ID); int serverSyncVersionColumn = diffsCursor.getColumnIndexOrThrow(_SYNC_VERSION); int serverSyncLocalIdColumn = diffsCursor.getColumnIndexOrThrow(_SYNC_LOCAL_ID); String lastSyncId = null; int diffsCount = 0; int localCount = 0; localCursor.moveToFirst(); deletedCursor.moveToFirst(); while (diffsCursor.moveToNext()) { if (mIsMergeCancelled) { localCursor.close(); deletedCursor.close(); diffsCursor.close(); return; } mDb.yieldIfContended(); String serverSyncId = diffsCursor.getString(serverSyncIDColumn); String serverSyncVersion = diffsCursor.getString(serverSyncVersionColumn); long localRowId = 0; String localSyncVersion = null; diffsCount++; context.setStatusText("Processing " + diffsCount + "/" + diffsCursor.getCount()); if (Log.isLoggable(TAG, Log.VERBOSE)) Log.v(TAG, "processing server entry " + diffsCount + ", " + serverSyncId); if (TRACE) { if (diffsCount == 10) { Debug.startMethodTracing("atmtrace"); } if (diffsCount == 20) { Debug.stopMethodTracing(); } } boolean conflict = false; boolean update = false; boolean insert = false; if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "found event with serverSyncID " + serverSyncId); } if (TextUtils.isEmpty(serverSyncId)) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.e(TAG, "server entry doesn't have a serverSyncID"); } continue; } // It is possible that the sync adapter wrote the same record multiple times, // e.g. if the same record came via multiple feeds. If this happens just ignore // the duplicate records. if (serverSyncId.equals(lastSyncId)) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "skipping record with duplicate remote server id " + lastSyncId); } continue; } lastSyncId = serverSyncId; String localSyncID = null; boolean localSyncDirty = false; while (!localCursor.isAfterLast()) { if (mIsMergeCancelled) { localCursor.deactivate(); deletedCursor.deactivate(); diffsCursor.deactivate(); return; } localCount++; localSyncID = localCursor.getString(2); // If the local record doesn't have a _sync_id then // it is new. Ignore it for now, we will send an insert // the the server later. if (TextUtils.isEmpty(localSyncID)) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "local record " + localCursor.getLong(1) + " has no _sync_id, ignoring"); } localCursor.moveToNext(); localSyncID = null; continue; } int comp = serverSyncId.compareTo(localSyncID); // the local DB has a record that the server doesn't have if (comp > 0) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "local record " + localCursor.getLong(1) + " has _sync_id " + localSyncID + " that is < server _sync_id " + serverSyncId); } if (diffsArePartial) { localCursor.moveToNext(); } else { deleteRow(localCursor); if (mDeletedTable != null) { mDb.delete(mDeletedTable, _SYNC_ID +"=?", new String[] {localSyncID}); } syncResult.stats.numDeletes++; mDb.yieldIfContended(); } localSyncID = null; continue; } // the server has a record that the local DB doesn't have if (comp < 0) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "local record " + localCursor.getLong(1) + " has _sync_id " + localSyncID + " that is > server _sync_id " + serverSyncId); } localSyncID = null; } // the server and the local DB both have this record if (comp == 0) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "local record " + localCursor.getLong(1) + " has _sync_id " + localSyncID + " that matches the server _sync_id"); } localSyncDirty = localCursor.getInt(0) != 0; localRowId = localCursor.getLong(1); localSyncVersion = localCursor.getString(3); localCursor.moveToNext(); } break; } // If this record is in the deleted table then update the server version // in the deleted table, if necessary, and then ignore it here. // We will send a deletion indication to the server down a // little further. if (findInCursor(deletedCursor, deletedSyncIDColumn, serverSyncId)) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "remote record " + serverSyncId + " is in the deleted table"); } final String deletedSyncVersion = deletedCursor.getString(deletedSyncVersionColumn); if (!TextUtils.equals(deletedSyncVersion, serverSyncVersion)) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "setting version of deleted record " + serverSyncId + " to " + serverSyncVersion); } ContentValues values = new ContentValues(); values.put(_SYNC_VERSION, serverSyncVersion); mDb.update(mDeletedTable, values, "_sync_id=?", new String[]{serverSyncId}); } continue; } // If the _sync_local_id is present in the diffsCursor // then this record corresponds to a local record that was just // inserted into the server and the _sync_local_id is the row id // of the local record. Set these fields so that the next check // treats this record as an update, which will allow the // merger to update the record with the server's sync id if (!diffsCursor.isNull(serverSyncLocalIdColumn)) { localRowId = diffsCursor.getLong(serverSyncLocalIdColumn); if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "the remote record with sync id " + serverSyncId + " has a local sync id, " + localRowId); } localSyncID = serverSyncId; localSyncDirty = false; localSyncVersion = null; } if (!TextUtils.isEmpty(localSyncID)) { // An existing server item has changed boolean recordChanged = (localSyncVersion == null) || !serverSyncVersion.equals(localSyncVersion); if (recordChanged) { if (localSyncDirty) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "remote record " + serverSyncId + " conflicts with local _sync_id " + localSyncID + ", local _id " + localRowId); } conflict = true; } else { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "remote record " + serverSyncId + " updates local _sync_id " + localSyncID + ", local _id " + localRowId); } update = true; } } } else { // the local db doesn't know about this record so add it if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "remote record " + serverSyncId + " is new, inserting"); } insert = true; } if (update) { updateRow(localRowId, serverDiffs, diffsCursor); syncResult.stats.numUpdates++; } else if (conflict) { resolveRow(localRowId, serverSyncId, serverDiffs, diffsCursor); syncResult.stats.numUpdates++; } else if (insert) { insertRow(serverDiffs, diffsCursor); syncResult.stats.numInserts++; } } if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "processed " + diffsCount + " server entries"); } // If tombstones aren't in use delete any remaining local rows that // don't have corresponding server rows. Keep the rows that don't // have a sync id since those were created locally and haven't been // synced to the server yet. if (!diffsArePartial) { while (!localCursor.isAfterLast() && !TextUtils.isEmpty(localCursor.getString(2))) { if (mIsMergeCancelled) { localCursor.deactivate(); deletedCursor.deactivate(); diffsCursor.deactivate(); return; } localCount++; final String localSyncId = localCursor.getString(2); if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "deleting local record " + localCursor.getLong(1) + " _sync_id " + localSyncId); } deleteRow(localCursor); if (mDeletedTable != null) { mDb.delete(mDeletedTable, _SYNC_ID + "=?", new String[] {localSyncId}); } syncResult.stats.numDeletes++; mDb.yieldIfContended(); } } if (Log.isLoggable(TAG, Log.VERBOSE)) Log.v(TAG, "checked " + localCount + " local entries"); diffsCursor.deactivate(); localCursor.deactivate(); deletedCursor.deactivate(); if (Log.isLoggable(TAG, Log.VERBOSE)) Log.v(TAG, "applying deletions from the server"); // Apply deletions from the server if (mDeletedTableURL != null) { diffsCursor = serverDiffs.query(mDeletedTableURL, null, null, null, null); while (diffsCursor.moveToNext()) { if (mIsMergeCancelled) { diffsCursor.deactivate(); return; } // delete all rows that match each element in the diffsCursor fullyDeleteMatchingRows(diffsCursor, account, syncResult); mDb.yieldIfContended(); } diffsCursor.deactivate(); } } private void fullyDeleteMatchingRows(Cursor diffsCursor, String account, SyncResult syncResult) { int serverSyncIdColumn = diffsCursor.getColumnIndexOrThrow(_SYNC_ID); final boolean deleteBySyncId = !diffsCursor.isNull(serverSyncIdColumn); // delete the rows explicitly so that the delete operation can be overridden final Cursor c; final String[] selectionArgs; if (deleteBySyncId) { selectionArgs = new String[]{diffsCursor.getString(serverSyncIdColumn), account}; c = mDb.query(mTable, new String[]{BaseColumns._ID}, SELECT_BY_SYNC_ID_AND_ACCOUNT, selectionArgs, null, null, null); } else { int serverSyncLocalIdColumn = diffsCursor.getColumnIndexOrThrow(_SYNC_LOCAL_ID); selectionArgs = new String[]{diffsCursor.getString(serverSyncLocalIdColumn)}; c = mDb.query(mTable, new String[]{BaseColumns._ID}, SELECT_BY_ID, selectionArgs, null, null, null); } try { c.moveToFirst(); while (!c.isAfterLast()) { deleteRow(c); // advances the cursor syncResult.stats.numDeletes++; } } finally { c.deactivate(); } if (deleteBySyncId && mDeletedTable != null) { mDb.delete(mDeletedTable, SELECT_BY_SYNC_ID_AND_ACCOUNT, selectionArgs); } } /** * Converts cursor into a Map, using the correct types for the values. */ protected void cursorRowToContentValues(Cursor cursor, ContentValues map) { DatabaseUtils.cursorRowToContentValues(cursor, map); } /** * Finds local changes, placing the results in the given result object. * @param temporaryInstanceFactory As an optimization for the case * where there are no client-side diffs, mergeResult may initially * have no {@link android.content.TempProviderSyncResult#tempContentProvider}. If this is * the first in the sequence of AbstractTableMergers to find * client-side diffs, it will use the given ContentProvider to * create a temporary instance and store its {@link * ContentProvider} in the mergeResult. * @param account * @param syncResult */ private void findLocalChanges(TempProviderSyncResult mergeResult, SyncableContentProvider temporaryInstanceFactory, String account, SyncResult syncResult) { SyncableContentProvider clientDiffs = mergeResult.tempContentProvider; if (Log.isLoggable(TAG, Log.VERBOSE)) Log.v(TAG, "generating client updates"); final String[] accountSelectionArgs = new String[]{account}; // Generate the client updates and insertions // Create a cursor for dirty records Cursor localChangesCursor = mDb.query(mTable, null, SELECT_UNSYNCED, accountSelectionArgs, null, null, null); long numInsertsOrUpdates = localChangesCursor.getCount(); while (localChangesCursor.moveToNext()) { if (mIsMergeCancelled) { localChangesCursor.close(); return; } if (clientDiffs == null) { clientDiffs = temporaryInstanceFactory.getTemporaryInstance(); } mValues.clear(); cursorRowToContentValues(localChangesCursor, mValues); mValues.remove("_id"); DatabaseUtils.cursorLongToContentValues(localChangesCursor, "_id", mValues, _SYNC_LOCAL_ID); clientDiffs.insert(mTableURL, mValues); } localChangesCursor.close(); // Generate the client deletions if (Log.isLoggable(TAG, Log.VERBOSE)) Log.v(TAG, "generating client deletions"); long numEntries = DatabaseUtils.queryNumEntries(mDb, mTable); long numDeletedEntries = 0; if (mDeletedTable != null) { Cursor deletedCursor = mDb.query(mDeletedTable, syncIdAndVersionProjection, _SYNC_ACCOUNT + "=? AND " + _SYNC_ID + " IS NOT NULL", accountSelectionArgs, null, null, mDeletedTable + "." + _SYNC_ID); numDeletedEntries = deletedCursor.getCount(); while (deletedCursor.moveToNext()) { if (mIsMergeCancelled) { deletedCursor.close(); return; } if (clientDiffs == null) { clientDiffs = temporaryInstanceFactory.getTemporaryInstance(); } mValues.clear(); DatabaseUtils.cursorRowToContentValues(deletedCursor, mValues); clientDiffs.insert(mDeletedTableURL, mValues); } deletedCursor.close(); } if (clientDiffs != null) { mergeResult.tempContentProvider = clientDiffs; } syncResult.stats.numDeletes += numDeletedEntries; syncResult.stats.numUpdates += numInsertsOrUpdates; syncResult.stats.numEntries += numEntries; } }