package edu.mit.mobile.android.locast.sync;
/*
* Copyright (C) 2011 MIT Mobile Experience Lab
*
* 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
* of the License, 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.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.impl.cookie.DateParseException;
import org.apache.http.impl.cookie.DateUtils;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import android.accounts.Account;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
import android.content.ContentProviderOperation.Builder;
import android.content.ContentProviderResult;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.OperationApplicationException;
import android.content.SyncResult;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.RemoteException;
import android.util.Log;
import edu.mit.mobile.android.content.ProviderUtils;
import edu.mit.mobile.android.locast.Constants;
import edu.mit.mobile.android.locast.data.Cast;
import edu.mit.mobile.android.locast.data.CastMedia;
import edu.mit.mobile.android.locast.data.Comment;
import edu.mit.mobile.android.locast.data.Event;
import edu.mit.mobile.android.locast.data.Itinerary;
import edu.mit.mobile.android.locast.data.JsonSyncableItem;
import edu.mit.mobile.android.locast.data.MediaProvider;
import edu.mit.mobile.android.locast.data.NoPublicPath;
import edu.mit.mobile.android.locast.data.SyncException;
import edu.mit.mobile.android.locast.data.SyncMap;
import edu.mit.mobile.android.locast.net.NetworkClient;
import edu.mit.mobile.android.locast.net.NetworkProtocolException;
import edu.mit.mobile.android.utils.LastUpdatedMap;
import edu.mit.mobile.android.utils.StreamUtils;
public class SyncEngine {
private static final String TAG = SyncEngine.class.getSimpleName();
/**
* If syncing a server URI that is destined for a specific local URI space, add the destination
* URI here.
*/
public final static String EXTRA_DESTINATION_URI = "edu.mit.mobile.android.locast.EXTRA_DESTINATION_URI";
private static final String CONTENT_TYPE_PREFIX_DIR = "vnd.android.cursor.dir";
private static final HashMap<String, Class<? extends JsonSyncableItem>> TYPE_MAP = new HashMap<String, Class<? extends JsonSyncableItem>>();
private static final boolean DEBUG = Constants.DEBUG;
static {
TYPE_MAP.put(MediaProvider.TYPE_CAST_DIR, Cast.class);
TYPE_MAP.put(MediaProvider.TYPE_CAST_ITEM, Cast.class);
TYPE_MAP.put(MediaProvider.TYPE_CASTMEDIA_DIR, CastMedia.class);
TYPE_MAP.put(MediaProvider.TYPE_CASTMEDIA_ITEM, CastMedia.class);
TYPE_MAP.put(MediaProvider.TYPE_COMMENT_DIR, Comment.class);
TYPE_MAP.put(MediaProvider.TYPE_COMMENT_ITEM, Comment.class);
TYPE_MAP.put(MediaProvider.TYPE_ITINERARY_DIR, Itinerary.class);
TYPE_MAP.put(MediaProvider.TYPE_ITINERARY_ITEM, Itinerary.class);
TYPE_MAP.put(MediaProvider.TYPE_EVENT_DIR, Event.class);
TYPE_MAP.put(MediaProvider.TYPE_EVENT_ITEM, Event.class);
}
private final Context mContext;
private final NetworkClient mNetworkClient;
/**
* in nanoseconds
*/
private static final long TIMEOUT_MAX_ITEM_WAIT = (long) (30 * 1e9);
/**
* in nanoseconds
*/
static final long TIMEOUT_AUTO_SYNC_MINIMUM = (long) (60 * 1e9);
private final LastUpdatedMap<Uri> mLastUpdated = new LastUpdatedMap<Uri>(
TIMEOUT_AUTO_SYNC_MINIMUM);
// the amount of time by which an age can differ and still be considered
// equal
private static final long AGE_EQUALITY_SLOP = 1000; // ms
private static final String[] SYNC_PROJECTION = new String[] {
JsonSyncableItem._ID,
JsonSyncableItem._PUBLIC_URI,
JsonSyncableItem._MODIFIED_DATE,
JsonSyncableItem._SERVER_MODIFIED_DATE,
JsonSyncableItem._CREATED_DATE
};
private static final String SELECTION_UNPUBLISHED = JsonSyncableItem._PUBLIC_URI + " ISNULL";
final String[] PUB_URI_PROJECTION = new String[] { JsonSyncableItem._ID,
JsonSyncableItem._PUBLIC_URI };
private static int FORMAT_ARGS_DEBUG = android.text.format.DateUtils.FORMAT_SHOW_TIME
| android.text.format.DateUtils.FORMAT_SHOW_YEAR
| android.text.format.DateUtils.FORMAT_SHOW_DATE;
public SyncEngine(Context context, NetworkClient networkClient) {
mContext = context;
mNetworkClient = networkClient;
}
public boolean sync(Uri toSync, Account account, Bundle extras, ContentProviderClient provider,
SyncResult syncResult) throws RemoteException, SyncException, JSONException,
IOException, NetworkProtocolException, NoPublicPath, OperationApplicationException,
InterruptedException {
String pubPath = null;
//
// Handle http or https uris separately. These require the
// destination uri.
//
if ("http".equals(toSync.getScheme()) || "https".equals(toSync.getScheme())) {
pubPath = toSync.toString();
if (!extras.containsKey(EXTRA_DESTINATION_URI)) {
throw new IllegalArgumentException(
"missing EXTRA_DESTINATION_URI when syncing HTTP URIs");
}
toSync = Uri.parse(extras.getString(EXTRA_DESTINATION_URI));
}
final String type = provider.getType(toSync);
final boolean isDir = type.startsWith(CONTENT_TYPE_PREFIX_DIR);
final boolean manualSync = extras.getBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, false);
// skip any items already sync'd
if (!manualSync && mLastUpdated.isUpdatedRecently(toSync)) {
if (DEBUG) {
Log.d(TAG, "not syncing " + toSync + " as it's been updated recently");
}
syncResult.stats.numSkippedEntries++;
return false;
}
// the sync map will convert the json data to ContentValues
final SyncMap syncMap = getSyncMap(provider, toSync);
final Uri toSyncWithoutQuerystring = toSync.buildUpon().query(null).build();
final HashMap<String, SyncStatus> syncStatuses = new HashMap<String, SyncEngine.SyncStatus>();
final ArrayList<ContentProviderOperation> cpo = new ArrayList<ContentProviderOperation>();
final LinkedList<String> cpoPubUris = new LinkedList<String>();
//
// first things first, upload any content that needs to be
// uploaded.
//
uploadUnpublished(toSync, provider, syncMap, syncStatuses, syncResult);
if (Thread.interrupted()) {
throw new InterruptedException();
}
// this should ensure that all items have a pubPath when we
// query it below.
try {
if (pubPath == null) {
// we should avoid calling this too much as it
// can be expensive
pubPath = MediaProvider.getPublicPath(mContext, toSync);
}
} catch (final NoPublicPath e) {
// if it's an item, we can handle it.
if (isDir) {
throw e;
}
}
if (pubPath == null) {
// this should have been updated already by the initial
// upload, so something must be wrong
throw new SyncException("never got a public path for " + toSync);
}
if (DEBUG) {
Log.d(TAG, "sync(toSync=" + toSync + ", account=" + account + ", extras=" + extras
+ ", manualSync=" + manualSync + ",...)");
Log.d(TAG, "pubPath: " + pubPath);
}
final long request_time = System.currentTimeMillis();
HttpResponse hr = mNetworkClient.get(pubPath);
final long response_time = System.currentTimeMillis();
// the time compensation below allows a time-based synchronization to function even if the
// local clock is entirely wrong. The server's time is extracted using the Date header and
// all are compared relative to the respective clock reference. Any data that's stored on
// the mobile should be stored relative to the local clock and the server will respect the
// same.
long serverTime;
try {
serverTime = getServerTime(hr);
} catch (final DateParseException e) {
Log.w(TAG,
"could not retrieve date from server. Using local time, which may be incorrect.",
e);
serverTime = System.currentTimeMillis();
}
// TODO check out
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html
final long response_delay = response_time - request_time;
if (DEBUG) {
Log.d(TAG, "request took " + response_delay + "ms");
}
final long localTime = request_time;
// add this to the server time to get the local time
final long localOffset = (localTime - serverTime);
if (Math.abs(localOffset) > 30 * 60 * 1000) {
Log.w(TAG, "local clock is off by " + localOffset + "ms");
}
if (Thread.interrupted()) {
throw new InterruptedException();
}
final HttpEntity ent = hr.getEntity();
String selection;
String selectionInverse;
String[] selectionArgs;
if (isDir) {
final JSONArray ja = new JSONArray(StreamUtils.inputStreamToString(ent.getContent()));
ent.consumeContent();
final int len = ja.length();
selectionArgs = new String[len];
// build the query to see which items are already in the
// database
final StringBuilder sb = new StringBuilder();
sb.append("(");
for (int i = 0; i < len; i++) {
if (Thread.interrupted()) {
throw new InterruptedException();
}
final SyncStatus syncStatus = loadItemFromJsonObject(ja.getJSONObject(i), syncMap,
serverTime);
syncStatuses.put(syncStatus.remote, syncStatus);
selectionArgs[i] = syncStatus.remote;
// add in a placeholder for the query
sb.append('?');
if (i != (len - 1)) {
sb.append(',');
}
}
sb.append(")");
final String placeholders = sb.toString();
selection = JsonSyncableItem._PUBLIC_URI + " IN " + placeholders;
selectionInverse = JsonSyncableItem._PUBLIC_URI + " NOT IN " + placeholders;
} else {
final JSONObject jo = new JSONObject(StreamUtils.inputStreamToString(ent.getContent()));
ent.consumeContent();
final SyncStatus syncStatus = loadItemFromJsonObject(jo, syncMap, serverTime);
syncStatuses.put(syncStatus.remote, syncStatus);
selection = JsonSyncableItem._PUBLIC_URI + "=?";
selectionInverse = JsonSyncableItem._PUBLIC_URI + "!=?";
selectionArgs = new String[] { syncStatus.remote };
}
// first check without the querystring. This will ensure that we
// properly mark things that we already have in the database.
final Cursor check = provider.query(toSyncWithoutQuerystring, SYNC_PROJECTION, selection,
selectionArgs, null);
// these items are on both sides
try {
final int pubUriCol = check.getColumnIndex(JsonSyncableItem._PUBLIC_URI);
final int idCol = check.getColumnIndex(JsonSyncableItem._ID);
// All the items in this cursor should be found on both
// the client and the server.
for (check.moveToFirst(); !check.isAfterLast(); check.moveToNext()) {
if (Thread.interrupted()) {
throw new InterruptedException();
}
final long id = check.getLong(idCol);
final Uri localUri = ContentUris.withAppendedId(toSync, id);
final String pubUri = check.getString(pubUriCol);
final SyncStatus itemStatus = syncStatuses.get(pubUri);
itemStatus.state = SyncState.BOTH_UNKNOWN;
itemStatus.local = localUri;
// make the status searchable by both remote and
// local uri
syncStatuses.put(localUri.toString(), itemStatus);
}
} finally {
check.close();
}
Cursor c = provider.query(toSync, SYNC_PROJECTION, selection, selectionArgs, null);
// these items are on both sides
try {
final int pubUriCol = c.getColumnIndex(JsonSyncableItem._PUBLIC_URI);
final int localModifiedCol = c.getColumnIndex(JsonSyncableItem._MODIFIED_DATE);
final int serverModifiedCol = c.getColumnIndex(JsonSyncableItem._SERVER_MODIFIED_DATE);
final int idCol = c.getColumnIndex(JsonSyncableItem._ID);
// All the items in this cursor should be found on both
// the client and the server.
for (c.moveToFirst(); !c.isAfterLast(); c.moveToNext()) {
if (Thread.interrupted()) {
throw new InterruptedException();
}
final long id = c.getLong(idCol);
final Uri localUri = ContentUris.withAppendedId(toSync, id);
final String pubUri = c.getString(pubUriCol);
final SyncStatus itemStatus = syncStatuses.get(pubUri);
if (itemStatus.state == SyncState.ALREADY_UP_TO_DATE
|| itemStatus.state == SyncState.NOW_UP_TO_DATE) {
if (DEBUG) {
Log.d(TAG, localUri + "(" + pubUri + ")" + " is already up to date.");
}
continue;
}
itemStatus.local = localUri;
// make the status searchable by both remote and local uri
syncStatuses.put(localUri.toString(), itemStatus);
// last modified as stored in the DB, in phone time
final long itemLocalModified = c.getLong(localModifiedCol);
// last modified as stored in the DB, in server time
final long itemServerModified = c.getLong(serverModifiedCol);
final long localAge = localTime - itemLocalModified;
final long remoteAge = serverTime - itemStatus.remoteModifiedTime;
final long ageDifference = Math.abs(localAge - remoteAge);
// up to date, as far remote -> local goes
if (itemServerModified == itemStatus.remoteModifiedTime) {
itemStatus.state = SyncState.ALREADY_UP_TO_DATE;
if (DEBUG) {
Log.d(TAG, pubUri + " is up to date.");
}
// need to download
} else if (localAge > remoteAge) {
if (DEBUG) {
final long serverModified = itemStatus.remoteModifiedTime;
Log.d(TAG,
pubUri
+ " : local is "
+ ageDifference
+ "ms older ("
+ android.text.format.DateUtils.formatDateTime(mContext,
itemLocalModified, FORMAT_ARGS_DEBUG)
+ ") than remote ("
+ android.text.format.DateUtils.formatDateTime(mContext,
serverModified, FORMAT_ARGS_DEBUG)
+ "); updating local copy...");
}
itemStatus.state = SyncState.REMOTE_DIRTY;
final ContentProviderOperation.Builder b = ContentProviderOperation
.newUpdate(localUri);
// update this so it's in the local timescale
correctServerOffset(itemStatus.remoteCVs, JsonSyncableItem._CREATED_DATE,
JsonSyncableItem._CREATED_DATE, localOffset);
correctServerOffset(itemStatus.remoteCVs,
JsonSyncableItem._SERVER_MODIFIED_DATE,
JsonSyncableItem._MODIFIED_DATE, localOffset);
b.withValues(itemStatus.remoteCVs);
b.withExpectedCount(1);
cpo.add(b.build());
cpoPubUris.add(pubUri);
syncResult.stats.numUpdates++;
// need to upload
} else if (localAge < remoteAge) {
if (DEBUG) {
final long serverModified = itemStatus.remoteModifiedTime;
Log.d(TAG,
pubUri
+ " : local is "
+ ageDifference
+ "ms newer ("
+ android.text.format.DateUtils.formatDateTime(mContext,
itemLocalModified, FORMAT_ARGS_DEBUG)
+ ") than remote ("
+ android.text.format.DateUtils.formatDateTime(mContext,
serverModified, FORMAT_ARGS_DEBUG)
+ "); publishing to server...");
}
itemStatus.state = SyncState.LOCAL_DIRTY;
mNetworkClient.putJson(pubPath,
JsonSyncableItem.toJSON(mContext, localUri, c, syncMap));
}
mLastUpdated.markUpdated(localUri);
syncResult.stats.numEntries++;
} // end for
} finally {
c.close();
}
/*
* Apply updates in bulk
*/
if (cpo.size() > 0) {
if (DEBUG) {
Log.d(TAG, "applying " + cpo.size() + " bulk updates...");
}
final ContentProviderResult[] r = provider.applyBatch(cpo);
if (DEBUG) {
Log.d(TAG, "Done applying updates. Running postSync handler...");
}
for (int i = 0; i < r.length; i++) {
final ContentProviderResult res = r[i];
final SyncStatus ss = syncStatuses.get(cpoPubUris.get(i));
if (ss == null) {
Log.e(TAG, "can't get sync status for " + res.uri);
continue;
}
syncMap.onPostSyncItem(mContext, ss.local, ss.remoteJson,
res.count != null ? res.count == 1 : true);
ss.state = SyncState.NOW_UP_TO_DATE;
}
if (DEBUG) {
Log.d(TAG, "done running postSync handler.");
}
cpo.clear();
cpoPubUris.clear();
}
if (Thread.interrupted()) {
throw new InterruptedException();
}
/*
* Look through the SyncState.state values and find ones that need to be stored.
*/
for (final Map.Entry<String, SyncStatus> entry : syncStatuses.entrySet()) {
if (Thread.interrupted()) {
throw new InterruptedException();
}
final String pubUri = entry.getKey();
final SyncStatus status = entry.getValue();
if (status.state == SyncState.REMOTE_ONLY) {
if (DEBUG) {
Log.d(TAG, pubUri + " is not yet stored locally, adding...");
}
// update this so it's in the local timescale
correctServerOffset(status.remoteCVs, JsonSyncableItem._CREATED_DATE,
JsonSyncableItem._CREATED_DATE, localOffset);
correctServerOffset(status.remoteCVs, JsonSyncableItem._SERVER_MODIFIED_DATE,
JsonSyncableItem._MODIFIED_DATE, localOffset);
final ContentProviderOperation.Builder b = ContentProviderOperation
.newInsert(toSync);
b.withValues(status.remoteCVs);
cpo.add(b.build());
cpoPubUris.add(pubUri);
syncResult.stats.numInserts++;
}
}
/*
* Execute the content provider operations in bulk.
*/
if (cpo.size() > 0) {
if (DEBUG) {
Log.d(TAG, "bulk inserting " + cpo.size() + " items...");
}
final ContentProviderResult[] r = provider.applyBatch(cpo);
if (DEBUG) {
Log.d(TAG, "applyBatch completed. Processing results...");
}
int successful = 0;
for (int i = 0; i < r.length; i++) {
final ContentProviderResult res = r[i];
if (res.uri == null) {
syncResult.stats.numSkippedEntries++;
Log.e(TAG, "result from content provider bulk operation returned null");
continue;
}
final String pubUri = cpoPubUris.get(i);
final SyncStatus ss = syncStatuses.get(pubUri);
if (ss == null) {
syncResult.stats.numSkippedEntries++;
Log.e(TAG, "could not find sync status for " + cpoPubUris.get(i));
continue;
}
ss.local = res.uri;
if (DEBUG) {
Log.d(TAG, "onPostSyncItem(" + res.uri + ", ...); pubUri: " + pubUri);
}
syncMap.onPostSyncItem(mContext, res.uri, ss.remoteJson,
res.count != null ? res.count == 1 : true);
ss.state = SyncState.NOW_UP_TO_DATE;
successful++;
}
if (DEBUG) {
Log.d(TAG, successful + " batch inserts successfully applied.");
}
} else {
if (DEBUG) {
Log.d(TAG, "no updates to perform.");
}
}
/**
* Look through all the items that we didn't already find on the server side, but which
* still have a public uri. They should be checked to make sure they're not deleted.
*/
c = provider.query(
toSync,
SYNC_PROJECTION,
ProviderUtils.addExtraWhere(selectionInverse, JsonSyncableItem._PUBLIC_URI
+ " NOT NULL"), selectionArgs, null);
try {
final int idCol = c.getColumnIndex(JsonSyncableItem._ID);
final int pubUriCol = c.getColumnIndex(JsonSyncableItem._PUBLIC_URI);
cpo.clear();
for (c.moveToFirst(); !c.isAfterLast(); c.moveToNext()) {
final String pubUri = c.getString(pubUriCol);
SyncStatus ss = syncStatuses.get(pubUri);
final Uri item = isDir ? ContentUris.withAppendedId(toSyncWithoutQuerystring,
c.getLong(idCol)) : toSync;
if (ss == null) {
ss = syncStatuses.get(item.toString());
}
if (DEBUG) {
Log.d(TAG,
item
+ " was not found in the main list of items on the server ("
+ pubPath + "), but appears to be a child of "
+ toSync);
if (ss != null) {
Log.d(TAG, "found sync status for " + item + ": " + ss);
}
}
if (ss != null) {
switch (ss.state) {
case ALREADY_UP_TO_DATE:
case NOW_UP_TO_DATE:
Log.d(TAG, item
+ " is already up to date. No need to see if it was deleted.");
continue;
case BOTH_UNKNOWN:
Log.d(TAG,
item
+ " was found on both sides, but has an unknown sync status. Skipping...");
continue;
default:
Log.w(TAG, "got an unexpected state for " + item + ": " + ss);
}
} else {
ss = new SyncStatus(pubUri, SyncState.LOCAL_ONLY);
ss.local = item;
hr = mNetworkClient.head(pubUri);
switch (hr.getStatusLine().getStatusCode()) {
case 200:
Log.d(TAG, "HEAD " + pubUri + " returned 200");
ss.state = SyncState.BOTH_UNKNOWN;
break;
case 404:
Log.d(TAG, "HEAD " + pubUri + " returned 404. Deleting locally...");
ss.state = SyncState.DELETED_REMOTELY;
final ContentProviderOperation deleteOp = ContentProviderOperation
.newDelete(
ContentUris.withAppendedId(toSyncWithoutQuerystring,
c.getLong(idCol))).build();
cpo.add(deleteOp);
break;
default:
syncResult.stats.numIoExceptions++;
Log.w(TAG,
"HEAD " + pubUri + " got unhandled result: "
+ hr.getStatusLine());
}
}
syncStatuses.put(pubUri, ss);
} // for cursor
if (cpo.size() > 0) {
final ContentProviderResult[] results = provider.applyBatch(cpo);
for (final ContentProviderResult result : results) {
if (result.count != 1) {
throw new SyncException("Error deleting item");
}
}
}
} finally {
c.close();
}
syncStatuses.clear();
mLastUpdated.markUpdated(toSync);
return true;
}
/**
* Uploads any unpublished casts.
*
* This is the method that does all the hard work.
*
* @param itemDir
* @param provider
* @param syncMap
* @param syncResult
* @return the number of casts uploaded.
* @throws JSONException
* @throws NetworkProtocolException
* @throws IOException
* @throws NoPublicPath
* @throws RemoteException
* @throws OperationApplicationException
* @throws SyncException
* @throws InterruptedException
*/
private int uploadUnpublished(Uri itemDir, ContentProviderClient provider, SyncMap syncMap,
HashMap<String, SyncEngine.SyncStatus> syncStatuses, SyncResult syncResult)
throws JSONException, NetworkProtocolException, IOException, NoPublicPath,
RemoteException, OperationApplicationException, SyncException, InterruptedException {
int count = 0;
final ArrayList<ContentProviderOperation> cpo = new ArrayList<ContentProviderOperation>();
final Cursor uploadMe = provider.query(itemDir, null, SELECTION_UNPUBLISHED, null, null);
final int idCol = uploadMe.getColumnIndex(JsonSyncableItem._ID);
final int toUpload = uploadMe.getCount();
final String[] localUris = new String[toUpload];
int i = 0;
try {
for (uploadMe.moveToFirst(); !uploadMe.isAfterLast(); uploadMe.moveToNext()) {
if (Thread.interrupted()) {
throw new InterruptedException();
}
final Uri localUri = ContentUris.withAppendedId(itemDir, uploadMe.getLong(idCol));
final String postUri = MediaProvider.getPostPath(mContext, localUri);
final JSONObject jo = JsonSyncableItem
.toJSON(mContext, localUri, uploadMe, syncMap);
if (DEBUG) {
Log.d(TAG, "uploading " + localUri + " to " + postUri);
}
final HttpResponse res = mNetworkClient.post(postUri, jo.toString());
mNetworkClient.checkStatusCode(res, true);
long serverTime;
try {
serverTime = getServerTime(res);
} catch (final DateParseException e) {
serverTime = System.currentTimeMillis();
}
final JSONObject newJo = NetworkClient.toJsonObject(res);
try {
final SyncStatus ss = loadItemFromJsonObject(newJo, syncMap, serverTime);
final Builder update = ContentProviderOperation.newUpdate(localUri);
update.withValues(ss.remoteCVs);
cpo.add(update.build());
localUris[i] = localUri.toString();
syncStatuses.put(localUris[i], ss);
i++;
count++;
syncResult.stats.numEntries++;
syncResult.stats.numUpdates++;
} catch (final JSONException e) {
if (DEBUG) {
Log.e(TAG, "result was " + newJo.toString());
}
throw e;
}
}
} finally {
uploadMe.close();
}
final ContentProviderResult[] cpr = provider.applyBatch(cpo);
for (i = 0; i < cpr.length; i++) {
if (cpr[i].count != 1) {
Log.e(TAG, "error updating " + localUris[i]);
syncResult.stats.numSkippedEntries++;
continue;
}
final SyncStatus ss = syncStatuses.get(localUris[i]);
syncMap.onPostSyncItem(mContext, itemDir, ss.remoteJson, true);
}
return count;
}
/**
* Gets the server's time.
*
* @param hr
* @return the time that the response was generated, according to the server
* @throws DateParseException
* if the header is missing or if it can't be parsed.
*/
private long getServerTime(HttpResponse hr) throws DateParseException {
final Header hDate = hr.getFirstHeader("Date");
if (hDate == null) {
throw new DateParseException("No Date header in http response");
}
return DateUtils.parseDate(hDate.getValue()).getTime();
}
/**
* Uploads any unpublished items.
*
* @param itemDir
* @param account
* @param extras
* @param provider
* @param syncResult
* @return the number of casts uploaded.
* @throws RemoteException
* @throws SyncException
* @throws JSONException
* @throws NetworkProtocolException
* @throws IOException
* @throws NoPublicPath
* @throws OperationApplicationException
* @throws InterruptedException
*/
public int uploadUnpublished(Uri itemDir, Account account, Bundle extras,
ContentProviderClient provider, SyncResult syncResult) throws RemoteException,
SyncException, JSONException, NetworkProtocolException, IOException, NoPublicPath,
OperationApplicationException, InterruptedException {
return uploadUnpublished(itemDir, provider, getSyncMap(provider, itemDir),
new HashMap<String, SyncEngine.SyncStatus>(), syncResult);
}
/**
* Loads the an item from a JSONObject into a SyncStatus object.
*
* Sets {@link SyncStatus#remoteCVs}, {@link SyncStatus#remoteModifiedTime},
* {@link SyncStatus#remoteJson}, {@link SyncStatus#remote}
*
* @param jo
* @param syncMap
* @param serverTime
* @return
* @throws JSONException
* @throws IOException
* @throws NetworkProtocolException
*/
private SyncStatus loadItemFromJsonObject(JSONObject jo, SyncMap syncMap, long serverTime)
throws JSONException, IOException, NetworkProtocolException {
final ContentValues cv = JsonSyncableItem.fromJSON(mContext, null, jo, syncMap);
final String remoteUri = cv.getAsString(JsonSyncableItem._PUBLIC_URI);
final long remoteModified = cv.getAsLong(JsonSyncableItem._SERVER_MODIFIED_DATE);
// the status starts out based on this knowledge and gets filled in
// as the sync progresses
final SyncStatus syncStatus = new SyncStatus(remoteUri, SyncState.REMOTE_ONLY);
syncStatus.remoteModifiedTime = remoteModified;
syncStatus.remoteCVs = cv;
syncStatus.remoteJson = jo;
syncStatus.remote = remoteUri;
return syncStatus;
}
/**
* The mobile needs to store the modified date in its own timescale, so it can tell if a local
* update is newer than that of the server.
*
* @param cv
* @param fromKey
* @param localOffset
*/
private void correctServerOffset(ContentValues cv, String fromKey, String destKey,
long localOffset) {
final long serverModified = cv.getAsLong(fromKey);
cv.put(destKey, serverModified + localOffset);
}
/**
* Retrieves the class which maps to the given type of the specified uri. The map is statically
* defined in this class.
*
* @param provider
* a provider which can retrieve the type for the given uri
* @param data
* a uri to a dir or item which the engine knows how to handle
* @return the class which contains the sync map (and other helpers) for the specified uri
* @throws SyncException
* if the map cannot be found
* @throws RemoteException
* if there is an error communicating with the provider
*/
public Class<? extends JsonSyncableItem> getSyncClass(ContentProviderClient provider, Uri data)
throws SyncException, RemoteException {
final String type = provider.getType(data);
final Class<? extends JsonSyncableItem> syncable = TYPE_MAP.get(type);
if (syncable == null) {
throw new SyncException("cannot find " + data + ", which has type " + type
+ ", in the SyncEngine's sync map");
}
return syncable;
}
/**
* Retrieves the sync map from the class that maps to the given uri
*
* @param provider
* @param toSync
* @return
* @throws RemoteException
* @throws SyncException
*/
public SyncMap getSyncMap(ContentProviderClient provider, Uri toSync) throws RemoteException,
SyncException {
final Class<? extends JsonSyncableItem> syncable = getSyncClass(provider, toSync);
try {
final Field syncMap = syncable.getField("SYNC_MAP");
final int modifiers = syncMap.getModifiers();
if (!Modifier.isStatic(modifiers)) {
throw new SyncException("sync map for " + syncable + " is not static");
}
return (SyncMap) syncMap.get(null);
} catch (final SecurityException e) {
final SyncException se = new SyncException("error extracting sync map");
se.initCause(e);
throw se;
} catch (final NoSuchFieldException e) {
final SyncException se = new SyncException("SYNC_MAP static field missing from "
+ syncable);
se.initCause(e);
throw se;
} catch (final IllegalArgumentException e) {
final SyncException se = new SyncException("error extracting sync map");
se.initCause(e);
throw se;
} catch (final IllegalAccessException e) {
final SyncException se = new SyncException("error extracting sync map");
se.initCause(e);
throw se;
}
}
private enum SyncState {
/**
* The item was up to date before the sync began.
*/
ALREADY_UP_TO_DATE,
/**
* The item is now up to date, as a result of the sync.
*/
NOW_UP_TO_DATE,
BOTH_UNKNOWN,
/**
* The item exists both remotely and locally, but has been changed on the local side.
*/
LOCAL_DIRTY,
/**
* The item exists both remotely and locally, but has been changed on the remote side.
*/
REMOTE_DIRTY,
/**
* The item is only stored locally.
*/
LOCAL_ONLY,
/**
* Item is only stored on the remote side.
*/
REMOTE_ONLY,
DELETED_LOCALLY, DELETED_REMOTELY
}
private static class SyncStatus {
public SyncStatus(String remote, SyncState state) {
this.remote = remote;
this.state = state;
}
Uri local;
String remote;
SyncState state;
long remoteModifiedTime;
JSONObject remoteJson;
ContentValues remoteCVs;
public boolean isUpToDate() {
return state == SyncState.ALREADY_UP_TO_DATE || state == SyncState.NOW_UP_TO_DATE;
}
@Override
public String toString() {
return "[state: " + state + ", local uri: " + local + ", remote uri: " + remote
+ ", ...]";
}
}
}