/** * Copyright 2013-2016 Amazon.com, * Inc. or its affiliates. All Rights Reserved. * * Licensed under the Amazon Software License (the "License"). * You may not use this file except in compliance with the * License. A copy of the License is located at * * http://aws.amazon.com/asl/ * * or in the "license" file accompanying this file. This file is * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR * CONDITIONS OF ANY KIND, express or implied. See the License * for the specific language governing permissions and * limitations under the License. */ package com.amazonaws.mobileconnectors.cognito; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.util.Log; import com.amazonaws.auth.CognitoCachingCredentialsProvider; import com.amazonaws.mobileconnectors.cognito.exceptions.DataConflictException; import com.amazonaws.mobileconnectors.cognito.exceptions.DataStorageException; import com.amazonaws.mobileconnectors.cognito.exceptions.DatasetNotFoundException; import com.amazonaws.mobileconnectors.cognito.exceptions.NetworkException; import com.amazonaws.mobileconnectors.cognito.internal.storage.CognitoSyncStorage; import com.amazonaws.mobileconnectors.cognito.internal.storage.LocalStorage; import com.amazonaws.mobileconnectors.cognito.internal.storage.RemoteDataStorage; import com.amazonaws.mobileconnectors.cognito.internal.storage.RemoteDataStorage.DatasetUpdates; import com.amazonaws.mobileconnectors.cognito.internal.storage.SQLiteLocalStorage; import com.amazonaws.mobileconnectors.cognito.internal.util.DatasetUtils; import com.amazonaws.mobileconnectors.cognito.internal.util.StringUtils; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; /** * Default implementation of {@link Dataset}. It uses {@link CognitoSyncStorage} * as remote storage and {@link SQLiteLocalStorage} as local storage. */ class DefaultDataset implements Dataset { private static final String TAG = "DefaultDataset"; /** * Max number of retries during synchronize before it gives up. */ private static final int MAX_RETRY = 3; /** * Context that the dataset is attached to */ private final Context context; /** * Non empty dataset name */ private final String datasetName; /** * Local storage */ private final LocalStorage local; /** * Remote storage */ private final RemoteDataStorage remote; /** * Identity id */ private final CognitoCachingCredentialsProvider provider; /** * Constructs a DefaultDataset object * * @param context context of this dataset * @param datasetName non empty dataset name * @param provider the credentials provider * @param local an instance of LocalStorage * @param remote an instance of RemoteDataStorage */ public DefaultDataset(Context context, String datasetName, CognitoCachingCredentialsProvider provider, LocalStorage local, RemoteDataStorage remote) { this.context = context; this.datasetName = datasetName; this.provider = provider; this.local = local; this.remote = remote; } @Override public void put(String key, String value) { local.putValue(getIdentityId(), datasetName, DatasetUtils.validateRecordKey(key), value); } @Override public void remove(String key) { local.putValue(getIdentityId(), datasetName, DatasetUtils.validateRecordKey(key), null); } @Override public String get(String key) { return local.getValue(getIdentityId(), datasetName, DatasetUtils.validateRecordKey(key)); } @Override public void synchronize(final SyncCallback callback) { if (callback == null) { throw new IllegalArgumentException("callback can't be null"); } if (!isNetworkAvailable(context)) { callback.onFailure(new NetworkException("Network connectivity unavailable.")); return; } discardPendingSyncRequest(); new Thread(new Runnable() { @Override public void run() { Log.d(TAG, "start to synchronize " + datasetName); boolean result = false; try { List<String> mergedDatasets = getLocalMergedDatasets(); boolean doSync = true; if (!mergedDatasets.isEmpty()) { Log.i(TAG, "detected merge datasets " + datasetName); doSync = callback.onDatasetsMerged(DefaultDataset.this, mergedDatasets); } if (doSync) { result = synchronizeInternal(callback, MAX_RETRY); } } catch (Exception e) { callback.onFailure(new DataStorageException("Unknown exception", e)); } if (result) { Log.d(TAG, "successfully synchronize " + datasetName); } else { Log.d(TAG, "failed to synchronize " + datasetName); } } }).start(); } /** * Deletes the remote dataset, and purges the local dataset. * * @param callback * @return Success or failure */ boolean deleteLocalAndPurgeRemoteDataset(final SyncCallback callback) { try { try { remote.deleteDataset(datasetName); } catch (DatasetNotFoundException e) { // This exception will fire if this was a local-only dataset and // should be ignored } local.purgeDataset(getIdentityId(), datasetName); callback.onSuccess(DefaultDataset.this, Collections.<Record> emptyList()); return true; } catch (DataStorageException dse) { callback.onFailure(dse); return false; } } /** * Performs the merge callback, and resumes or cancels depending on the * response * * @param callback the SyncCallback * @param datasetUpdates The current updates from the remote * @param retry The current retry count * @return If the synchronization succeeded */ boolean handleDatasetMerge(final SyncCallback callback, final DatasetUpdates datasetUpdates, int retry) { boolean resume = callback.onDatasetsMerged(DefaultDataset.this, new ArrayList<String>(datasetUpdates.getMergedDatasetNameList())); if (resume) { return synchronizeInternal(callback, --retry); } else { callback.onFailure(new DataStorageException("Manual cancel")); return false; } } /** * Deletes and purges the local dataset if the client wants to continue the * synchronization upon the remote dataset being deleted, and calls * appropriate callbacks * * @param callback the SyncCallback * @param datasetUpdates The updates from the remote * @return The result of the client onDatasetDeleted callback */ boolean removeLocalDataset(final SyncCallback callback, final DatasetUpdates datasetUpdates) { boolean resume = callback .onDatasetDeleted(DefaultDataset.this, datasetUpdates.getDatasetName()); if (resume) { // remove both records and metadata local.deleteDataset(getIdentityId(), datasetName); local.purgeDataset(getIdentityId(), datasetName); callback.onSuccess(DefaultDataset.this, Collections.<Record> emptyList()); return true; } else { callback.onFailure(new DataStorageException("Manual cancel")); return false; } } /** * Handles remote records (if there are any) by A. Handling conflicts B. * Updating the local store with new remote records C. Updating the local * sync count * * @param callback * @param datasetUpdates * @return True, unless the developer does not want to continue syncing upon * a sync conflict */ boolean handleRemoteRecords(final SyncCallback callback, final DatasetUpdates datasetUpdates) { List<Record> remoteRecords = datasetUpdates.getRecords(); if (!remoteRecords.isEmpty()) { // if conflict, prompt developer/user with callback List<SyncConflict> conflicts = new ArrayList<SyncConflict>(); Iterator<Record> iter = remoteRecords.iterator(); while (iter.hasNext()) { Record remoteRecord = iter.next(); Record localRecord = local.getRecord(getIdentityId(), datasetName, remoteRecord.getKey()); // only when local is changed and its value is different if (localRecord != null && localRecord.isModified() && localRecord.getSyncCount() != remoteRecord.getSyncCount() && !StringUtils.equals(localRecord.getValue(), remoteRecord.getValue())) { conflicts.add(new SyncConflict(remoteRecord, localRecord)); // remove it from remote changes, it has been marked as a // conflict // and will be updated by conflict resolution iter.remove(); } } if (!conflicts.isEmpty()) { Log.i(TAG, String.format("%d records in conflict!", conflicts.size())); if (!callback.onConflict(DefaultDataset.this, conflicts)) { // if they didn't want to continue on resolving conflicts // return return false; } } // if there are non-conflicting records from the remote, update them // in local if (!remoteRecords.isEmpty()) { Log.i(TAG, String.format("save %d records to local", remoteRecords.size())); local.putRecords(getIdentityId(), datasetName, remoteRecords); } // new last sync count Log.i(TAG, String.format("updated sync count %d", datasetUpdates.getSyncCount())); local.updateLastSyncCount(getIdentityId(), datasetName, datasetUpdates.getSyncCount()); } return true; } /** * Handles local modifications by: A. Pushing local changes to remote B. * Putting the result of the remote push to the local store C. Updating the * last sync count * * @param callback the SyncCallback * @param datasetUpdates The updates from the remote store * @param retry The current retry count * @return If this portion of the synchronization was successful */ boolean handleLocalModifications(final SyncCallback callback, final DatasetUpdates datasetUpdates, int retry) { // push changes to remote List<Record> localChanges = getModifiedRecords(); if (!localChanges.isEmpty()) { long lastSyncCount = datasetUpdates.getSyncCount(); long maxPatchSyncCount = 0; for (Record record : localChanges) { if (record.getSyncCount() > maxPatchSyncCount) { maxPatchSyncCount = record.getSyncCount(); } } Log.i(TAG, String.format("push %d records to remote", localChanges.size())); List<Record> result = null; try { SharedPreferences sp = getSharedPreferences(); String deviceId = sp.getString(namespaceIdPlatform("deviceId"), null); result = remote.putRecords(datasetName, localChanges, datasetUpdates.getSyncSessionToken(), deviceId); } catch (DataConflictException dce) { Log.i(TAG, "conflicts detected when pushing changes to remote."); if (lastSyncCount > maxPatchSyncCount) { local.updateLastSyncCount(getIdentityId(), datasetName, maxPatchSyncCount); } return synchronizeInternal(callback, --retry); } catch (DataStorageException dse) { callback.onFailure(dse); return false; } // update local meta data local.conditionallyPutRecords(getIdentityId(), datasetName, result, localChanges); // verify the server sync count is increased exactly by one, meaning // no // other updates were made during this update. long newSyncCount = 0; for (Record record : result) { newSyncCount = newSyncCount < record.getSyncCount() ? record.getSyncCount() : newSyncCount; } if (newSyncCount == lastSyncCount + 1) { Log.i(TAG, String.format("updated sync count %d", newSyncCount)); local.updateLastSyncCount(getIdentityId(), datasetName, newSyncCount); } } // call back callback.onSuccess(DefaultDataset.this, datasetUpdates.getRecords()); return true; } /** * Internal method for synchronization. * * @param callback callback during synchronization * @param retry number of retries before it's considered failure * @return true if synchronize successfully, false otherwise */ synchronized boolean synchronizeInternal(final SyncCallback callback, int retry) { if (retry < 0) { Log.e(TAG, "Synchronize failed because it exceeded the maximum retries"); callback.onFailure(new DataStorageException( "Synchronize failed because it exceeded the maximum retries")); return false; } long lastSyncCount = local.getLastSyncCount(getIdentityId(), datasetName); // if dataset is deleted locally, push it to remote if (lastSyncCount == -1) { return deleteLocalAndPurgeRemoteDataset(callback); } // get latest modified records from remote Log.d(TAG, "get latest modified records since " + lastSyncCount); DatasetUpdates datasetUpdates = null; try { datasetUpdates = remote.listUpdates(datasetName, lastSyncCount); } catch (DataStorageException e) { callback.onFailure(e); return false; } if (!datasetUpdates.getMergedDatasetNameList().isEmpty()) { return handleDatasetMerge(callback, datasetUpdates, retry); } // if the dataset doesn't exist or is deleted, trigger onDelete if (lastSyncCount != 0 && !datasetUpdates.isExists() || datasetUpdates.isDeleted()) { return removeLocalDataset(callback, datasetUpdates); } if (!handleRemoteRecords(callback, datasetUpdates)) { return false; } return handleLocalModifications(callback, datasetUpdates, retry); } @Override public List<Record> getAllRecords() { return local.getRecords(getIdentityId(), datasetName); } @Override public long getTotalSizeInBytes() { long size = 0; for (Record record : local.getRecords(getIdentityId(), datasetName)) { size += DatasetUtils.computeRecordSize(record); } return size; } @Override public long getSizeInBytes(String key) { return DatasetUtils.computeRecordSize(local.getRecord(getIdentityId(), datasetName, DatasetUtils.validateRecordKey(key))); } @Override public boolean isChanged(String key) { Record record = local.getRecord(getIdentityId(), datasetName, DatasetUtils.validateRecordKey(key)); return (record != null && record.isModified()); } @Override public void delete() { local.deleteDataset(getIdentityId(), datasetName); } @Override public DatasetMetadata getDatasetMetadata() { return local.getDatasetMetadata(getIdentityId(), datasetName); } @Override public void resolve(List<Record> remoteRecords) { local.putRecords(getIdentityId(), datasetName, remoteRecords); } @Override public void putAll(Map<String, String> values) { for (String key : values.keySet()) { DatasetUtils.validateRecordKey(key); } local.putAllValues(getIdentityId(), datasetName, values); } @Override public Map<String, String> getAll() { Map<String, String> map = new HashMap<String, String>(); for (Record record : local.getRecords(getIdentityId(), datasetName)) { if (!record.isDeleted()) { map.put(record.getKey(), record.getValue()); } } return map; } String getIdentityId() { return DatasetUtils.getIdentityId(provider); } /** * Gets a list of records that have been modified (marking as deleted * included). * * @return a list of locally modified records */ List<Record> getModifiedRecords() { return local.getModifiedRecords(getIdentityId(), datasetName); } /** * Gets a list of merged datasets that are marked as merged but haven't been * processed. * * @param datasetName dataset name * @return a list dataset names that are marked as merged */ List<String> getLocalMergedDatasets() { List<String> mergedDatasets = new ArrayList<String>(); String prefix = datasetName + "."; for (DatasetMetadata dataset : local.getDatasets(getIdentityId())) { if (dataset.getDatasetName().startsWith(prefix)) { mergedDatasets.add(dataset.getDatasetName()); } } return mergedDatasets; } /** * Pending sync request, set when connectivity is unavailable */ private SyncOnConnectivity pendingSyncRequest = null; /** * This customized broadcast receiver will perform a sync once the * connectivity is back. */ static class SyncOnConnectivity extends BroadcastReceiver { WeakReference<Dataset> datasetRef; WeakReference<SyncCallback> callbackRef; SyncOnConnectivity(Dataset dataset, SyncCallback callback) { datasetRef = new WeakReference<Dataset>(dataset); callbackRef = new WeakReference<Dataset.SyncCallback>(callback); } @Override public void onReceive(Context context, Intent intent) { if (!DefaultDataset.isNetworkAvailable(context)) { Log.d(TAG, "Connectivity is unavailable."); return; } Log.d(TAG, "Connectivity is available. Try synchronizing."); context.unregisterReceiver(this); // dereference dataset and callback Dataset dataset = datasetRef.get(); SyncCallback callback = callbackRef.get(); // make sure they are valid if (dataset == null || callback == null) { Log.w(TAG, "Abort syncOnConnectivity because either dataset " + "or callback was garbage collected"); } else { dataset.synchronize(callback); } } } @Override public void synchronizeOnConnectivity(SyncCallback callback) { if (isNetworkAvailable(context)) { synchronize(callback); } else { discardPendingSyncRequest(); Log.d(TAG, "Connectivity is unavailable. " + "Scheduling synchronize for when connectivity is resumed."); pendingSyncRequest = new SyncOnConnectivity(this, callback); // listen to only connectivity change context.registerReceiver(pendingSyncRequest, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); } } void discardPendingSyncRequest() { if (pendingSyncRequest != null) { Log.d(TAG, "Discard previous pending sync request"); synchronized (this) { try { context.unregisterReceiver(pendingSyncRequest); } catch (IllegalArgumentException e) { // ignore in case it has been unregistered Log.d(TAG, "SyncOnConnectivity has been unregistered."); } pendingSyncRequest = null; } } } static boolean isNetworkAvailable(Context context) { ConnectivityManager cm = (ConnectivityManager) context .getSystemService(Context.CONNECTIVITY_SERVICE); if (cm == null) { return false; } NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); return activeNetwork != null && activeNetwork.isConnected(); } @Override public long getLastSyncCount() { return local.getLastSyncCount(getIdentityId(), datasetName); } @Override public void unsubscribe() { String deviceId = getSharedPreferences().getString(namespaceIdPlatform("deviceId"), ""); if (deviceId.isEmpty()) { throw new IllegalStateException("Device hasn't been registered yet"); } remote.unsubscribeFromDataset(datasetName, deviceId); } @Override public void subscribe() { String deviceId = getSharedPreferences().getString(namespaceIdPlatform("deviceId"), ""); if (deviceId.isEmpty()) { throw new IllegalStateException("Device hasn't been registered yet"); } remote.subscribeToDataset(datasetName, deviceId); } private SharedPreferences getSharedPreferences() { return context.getSharedPreferences("com.amazonaws.mobileconnectors.cognito", Context.MODE_PRIVATE); } // prefix the key with identity id and platform String namespaceIdPlatform(String key) { String platform = getSharedPreferences().getString(namespaceId("platform"), ""); return namespaceId(platform) + "." + key; } // prefix the name with the cached identity id String namespaceId(String key) { // This is always called by something requiring a separate thread, the get // id call checks the cache first, and it's called in sync after pulling from // remote (which would require an id). As a result, we use get id, not the cache. return provider.getIdentityId() + "." + key; } }