/*
* Copyright (C) 2012-2016 The Android Money Manager Ex Project Team
*
* 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 3
* 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, see <http://www.gnu.org/licenses/>.
*/
package com.money.manager.ex.sync;
import android.app.Activity;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.Intent;
import android.os.Messenger;
import android.text.TextUtils;
import android.widget.Toast;
import com.cloudrail.si.types.CloudMetaData;
import com.money.manager.ex.Constants;
import com.money.manager.ex.MoneyManagerApplication;
import com.money.manager.ex.R;
import com.money.manager.ex.core.IntentFactory;
import com.money.manager.ex.core.UIHelper;
import com.money.manager.ex.home.DatabaseMetadata;
import com.money.manager.ex.home.DatabaseMetadataFactory;
import com.money.manager.ex.home.MainActivity;
import com.money.manager.ex.home.RecentDatabasesProvider;
import com.money.manager.ex.settings.AppSettings;
import com.money.manager.ex.settings.SyncPreferences;
import com.money.manager.ex.utils.MmxDatabaseUtils;
import com.money.manager.ex.utils.MmxDate;
import com.money.manager.ex.utils.MmxDateTimeUtils;
import com.money.manager.ex.utils.NetworkUtils;
import org.apache.commons.io.IOUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Date;
import java.util.List;
import java.util.concurrent.Callable;
import javax.inject.Inject;
import dagger.Lazy;
import rx.Single;
import rx.functions.Action1;
import timber.log.Timber;
/**
* Class used to manage the database file synchronization process.
*/
public class SyncManager {
@Inject Lazy<MmxDateTimeUtils> dateTimeUtilsLazy;
@Inject
public SyncManager(Context context) {
mContext = context;
mStorageClient = new CloudStorageClient(context);
MoneyManagerApplication.getApp().iocComponent.inject(this);
}
@Inject Lazy<RecentDatabasesProvider> mDatabases;
private Context mContext;
CloudStorageClient mStorageClient;
private SyncPreferences mPreferences;
// Used to temporarily disable auto-upload while performing batch updates.
private boolean mAutoUploadDisabled = false;
public void abortScheduledUpload() {
Timber.d("Aborting scheduled download");
PendingIntent pendingIntent = getPendingIntentForDelayedUpload();
getAlarmManager().cancel(pendingIntent);
}
public Context getContext() {
return mContext;
}
/**
* Performs checks if automatic synchronization should be performed.
* Used also on immediate upload after file changed.
* @return boolean indicating if auto sync should be done.
*/
public boolean canSync() {
// check if enabled.
if (!isActive()) return false;
// should we sync only on wifi?
if (getPreferences().shouldSyncOnlyOnWifi()) {
Timber.d("Preferences set to sync on WiFi only.");
// check if we are on WiFi connection.
NetworkUtils network = new NetworkUtils(getContext());
if (!network.isOnWiFi()) {
Timber.i("Not on WiFi connection. Not synchronizing.");
return false;
}
}
return true;
}
public boolean isRemoteFileModified(CloudMetaData remoteFile) {
String dateString = getDatabases().getCurrent().remoteLastChangedDate;
if (TextUtils.isEmpty(dateString)) {
// no remote file-change information found!
throw new RuntimeException(getContext().getString(R.string.no_remote_change_date));
}
Date cachedLastModified = MmxDate.fromIso8601(dateString).toDate();
Date remoteLastModified = getModificationDateFrom(remoteFile);
return !remoteLastModified.equals(cachedLastModified);
}
public void disableAutoUpload() {
mAutoUploadDisabled = true;
}
/**
* Download the remote file into the local path.
* @param remoteFile The remote file metadata.
* @param localFile Local file path. Normally a temp file.
* @return RxJava Single
*/
public Single<Void> downloadSingle(final CloudMetaData remoteFile, final File localFile) {
return Single.fromCallable(new Callable<Void>() {
@Override
public Void call() throws Exception {
downloadFile(remoteFile, localFile);
return null;
}
})
.doOnSuccess(new Action1<Void>() {
@Override
public void call(Void aVoid) {
// clear local changes
resetLocalChanges();
// update any renewed tokens
mStorageClient.cacheCredentials();
abortScheduledUpload();
}
});
}
/**
* Called whenever the database has changed and should be uploaded.
* (Re-)Sets the timer for delayed sync of the database.
*/
public void dataChanged() {
if (!isSyncEnabled()) return;
// Check if the current database is linked to a cloud service.
String remotePath = getRemotePath();
if (TextUtils.isEmpty(remotePath)) return;
// Mark local file as changed.
markLocalFileChanged(true);
// Should we upload automatically?
if (mAutoUploadDisabled) return;
if (!canSync()) {
Timber.i("No network connection. Not synchronizing.");
return;
}
// Should we schedule an upload?
SyncPreferences preferences = new SyncPreferences(getContext());
if (preferences.getUploadImmediately()) {
scheduleDelayedUpload();
}
}
public void enableAutoUpload() {
mAutoUploadDisabled = false;
}
/**
* Assembles the path where the local synchronised file is expected to be found.
* @return The path of the local cached copy of the remote database.
*/
public String getDefaultLocalPath() {
String remoteFile = getRemotePath();
// now get only the file name
String remoteFileName = new File(remoteFile).getName();
String localPath = getExternalStorageDirectoryForSync().getPath();
if (!localPath.endsWith(File.separator)) {
localPath += File.separator;
}
return localPath + remoteFileName;
}
public Single<List<CloudMetaData>> getRemoteFolderContentsSingle(String folder) {
return mStorageClient.getContents(folder);
}
/**
* Gets last saved datetime of the remote file modification from the preferences.
* Get the saved date from Database Metadata.
* @param remotePath file name, key
* @return date of last modification
*/
@Deprecated
public MmxDate getRemoteLastModifiedDatePreferenceFor(String remotePath) {
String dateString = getPreferences().get(remotePath, null);
if (TextUtils.isEmpty(dateString)) return null;
return new MmxDate(dateString, Constants.ISO_8601_FORMAT);
}
public Date getModificationDateFrom(CloudMetaData remoteFile) {
return new MmxDate(remoteFile.getModifiedAt()).toDate();
}
public String getRemotePath() {
DatabaseMetadata db = getDatabases().getCurrent();
if (db == null) return null;
String fileName = db.remotePath;
return fileName;
}
public void invokeSyncService(String action) {
// Validation.
String remoteFile = getRemotePath();
// We need a value in remote file name preferences.
if (TextUtils.isEmpty(remoteFile)) return;
// Action
ProgressDialog progressDialog = null;
// Create progress dialog only if called from the UI.
if ((getContext() instanceof Activity)) {
//progress dialog shown only when downloading an updated db file.
progressDialog = new ProgressDialog(getContext());
progressDialog.setCancelable(false);
progressDialog.setMessage(getContext().getString(R.string.syncProgress));
progressDialog.setIndeterminate(true);
// progressDialog.show();
}
Messenger messenger = null;
if (getContext() instanceof Activity) {
// Messenger handles received messages from the sync service. Can run only in a looper thread.
messenger = new Messenger(new SyncServiceMessageHandler(getContext(), progressDialog, remoteFile));
}
String localFile = getDatabases().getCurrent().localPath;
Intent syncServiceIntent = IntentFactory.getSyncServiceIntent(getContext(), action,
localFile, remoteFile, messenger);
// start service
getContext().startService(syncServiceIntent);
// Reset any other scheduled uploads as the current operation will modify the files.
abortScheduledUpload();
// The messages from the service are received via messenger.
}
/**
* Indicates whether synchronization can be performed, meaning all of the criteria must be
* true: sync enabled, respect wi-fi sync setting, provider is selected, network is online,
* remote file is set.
* @return A boolean indicating that sync can be performed.
*/
public boolean isActive() {
if (!isSyncEnabled()) return false;
// network is online.
NetworkUtils networkUtils = new NetworkUtils(getContext());
if (!networkUtils.isOnline()) return false;
// wifi preferences
if (getPreferences().shouldSyncOnlyOnWifi()) {
if (!networkUtils.isOnWiFi()) return false;
}
// Remote file must be set.
if (TextUtils.isEmpty(getRemotePath())) {
return false;
}
// check if a provider is selected? Default is Dropbox, so no need.
return true;
}
boolean isSyncEnabled() {
return getPreferences().isSyncEnabled();
}
/**
* Retrieves the remote metadata. Retries once on fail to work around #957.
* @return Remote file metadata.
*/
public CloudMetaData loadMetadata(String remotePath) {
return mStorageClient.loadMetadata(remotePath);
}
public Single<Void> login() {
return mStorageClient.login();
}
public Single<Void> logout() {
return mStorageClient.logout();
}
/**
* Resets the synchronization preferences and cache.
*/
void resetPreferences() {
getPreferences().clear();
// reset provider cache
mStorageClient.createProviders();
mStorageClient.cacheCredentials();
}
public void setEnabled(boolean enabled) {
getPreferences().setSyncEnabled(enabled);
}
public void setProvider(CloudStorageProviderEnum provider) {
mStorageClient.setProvider(provider);
}
public void setSyncInterval(int minutes) {
getPreferences().setSyncInterval(minutes);
}
public void startSyncServiceHeartbeat() {
Intent intent = new Intent(getContext(), SyncSchedulerBroadcastReceiver.class);
intent.setAction(SyncSchedulerBroadcastReceiver.ACTION_START);
getContext().sendBroadcast(intent);
// SyncSchedulerBroadcastReceiver does not receive a brodcast when using LocalManager!
// LocalBroadcastManager.getInstance(getContext()).sendBroadcast(intent);
}
public void stopSyncServiceAlarm() {
Intent intent = new Intent(mContext, SyncSchedulerBroadcastReceiver.class);
intent.setAction(SyncSchedulerBroadcastReceiver.ACTION_STOP);
getContext().sendBroadcast(intent);
// SyncSchedulerBroadcastReceiver does not receive a brodcast when using LocalManager!
// LocalBroadcastManager.getInstance(getContext()).sendBroadcast(intent);
}
public void triggerSynchronization() {
if (!isActive()) return;
// Make sure that the current database is also the one linked in the cloud.
String localPath = MoneyManagerApplication.getDatabasePath(getContext());
if (TextUtils.isEmpty(localPath)) {
new UIHelper(getContext()).showToast(R.string.filenames_differ);
return;
}
String remotePath = getRemotePath();
if (TextUtils.isEmpty(remotePath)) {
Toast.makeText(getContext(), R.string.select_remote_file, Toast.LENGTH_SHORT).show();
return;
}
// easy comparison, just by the file name.
if (!areFileNamesSame(localPath, remotePath)) {
// The current file was probably opened through Open Database.
Toast.makeText(getContext(), R.string.db_not_dropbox, Toast.LENGTH_LONG).show();
return;
}
invokeSyncService(SyncConstants.INTENT_ACTION_SYNC);
}
public void triggerDownload() {
invokeSyncService(SyncConstants.INTENT_ACTION_DOWNLOAD);
}
public void triggerUpload() {
DatabaseMetadata db = getDatabases().getCurrent();
if (db == null) {
throw new RuntimeException("Cannot upload: local database not set.");
}
String localFile = db.localPath;
String remoteFile = db.remotePath;
// trigger upload
Intent service = new Intent(getContext(), SyncService.class);
service.setAction(SyncConstants.INTENT_ACTION_UPLOAD);
service.putExtra(SyncConstants.INTENT_EXTRA_LOCAL_FILE, localFile);
service.putExtra(SyncConstants.INTENT_EXTRA_REMOTE_FILE, remoteFile);
// start service
getContext().startService(service);
}
/**
* Upload the file to cloud storage.
* @param localPath The path to the file to upload.
* @param remoteFile The remote path.
*/
public boolean upload(String localPath, String remoteFile) {
File localFile = new File(localPath);
if (!localFile.exists()) return false;
FileInputStream input;
try {
input = new FileInputStream(localFile);
} catch (FileNotFoundException e) {
Timber.e(e, "opening local file for upload");
return false;
}
// Transfer the file.
try {
mStorageClient.upload(remoteFile, input, localFile.length(), true);
} catch (Exception e) {
Timber.e(e, "uploading database file");
return false;
}
try {
input.close();
} catch (IOException e) {
Timber.e(e, "closing input stream after upload");
}
// set last modified date
CloudMetaData remoteFileMetadata = loadMetadata(remoteFile);
if (remoteFileMetadata == null) {
Timber.w("Could not retrieve metadata after upload! Aborting.");
return false;
}
saveRemoteLastModifiedDate(localPath, remoteFileMetadata);
// Reset local changes indicator. todo this must handle changes made during the upload!
resetLocalChanges();
// // set remote file, if not set (setLinkedRemoteFile)
// if (TextUtils.isEmpty(getRemotePath())) {
// setRemotePath(remoteFile);
// }
// update any renewed tokens
mStorageClient.cacheCredentials();
return true;
}
/**
* Sets the downloaded database as current. Restarts the Main Activity.
*/
public void useDownloadedDatabase() {
// Do this only if called from an activity.
if (!(getContext() instanceof Activity)) return;
MmxDatabaseUtils dbUtils = new MmxDatabaseUtils(getContext());
String localFile = MoneyManagerApplication.getDatabasePath(getContext());
DatabaseMetadata db = getDatabases().get(localFile);
if (db == null) {
db = DatabaseMetadataFactory.getInstance(localFile, getRemotePath());
}
boolean isDbSet = dbUtils.useDatabase(db);
if (!isDbSet) {
Timber.w("could not change the database");
return;
}
Intent intent = IntentFactory.getMainActivityNew(getContext());
// Send info to not check for updates as it is redundant in this case.
intent.putExtra(MainActivity.EXTRA_SKIP_REMOTE_CHECK, true);
getContext().startActivity(intent);
}
/*
Private
*/
/**
* Compares the local and remote db filenames. Use for safety check before synchronization.
* @return A boolean indicating if the filenames are the same.
*/
private boolean areFileNamesSame(String localPath, String remotePath) {
if (TextUtils.isEmpty(localPath)) return false;
if (TextUtils.isEmpty(remotePath)) return false;
File localFile = new File(localPath);
String localName = localFile.getName();
File remoteFile = new File(remotePath);
String remoteName = remoteFile.getName();
return localName.equalsIgnoreCase(remoteName);
}
private RecentDatabasesProvider getDatabases() {
return mDatabases.get();
}
/**
* Save the last modified datetime of the remote file into Settings for comparison during
* the synchronization.
* @param file file name
*/
void saveRemoteLastModifiedDate(String localPath, CloudMetaData file) {
MmxDate date = new MmxDate(file.getModifiedAt());
Timber.d("Saving last modification date %s for remote file %s", date.toString(), file);
DatabaseMetadata currentDb = getDatabases().get(localPath);
String newChangedDate = date.toString(Constants.ISO_8601_FORMAT);
// Do not save if the date has not changed.
if (!TextUtils.isEmpty(currentDb.remoteLastChangedDate) && currentDb.remoteLastChangedDate.equals(newChangedDate)) {
return;
}
// Save.
currentDb.setRemoteLastChangedDate(date);
getDatabases().save();
}
/**
* Downloads the file from the storage service.
* @param remoteFile Remote file entry
* @param localFile Local file reference
* @return Indicator whether the download was successful.
*/
private void downloadFile(CloudMetaData remoteFile, File localFile) throws IOException {
InputStream inputStream = mStorageClient.download(remoteFile.getPath());
OutputStream outputStream = new FileOutputStream(localFile, false);
IOUtils.copy(inputStream, outputStream);
inputStream.close();
outputStream.close();
}
private File getExternalStorageDirectoryForSync() {
// todo check this after refactoring the database utils.
MmxDatabaseUtils dbUtils = new MmxDatabaseUtils(getContext());
File folder = new File(dbUtils.getDefaultDatabaseDirectory());
// manage folder
if (folder.exists() && folder.isDirectory() && folder.canWrite()) {
// create a folder for remote files
File folderSync = new File(folder + "/sync");
// check if folder exists otherwise create
if (!folderSync.exists()) {
if (!folderSync.mkdirs()) return getContext().getFilesDir();
}
return folderSync;
} else {
return mContext.getFilesDir();
}
}
private SyncPreferences getPreferences() {
if (mPreferences == null) {
mPreferences = new SyncPreferences(getContext());
}
return mPreferences;
}
private void markLocalFileChanged(boolean changed) {
String localPath = new AppSettings(getContext()).getDatabaseSettings().getDatabasePath();
DatabaseMetadata currentDbEntry = getDatabases().get(localPath);
if (currentDbEntry.isLocalFileChanged == changed) return;
currentDbEntry.isLocalFileChanged = changed;
getDatabases().save();
}
private void resetLocalChanges() {
markLocalFileChanged(false);
}
/**
* Schedule delayed upload via timer.
*/
private void scheduleDelayedUpload() {
PendingIntent pendingIntent = getPendingIntentForDelayedUpload();
AlarmManager alarm = getAlarmManager();
Timber.d("Setting delayed upload alarm.");
// start the sync service after 30 seconds.
alarm.set(AlarmManager.RTC_WAKEUP, new MmxDate().getMillis() + 30*1000, pendingIntent);
}
private PendingIntent getPendingIntentForDelayedUpload() {
DatabaseMetadata db = getDatabases().getCurrent();
Intent intent = new Intent(getContext(), SyncService.class);
intent.setAction(SyncConstants.INTENT_ACTION_SYNC);
intent.putExtra(SyncConstants.INTENT_EXTRA_LOCAL_FILE, db.localPath);
intent.putExtra(SyncConstants.INTENT_EXTRA_REMOTE_FILE, db.remotePath);
return PendingIntent.getService(getContext(),
SyncConstants.REQUEST_DELAYED_SYNC,
intent, PendingIntent.FLAG_CANCEL_CURRENT);
}
private AlarmManager getAlarmManager() {
return (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE);
}
}