/* * Funambol is a mobile platform developed by Funambol, Inc. * Copyright (C) 2003 - 2007 Funambol, Inc. * * This program is free software; you can redistribute it and/or modify it under * the terms of the GNU Affero General Public License version 3 as published by * the Free Software Foundation with the addition of the following permission * added to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED * WORK IN WHICH THE COPYRIGHT IS OWNED BY FUNAMBOL, FUNAMBOL DISCLAIMS THE * WARRANTY OF NON INFRINGEMENT OF THIRD PARTY RIGHTS. * * 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 Affero General Public License * along with this program; if not, see http://www.gnu.org/licenses or write to * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, * MA 02110-1301 USA. * * You can contact Funambol, Inc. headquarters at 643 Bair Island Road, Suite * 305, Redwood City, CA 94063, USA, or at email address info@funambol.com. * * The interactive user interfaces in modified source and object code versions * of this program must display Appropriate Legal Notices, as required under * Section 5 of the GNU Affero General Public License version 3. * * In accordance with Section 7(b) of the GNU Affero General Public License * version 3, these Appropriate Legal Notices must retain the display of the * "Powered by Funambol" logo. If the display of the logo is not reasonably * feasible for technical reasons, the Appropriate Legal Notices must display * the words "Powered by Funambol". */ package com.funambol.sapisync; import java.util.Hashtable; import java.util.Date; import com.funambol.org.json.me.JSONArray; import com.funambol.org.json.me.JSONException; import com.funambol.org.json.me.JSONObject; import com.funambol.sync.SyncException; import com.funambol.sync.SyncItem; import com.funambol.sync.SyncSource; import com.funambol.sync.TwinDetectionSource; import com.funambol.sapisync.source.JSONSyncSource; import com.funambol.sapisync.source.JSONSyncItem; import com.funambol.sync.Filter; import com.funambol.sync.SyncFilter; import com.funambol.util.Log; public class SapiSyncStrategy { private static final String TAG_LOG = "SapiSyncStrategy"; protected static final int FULL_SYNC_DOWNLOAD_LIMIT = 300; private JSONArray addedArray = null; private JSONArray updatedArray = null; private JSONArray deletedArray = null; private Hashtable localUpdated; private Hashtable localDeleted; private Hashtable localRenamed; private SapiSyncHandler sapiSyncHandler; private JSONObject removedItemMarker; private long downloadNextAnchor; private String addedServerUrl = null; private String updatedServerUrl = null; private long clientServerTimeDifference = 0; private SapiSyncUtils utils = new SapiSyncUtils(); public SapiSyncStrategy(SapiSyncHandler sapiSyncHandler, JSONObject removedItemMarker) { this.sapiSyncHandler = sapiSyncHandler; this.removedItemMarker = removedItemMarker; } /** * This method computes the set of items to be downloaded from the server in * an incremental sync. * The set of data to be downloaded depends on many things, including the * changes made locally. After this method the src update/delete items have * been consumed and the getNextNewItem and getNextUpdItem will return null. */ public void prepareUpload(SyncSource src, int downloadSyncMode, int uploadSyncMode, boolean resume, MappingTable mapping, boolean incrementalDownload, boolean incrementalUpload, Hashtable twins) throws SyncException, JSONException { // Check what is available on the server and what changed locally to // determine the list of items to be exchanged if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Computing changes set for fast sync"); } addedArray = null; updatedArray = null; deletedArray = null; // Get all the info on what changes are to be sent if (uploadSyncMode != SyncSource.NO_SYNC) { if (incrementalUpload) { prepareSyncIncrementalUpload(src, mapping, twins); } else { prepareSyncFullUpload(src, mapping, downloadSyncMode, incrementalDownload, twins); } } else { localUpdated = null; localDeleted = null; localRenamed = null; } // Resolve conflicts finalizePreparePhase(src, mapping, twins, incrementalDownload, incrementalUpload); } public JSONArray getServerAddedItems() { return addedArray; } public JSONArray getServerUpdatedItems() { return updatedArray; } public JSONArray getServerDeletedItems() { return deletedArray; } public long getDownloadNextAnchor() { return downloadNextAnchor; } public String getAddedServerUrl() { return addedServerUrl; } public String getUpdatedServerUrl() { return updatedServerUrl; } public void setClientServerTimeDifference(long clientServerTimeDifference) { this.clientServerTimeDifference = clientServerTimeDifference; } public void setSapiSyncHandler(SapiSyncHandler sapiSyncHandler) { this.sapiSyncHandler = sapiSyncHandler; } public Hashtable getLocalUpdates() { return localUpdated; } public Hashtable getLocalDeletes() { return localDeleted; } private void prepareSyncIncrementalUpload(SyncSource src, MappingTable mapping, Hashtable twins) throws SyncException, JSONException { localUpdated = new Hashtable(); localDeleted = new Hashtable(); localRenamed = new Hashtable(); JSONSyncItem localUpdatedItem = (JSONSyncItem)src.getNextUpdatedItem(); while(localUpdatedItem != null) { if (localUpdatedItem.isItemKeyUpdated() && localUpdatedItem.getOldKey() != null) { localRenamed.put(localUpdatedItem.getOldKey(), localUpdatedItem); } localUpdated.put(localUpdatedItem.getKey(), localUpdatedItem); localUpdatedItem = (JSONSyncItem)src.getNextUpdatedItem(); } SyncItem localDeletedItem = src.getNextDeletedItem(); while(localDeletedItem != null) { localDeleted.put(localDeletedItem.getKey(), localDeletedItem); localDeletedItem = src.getNextDeletedItem(); } } private void prepareSyncFullUpload(SyncSource src, MappingTable mapping, int downloadSyncMode, boolean incrementalDownload, Hashtable twins) throws SyncException, JSONException { // In a full upload we need to know all the server items in order to // detect twins and avoid duplicates. // We do it only if we don't already perform a full download in this sync if (incrementalDownload || downloadSyncMode == SyncSource.NO_SYNC) { int offset = 0; boolean done = false; do { SapiSyncHandler.FullSet fullSet = sapiSyncHandler.getItems( src.getConfig().getRemoteUri(), utils.getDataTag(src), null, Integer.toString(FULL_SYNC_DOWNLOAD_LIMIT), Integer.toString(offset), null); if (fullSet != null && fullSet.items != null && fullSet.items.length() > 0) { // This will find all the twins that will be skipped during the upload discardTwinAndConflictFromList(src, fullSet.items, null, null, fullSet.serverUrl, mapping, twins, true); offset += fullSet.items.length(); if ((fullSet.items.length() < FULL_SYNC_DOWNLOAD_LIMIT)) { done = true; } } else { done = true; } } while(!done); } } private void finalizePreparePhase(SyncSource src, MappingTable mapping, Hashtable twins, boolean incrementalDownload, boolean incrementalUpload) throws JSONException { // Now we have all the required information to decide what we need // to download/upload. It is here that we resolve conflicts and // twins if (addedArray != null) { // The server has items to send. // First of all check if this command is a real add or an update for(int i=0;i<addedArray.length();++i) { JSONObject item = addedArray.getJSONObject(i); String guid = item.getString(SapiSyncManager.ID_FIELD); String localItemId = mapping.getLuid(guid); if (localItemId != null) { ItemComparisonResult equal = compareItems(item, mapping); if (equal.getIdentical()) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Server sent an add which already exists on client, ignore it"); } addedArray.put(i, removedItemMarker); } else { // This is rather an update because the guid is already // in the mapping table if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Turning an add into an update"); } // Nullify this item addedArray.put(i, removedItemMarker); if (updatedArray == null) { updatedArray = new JSONArray(); updatedServerUrl = addedServerUrl; } updatedArray.put(item); // Update the item's properties according to what // changed in this update setUpdatedProperties(item, equal, mapping.getLuid(guid)); } } else { // On the first sync after an upgrade, the mapping is empty // because in old sync engine we did not retain mapping info // Some items of the last sync can still be reported as part // of the change set, we need to skip them by using twin // detection if (src instanceof TwinDetectionSource) { TwinDetectionSource tds = (TwinDetectionSource)src; JSONSyncItem jItem = new JSONSyncItem(guid, src.getType(), SyncItem.STATE_NEW, null, item, null); if (tds.findTwin(jItem) != null) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Found a twin item, ignoring its add " + guid); } addedArray.put(i, removedItemMarker); } } } } } // Now check if there is any update which is not an update, but // just an add instead (i.e. it does not exist in our mapping) if (updatedArray != null) { for(int i=0;i<updatedArray.length();++i) { JSONObject item = updatedArray.getJSONObject(i); String guid = item.getString(SapiSyncManager.ID_FIELD); String localItemId = mapping.getLuid(guid); if (localItemId == null) { // On the first sync after an upgrade, the mapping is empty // because in old sync engine we did not retain mapping info // Some items of the last sync can still be reported as part // of the change set, we need to skip them by using twin // detection boolean twin = false; if (src instanceof TwinDetectionSource) { JSONSyncItem jItem = new JSONSyncItem(guid, src.getType(), SyncItem.STATE_NEW, null, item, null); TwinDetectionSource tds = (TwinDetectionSource)src; if (tds.findTwin(jItem) != null) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Found a twin item, ignoring its update " + guid); } updatedArray.put(i, removedItemMarker); twin = true; } } if (!twin) { // This is rather an add because the guid is not in the // mapping table if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Turning an update into an add"); } // Nullify this item updatedArray.put(i, removedItemMarker); if (addedArray == null) { addedArray = new JSONArray(); addedServerUrl = updatedServerUrl; } addedArray.put(item); } } else { ItemComparisonResult equal = compareItems(item, mapping); if (equal.getIdentical()) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Server sent an update for an item already on the client, ignore it"); } updatedArray.put(i, removedItemMarker); } else { // Update the item's properties according to what // changed in this update setUpdatedProperties(item, equal, mapping.getLuid(guid)); } } } } // Now check the added/updated lists searching for twins and // conflicts // If either full upload or download is needed, then we perform a deep // twin search boolean deepTwinSearch = !incrementalDownload || !incrementalUpload; if (addedArray != null) { discardTwinAndConflictFromList(src, addedArray, localUpdated, localDeleted, addedServerUrl, mapping, twins, deepTwinSearch); } if (updatedArray != null) { discardTwinAndConflictFromList(src, updatedArray, localUpdated, localDeleted, updatedServerUrl, mapping, twins, deepTwinSearch); } if (deletedArray != null) { handleServerDeleteConflicts(src, deletedArray, localUpdated, localDeleted, localRenamed, mapping); } } private void handleServerDeleteConflicts(SyncSource src, JSONArray serverDeletes, Hashtable localMods, Hashtable localDel, Hashtable localRenamed, MappingTable mapping) throws JSONException { for(int i=0;i<serverDeletes.length();++i) { String guid = serverDeletes.getString(i); String luid = mapping.getLuid(guid); if (luid != null) { // Check if we have a local update or delete for this item if (localMods != null && localMods.get(luid) != null) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Found a server delete local update conflict, client wins"); } serverDeletes.put(i, ""); } else if (localDel != null && localDel.get(luid) != null) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Found a server delete local delete conflict, ignore server delete"); } serverDeletes.put(i, ""); } else if (localRenamed != null && localRenamed.get(luid) != null) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Found a server delete local rename conflict, ignore server delete"); } serverDeletes.put(i, ""); } } } } public boolean resolveTwinAndConflicts(SyncSource src, JSONObject item, Hashtable localMods, Hashtable localDel, String serverUrl, MappingTable mapping, Hashtable twins, boolean deepTwinSearch) throws JSONException { boolean download = true; if (src instanceof TwinDetectionSource) { // If a twin search is needed, then we perform it here String guid = item.getString(SapiSyncManager.ID_FIELD); if (deepTwinSearch) { long size = Long.parseLong(item.getString(SapiSyncManager.SIZE_FIELD)); SyncItem syncItem = utils.createSyncItem(src, guid, SyncItem.STATE_NEW, size, item, serverUrl); syncItem.setGuid(guid); TwinDetectionSource twinSource = (TwinDetectionSource)src; SyncItem twin = twinSource.findTwin(syncItem); if (twin != null) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Found a twin for incoming command, ignoring it " + guid); } download = false; // This item exists already on client and server. We // don't need to upload it again. This shall change once // we support updates twins.put(twin.getKey(), twin); } } else { // We simply check if we have this same item in the // mapping already } // Now we check if the client has a pending delete for this // item. If an item is scheduled for deletion, then its id // must be in the mapping, so we can get its luid if (mapping != null) { String luid = mapping.getLuid(guid); if (luid != null && localDel != null && localDel.get(luid) != null) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Conflict detected, item sent by the server has been deleted " + "on client. Receiving again " + luid); } if (item.has("nocontent")) { // Since the item was locally removed, we shall // remove the nocontent property and download // the content once again (we must also ignore // renaming as this is just like a new add) item.remove("nocontent"); item.remove("oldkey"); } } else if (luid != null && localMods != null && localMods.get(luid) != null) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Conflict detected, item modified both on client and server side " + luid); Log.info(TAG_LOG, "The most recent change shall win"); } JSONSyncItem localItem = (JSONSyncItem)localMods.get(luid); long localLastMod = localItem.getLastModified(); long remoteLastMod; if (item.has(SapiSyncManager.UPLOAD_DATE_FIELD)) { remoteLastMod = item.getLong(SapiSyncManager.UPLOAD_DATE_FIELD); } else { remoteLastMod = -1; } if (localLastMod == -1 || remoteLastMod == -1) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "No local or remote modification timestamp available. Client wins"); } download = false; } else { // Pick the most recent one localLastMod += clientServerTimeDifference; if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Comparing local last mod " + localLastMod + " with remote last mod " + remoteLastMod); } if (localLastMod > remoteLastMod) { // Client wins if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Client wins"); } download = false; } else { // Server wins if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Server wins"); } localMods.remove(luid); } } } else if (luid != null && localRenamed != null && localRenamed.get(luid) != null) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Conflict detected, item renamed on client and modified on server " + luid); Log.info(TAG_LOG, "Client wins"); } download = false; } } } return download; } private void discardTwinAndConflictFromList(SyncSource src, JSONArray items, Hashtable localMods, Hashtable localDel, String serverUrl, MappingTable mapping, Hashtable twins, boolean deepTwinSearch) throws JSONException { for(int i=0;i<items.length();++i) { JSONObject item = items.getJSONObject(i); if (resolveTwinAndConflicts(src, item, localMods, localDel, serverUrl, mapping, twins, deepTwinSearch)) { items.put(i, removedItemMarker); } } } private ItemComparisonResult compareItems(JSONObject item, MappingTable mapping) throws JSONException { String guid = item.getString(SapiSyncManager.ID_FIELD); String remoteCRC = item.getString(SapiSyncManager.CRC_FIELD); String localCRC = mapping.getCRC(guid); String localName = mapping.getName(guid); String remoteName = item.getString(SapiSyncManager.NAME_FIELD); // We need to know if the content or the metadata changed if (Log.isLoggable(Log.DEBUG)) { Log.debug(TAG_LOG, "Comparing items corresponding to id " + guid); Log.debug(TAG_LOG, "Local name " + localName + " local CRC " + localCRC); Log.debug(TAG_LOG, "Remote name " + remoteName + " remote CRC " + remoteCRC); } boolean contentEqual = (localCRC != null && localCRC.equals(remoteCRC)); boolean metaEqual = (localName != null && localName.equals(remoteName)); return new ItemComparisonResult(contentEqual, metaEqual); } private void setUpdatedProperties(JSONObject item, ItemComparisonResult equal, String oldKey) throws JSONException { // Set the content change properties if (equal.getContentEqual()) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "This update did not change the item content"); } item.put("nocontent", true); } if (!equal.getMetaEqual()) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "This update changed the item metadata"); } item.put("oldkey", oldKey); } } private class ItemComparisonResult { private boolean contentEqual; private boolean metaEqual; public ItemComparisonResult(boolean contentEqual, boolean metaEqual) { this.contentEqual = contentEqual; this.metaEqual = metaEqual; } public boolean getContentEqual() { return contentEqual; } public boolean getMetaEqual() { return metaEqual; } public boolean getIdentical() { return getContentEqual() && getMetaEqual(); } } }