/*
* Copyright (C) 2010 Josh Guilfoyle <jasta@devtcg.org>
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*/
package org.devtcg.five.provider;
import org.devtcg.five.service.SyncContext;
import android.content.ContentProvider;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.provider.BaseColumns;
import android.util.Log;
/**
* This code and design is largely based on Android's AbstractTableMerger, so
* the copyright header above is probably void. Should be licensed under Apache
* as per the original.
*/
public abstract class AbstractTableMerger
{
private static final String TAG = "AbstractTableMerger";
/**
* Print excessive debug of each entry being merged.
*/
private static final boolean DEBUG_ENTRIES = true;
protected final SQLiteDatabase mDb;
protected final String mTable;
protected final String mDeletedTable;
protected final Uri mTableUri;
protected final Uri mDeletedTableUri;
public interface SyncableColumns extends BaseColumns
{
/**
* Timestamp of last modification.
*/
public static final String _SYNC_TIME = "_sync_time";
/**
* Sync id corresponding to the main (non-temporary) provider.
*/
public static final String _SYNC_ID = "_sync_id";
}
public AbstractTableMerger(SQLiteDatabase db, String table, String deletedTable,
Uri tableUri, Uri deletedTableUri)
{
mDb = db;
mTable = table;
mTableUri = tableUri;
mDeletedTable = deletedTable;
mDeletedTableUri = deletedTableUri;
}
protected SQLiteDatabase getDatabase()
{
return mDb;
}
public void merge(Context context, SyncContext syncContext,
AbstractSyncProvider serverDiffs, AbstractSyncProvider clientDiffs)
{
if (serverDiffs != null)
{
if (!mDb.isDbLockedByCurrentThread()) {
throw new IllegalStateException("this must be called from within a DB transaction");
}
mergeServerDiffs(context, syncContext, serverDiffs);
notifyChanges(context);
}
if (clientDiffs != null)
findLocalChanges(context, syncContext, clientDiffs);
}
private void mergeServerDiffs(Context context, SyncContext syncContext,
AbstractSyncProvider serverDiffs)
{
Log.d(TAG, mTable + ": beginning table merge");
try {
/*
* Step 1: process server intiated deletes. This is done first in
* case the id has been re-used by the server (for instance, server
* deleted id 1, then inserted a new record to fill that same id).
*/
Log.d(TAG, mTable + ": applying server deletions...");
int deleteCount = mergeServerDeletions(context, syncContext, serverDiffs);
/*
* Step 2: process server initiated inserts and modifications.
*/
Log.d(TAG, mTable + ": applying server modifications...");
int diffCount = mergeServerChanges(context, syncContext, serverDiffs);
Log.d(TAG, mTable + ": table merge complete, processed " +
deleteCount + " deletes, " +
diffCount + " inserts/updates");
} catch (Exception e) {
/* Aiiee!! How can we capture this failure? */
Log.e(TAG, mTable + ": table merge failed!", e);
syncContext.mergeError = true;
syncContext.errorMessage = e.toString();
}
}
/**
* Merge collected server deletion requests into the main database table.
*
* @return Number of deletions successfully applied.
*/
private int mergeServerDeletions(Context context, SyncContext syncContext,
AbstractSyncProvider serverDiffs)
{
/* Set containing all deleted entries (to be merged into main provider). */
Cursor deletedCursor = serverDiffs.query(mDeletedTableUri, null, null, null, null);
try {
int deleteCount = 0;
int deletedSyncIdColumn = deletedCursor.getColumnIndexOrThrow(SyncableColumns._SYNC_ID);
while (deletedCursor.moveToNext())
{
mDb.yieldIfContendedSafely();
long syncId = deletedCursor.getLong(deletedSyncIdColumn);
/*
* Locate the local record and request its deletion. This design
* is copied from Android's AbstractTableMerger (as is most of
* this class) but I find myself surprised by the relatively
* poor efficiency employed here.
*
* Seems like a manual join could achieve much better
* performance here. We could use the local data set queried in
* mergeServerDiffs and then order our deleted records and walk
* along the two cursors. This introduces overhead for the
* common case of few deletes but it could be tuned for use when
* the number of deletions reaches some threshold like 20% of
* total records.
*/
Cursor localCursor = mDb.query(mTable, null,
SyncableColumns._SYNC_ID + " = ?", new String[] { String.valueOf(syncId) },
null, null, null);
try {
int matches = localCursor.getCount();
if (matches == 0)
{
/*
* This might happen if the local side has already
* deleted the record prior to syncing. Not a big deal,
* but warn just in case.
*/
Log.d(TAG, "received deletion request from server for _sync_id " +
syncId + ", but there is no matching local record.");
}
else if (matches > 1)
{
/*
* This is a much weirder situation. We should have
* never permitted a database entry to be inserted with
* a _SYNC_ID matching a previous record. This makes no
* sense at all and should absolutely never happen.
* Server bug? Client bug? Malicious server? Hmm...
*/
Log.d(TAG, "multiple records matched delete request for _sync_id " + syncId);
}
while (localCursor.moveToNext())
{
if (DEBUG_ENTRIES)
{
long localId = localCursor.getLong(
localCursor.getColumnIndexOrThrow(SyncableColumns._ID));
Log.d(TAG, "deleting local record " + localId + " with _sync_id " + syncId);
}
deleteRow(context, serverDiffs, localCursor);
syncContext.numberOfDeletes++;
deleteCount++;
}
} finally {
localCursor.close();
}
}
return deleteCount;
} finally {
deletedCursor.close();
}
}
/**
* Merge collected server inserts and modifications.
*/
private int mergeServerChanges(Context context, SyncContext syncContext,
AbstractSyncProvider serverDiffs)
{
/* Set containing all local entries so we can merge (insert/update/resolve). */
Cursor localCursor = mDb.query(mTable,
new String[] { SyncableColumns._ID, SyncableColumns._SYNC_TIME,
SyncableColumns._SYNC_ID }, null, null, null, null,
SyncableColumns._SYNC_ID);
/* Set containing all diffed entries (to be merged into main provider). */
Cursor diffsCursor = serverDiffs.query(mTableUri, null, null, null,
SyncableColumns._SYNC_ID);
try {
int localCount = 0;
int diffsCount = 0;
int diffsIdColumn = diffsCursor.getColumnIndexOrThrow(SyncableColumns._ID);
int diffsSyncTimeColumn = diffsCursor.getColumnIndexOrThrow(SyncableColumns._SYNC_TIME);
int diffsSyncIdColumn = diffsCursor.getColumnIndexOrThrow(SyncableColumns._SYNC_ID);
/*
* Move it to the first record to match the diffsCursor position
* when it enters the loop below.
*/
boolean localSetHasRows = localCursor.moveToFirst();
/*
* Walk the diffs cursor, replaying each change onto the local
* cursor. This is a merge.
*/
while (diffsCursor.moveToNext() == true)
{
mDb.yieldIfContendedSafely();
String id = diffsCursor.getString(diffsIdColumn);
long syncTime = diffsCursor.getLong(diffsSyncTimeColumn);
long syncId = diffsCursor.getLong(diffsSyncIdColumn);
diffsCount++;
if (DEBUG_ENTRIES)
Log.d(TAG, "processing entry #" + diffsCount + ", syncId=" + syncId);
/* TODO: conflict is not handled yet. */
MergeOp mergeOp = MergeOp.NONE;
long localRowId = -1;
long localSyncTime = -1;
/*
* Position the local cursor to align with the diff cursor. The
* two cursor "walk together" to determine if entries are new,
* updating, or conflicting.
*/
while (localSetHasRows == true && localCursor.isAfterLast() == false)
{
localCount++;
long localId = localCursor.getLong(0);
/*
* If the local record doesn't have a _sync_id, then it is
* new locally. No need to merge it now.
*/
if (localCursor.isNull(2))
{
if (DEBUG_ENTRIES)
Log.d(TAG, "local record " + localId + " has no _sync_id, ignoring");
localCursor.moveToNext();
continue;
}
long localSyncId = localCursor.getLong(2);
/* The partial diffs set is ignoring this record, move along. */
if (syncId > localSyncId)
{
localCursor.moveToNext();
continue;
}
/* The server has a record that the local database doesn't have. */
if (syncId < localSyncId)
{
if (DEBUG_ENTRIES)
Log.d(TAG, "local record " + localId + " has _sync_id > server _sync_id " + syncId);
localRowId = -1;
}
/* The server and the local database both have this record. */
else /* if (syncId == localSyncId) */
{
if (DEBUG_ENTRIES)
Log.d(TAG, "local record " + localId + " has _sync_id that matches server _sync_id " + syncId);
localRowId = localId;
localSyncTime = localCursor.getLong(1);
localCursor.moveToNext();
}
/* We're positioned along with the diffSet. */
break;
}
/*
* TODO: We don't handle conflict resolution, we always treat a
* server diff as an update locally regardless of what the
* values were before!
*/
if (localRowId >= 0)
/* An existing item has changed... */
mergeOp = MergeOp.UPDATE;
else
{
/* The local database doesn't know about this record yet. */
if (DEBUG_ENTRIES)
Log.d(TAG, "remote record " + syncId + " is new, inserting");
mergeOp = MergeOp.INSERT;
}
switch (mergeOp)
{
case INSERT:
insertRow(context, serverDiffs, diffsCursor);
syncContext.numberOfInserts++;
break;
case UPDATE:
updateRow(context, serverDiffs, localRowId, diffsCursor);
syncContext.numberOfUpdates++;
break;
default:
throw new RuntimeException("TODO");
}
}
return diffsCount;
} finally {
diffsCursor.close();
localCursor.close();
}
}
private void findLocalChanges(Context context, SyncContext syncContext,
AbstractSyncProvider clientDiffs)
{
throw new UnsupportedOperationException("Client sync is not supported yet");
}
/**
* Called after merge has completed.
*/
public abstract void notifyChanges(Context context);
/**
* Process a server initiated insert by inserting the record into the main
* provider.
*
* @param context
* @param diffs
* Temporary content provider holding all sync entries received
* from the server.
* @param diffsCursor
* Cursor positioned at a temporary record sent from the server.
*/
public abstract void insertRow(Context context, ContentProvider diffs, Cursor diffsCursor);
/**
* Process a server initiated delete by deleting the record from the main
* provider.
*
* @param context
* @param diffs
* Temporary content provider holding all sync entries received
* from the server.
* @param localCursor
* Cursor positioned at the local record to delete. Unlike
* {@link #insertRow} and {@link #updateRow}, this cursor was
* queried from the main provider not <code>diffs</code>.
*/
public abstract void deleteRow(Context context, ContentProvider diffs, Cursor localCursor);
/**
* Process a server initiated modification by applying all columns from the
* provided record to the local record specified.
*
* @param context
* @param diffs
* Temporary content provider holding all sync entries received
* from the server.
* @param id
* Local record id into which the merge occurs.
* @param localCursor
* Cursor positioned at a temporary record sent from the server.
*/
public abstract void updateRow(Context context, ContentProvider diffs,
long id, Cursor diffsCursor);
/**
* Process a server initiated modification which conflicts with local
* modifications. This is currently not implemented and not used. For now,
* server always wins.
*/
public void resolveRow(Context context, ContentProvider main, long id, String syncId,
Cursor diffsCursor)
{
throw new RuntimeException("This table merger does not handle conflicts, but one was detected with id=" + id + ", syncId=" + syncId);
}
private enum MergeOp
{
NONE, INSERT, UPDATE, CONFLICTED, DELETE
}
}