diff --git a/app/src/main/java/foundation/e/drive/EdriveApplication.java b/app/src/main/java/foundation/e/drive/EdriveApplication.java index 586b5073666627e485b2aff34f05308592c003ca..fe9b303fa45e39dc128db77f2cf5da233227753b 100644 --- a/app/src/main/java/foundation/e/drive/EdriveApplication.java +++ b/app/src/main/java/foundation/e/drive/EdriveApplication.java @@ -12,14 +12,12 @@ import android.accounts.Account; import android.accounts.AccountManager; import android.app.Application; import android.content.Context; -import android.content.Intent; import android.content.SharedPreferences; import android.os.Environment; import foundation.e.drive.FileObservers.FileEventListener; import foundation.e.drive.FileObservers.RecursiveFileObserver; import foundation.e.drive.database.FailedSyncPrefsManager; -import foundation.e.drive.services.SynchronizationService; import foundation.e.drive.utils.AppConstants; import foundation.e.drive.utils.CommonUtils; import foundation.e.drive.utils.ReleaseTree; @@ -58,27 +56,21 @@ public class EdriveApplication extends Application { .apply(); } - startRecursiveFileObserver(); FailedSyncPrefsManager.getInstance(getApplicationContext()).clearPreferences(); - - final Intent SynchronizationServiceIntent = new Intent(getApplicationContext(), SynchronizationService.class); - startService(SynchronizationServiceIntent); } /** * Start Recursive FileObserver if not already watching */ - public void startRecursiveFileObserver() { + synchronized public void startRecursiveFileObserver() { if (!mFileObserver.isWatching()) { - fileEventListener.bindToSynchronizationService(); mFileObserver.startWatching(); Timber.d("Started RecursiveFileObserver on root folder"); } } - public void stopRecursiveFileObserver() { + synchronized public void stopRecursiveFileObserver() { mFileObserver.stopWatching(); - fileEventListener.unbindFromSynchronizationService(); Timber.d("Stopped RecursiveFileObserver on root folder"); } diff --git a/app/src/main/java/foundation/e/drive/FileObservers/FileEventListener.java b/app/src/main/java/foundation/e/drive/FileObservers/FileEventListener.java index 2d724ef838d8b95c28001351dc1bc2ac55b8414b..aa621e6fc590bb97a8890f8c3807d37d848d2ce7 100644 --- a/app/src/main/java/foundation/e/drive/FileObservers/FileEventListener.java +++ b/app/src/main/java/foundation/e/drive/FileObservers/FileEventListener.java @@ -28,8 +28,8 @@ import foundation.e.drive.database.DbHelper; import foundation.e.drive.models.SyncRequest; import foundation.e.drive.models.SyncedFileState; import foundation.e.drive.models.SyncedFolder; -import foundation.e.drive.services.SynchronizationService; -import foundation.e.drive.utils.SynchronizationServiceConnection; +import foundation.e.drive.synchronization.SyncRequestCollector; +import foundation.e.drive.synchronization.SyncProxy; import timber.log.Timber; /** @@ -39,7 +39,6 @@ import timber.log.Timber; public class FileEventListener { private final Context appContext; - private final SynchronizationServiceConnection serviceConnection = new SynchronizationServiceConnection(); public FileEventListener(@NonNull Context applicationContext) { Timber.tag(FileEventListener.class.getSimpleName()); @@ -108,14 +107,10 @@ public class FileEventListener { private void sendSyncRequestToSynchronizationService(@NonNull SyncRequest request) { Timber.d("Sending a SyncRequest for %s", request.getSyncedFileState().getName()); - SynchronizationService service = serviceConnection.getSynchronizationService(); + final SyncRequestCollector syncManager = SyncProxy.INSTANCE; + syncManager.queueSyncRequest(request, appContext.getApplicationContext()); + syncManager.startSynchronization(appContext); - if (service != null) { - service.queueSyncRequest(request); - service.startSynchronization(); - } else { - Timber.d("Impossible to send SyncRequest. FileEventListener is not bound to SynchronizationService"); - } } /** @@ -245,12 +240,4 @@ public class FileEventListener { this.sendSyncRequestToSynchronizationService(disableSyncingRequest); } } - - public void unbindFromSynchronizationService(){ - serviceConnection.unBind(appContext); - } - - public void bindToSynchronizationService(){ - serviceConnection.bindService(appContext); - } } diff --git a/app/src/main/java/foundation/e/drive/receivers/BootCompletedReceiver.java b/app/src/main/java/foundation/e/drive/receivers/BootCompletedReceiver.java index ce806ce1d6f92f8981b022ce16525592c8ec092e..1f67e5a741a0cd27d5c80d4dc578cef9b55d5569 100644 --- a/app/src/main/java/foundation/e/drive/receivers/BootCompletedReceiver.java +++ b/app/src/main/java/foundation/e/drive/receivers/BootCompletedReceiver.java @@ -8,6 +8,7 @@ package foundation.e.drive.receivers; +import android.app.Application; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; @@ -18,6 +19,7 @@ import androidx.annotation.NonNull; import foundation.e.drive.BuildConfig; import foundation.e.drive.database.DbHelper; +import foundation.e.drive.synchronization.SyncProxy; import foundation.e.drive.utils.AppConstants; import foundation.e.drive.utils.CommonUtils; import timber.log.Timber; @@ -51,6 +53,8 @@ public class BootCompletedReceiver extends BroadcastReceiver { Timber.e(exception); } } + + SyncProxy.INSTANCE.startListeningFiles((Application) context.getApplicationContext()); } } diff --git a/app/src/main/java/foundation/e/drive/services/ObserverService.java b/app/src/main/java/foundation/e/drive/services/ObserverService.java index 813b02b954fabea1519dcd631149e109057dab1e..fe8faf1adaf7d6ab3106e0828f16b3d732d5eb0b 100644 --- a/app/src/main/java/foundation/e/drive/services/ObserverService.java +++ b/app/src/main/java/foundation/e/drive/services/ObserverService.java @@ -14,7 +14,6 @@ import static foundation.e.drive.utils.AppConstants.SETUP_COMPLETED; import android.accounts.Account; import android.accounts.AccountManager; import android.app.Service; -import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; @@ -48,11 +47,12 @@ import foundation.e.drive.models.SyncedFileState; import foundation.e.drive.models.SyncedFolder; import foundation.e.drive.operations.ListFileRemoteOperation; import foundation.e.drive.receivers.DebugCmdReceiver; +import foundation.e.drive.synchronization.SyncRequestCollector; +import foundation.e.drive.synchronization.SyncProxy; import foundation.e.drive.utils.AppConstants; import foundation.e.drive.utils.CommonUtils; import foundation.e.drive.utils.DavClientProvider; import foundation.e.drive.utils.ServiceExceptionHandler; -import foundation.e.drive.utils.SynchronizationServiceConnection; import timber.log.Timber; /** @@ -62,26 +62,14 @@ import timber.log.Timber; * This service look for remote or looale file to synchronize */ public class ObserverService extends Service implements OnRemoteOperationListener{ - private final static int INTERSYNC_MINIMUM_DELAY = 900000; // min delay between two sync in ms. + private final static int INTERSYNC_MINIMUM_DELAY = 900000; // min delay execution two sync in ms. private List mSyncedFolders; //List of synced folder - private boolean isWorking = false; private Account mAccount; private HashMap syncRequests; //integer is SyncedFileState id; Parcelable is the operation - private SynchronizationServiceConnection synchronizationServiceConnection = new SynchronizationServiceConnection() { - @Override - public void onServiceConnected(@Nullable ComponentName componentName, @NonNull IBinder iBinder) { - super.onServiceConnected(componentName, iBinder); - final SharedPreferences prefs = getApplicationContext().getSharedPreferences(AppConstants.SHARED_PREFERENCE_NAME, Context.MODE_PRIVATE); - - if (!checkStartCondition(prefs, forcedSync)) { - stopSelf(); - return; - } - begin(); - } - }; + private final SyncRequestCollector syncManager = SyncProxy.INSTANCE; + private Handler handler; private HandlerThread handlerThread; // protected to avoid SyntheticAccessor @@ -91,7 +79,6 @@ public class ObserverService extends Service implements OnRemoteOperationListene @Override public void onDestroy(){ Timber.v("onDestroy()"); - synchronizationServiceConnection.unBind(getApplicationContext()); if (handlerThread != null) handlerThread.quitSafely(); mSyncedFolders = null; super.onDestroy(); @@ -101,7 +88,6 @@ public class ObserverService extends Service implements OnRemoteOperationListene public void onCreate() { super.onCreate(); Timber.tag(ObserverService.class.getSimpleName()); - synchronizationServiceConnection.bindService(getApplicationContext()); } @Override @@ -120,6 +106,13 @@ public class ObserverService extends Service implements OnRemoteOperationListene this.syncRequests = new HashMap<>(); + if (!checkStartCondition(prefs, forcedSync)) { + syncManager.startListeningFiles(getApplication()); + stopSelf(); + return START_NOT_STICKY; + } + + begin(); return START_STICKY; } @@ -156,11 +149,6 @@ public class ObserverService extends Service implements OnRemoteOperationListene return false; } - if (isWorking) { - Timber.d("ObserverService is already working"); - return false; - } - // Check minimum delay since last call & not forced sync /*@todo is it really usefull to check time beetween to start as it is started by WorkManager? it matters only if we want to consider forced sync */ @@ -178,12 +166,10 @@ public class ObserverService extends Service implements OnRemoteOperationListene return false; } - final SynchronizationService service = - synchronizationServiceConnection.getSynchronizationService(); - final boolean isSyncServicePaused = service != null && service.pauseSync(); - Timber.d("isSyncServicePaused ? %s", isSyncServicePaused); - return isSyncServicePaused; + final boolean startAllowed = syncManager.onPeriodicScanStart(getApplication()); + Timber.d("starting periodic scan is allowed ? %s", startAllowed); + return startAllowed; } /* Common methods */ @@ -193,7 +179,6 @@ public class ObserverService extends Service implements OnRemoteOperationListene */ // protected to avoid SyntheticAccessor protected void begin(){ - this.isWorking = true; clearCachedFile(); deleteOldestCrashlogs(); startScan(true); @@ -330,39 +315,22 @@ public class ObserverService extends Service implements OnRemoteOperationListene startScan(false); - SynchronizationService service = synchronizationServiceConnection.getSynchronizationService(); - if (service != null) { - service.unPauseSync(); - } - if (!syncRequests.isEmpty()) { Timber.d("syncRequests contains %s", syncRequests.size()); - passSyncRequestsToSynchronizationService(); + syncManager.queueSyncRequests(syncRequests.values(), getApplicationContext()); + syncManager.startSynchronization(getApplicationContext()); } else { Timber.i("There is no file to sync."); getSharedPreferences(AppConstants.SHARED_PREFERENCE_NAME, Context.MODE_PRIVATE) .edit() .putLong(AppConstants.KEY_LAST_SYNC_TIME, System.currentTimeMillis()) .apply(); + syncManager.startListeningFiles(getApplication()); } - this.isWorking = false; this.stopSelf(); } - /** - * Send all gathered SyncRequest to SynchronizationService - */ - private void passSyncRequestsToSynchronizationService() { - SynchronizationService service = synchronizationServiceConnection.getSynchronizationService(); - if (service != null) { - service.queueSyncRequests(syncRequests.values()); - service.startSynchronization(); - } else { - Timber.e("ERROR: binding to SynchronizationService lost"); - } - } - /** * Prepare the list of files and SyncedFileState for synchronisation */ diff --git a/app/src/main/java/foundation/e/drive/services/SynchronizationService.java b/app/src/main/java/foundation/e/drive/services/SynchronizationService.java index 08b7f76c44c8d2ed7ff878cddb12be19fd7da4ec..42700dc3ebfed8f8d2b1527bb13173e1511afa43 100644 --- a/app/src/main/java/foundation/e/drive/services/SynchronizationService.java +++ b/app/src/main/java/foundation/e/drive/services/SynchronizationService.java @@ -15,7 +15,6 @@ import android.app.Service; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; -import android.os.Binder; import android.os.Handler; import android.os.HandlerThread; import android.os.IBinder; @@ -28,10 +27,7 @@ import com.owncloud.android.lib.common.operations.RemoteOperation; import com.owncloud.android.lib.common.operations.RemoteOperationResult; import java.io.File; -import java.util.Collection; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentLinkedDeque; import foundation.e.drive.R; import foundation.e.drive.database.DbHelper; @@ -41,6 +37,8 @@ import foundation.e.drive.models.SyncWrapper; import foundation.e.drive.models.SyncedFileState; import foundation.e.drive.operations.DownloadFileOperation; import foundation.e.drive.operations.UploadFileOperation; +import foundation.e.drive.synchronization.SyncManager; +import foundation.e.drive.synchronization.SyncProxy; import foundation.e.drive.utils.AppConstants; import foundation.e.drive.utils.CommonUtils; import foundation.e.drive.utils.DavClientProvider; @@ -51,10 +49,7 @@ import timber.log.Timber; * @author Vincent Bourgmayer */ public class SynchronizationService extends Service implements OnRemoteOperationListener, FileSyncDisabler.FileSyncDisablingListener { - private final SynchronizationBinder binder = new SynchronizationBinder(); - private final static long maxPauseTimeInMs = 300000; //5 minutes, might need to be adapted - private ConcurrentLinkedDeque syncRequestQueue; - private ConcurrentHashMap startedSync; //Integer is thread index (1 to workerAmount) + private final SyncManager syncManager = SyncProxy.INSTANCE; private Account account; private final int workerAmount = 2; @@ -64,7 +59,6 @@ public class SynchronizationService extends Service implements OnRemoteOperation private OwnCloudClient ocClient; private Handler handler; private HandlerThread handlerThread; - private long pauseStartTime = 0L; @Override public void onCreate() { @@ -84,12 +78,11 @@ public class SynchronizationService extends Service implements OnRemoteOperation if (account == null) { Timber.d("No account available"); + syncManager.startListeningFiles(getApplication()); stopSelf(); return START_NOT_STICKY; } - syncRequestQueue = new ConcurrentLinkedDeque<>(); - startedSync = new ConcurrentHashMap<>(); threadPool = new Thread[workerAmount]; ocClient = DavClientProvider.getInstance().getClientInstance(account, getApplicationContext()); @@ -101,6 +94,8 @@ public class SynchronizationService extends Service implements OnRemoteOperation handlerThread.start(); handler = new Handler(handlerThread.getLooper()); + startSynchronization(); + return START_REDELIVER_INTENT; } @@ -113,114 +108,6 @@ public class SynchronizationService extends Service implements OnRemoteOperation super.onDestroy(); } - @Nullable - @Override - public IBinder onBind(@Nullable Intent intent) { - return binder; - } - - /** - * Try to pause synchronization. - * Can only be paused if no sync is already running and no request queue is started - * @return true if service successfully paused - */ - public boolean pauseSync() { - pauseStartTime = System.currentTimeMillis(); - - final boolean isQueueEmpty = syncRequestQueue.isEmpty(); - final boolean isNoStartedSync = startedSync.values().stream().noneMatch(SyncWrapper::isRunning); - - Timber.v("is queue empty ? %s ; is no started sync ? %s", isQueueEmpty, isNoStartedSync); - final boolean isPausable = isQueueEmpty && isNoStartedSync; - if (!isPausable) pauseStartTime = 0L; - return isPausable; - } - - /** - * unpause synchronization - */ - public void unPauseSync() { - pauseStartTime = 0L; - } - - private boolean isPaused() { - if (pauseStartTime == 0L) return false; - return System.currentTimeMillis() - pauseStartTime < maxPauseTimeInMs; - } - - /** - * Add a SyncRequest into waiting queue if it matches some conditions: - * - an equivalent sync Request (same file & same operation type) isn't already running - * - failure limit isn't reach for this file - * - * It also remove already existing request for the same file from the waiting queue - * @param request request to add to waiting queue - */ - public void queueSyncRequest(@NonNull SyncRequest request) { - for (SyncWrapper syncWrapper : startedSync.values()) { - //noinspection EqualsBetweenInconvertibleTypes - if (syncWrapper.isRunning() && syncWrapper.equals(request)) { - return; - } - } - if (skipBecauseOfPreviousFail(request)) return; - - syncRequestQueue.remove(request); - syncRequestQueue.add(request); - } - - /** - * Add a collection of SyncRequest into waiting queue if they met some conditions: - * - an equivalent sync Request (same file & same operation type) isn't already running - * - failure limit isn't reach for this file - * - * It also remove already existing request for the same file from the waiting queue - * @param requests collections of requests to add - */ - public void queueSyncRequests(@NonNull Collection requests) { - for (SyncWrapper syncWrapper : startedSync.values()) { - if (syncWrapper.isRunning()) { - //noinspection EqualsBetweenInconvertibleTypes - requests.removeIf(syncWrapper::equals); - } - } - requests.removeIf(this::skipBecauseOfPreviousFail); - - syncRequestQueue.removeAll(requests); - syncRequestQueue.addAll(requests); - } - - /** - * Progressively delay synchronization of a file in case of failure - * @param request SyncRequest for the given file - * @return false if file can be sync or true if it must wait - */ - private boolean skipBecauseOfPreviousFail(SyncRequest request) { - final FailedSyncPrefsManager failedSyncPref = FailedSyncPrefsManager.getInstance(getApplicationContext()); - final int fileStateId = request.getSyncedFileState().getId(); - final int failureCounter = failedSyncPref.getFailureCounterForFile(fileStateId); - - long delay; - switch (failureCounter) { - case 0: - return false; - case 1: - delay = 3600; //1h - break; - case 2: - delay = 7200; //2h - break; - case 3: - delay = 18000; // 5h - break; - default: - return true; - } - - final long timeStamp = System.currentTimeMillis() / 1000; - return timeStamp < failedSyncPref.getLastFailureTimeForFile(fileStateId) + delay; - } - public void startSynchronization(){ Timber.d("startAllThreads"); for(int i =-1; ++i < workerAmount;){ @@ -236,18 +123,22 @@ public class SynchronizationService extends Service implements OnRemoteOperation if (!isNetworkAvailable()) { Timber.d("No network available: Clear syncRequestQueue"); - syncRequestQueue.clear(); + syncManager.clearRequestQueue(); return; } - if (!canStart(threadIndex) || isPaused()) { - Timber.d("Can't start thread #%s, Sync is paused or thread is already running", threadIndex); + if (!canStart(threadIndex)) { + Timber.d("Can't start thread #%s, thread is already running", threadIndex); return; } - final SyncRequest request = this.syncRequestQueue.poll(); //return null if empty + final SyncRequest request = this.syncManager.pollSyncRequest(); //return null if empty if (request == null) { Timber.d("Thread #%s: No more sync request to start.", threadIndex); + syncManager.removeStartedRequest(threadIndex); + if (!syncManager.isAnySyncRequestRunning()) { + syncManager.startListeningFiles(getApplication()); + } return; } @@ -263,7 +154,7 @@ public class SynchronizationService extends Service implements OnRemoteOperation final FileSyncDisabler fileSyncDisabler = new FileSyncDisabler(request.getSyncedFileState()); threadPool[threadIndex] = new Thread(fileSyncDisabler.getRunnable(handler, threadIndex, getApplicationContext(), this)); threadPool[threadIndex].start(); - startedSync.put(threadIndex, syncWrapper); + syncManager.addStartedRequest(threadIndex,syncWrapper); return; } @@ -278,7 +169,7 @@ public class SynchronizationService extends Service implements OnRemoteOperation //noinspection deprecation threadPool[threadIndex] = operation.execute(ocClient, this, handler); - startedSync.put(threadIndex, syncWrapper); + syncManager.addStartedRequest(threadIndex, syncWrapper); } /** @@ -287,7 +178,7 @@ public class SynchronizationService extends Service implements OnRemoteOperation * @return false if nogo */ private boolean canStart(int threadIndex) { - final SyncWrapper syncWrapper = startedSync.get(threadIndex); + final SyncWrapper syncWrapper = syncManager.getStartedRequestOnThread(threadIndex); return (syncWrapper == null || !syncWrapper.isRunning()); } @@ -318,7 +209,11 @@ public class SynchronizationService extends Service implements OnRemoteOperation break; } - for (Map.Entry keyValue : startedSync.entrySet()) { + if (isNetworkDisconnected) { + syncManager.clearRequestQueue(); + } + + for (Map.Entry keyValue : syncManager.getStartedRequests().entrySet()) { final SyncWrapper wrapper = keyValue.getValue(); final RemoteOperation wrapperOperation = wrapper.getRemoteOperation(); if (wrapperOperation != null && wrapperOperation.equals(callerOperation)) { @@ -328,10 +223,6 @@ public class SynchronizationService extends Service implements OnRemoteOperation break; } } - - if (isNetworkDisconnected) { - syncRequestQueue.clear(); - } } private void logSyncResult(@NonNull RemoteOperationResult.ResultCode resultCode, @NonNull RemoteOperation callerOperation) { @@ -371,7 +262,7 @@ public class SynchronizationService extends Service implements OnRemoteOperation @Override public void onSyncDisabled(int threadId, boolean succeed) { - final SyncWrapper wrapper = startedSync.get(threadId); + final SyncWrapper wrapper = syncManager.getStartedRequestOnThread(threadId); if (wrapper != null) { final SyncRequest request = wrapper.getRequest(); Timber.d("%s sync disabled ? %s", request.getSyncedFileState().getLocalPath(), succeed); @@ -381,10 +272,9 @@ public class SynchronizationService extends Service implements OnRemoteOperation startWorker(threadId); } - public class SynchronizationBinder extends Binder { - @NonNull - public SynchronizationService getService(){ - return SynchronizationService.this; - } + @Nullable + @Override + public IBinder onBind(@Nullable Intent intent) { + return null; } } \ No newline at end of file diff --git a/app/src/main/java/foundation/e/drive/synchronization/StateMachine.kt b/app/src/main/java/foundation/e/drive/synchronization/StateMachine.kt new file mode 100644 index 0000000000000000000000000000000000000000..7be44b40581a84f2e08921783777455a4f8f853d --- /dev/null +++ b/app/src/main/java/foundation/e/drive/synchronization/StateMachine.kt @@ -0,0 +1,81 @@ +/* + * Copyright © MURENA SAS 2023. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ +package foundation.e.drive.synchronization + +import androidx.annotation.VisibleForTesting +import timber.log.Timber + +enum class SyncState { + PERIODIC_SCAN, + SYNCHRONIZING, + LISTENING_FILES, + IDLE +} + +/** + * This class handle Synchronization state of the app + * It can be synchronizing (transfering file) + * It can be listening for file event + * It can be Scanning cloud & device for missed files + * It can be Idle (i.e: after boot, before restart) + * @author Vincent Bourgmayer + */ +object StateMachine { + + + var currentState: SyncState = SyncState.IDLE + @VisibleForTesting + set + + private var lastUpdateTimeInMs = 0L //todo use it to prevent apps to be blocked in one state + + @Synchronized + fun changeState(newState: SyncState): Boolean { + val previousState = currentState + val isStateChanged = when (newState) { + SyncState.PERIODIC_SCAN -> setPeriodicScanState() + SyncState.SYNCHRONIZING -> setSynchronizing() + SyncState.LISTENING_FILES -> setListeningFilesState() + SyncState.IDLE -> false + } + + if (!isStateChanged) { + Timber.d("Failed to change state to %s, from %s", newState, previousState) + return false + } + + Timber.i("Change Sync state to %s from %s", newState, previousState) + lastUpdateTimeInMs = System.currentTimeMillis() + return true + } + + private fun setListeningFilesState(): Boolean { + currentState = SyncState.LISTENING_FILES + return true + } + + private fun setPeriodicScanState(): Boolean { + if (currentState == SyncState.SYNCHRONIZING) { + Timber.d("Cannot change state: files sync is running") + return false + } + + if (currentState == SyncState.PERIODIC_SCAN) { + Timber.d("Cannot change state: Periodic scan is already running") + return false + } + + currentState = SyncState.PERIODIC_SCAN + return true + } + + private fun setSynchronizing(): Boolean { + currentState = SyncState.SYNCHRONIZING + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/drive/synchronization/SyncProxy.kt b/app/src/main/java/foundation/e/drive/synchronization/SyncProxy.kt new file mode 100644 index 0000000000000000000000000000000000000000..13c458e1ef2d80c3dd8eb4d2119d42ae2e77b038 --- /dev/null +++ b/app/src/main/java/foundation/e/drive/synchronization/SyncProxy.kt @@ -0,0 +1,238 @@ +/* + * Copyright © MURENA SAS 2023. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ +package foundation.e.drive.synchronization + +import android.app.Application +import android.content.Context +import android.content.Intent +import foundation.e.drive.EdriveApplication +import foundation.e.drive.database.FailedSyncPrefsManager +import foundation.e.drive.models.SyncRequest +import foundation.e.drive.models.SyncWrapper +import foundation.e.drive.services.SynchronizationService +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedQueue + +/** + * This interface is used to restrict access to a limited set of method + * @author Vincent Bourgmayer + */ +interface SyncRequestCollector { + fun queueSyncRequest(request: SyncRequest, context: Context) + fun queueSyncRequests(requests: MutableCollection, context: Context) + fun startSynchronization(context: Context) + fun onPeriodicScanStart(application: Application): Boolean + fun startListeningFiles(application: Application) +} + +/** + * This interface is used to restrict access to a limited set of method + * @author Vincent Bourgmayer + */ +interface SyncManager { + fun pollSyncRequest(): SyncRequest? + fun addStartedRequest(threadId: Int, syncWrapper: SyncWrapper) + fun isQueueEmpty(): Boolean + fun clearRequestQueue() + fun getStartedRequestOnThread(threadIndex: Int): SyncWrapper? + fun getStartedRequests(): ConcurrentHashMap + fun isAnySyncRequestRunning(): Boolean + fun removeStartedRequest(threadIndex: Int) + fun startListeningFiles(application: Application) +} + +/** + * This class goals is to act as a proxy between file's change detection & performing synchronization + * todo 3. one improvement could be to handle file that has just been synced in order to prevent instant detection to trigger a syncRequest in reaction (i.e in case of a download) + * + * This object must allow concurrent access between (periodic | instant) file's change detection and synchronization + * it holds the SyncRequest Queue and a list of running sync + * @author Vincent Bourgmayer + */ +object SyncProxy: SyncRequestCollector, SyncManager { + private val syncRequestQueue: ConcurrentLinkedQueue = ConcurrentLinkedQueue() //could we use channel instead ? + private val startedRequest: ConcurrentHashMap = ConcurrentHashMap() + + /** + * Add a SyncRequest into waiting queue if it matches some conditions: + * - an equivalent sync Request (same file & same operation type) isn't already running + * - failure limit isn't reach for this file + * + * It also remove already existing request for the same file from the waiting queue + * @param request request to add to waiting queue + * @param context used to check previous failure of file sync + */ + override fun queueSyncRequest(request: SyncRequest, context: Context) { + for (syncWrapper in startedRequest.values) { + if (syncWrapper.isRunning && syncWrapper == request) { + return + } + } + + if (skipBecauseOfPreviousFail(request, context)) return + + syncRequestQueue.remove(request) + syncRequestQueue.add(request) + } + + /** + * Add a collection of SyncRequest into waiting queue if they met some conditions: + * - an equivalent sync Request (same file & same operation type) isn't already running + * - failure limit isn't reach for this file + * + * It also remove already existing request for the same file from the waiting queue + * @param requests collections of requests to add + * @param context used to check previous failure of file sync + */ + override fun queueSyncRequests(requests: MutableCollection, context: Context) { + for (syncWrapper in startedRequest.values) { + if (syncWrapper.isRunning) { + requests.removeIf { obj: SyncRequest? -> + syncWrapper == obj + } + } + } + + requests.removeIf { request: SyncRequest? -> + skipBecauseOfPreviousFail(request!!, context) + } + + syncRequestQueue.removeAll(requests.toSet()) + syncRequestQueue.addAll(requests) + } + + /** + * Try to start synchronization + * + * some rules: + * - Restart FileEventListener if previous state what Periodic Scan + * - Start synchronization + * @param context use ApplicationContext + */ + override fun startSynchronization(context: Context) { + if (context !is EdriveApplication) { + Timber.d("Invalid parameter: startSynchronization(context)") + return + } + + if (syncRequestQueue.isEmpty()) { + Timber.d("Request queue is empty") + return + } + + val previousSyncState = StateMachine.currentState + val isStateChanged = StateMachine.changeState(SyncState.SYNCHRONIZING) + + if (!isStateChanged) return + + if (previousSyncState == SyncState.PERIODIC_SCAN) { + context.startRecursiveFileObserver() + } + + if (previousSyncState != SyncState.SYNCHRONIZING) { + val intent = Intent(context, SynchronizationService::class.java) + context.startService(intent) + } + } + + /** + * Called when periodic scan try to start + * @param application need EdriveApplication instance or will return false + * @return true if the periodic scan is allowed + */ + override fun onPeriodicScanStart(application: Application): Boolean { + if (application !is EdriveApplication) { + Timber.d("Invalid parameter: : startPeriodicScan(application)") + return false + } + + val isStateChanged = StateMachine.changeState(SyncState.PERIODIC_SCAN) + if (!isStateChanged) return false + + application.stopRecursiveFileObserver() + + return true + } + + /** + * File synchronization is finished + * restart FileObserver if required + * @param application need EdriveApplication instance or will exit + */ + override fun startListeningFiles(application: Application) { + if (application !is EdriveApplication) { + Timber.d("Invalid parameter: startListeningFiles(application)") + return + } + + val previousSyncState = StateMachine.currentState + val isStateChanged = StateMachine.changeState(SyncState.LISTENING_FILES) + + if (!isStateChanged) return + + if (previousSyncState == SyncState.IDLE || previousSyncState == SyncState.PERIODIC_SCAN) { + application.startRecursiveFileObserver() + } + } + + + /** + * Progressively delay synchronization of a file in case of failure + * @param request SyncRequest for the given file + * @return false if file can be sync or true if it must wait + */ + private fun skipBecauseOfPreviousFail(request: SyncRequest, context: Context): Boolean { + val failedSyncPref = FailedSyncPrefsManager.getInstance(context) + val fileStateId = request.syncedFileState.id + val delay: Long = when (failedSyncPref.getFailureCounterForFile(fileStateId)) { + 0 -> return false + 1 -> 3600 //1h + 2 -> 7200 //2h + 3 -> 18000 // 5h + else -> return true + } + + val timeStamp = System.currentTimeMillis() / 1000 + return timeStamp < failedSyncPref.getLastFailureTimeForFile(fileStateId) + delay + } + + override fun pollSyncRequest(): SyncRequest? { + return syncRequestQueue.poll() + } + + override fun addStartedRequest(threadId: Int, syncWrapper: SyncWrapper) { + startedRequest[threadId] = syncWrapper + } + + override fun isQueueEmpty(): Boolean { + return syncRequestQueue.isEmpty() + } + + override fun getStartedRequests(): ConcurrentHashMap { + return startedRequest + } + + override fun isAnySyncRequestRunning(): Boolean { + return startedRequest.isNotEmpty() + } + + override fun clearRequestQueue() { + syncRequestQueue.clear() + } + + override fun getStartedRequestOnThread(threadIndex: Int): SyncWrapper? { + return startedRequest[threadIndex] + } + + override fun removeStartedRequest(threadIndex: Int) { + if (startedRequest[threadIndex]?.isRunning == false) { + startedRequest.remove(threadIndex) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/drive/utils/SynchronizationServiceConnection.java b/app/src/main/java/foundation/e/drive/utils/SynchronizationServiceConnection.java deleted file mode 100644 index 1227e96c3352f6af23fd83f43ea54cbae177c4d6..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/drive/utils/SynchronizationServiceConnection.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright © ECORP SAS 2022. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package foundation.e.drive.utils; - -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.os.IBinder; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import foundation.e.drive.services.SynchronizationService; -import timber.log.Timber; - -/** - * @author Vincent Bourgmayer - */ -public class SynchronizationServiceConnection implements ServiceConnection { - - @Nullable - private SynchronizationService synchronizationService = null; - private boolean attemptingToBind = false; - - @Override - public void onServiceConnected(@Nullable ComponentName componentName, @NonNull IBinder iBinder) { - Timber.tag(SynchronizationServiceConnection.class.getSimpleName()) - .v("binding to SynchronizationService"); - attemptingToBind = false; - final SynchronizationService.SynchronizationBinder binder = (SynchronizationService.SynchronizationBinder) iBinder; - synchronizationService = binder.getService(); - } - - @Override - public void onServiceDisconnected(@Nullable ComponentName componentName) { - Timber.tag(SynchronizationServiceConnection.class.getSimpleName()) - .v("onServiceDisconnected()"); - synchronizationService = null; - } - - /** - * Get SynchronizationService. Might be null! - * @return - */ - @Nullable - public SynchronizationService getSynchronizationService() { - return synchronizationService; - } - - /** - * Sources: https://stackoverflow.com/questions/8614335/android-how-to-safely-unbind-a-service - * @param context - */ - public void unBind(@NonNull Context context) { - attemptingToBind = false; - if (synchronizationService != null) { - context.unbindService(this); - synchronizationService = null; - } - } - - /** - * Sources: https://stackoverflow.com/questions/8614335/android-how-to-safely-unbind-a-service - * @param context - */ - public void bindService(@NonNull Context context) { - if (!attemptingToBind) { - attemptingToBind = true; - context.bindService(new Intent(context, SynchronizationService.class), this, Context.BIND_AUTO_CREATE); - } - } -} diff --git a/app/src/main/java/foundation/e/drive/work/FirstStartWorker.java b/app/src/main/java/foundation/e/drive/work/FirstStartWorker.java index aaf7f8e5ca345856b8fda4cb1c326c02a4d1afab..0ebce10f2fa3c48ccfd0f327ff9280b3c2cb94a9 100644 --- a/app/src/main/java/foundation/e/drive/work/FirstStartWorker.java +++ b/app/src/main/java/foundation/e/drive/work/FirstStartWorker.java @@ -55,11 +55,8 @@ public class FirstStartWorker extends Worker { enqueuePeriodicFileScanWorkRequest(appContext); - getApplicationContext().startService(new Intent(getApplicationContext(), foundation.e.drive.services.SynchronizationService.class)); getApplicationContext().startService(new Intent(getApplicationContext(), foundation.e.drive.services.ObserverService.class)); - ((EdriveApplication) getApplicationContext()).startRecursiveFileObserver(); - return Result.success(); } catch (Exception exception) { Timber.e(exception); diff --git a/app/src/test/java/foundation/e/drive/services/ObserverServiceTest.java b/app/src/test/java/foundation/e/drive/services/ObserverServiceTest.java deleted file mode 100644 index e9a9b7076c0f23c54de138250b055d9f61ca5144..0000000000000000000000000000000000000000 --- a/app/src/test/java/foundation/e/drive/services/ObserverServiceTest.java +++ /dev/null @@ -1,274 +0,0 @@ -package foundation.e.drive.services; - -import android.accounts.AccountManager; -import android.app.job.JobScheduler; -import android.content.Context; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; - -import com.owncloud.android.lib.common.operations.RemoteOperationResult; -import com.owncloud.android.lib.resources.files.CreateFolderRemoteOperation; - -import org.junit.Ignore; -import org.junit.Test; -import org.robolectric.Robolectric; -import org.robolectric.RuntimeEnvironment; -import org.robolectric.android.controller.ServiceController; -import org.robolectric.shadows.ShadowLog; -import org.robolectric.shadows.ShadowNetworkInfo; - -import java.io.File; -import java.util.List; - -import foundation.e.drive.TestUtils; -import foundation.e.drive.database.DbHelper; -import foundation.e.drive.models.SyncedFolder; -import foundation.e.drive.utils.AppConstants; -import foundation.e.drive.utils.CommonUtils; - - -import static foundation.e.drive.TestUtils.TEST_LOCAL_ROOT_FOLDER_PATH; -import static foundation.e.drive.TestUtils.TEST_REMOTE_ROOT_FOLDER_PATH; -import static foundation.e.drive.TestUtils.getValidAccount; -import static foundation.e.drive.utils.AppConstants.SETUP_COMPLETED; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; -import static org.robolectric.Shadows.shadowOf; - -import androidx.test.core.app.ApplicationProvider; - -/** - * The RunWith & Config annotation are done in the AbstractServiceIT - */ -public class ObserverServiceTest extends AbstractServiceIT { - private final ServiceController syncServiceController; - private final SynchronizationService syncService; - - public ObserverServiceTest(){ - syncServiceController = Robolectric.buildService(SynchronizationService.class); - syncService = syncServiceController.get(); - mServiceController = Robolectric.buildService(ObserverService.class); - mService = mServiceController.get(); - context = ApplicationProvider.getApplicationContext(); - accountManager = AccountManager.get(context); - jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); - contentResolver = context.getContentResolver(); - sharedPreferences = context.getSharedPreferences( AppConstants.SHARED_PREFERENCE_NAME, Context.MODE_PRIVATE); - connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - dbHelper = new DbHelper(context); - client = TestUtils.getNcClient(context); - } - - /** - * Set the network status to an available wifi - */ - private void setWifiNetworkStatus() { - NetworkInfo netInfo = ShadowNetworkInfo.newInstance(null, - ConnectivityManager.TYPE_WIFI, 0, true, NetworkInfo.State.CONNECTED); - assertEquals("NetworkInfo type is invalid",ConnectivityManager.TYPE_WIFI,netInfo.getType()); - shadowOf(connectivityManager).setActiveNetworkInfo(netInfo); - } - - /** - * Create a network status where no connection is available - */ - private void setUnavailableWifiNetworkStatus() { - NetworkInfo netInfo = ShadowNetworkInfo.newInstance(null, - ConnectivityManager.TYPE_WIFI, 0, true, NetworkInfo.State.DISCONNECTED); - assertEquals("NetworkInfo type is invalid",ConnectivityManager.TYPE_WIFI,netInfo.getType()); - - shadowOf(connectivityManager).setActiveNetworkInfo(netInfo); - } - - /** - * Create a single 'SyncedFolder' instance for 'eDrive-test' folder - * @return SyncedFolder instance - */ - private SyncedFolder createSingleTestSyncedFolder() { - final File folder = new File(TEST_LOCAL_ROOT_FOLDER_PATH); - try{ - folder.mkdirs(); - }catch(SecurityException e){ - fail(e.getMessage()); - } - final SyncedFolder sFolder = new SyncedFolder("Images", TEST_LOCAL_ROOT_FOLDER_PATH, TEST_REMOTE_ROOT_FOLDER_PATH, true); - - return sFolder; - } - - - /** - * Send request to server to create the remote folder of the given syncedFolder - * @param folder the local folder metadata to create - */ - private void createRemoteFolder(SyncedFolder folder) { - try { - TestUtils.testConnection(client, context); - }catch(Exception e){ - System.out.println("Test connection failed :"+e.getMessage()); - } - - final CreateFolderRemoteOperation op = new CreateFolderRemoteOperation(folder.getRemoteFolder(), true); - final RemoteOperationResult result = op.execute(client); //Give SSL issue - - assertTrue("Creation of remote test folder failed",result.isSuccess()); - - final int dbFolderListSize =DbHelper.getAllSyncedFolders(context).size(); - assertEquals("Expected DB's SyncedFolder table content was 1, but got:"+dbFolderListSize, 1, dbFolderListSize); - - - initial_folder_number =1; - } - - - /** - * Run a test which correspond to a non blocking context - */ - @Ignore("Ignore until a correct assertion has been found") - @Test - public void shouldWork() { - setWifiNetworkStatus(); - prepareValidAccount(); - enableMediaAndSettingsSync(getValidAccount()); - createRemoteFolder(createSingleTestSyncedFolder()); - registerSharedPref(); - - mServiceController.create().startCommand(0, 0); - - List logs = ShadowLog.getLogs(); - ShadowLog.LogItem lastLog = logs.get(logs.size()-3); - - assertEquals("expected: 'Going to scan remote files' but found: '"+lastLog.msg, "Going to scan remote files", lastLog.msg); - } - - - - - /** - * This assert that ObserverService doesn't start scanning remote or local if there is no network - */ - @Ignore("Ignore until a way to prepare unavailable Network has been founded") - @Test - public void noNetwork_shouldStop() { - prepareValidAccount(); - enableMediaAndSettingsSync(getValidAccount()); - - registerSharedPref(); - - boolean haveNetworkConnexion = CommonUtils.haveNetworkConnection(RuntimeEnvironment.application, true); - String msg = "CommonUtils.haveNetworkConnexion should return false but return "+haveNetworkConnexion; - assertFalse(msg, haveNetworkConnexion); - - mServiceController.create().startCommand(0, 0); - - List logs = ShadowLog.getLogs(); - ShadowLog.LogItem lastLog = logs.get(logs.size()-1); - assertEquals("Last log isn't the one expected", "There is no Internet connexion.", lastLog.msg); - - } - - - /** - * This assert that ObserverService won't start if the minimum delay between two sync isn't over - */ - @Ignore("Binding to synchronizationService make test fails") - @Test - public void lastSyncWasLessThan15minAgo_shouldStop() { - last_sync_time = System.currentTimeMillis() - 899900; - setWifiNetworkStatus(); - prepareValidAccount(); - enableMediaAndSettingsSync(getValidAccount()); - registerSharedPref(); - - //Start the service - mServiceController.create().startCommand(0, 0); - - List logs = ShadowLog.getLogs(); - ShadowLog.LogItem lastLog = logs.get(logs.size()-1); - - //Note: Checking log is the only way I've found to check that the service stopped as expected - assertEquals("Last log isn't the one expected", "Delay between now and last call is too short", lastLog.msg); - - } - - /** - * This assert that ObserverService will do its job if the minimum delay between two sync is over - */ - @Ignore("Binding to synchronizationService make test fails") - @Test - public void lastSync15minAnd30secAgo_shouldStart() { - //decrease 15min and 30sec - last_sync_time = System.currentTimeMillis() - 930000; - setWifiNetworkStatus(); - prepareValidAccount(); - enableMediaAndSettingsSync(getValidAccount()); - registerSharedPref(); - syncServiceController.create().startCommand(0, 0); - - //Start the service - mServiceController.create().startCommand(0, 0); - - List logs = ShadowLog.getLogs(); - for(ShadowLog.LogItem log : logs){ - assertFalse("Log shouldn't contains 'delay between now and last call is too short' but it does", log.msg.equals("Delay between now and last call is too short")); //There isn't assertNotEquals - } - } - - /** - * This assert that ObserverService won't start if it's already running - */ - @Ignore("Not yet implemented") - @Test - public void syncAlreadyStarted_shouldStop() { - //@TODO need to find how to access the "isRunning" private field inside the ObserverService for this test - fail("Not yet implemented "); - } - - - /** - * Check that service stop if no account provided - */ - @Ignore("Binding to synchronizationService make test fails") - @Test - public void noAccount_shouldStop() { - - mServiceController.create().startCommand(0, 0); - - List logs = ShadowLog.getLogs(); - ShadowLog.LogItem lastLog = logs.get(logs.size()-1); - - assertEquals("Last expected log wasn't: 'No account registered' but "+lastLog.msg, "No account registered",lastLog.msg ); - assertTrue("jobScheduler expected to be have no pending job",jobScheduler.getAllPendingJobs().isEmpty()); - } - - /** - * This test will assert that the ObserverService won't do its job - * if Initialization hasn't been done - */ - @Ignore("Binding to synchronizationService make test fails") - @Test - public void setupNotDone_shouldStop() { - init_done = false; //This is the key settings for this test - setWifiNetworkStatus(); - prepareValidAccount(); - enableMediaAndSettingsSync(getValidAccount()); - registerSharedPref(); - - assertFalse("SharedPref doesn't contains the expected value for Initialization_has_been_done key", sharedPreferences.getBoolean(SETUP_COMPLETED, true)); - - mServiceController.create().startCommand(0, 0); - //How to assert this... ? - - List logs = ShadowLog.getLogs(); - ShadowLog.LogItem lastLog = logs.get(logs.size()-1); - - //Note: Checking log is the only way I've found to check that the service stopped as expected - assertEquals("Last log isn't the one expected", "Initialization hasn't been done", lastLog.msg); - //assertTrue(logs.contains()); //@TODO how to assert that logs doesn't contain the expecteed message ? - - //tearDown - Remove DB if it had been created - mService.deleteDatabase(DbHelper.DATABASE_NAME); - } -} \ No newline at end of file diff --git a/app/src/test/java/foundation/e/drive/synchronization/StateMachineTest.kt b/app/src/test/java/foundation/e/drive/synchronization/StateMachineTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..c1674fca0c2c14533afa3ad6430cb53bed1f1cd9 --- /dev/null +++ b/app/src/test/java/foundation/e/drive/synchronization/StateMachineTest.kt @@ -0,0 +1,183 @@ +/* + * Copyright © MURENA SAS 2023. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ +package foundation.e.drive.synchronization + +import foundation.e.drive.synchronization.SyncState.IDLE +import foundation.e.drive.synchronization.SyncState.LISTENING_FILES +import foundation.e.drive.synchronization.SyncState.PERIODIC_SCAN +import foundation.e.drive.synchronization.SyncState.SYNCHRONIZING +import org.junit.Assert +import org.junit.Test + +class StateMachineTest { + + @Test + fun `Changing State from PERIODIC_SCAN to LISTENING_FILES should return true`() { + StateMachine.currentState = PERIODIC_SCAN + + val result = StateMachine.changeState(LISTENING_FILES) + Assert.assertTrue("Changing state from PERIODIC_SCAN to LISTENING_FILES returned false", result) + + val currentState = StateMachine.currentState + Assert.assertEquals("New current state should be LISTENING_FILES but is $currentState", LISTENING_FILES, currentState) + } + + @Test + fun `Changing State from PERIODIC_SCAN to PERIODIC_SCAN should return false`() { + StateMachine.currentState = PERIODIC_SCAN + + val result = StateMachine.changeState(PERIODIC_SCAN) + Assert.assertFalse("Changing state from PERIODIC_SCAN to PERIODIC_SCAN returned true", result) + + val currentState = StateMachine.currentState + Assert.assertEquals("Current state should remain PERIODIC_SCAN but is $currentState", PERIODIC_SCAN, currentState) + } + + @Test + fun `Changing State from PERIODIC_SCAN to SYNCHRONIZING should return true`() { + StateMachine.currentState = PERIODIC_SCAN + + val result = StateMachine.changeState(SYNCHRONIZING) + Assert.assertTrue("Changing state from PERIODIC_SCAN to SYNCHRONIZING returned false", result) + + val currentState = StateMachine.currentState + Assert.assertEquals("New current state should be SYNCHRONIZING but is $currentState", SYNCHRONIZING, currentState) + } + + @Test + fun `Changing State from PERIODIC_SCAN to IDLE should return false`() { + StateMachine.currentState = PERIODIC_SCAN + + val result = StateMachine.changeState(IDLE) + Assert.assertFalse("Changing state from PERIODIC_SCAN to IDLE returned false", result) + + val currentState = StateMachine.currentState + Assert.assertEquals("Current state should remain PERIODIC_SCAN but is $currentState", PERIODIC_SCAN, currentState) + } + + @Test + fun `Changing State from LISTENING_FILES to PERIODIC_SCAN should return true`() { + StateMachine.currentState = LISTENING_FILES + + val result = StateMachine.changeState(PERIODIC_SCAN) + Assert.assertTrue("Changing state from LISTENING_FILES to PERIODIC_SCAN returned false", result) + + val currentState = StateMachine.currentState + Assert.assertEquals("New current state should be PERIODIC_SCAN but is $currentState", PERIODIC_SCAN, currentState) + } + + @Test + fun `Changing State from LISTENING_FILE to SYNCHRONIZING should return true`() { + StateMachine.currentState = LISTENING_FILES + + val result = StateMachine.changeState(SYNCHRONIZING) + Assert.assertTrue("Changing state from LISTENING_FILES to SYNCHRONIZING returned false", result) + + val currentState = StateMachine.currentState + Assert.assertEquals("New current state should be SYNCHRONIZING but is $currentState", SYNCHRONIZING, currentState) + } + + @Test + fun `Changing State from LISTENING_FILE to IDLE should return false`() { + StateMachine.currentState = LISTENING_FILES + + val result = StateMachine.changeState(IDLE) + Assert.assertFalse("Changing state from LISTENING_FILES to IDLE returned true", result) + + val currentState = StateMachine.currentState + Assert.assertEquals("Current state should remain LISTENING_FILES but is $currentState", LISTENING_FILES, currentState) + } + + @Test + fun `Changing State from LISTENING_FILES to LISTENING_FILES should return true`() { + StateMachine.currentState = LISTENING_FILES + + val result = StateMachine.changeState(LISTENING_FILES) + Assert.assertTrue("Changing state from LISTENING_FILES to LISTENING_FILES returned false", result) + + val currentState = StateMachine.currentState + Assert.assertEquals("New current state should be LISTENING_FILES but is $currentState", LISTENING_FILES, currentState) + } + + @Test + fun `Changing State from SYNCHRONIZING to LISTENING_FILE should return true`() { + StateMachine.currentState = SYNCHRONIZING + + val result = StateMachine.changeState(LISTENING_FILES) + Assert.assertTrue("Changing state from SYNCHRONIZING to LISTENING_FILES returned false", result) + + val currentState = StateMachine.currentState + Assert.assertEquals("New current state should be LISTENING_FILES but is $currentState", LISTENING_FILES, currentState) + } + + @Test + fun `Changing State from SYNCHRONIZING to PERIODIC_SCAN should return false`() { + StateMachine.currentState = SYNCHRONIZING + + val result = StateMachine.changeState(PERIODIC_SCAN) + Assert.assertFalse("Changing state from SYNCHRONIZING to PERIODIC_SCAN returned true", result) + + val currentState = StateMachine.currentState + Assert.assertEquals("Current state should remain SYNCHRONIZING but is $currentState", SYNCHRONIZING, currentState) + } + + @Test + fun `Changing State from SYNCHRONIZING to SYNCHRONIZING should return true`() { + StateMachine.currentState = SYNCHRONIZING + + val result = StateMachine.changeState(SYNCHRONIZING) + Assert.assertTrue("Changing state from SYNCHRONIZING to SYNCHRONIZING returned false", result) + + val currentState = StateMachine.currentState + Assert.assertEquals("New Current state should be SYNCHRONIZING but is $currentState", SYNCHRONIZING, currentState) + } + + @Test + fun `Changing State from SYNCHRONIZING to IDLE should return false`() { + StateMachine.currentState = SYNCHRONIZING + + val result = StateMachine.changeState(IDLE) + Assert.assertFalse("Changing state from SYNCHRONIZING to IDLE returned true", result) + + val currentState = StateMachine.currentState + Assert.assertEquals("Current state should remain SYNCHRONIZING but is $currentState", SYNCHRONIZING, currentState) + } + + @Test + fun `Changing State from IDLE to LISTENING_FILES should return true`() { + StateMachine.currentState = IDLE + + val result = StateMachine.changeState(LISTENING_FILES) + Assert.assertTrue("Changing state from IDLE to LISTENING_FILES returned false", result) + + val currentState = StateMachine.currentState + Assert.assertEquals("New current state should be LISTENING_FILES but is $currentState", LISTENING_FILES, currentState) + } + + @Test + fun `Changing State from IDLE to SYNCHRONIZING should return true`() { + StateMachine.currentState = IDLE + + val result = StateMachine.changeState(SYNCHRONIZING) + Assert.assertTrue("Changing state from IDLE to SYNCHRONIZING returned false", result) + + val currentState = StateMachine.currentState + Assert.assertEquals("New current state should be SYNCHRONIZING but is $currentState", SYNCHRONIZING, currentState) + } + + @Test + fun `Changing State from IDLE to PERIODIC_SCAN should return true`() { + StateMachine.currentState = IDLE + + val result = StateMachine.changeState(PERIODIC_SCAN) + Assert.assertTrue("Changing state from IDLE to PERIODIC_SCAN returned false", result) + + val currentState = StateMachine.currentState + Assert.assertEquals("New current state should be PERIODIC_SCAN but is $currentState", PERIODIC_SCAN, currentState) + } +} \ No newline at end of file