/* * 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); } }