SyncManager.java
/*
* Copyright (c) 2014-present, salesforce.com, inc.
* All rights reserved.
* Redistribution and use of this software in source and binary forms, with or
* without modification, are permitted provided that the following conditions
* are met:
* - Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* - Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* - Neither the name of salesforce.com, inc. nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission of salesforce.com, inc.
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
package com.salesforce.androidsdk.smartsync.manager;
import android.text.TextUtils;
import android.util.Log;
import com.salesforce.androidsdk.accounts.UserAccount;
import com.salesforce.androidsdk.analytics.EventBuilderHelper;
import com.salesforce.androidsdk.app.SalesforceSDKManager;
import com.salesforce.androidsdk.auth.HttpAccess;
import com.salesforce.androidsdk.rest.ApiVersionStrings;
import com.salesforce.androidsdk.rest.RestClient;
import com.salesforce.androidsdk.rest.RestRequest;
import com.salesforce.androidsdk.rest.RestResponse;
import com.salesforce.androidsdk.smartstore.app.SmartStoreSDKManager;
import com.salesforce.androidsdk.smartstore.store.QuerySpec;
import com.salesforce.androidsdk.smartstore.store.SmartStore;
import com.salesforce.androidsdk.smartstore.store.SmartStore.SmartStoreException;
import com.salesforce.androidsdk.smartsync.app.SmartSyncSDKManager;
import com.salesforce.androidsdk.smartsync.util.Constants;
import com.salesforce.androidsdk.smartsync.util.SyncDownTarget;
import com.salesforce.androidsdk.smartsync.util.SyncOptions;
import com.salesforce.androidsdk.smartsync.util.SyncState;
import com.salesforce.androidsdk.smartsync.util.SyncState.MergeMode;
import com.salesforce.androidsdk.smartsync.util.SyncUpTarget;
import com.salesforce.androidsdk.util.JSONObjectHelper;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Sync Manager
*/
public class SyncManager {
// Constants
public static final int PAGE_SIZE = 2000;
private static final int UNCHANGED = -1;
private static final String TAG = "SyncManager";
// For user agent
private static final String SMART_SYNC = "SmartSync";
private static final String FEATURE_SMART_SYNC = "SY";
// Local fields
public static final String LOCALLY_CREATED = "__locally_created__";
public static final String LOCALLY_UPDATED = "__locally_updated__";
public static final String LOCALLY_DELETED = "__locally_deleted__";
public static final String LOCAL = "__local__";
// Static member
private static Map<String, SyncManager> INSTANCES = new HashMap<String, SyncManager>();
// Members
private Set<Long> runningSyncIds = new HashSet<Long>();
public final String apiVersion;
private final ExecutorService threadPool = Executors.newFixedThreadPool(1);
private SmartStore smartStore;
private RestClient restClient;
/**
* Private constructor
* @param smartStore
*/
private SyncManager(SmartStore smartStore, RestClient restClient) {
apiVersion = ApiVersionStrings.getVersionNumber(SalesforceSDKManager.getInstance().getAppContext());
this.smartStore = smartStore;
this.restClient = restClient;
SyncState.setupSyncsSoupIfNeeded(smartStore);
}
/**
* Returns the instance of this class associated with current user.
*
* @return Instance of this class.
*/
public static synchronized SyncManager getInstance() {
return getInstance(null, null);
}
/**
* Returns the instance of this class associated with this user account.
*
* @param account User account.
* @return Instance of this class.
*/
public static synchronized SyncManager getInstance(UserAccount account) {
return getInstance(account, null);
}
/**
* Returns the instance of this class associated with this user and community.
* Sync manager returned is ready to use.
*
* @param account User account.
* @param communityId Community ID.
* @return Instance of this class.
*/
public static synchronized SyncManager getInstance(UserAccount account, String communityId) {
return getInstance(account, communityId, null);
}
/**
* Returns the instance of this class associated with this user, community and smartstore.
*
* @param account User account. Pass null to user current user.
* @param communityId Community ID. Pass null if not applicable
* @param smartStore SmartStore instance. Pass null to use current user default smartstore.
*
* @return Instance of this class.
*/
public static synchronized SyncManager getInstance(UserAccount account, String communityId, SmartStore smartStore) {
if (account == null) {
account = SmartStoreSDKManager.getInstance().getUserAccountManager().getCurrentUser();
}
if (smartStore == null) {
smartStore = SmartSyncSDKManager.getInstance().getSmartStore(account, communityId);
}
String uniqueId = (account != null ? account.getUserId() : "") + ":"
+ smartStore.getDatabase().getPath();
SyncManager instance = INSTANCES.get(uniqueId);
if (instance == null) {
RestClient restClient = null;
/*
* If account is still null, there is no user logged in, which means, the default
* RestClient should be set to the unauthenticated RestClient instance.
*/
if (account == null) {
restClient = SalesforceSDKManager.getInstance().getClientManager().peekUnauthenticatedRestClient();
} else {
restClient = SalesforceSDKManager.getInstance().getClientManager().peekRestClient(account);
}
instance = new SyncManager(smartStore, restClient);
INSTANCES.put(uniqueId, instance);
}
SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_SMART_SYNC);
return instance;
}
/**
* Resets all the sync managers
*/
public static synchronized void reset() {
INSTANCES.clear();
}
/**
* Get details of a sync state
* @param syncId
* @return
* @throws JSONException
*/
public SyncState getSyncStatus(long syncId) throws JSONException {
return SyncState.byId(smartStore, syncId);
}
/**
* Create and run a sync down that will overwrite any modified records
* @param target
* @param soupName
* @param callback
* @return
* @throws JSONException
*/
public SyncState syncDown(SyncDownTarget target, String soupName, SyncUpdateCallback callback) throws JSONException {
SyncOptions options = SyncOptions.optionsForSyncDown(MergeMode.OVERWRITE);
return syncDown(target, options, soupName, callback);
}
/**
* Create and run a sync down
* @param target
* @param options
*@param soupName
* @param callback
* @return
* @throws JSONException
*/
public SyncState syncDown(SyncDownTarget target, SyncOptions options, String soupName, SyncUpdateCallback callback) throws JSONException {
SyncState sync = SyncState.createSyncDown(smartStore, target, options, soupName);
runSync(sync, callback);
return sync;
}
/**
* Re-run sync but only fetch new/modified records
* @param syncId
* @param callback
* @throws JSONException
*/
public SyncState reSync(long syncId, SyncUpdateCallback callback) throws JSONException {
if (runningSyncIds.contains(syncId)) {
throw new SmartSyncException("Cannot run reSync:" + syncId + ": still running");
}
SyncState sync = SyncState.byId(smartStore, syncId);
if (sync == null) {
throw new SmartSyncException("Cannot run reSync:" + syncId + ": no sync found");
}
if (sync.getType() != SyncState.Type.syncDown) {
throw new SmartSyncException("Cannot run reSync:" + syncId + ": wrong type:" + sync.getType());
}
sync.setTotalSize(-1);
runSync(sync, callback);
return sync;
}
/**
* Run a sync
* @param sync
* @param callback
*/
public void runSync(final SyncState sync, final SyncUpdateCallback callback) {
updateSync(sync, SyncState.Status.RUNNING, 0, callback);
threadPool.execute(new Runnable() {
@Override
public void run() {
try {
switch (sync.getType()) {
case syncDown:
syncDown(sync, callback);
break;
case syncUp:
syncUp(sync, callback);
break;
}
updateSync(sync, SyncState.Status.DONE, 100, callback);
} catch (Exception e) {
Log.e("SmartSyncMgr:runSync", "Error during sync: " + sync.getId(), e);
// Update status to failed
updateSync(sync, SyncState.Status.FAILED, UNCHANGED, callback);
}
}
});
}
/**
* Create and run a sync up
* @param target
* @param options
* @param soupName
* @param callback
* @return
* @throws JSONException
*/
public SyncState syncUp(SyncUpTarget target, SyncOptions options, String soupName, SyncUpdateCallback callback) throws JSONException {
SyncState sync = SyncState.createSyncUp(smartStore, target, options, soupName);
runSync(sync, callback);
return sync;
}
/**
* Removes local copies of records that have been deleted on the server
* or do not match the query results on the server anymore.
*
* @param syncId Sync ID.
*/
public void cleanResyncGhosts(long syncId) throws JSONException {
if (runningSyncIds.contains(syncId)) {
throw new SmartSyncException("Cannot run cleanResyncGhosts:" + syncId + ": still running");
}
final SyncState sync = SyncState.byId(smartStore, syncId);
if (sync == null) {
throw new SmartSyncException("Cannot run cleanResyncGhosts:" + syncId + ": no sync found");
}
if (sync.getType() != SyncState.Type.syncDown) {
throw new SmartSyncException("Cannot run cleanResyncGhosts:" + syncId + ": wrong type:" + sync.getType());
}
final String soupName = sync.getSoupName();
final String idFieldName = sync.getTarget().getIdFieldName();
/*
* Fetches list of IDs present in local soup that have not been modified locally.
*/
final Set<String> localIds = new HashSet<String>();
QuerySpec querySpec = QuerySpec.buildAllQuerySpec(soupName, idFieldName, QuerySpec.Order.ascending, 10);
int count = smartStore.countQuery(querySpec);
querySpec = QuerySpec.buildSmartQuerySpec("SELECT {" + soupName + ":" + idFieldName
+ "} FROM {" + soupName + "} WHERE {" + soupName + ":" + LOCAL + "}='false'", count);
final JSONArray localIdArray = smartStore.query(querySpec, 0);
if (localIdArray != null && localIdArray.length() > 0) {
for (int i = 0; i < localIdArray.length(); i++) {
final JSONArray idJson = localIdArray.optJSONArray(i);
if (idJson != null) {
localIds.add(idJson.optString(0));
}
}
}
/*
* Fetches list of IDs still present on the server from the list of local IDs
* and removes the list of IDs that are still present on the server.
*/
final Set<String> remoteIds = ((SyncDownTarget) sync.getTarget()).getListOfRemoteIds(this, localIds);
if (remoteIds != null) {
localIds.removeAll(remoteIds);
}
// Deletes extra IDs from SmartStore.
int localIdSize = localIds.size();
final JSONObject attributes = new JSONObject();
try {
if (localIdSize > 0) {
attributes.put("numRecords", localIdSize);
}
attributes.put("syncId", sync.getId());
attributes.put("syncTarget", sync.getTarget().getClass().getName());
} catch (JSONException e) {
Log.e(TAG, "Exception thrown while building attributes", e);
}
EventBuilderHelper.createAndStoreEvent("cleanResyncGhosts", null, TAG, attributes);
if (localIdSize > 0) {
String smartSql = String.format("SELECT {%s:%s} FROM {%s} WHERE {%s:%s} IN (%s)",
soupName, SmartStore.SOUP_ENTRY_ID, soupName, soupName, idFieldName,
"'" + TextUtils.join("', '", localIds) + "'");
querySpec = QuerySpec.buildSmartQuerySpec(smartSql, localIdSize);
smartStore.deleteByQuery(soupName, querySpec);
}
}
/**
* Update sync with new status, progress, totalSize
* @param sync
* @param status
* @param progress pass -1 to keep the current value
* @param callback
*/
private void updateSync(SyncState sync, SyncState.Status status, int progress, SyncUpdateCallback callback) {
try {
sync.setStatus(status);
if (progress != UNCHANGED) {
sync.setProgress(progress);
}
switch (status) {
case NEW:
break;
case RUNNING:
runningSyncIds.add(sync.getId());
break;
case DONE:
case FAILED:
int totalSize = sync.getTotalSize();
final JSONObject attributes = new JSONObject();
try {
if (totalSize > 0) {
attributes.put("numRecords", totalSize);
}
attributes.put("syncId", sync.getId());
attributes.put("syncTarget", sync.getTarget().getClass().getName());
} catch (JSONException e) {
Log.e(TAG, "Exception thrown while building attributes", e);
}
EventBuilderHelper.createAndStoreEvent(sync.getType().name(), null, TAG, attributes);
runningSyncIds.remove(sync.getId());
break;
}
sync.save(smartStore);
} catch (JSONException e) {
Log.e(TAG, "Unexpected json error for sync: " + sync.getId(), e);
} catch (SmartStoreException e) {
Log.e(TAG, "Unexpected smart store error for sync: " + sync.getId(), e);
}
finally {
callback.onUpdate(sync);
}
}
private void syncUp(SyncState sync, SyncUpdateCallback callback) throws Exception {
final String soupName = sync.getSoupName();
final SyncUpTarget target = (SyncUpTarget) sync.getTarget();
final SyncOptions options = sync.getOptions();
final List<String> fieldlist = options.getFieldlist();
final MergeMode mergeMode = options.getMergeMode();
final Set<String> dirtyRecordIds = target.getIdsOfRecordsToSyncUp(this, soupName);
int totalSize = dirtyRecordIds.size();
sync.setTotalSize(totalSize);
updateSync(sync, SyncState.Status.RUNNING, 0, callback);
int i = 0;
for (final String id : dirtyRecordIds) {
JSONObject record = smartStore.retrieve(soupName, Long.valueOf(id)).getJSONObject(0);
syncUpOneRecord(target, soupName, fieldlist, record, mergeMode);
// Updating status
int progress = (i + 1) * 100 / totalSize;
if (progress < 100) {
updateSync(sync, SyncState.Status.RUNNING, progress, callback);
}
// Incrementing i
i++;
}
}
private boolean isNewerThanServer(SyncUpTarget target, String objectType, String objectId, String lastModStr) throws JSONException, IOException {
if (lastModStr == null) {
// We didn't capture the last modified date so we can't really enforce merge mode, returning true so that we will behave like an "overwrite" merge mode
return true;
}
try {
String serverLastModStr = target.fetchLastModifiedDate(this, objectType, objectId);
if (serverLastModStr == null) {
// We were unable to get the last modified date from the server
return true;
}
long lastModifiedDate = Constants.TIMESTAMP_FORMAT.parse(lastModStr).getTime();
long serverLastModifiedDate = Constants.TIMESTAMP_FORMAT.parse(serverLastModStr).getTime();
return (serverLastModifiedDate <= lastModifiedDate);
} catch (Exception e) {
Log.e(TAG, "Couldn't figure out last modified date", e);
throw new SmartSyncException(e);
}
}
private void syncUpOneRecord(SyncUpTarget target, String soupName, List<String> fieldlist,
JSONObject record, MergeMode mergeMode) throws JSONException, IOException {
// Do we need to do a create, update or delete
boolean locallyDeleted = record.getBoolean(LOCALLY_DELETED);
boolean locallyCreated = record.getBoolean(LOCALLY_CREATED);
boolean locallyUpdated = record.getBoolean(LOCALLY_UPDATED);
Action action = null;
if (locallyDeleted)
action = Action.delete;
else if (locallyCreated)
action = Action.create;
else if (locallyUpdated)
action = Action.update;
if (action == null) {
// Nothing to do for this record
return;
}
// Getting type and id
final String objectType = (String) SmartStore.project(record, Constants.SOBJECT_TYPE);
final String objectId = record.getString(target.getIdFieldName());
final String lastModStr = record.optString(target.getModificationDateFieldName());
/*
* Checks if we are attempting to update a record that has been updated
* on the server AFTER the client's last sync down. If the merge mode
* passed in tells us to leave the record alone under these
* circumstances, we will do nothing and return here.
*/
if (mergeMode == MergeMode.LEAVE_IF_CHANGED &&
!locallyCreated &&
!isNewerThanServer(target, objectType, objectId, lastModStr)) {
// Nothing to do for this record
Log.i(TAG, "Record not synced since client does not have the latest from server");
return;
}
// Fields to save (in the case of create or update)
Map<String, Object> fields = new HashMap<String, Object>();
if (action == Action.create || action == Action.update) {
for (String fieldName : fieldlist) {
if (!fieldName.equals(target.getIdFieldName()) && !fieldName.equals(SyncUpTarget.MODIFICATION_DATE_FIELD_NAME)) {
fields.put(fieldName, SmartStore.project(record, fieldName));
}
}
}
// Create/update/delete record on server and update smartstore
String recordServerId;
int statusCode;
switch (action) {
case create:
recordServerId = target.createOnServer(this, objectType, fields);
if (recordServerId != null) {
record.put(target.getIdFieldName(), recordServerId);
cleanAndSaveRecord(soupName, record);
}
break;
case delete:
statusCode = (locallyCreated
? HttpURLConnection.HTTP_NOT_FOUND // if locally created it can't exist on the server - we don't need to actually do the deleteOnServer call
: target.deleteOnServer(this, objectType, objectId));
if (RestResponse.isSuccess(statusCode) || statusCode == HttpURLConnection.HTTP_NOT_FOUND) {
smartStore.delete(soupName, record.getLong(SmartStore.SOUP_ENTRY_ID));
}
break;
case update:
statusCode = target.updateOnServer(this, objectType, objectId, fields);
if (RestResponse.isSuccess(statusCode)) {
cleanAndSaveRecord(soupName, record);
}
// Handling remotely deleted records
else if (statusCode == HttpURLConnection.HTTP_NOT_FOUND) {
if (mergeMode == MergeMode.OVERWRITE) {
recordServerId = target.createOnServer(this, objectType, fields);
if (recordServerId != null) {
record.put(target.getIdFieldName(), recordServerId);
cleanAndSaveRecord(soupName, record);
}
}
else {
// Leave local record alone
}
}
break;
}
}
private void cleanAndSaveRecord(String soupName, JSONObject record) throws JSONException {
record.put(LOCAL, false);
record.put(LOCALLY_CREATED, false);
record.put(LOCALLY_UPDATED, false);
record.put(LOCALLY_DELETED, false);
smartStore.update(soupName, record, record.getLong(SmartStore.SOUP_ENTRY_ID));
}
private void syncDown(SyncState sync, SyncUpdateCallback callback) throws Exception {
String soupName = sync.getSoupName();
SyncDownTarget target = (SyncDownTarget) sync.getTarget();
MergeMode mergeMode = sync.getMergeMode();
long maxTimeStamp = sync.getMaxTimeStamp();
JSONArray records = target.startFetch(this, maxTimeStamp);
int countSaved = 0;
int totalSize = target.getTotalSize();
sync.setTotalSize(totalSize);
updateSync(sync, SyncState.Status.RUNNING, 0, callback);
final String idField = sync.getTarget().getIdFieldName();
while (records != null) {
// Save to smartstore.
saveRecordsToSmartStore(soupName, records, mergeMode, idField);
countSaved += records.length();
maxTimeStamp = Math.max(maxTimeStamp, target.getLatestModificationTimeStamp(records));
// Update sync status.
if (countSaved < totalSize) {
updateSync(sync, SyncState.Status.RUNNING, countSaved*100 / totalSize, callback);
}
// Fetch next records, if any.
records = target.continueFetch(this);
}
sync.setMaxTimeStamp(maxTimeStamp);
}
private SortedSet<String> toSortedSet(JSONArray jsonArray) throws JSONException {
SortedSet<String> set = new TreeSet<String>();
for (int i=0; i<jsonArray.length(); i++) {
set.add(jsonArray.getJSONArray(i).getString(0));
}
return set;
}
private void saveRecordsToSmartStore(String soupName, JSONArray records, MergeMode mergeMode, String idField)
throws JSONException {
// Gather ids of dirty records
Set<String> idsToSkip = null;
if (mergeMode == MergeMode.LEAVE_IF_CHANGED) {
idsToSkip = getDirtyRecordIds(soupName, idField);
}
synchronized(smartStore.getDatabase()) {
try {
smartStore.beginTransaction();
for (int i = 0; i < records.length(); i++) {
JSONObject record = records.getJSONObject(i);
// Skip?
if (mergeMode == MergeMode.LEAVE_IF_CHANGED) {
String id = JSONObjectHelper.optString(record, idField);
if (id != null && idsToSkip.contains(id)) {
continue; // don't write over dirty record
}
}
// Save
record.put(LOCAL, false);
record.put(LOCALLY_CREATED, false);
record.put(LOCALLY_UPDATED, false);
record.put(LOCALLY_DELETED, false);
smartStore.upsert(soupName, records.getJSONObject(i), idField, false);
}
smartStore.setTransactionSuccessful();
}
finally {
smartStore.endTransaction();
}
}
}
public SortedSet<String> getDirtyRecordIds(String soupName, String idField) throws JSONException {
SortedSet<String> ids = new TreeSet<String>();
String dirtyRecordsSql = String.format("SELECT {%s:%s} FROM {%s} WHERE {%s:%s} = 'true' ORDER BY {%s:%s} ASC", soupName, idField, soupName, soupName, LOCAL, soupName, idField);
final QuerySpec smartQuerySpec = QuerySpec.buildSmartQuerySpec(dirtyRecordsSql, PAGE_SIZE);
boolean hasMore = true;
for (int pageIndex = 0; hasMore; pageIndex++) {
JSONArray results = smartStore.query(smartQuerySpec, pageIndex);
hasMore = (results.length() == PAGE_SIZE);
ids.addAll(toSortedSet(results));
}
return ids;
}
/**
* Send request after adding user-agent header that says SmartSync
* @param restRequest
* @return
* @throws IOException
*/
public RestResponse sendSyncWithSmartSyncUserAgent(RestRequest restRequest) throws IOException {
return restClient.sendSync(restRequest, new HttpAccess.UserAgentInterceptor(SalesforceSDKManager.getInstance().getUserAgent(SMART_SYNC)));
}
/**
* @return SmartStore used by this SyncManager
*/
public SmartStore getSmartStore() {
return smartStore;
}
/**
* Enum for action
*
*/
public enum Action {
create,
update,
delete
}
/**
* Exception thrown by smart sync manager
*
*/
public static class SmartSyncException extends RuntimeException {
public SmartSyncException(String message) {
super(message);
}
public SmartSyncException(Throwable e) {
super(e);
}
private static final long serialVersionUID = 1L;
}
/**
* Sets the rest client to be used.
*
* @param restClient
*/
public void setRestClient(RestClient restClient) {
this.restClient = restClient;
}
/**
* @return rest client in use
*/
public RestClient getRestClient() {
return this.restClient;
}
/**
* Callback to get sync status udpates
*/
public interface SyncUpdateCallback {
void onUpdate(SyncState sync);
}
}