diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f8c0bfe3a27bf3c8676e74696719bd83647883ca..ac42e944d90f00c4a785e052e7b300a75e101e9f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -83,7 +83,6 @@ android:exported="true" android:label="@string/account_setting_metered_network" tools:ignore="ExportedContentProvider" /> - + + \ No newline at end of file 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 aa621e6fc590bb97a8890f8c3807d37d848d2ce7..1d5a9c506f6142e8c19ac9f35ce16460bf67ed48 100644 --- a/app/src/main/java/foundation/e/drive/FileObservers/FileEventListener.java +++ b/app/src/main/java/foundation/e/drive/FileObservers/FileEventListener.java @@ -105,12 +105,12 @@ public class FileEventListener { * @param request SyncRequest that should be executed asap */ private void sendSyncRequestToSynchronizationService(@NonNull SyncRequest request) { - Timber.d("Sending a SyncRequest for %s", request.getSyncedFileState().getName()); - final SyncRequestCollector syncManager = SyncProxy.INSTANCE; - syncManager.queueSyncRequest(request, appContext.getApplicationContext()); - syncManager.startSynchronization(appContext); - + final boolean requestAdded = syncManager.queueSyncRequest(request, appContext.getApplicationContext()); + if (requestAdded) { + Timber.d("Sending a SyncRequest for %s", request.getSyncedFileState().getName()); + syncManager.startSynchronization(appContext); + } } /** diff --git a/app/src/main/java/foundation/e/drive/account/receivers/AccountRemoveCallbackReceiver.java b/app/src/main/java/foundation/e/drive/account/receivers/AccountRemoveCallbackReceiver.java index bcfd6b5e6b3588b39fbaa487de87f7e811b3f985..668fb93d9f05257c6d556e2a18d91038e868b64c 100644 --- a/app/src/main/java/foundation/e/drive/account/receivers/AccountRemoveCallbackReceiver.java +++ b/app/src/main/java/foundation/e/drive/account/receivers/AccountRemoveCallbackReceiver.java @@ -13,6 +13,7 @@ import static foundation.e.drive.utils.AppConstants.SETUP_COMPLETED; import android.accounts.AccountManager; import android.annotation.SuppressLint; +import android.app.NotificationManager; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; @@ -20,13 +21,15 @@ import android.content.SharedPreferences; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.work.WorkManager; import java.io.File; import foundation.e.drive.EdriveApplication; +import foundation.e.drive.R; import foundation.e.drive.database.DbHelper; import foundation.e.drive.database.FailedSyncPrefsManager; -import foundation.e.drive.services.SynchronizationService; +import foundation.e.drive.synchronization.SyncWorker; import foundation.e.drive.utils.AppConstants; import foundation.e.drive.utils.DavClientProvider; import foundation.e.drive.utils.ViewUtils; @@ -43,21 +46,27 @@ public class AccountRemoveCallbackReceiver extends BroadcastReceiver { final SharedPreferences preferences = applicationContext.getSharedPreferences(AppConstants.SHARED_PREFERENCE_NAME, Context.MODE_PRIVATE); - if (!shouldProceedWithRemoval(intent, preferences)) { + if (!shouldProceedWithRemoval(intent, preferences, applicationContext)) { return; } + cancelWorkers(applicationContext); stopRecursiveFileObserver(applicationContext); - stopAllServices(applicationContext); deleteDatabase(applicationContext); cleanSharedPreferences(applicationContext, preferences); removeCachedFiles(applicationContext); - + deleteNotificationChannels(applicationContext); DavClientProvider.getInstance().cleanUp(); ViewUtils.updateWidgetView(applicationContext); } + + private void cancelWorkers(@NonNull Context context) { + final WorkManager workManager = WorkManager.getInstance(context); + workManager.cancelAllWorkByTag(AppConstants.WORK_GENERIC_TAG); + } + private void deleteDatabase(@NonNull Context applicationContext) { final boolean result = applicationContext.deleteDatabase(DbHelper.DATABASE_NAME); Timber.d("Remove Database: %s", result); @@ -69,16 +78,16 @@ public class AccountRemoveCallbackReceiver extends BroadcastReceiver { } } - private boolean shouldProceedWithRemoval(@NonNull Intent intent, @NonNull SharedPreferences preferences) { + private boolean shouldProceedWithRemoval(@NonNull Intent intent, @NonNull SharedPreferences preferences, @NonNull Context context) { if (isInvalidAction(intent) || intent.getExtras() == null) { Timber.w("Invalid account removal request"); return false; } String currentAccount = preferences.getString(AccountManager.KEY_ACCOUNT_NAME, ""); - String currentAccountType = preferences.getString(AccountManager.KEY_ACCOUNT_TYPE, ""); + String currentAccountType = context.getString(R.string.eelo_account_type); - if (currentAccount.isEmpty() || currentAccountType.isEmpty()) { + if (currentAccount.isEmpty()) { Timber.d("No account set up, ignoring account removal"); return false; } @@ -93,13 +102,6 @@ public class AccountRemoveCallbackReceiver extends BroadcastReceiver { return !"android.accounts.action.ACCOUNT_REMOVED".equals(intent.getAction()); } - private void stopAllServices(@NonNull Context applicationContext) { - - Intent synchronizationServiceIntent = new Intent(applicationContext, SynchronizationService.class); - boolean syncServiceStopResult = applicationContext.stopService(synchronizationServiceIntent); - Timber.d("stop SynchronizationService: %s", syncServiceStopResult); - } - private void cleanSharedPreferences(@NonNull Context applicationContext, @NonNull SharedPreferences prefs) { if (!applicationContext.deleteSharedPreferences(AppConstants.SHARED_PREFERENCE_NAME)) { //If removal failed, clear all data inside @@ -144,4 +146,16 @@ public class AccountRemoveCallbackReceiver extends BroadcastReceiver { return dir.delete(); } + + private void deleteNotificationChannels(Context context) { + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + notificationManager.cancelAll(); + try { + notificationManager.deleteNotificationChannel(AppConstants.notificationChannelID); + notificationManager.deleteNotificationChannel(SyncWorker.NOTIF_CHANNEL_ID); + } catch (Exception exception) { + Timber.e(exception, "Cannot delete notification Channel"); + } + } } diff --git a/app/src/main/java/foundation/e/drive/models/SyncWrapper.java b/app/src/main/java/foundation/e/drive/models/SyncWrapper.java index 6c73855a6c39ae2f18630e8c099537c2b5727151..6bbf6221f3ac5a56ce963b8e4b08540a9220883d 100644 --- a/app/src/main/java/foundation/e/drive/models/SyncWrapper.java +++ b/app/src/main/java/foundation/e/drive/models/SyncWrapper.java @@ -15,8 +15,8 @@ import androidx.annotation.Nullable; import com.owncloud.android.lib.common.operations.RemoteOperation; -import foundation.e.drive.operations.DownloadFileOperation; -import foundation.e.drive.operations.UploadFileOperation; +import foundation.e.drive.synchronization.tasks.DownloadFileOperation; +import foundation.e.drive.synchronization.tasks.UploadFileOperation; /** * This class encapsulates data, for SynchronizationService, about a thread which run a RemoteOperation @@ -26,7 +26,6 @@ import foundation.e.drive.operations.UploadFileOperation; public class SyncWrapper { private final SyncRequest request; private final RemoteOperation remoteOperation; - private boolean isRunning; /** * Build an instance of SyncThreadHolder for a file transfer @@ -36,7 +35,6 @@ public class SyncWrapper { public SyncWrapper(@NonNull final SyncRequest request, @Nullable final Account account, @NonNull final Context context) { this.request = request; remoteOperation = createRemoteOperation(request, account, context); - isRunning = true; } @Nullable @@ -44,14 +42,6 @@ public class SyncWrapper { return remoteOperation; } - public boolean isRunning() { - return isRunning; - } - - public synchronized void setRunning(boolean running) { - isRunning = running; - } - /** * Create the RemoteOperation (to perform file transfer) based on SyncRequest * @param request syncRequest for the file diff --git a/app/src/main/java/foundation/e/drive/periodicScan/FullScanWorker.kt b/app/src/main/java/foundation/e/drive/periodicScan/FullScanWorker.kt index 0a4776aec98e1fdc537fe9b6552278250523b81d..49cc9c533e850a707300423842e9db091c528273 100644 --- a/app/src/main/java/foundation/e/drive/periodicScan/FullScanWorker.kt +++ b/app/src/main/java/foundation/e/drive/periodicScan/FullScanWorker.kt @@ -56,7 +56,6 @@ class FullScanWorker(private val context: Context, private val workerParams: Wor val startAllowed = checkStartConditions(account, prefs, requestCollector) if (!startAllowed) { Timber.d("Start Periodic Scan is not allowed") - requestCollector.startListeningFiles(applicationContext as Application) return Result.failure() } @@ -66,12 +65,12 @@ class FullScanWorker(private val context: Context, private val workerParams: Wor return Result.success() } - val remoteSyncRequests = scanRemoteFiles(account, syncFolders.toMutableList()) + val remoteSyncRequests = scanRemoteFiles(account, syncFolders) syncRequests.putAll(remoteSyncRequests) Timber.d("${remoteSyncRequests.size} request collected from cloud") - val localSyncRequests = scanLocalFiles(syncFolders.toMutableList()) + val localSyncRequests = scanLocalFiles(syncFolders) syncRequests.putAll(localSyncRequests) Timber.d("${localSyncRequests.size} request collected from device") diff --git a/app/src/main/java/foundation/e/drive/services/SynchronizationService.java b/app/src/main/java/foundation/e/drive/services/SynchronizationService.java deleted file mode 100644 index 42700dc3ebfed8f8d2b1527bb13173e1511afa43..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/drive/services/SynchronizationService.java +++ /dev/null @@ -1,280 +0,0 @@ -/* - * Copyright © MURENA SAS 2022-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.services; - -import static foundation.e.drive.operations.UploadFileOperation.FILE_SIZE_FLOOR_FOR_CHUNKED; - -import android.accounts.Account; -import android.accounts.AccountManager; -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Handler; -import android.os.HandlerThread; -import android.os.IBinder; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.owncloud.android.lib.common.OwnCloudClient; -import com.owncloud.android.lib.common.operations.OnRemoteOperationListener; -import com.owncloud.android.lib.common.operations.RemoteOperation; -import com.owncloud.android.lib.common.operations.RemoteOperationResult; - -import java.io.File; -import java.util.Map; - -import foundation.e.drive.R; -import foundation.e.drive.database.DbHelper; -import foundation.e.drive.database.FailedSyncPrefsManager; -import foundation.e.drive.models.SyncRequest; -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; -import foundation.e.drive.work.FileSyncDisabler; -import timber.log.Timber; - -/** - * @author Vincent Bourgmayer - */ -public class SynchronizationService extends Service implements OnRemoteOperationListener, FileSyncDisabler.FileSyncDisablingListener { - private final SyncManager syncManager = SyncProxy.INSTANCE; - - private Account account; - private final int workerAmount = 2; - private Thread[] threadPool; - @SuppressWarnings("DeprecatedIsStillUsed") - @Deprecated - private OwnCloudClient ocClient; - private Handler handler; - private HandlerThread handlerThread; - - @Override - public void onCreate() { - super.onCreate(); - Timber.tag(SynchronizationService.class.getSimpleName()); - } - - @SuppressWarnings("deprecation") //for OwnCloudClient - @Override - public int onStartCommand(@NonNull Intent intent, int flags, int startId) { - Timber.v("onStartCommand()"); - - final SharedPreferences prefs = this.getSharedPreferences(AppConstants.SHARED_PREFERENCE_NAME, Context.MODE_PRIVATE); - final String accountName = prefs.getString(AccountManager.KEY_ACCOUNT_NAME, ""); - final String accountType = getApplicationContext().getString(R.string.eelo_account_type); - account = CommonUtils.getAccount(accountName, accountType, AccountManager.get(this)); - - if (account == null) { - Timber.d("No account available"); - syncManager.startListeningFiles(getApplication()); - stopSelf(); - return START_NOT_STICKY; - } - - threadPool = new Thread[workerAmount]; - - ocClient = DavClientProvider.getInstance().getClientInstance(account, getApplicationContext()); - if (ocClient == null) { - Timber.w("ocClient is null"); - } - - handlerThread = new HandlerThread("syncService_onResponse"); - handlerThread.start(); - handler = new Handler(handlerThread.getLooper()); - - startSynchronization(); - - return START_REDELIVER_INTENT; - } - - @Override - public void onDestroy() { - if (handlerThread != null) { - handlerThread.quitSafely(); - } - - super.onDestroy(); - } - - public void startSynchronization(){ - Timber.d("startAllThreads"); - for(int i =-1; ++i < workerAmount;){ - this.startWorker(i); - } - } - - private void startWorker(int threadIndex) { - if (ocClient == null) { - Timber.w("Can't start sync thread: ocClient is null"); - return; - } - - if (!isNetworkAvailable()) { - Timber.d("No network available: Clear syncRequestQueue"); - syncManager.clearRequestQueue(); - return; - } - - if (!canStart(threadIndex)) { - Timber.d("Can't start thread #%s, thread is already running", threadIndex); - return; - } - - 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; - } - - if (!CommonUtils.isThisSyncAllowed(account, request.getSyncedFileState().isMediaType())) { - Timber.d("thread #%s won't start cause sync is not allowed by user setting", threadIndex); - return; - } - - final SyncWrapper syncWrapper = new SyncWrapper(request, account, getApplicationContext()); - if (request.getOperationType().equals(SyncRequest.Type.DISABLE_SYNCING)) { - - Timber.d(" starts 'sync disabling' for file : %s on thread #%s", request.getSyncedFileState().getName(), threadIndex); - final FileSyncDisabler fileSyncDisabler = new FileSyncDisabler(request.getSyncedFileState()); - threadPool[threadIndex] = new Thread(fileSyncDisabler.getRunnable(handler, threadIndex, getApplicationContext(), this)); - threadPool[threadIndex].start(); - syncManager.addStartedRequest(threadIndex,syncWrapper); - return; - } - - @SuppressWarnings("rawtypes") - final RemoteOperation operation = syncWrapper.getRemoteOperation(); - - if (operation == null ) return; - - CommonUtils.createNotificationChannel(this); - Timber.v(" starts %s on thread #%s for: %s",request.getOperationType().name(), - threadIndex, request.getSyncedFileState().getName()); - - //noinspection deprecation - threadPool[threadIndex] = operation.execute(ocClient, this, handler); - syncManager.addStartedRequest(threadIndex, syncWrapper); - } - - /** - * Check if conditions are met to run a new file transfer - * @param threadIndex index of thread on which we want to perform the transfer - * @return false if nogo - */ - private boolean canStart(int threadIndex) { - final SyncWrapper syncWrapper = syncManager.getStartedRequestOnThread(threadIndex); - return (syncWrapper == null || !syncWrapper.isRunning()); - } - - private boolean isNetworkAvailable() { - final boolean meteredNetworkAllowed = CommonUtils.isMeteredNetworkAllowed(account); - return CommonUtils.haveNetworkConnection(getApplicationContext(), meteredNetworkAllowed); - } - - @Override - public void onRemoteOperationFinish(@NonNull RemoteOperation callerOperation, @NonNull RemoteOperationResult result) { - Timber.v("onRemoteOperationFinish()"); - - boolean isNetworkDisconnected = false; - - logSyncResult(result.getCode(), callerOperation); - - switch (result.getCode()) { - case UNKNOWN_ERROR: - case FORBIDDEN: - if (callerOperation instanceof UploadFileOperation) { - final int rowAffected = DbHelper.forceFoldertoBeRescan(((UploadFileOperation) callerOperation).getSyncedState().getId(), getApplicationContext()); - Timber.d("Force folder to be rescan next time (row affected) : %s", rowAffected); - } - break; - case NO_NETWORK_CONNECTION: - case WRONG_CONNECTION: - isNetworkDisconnected = true; - break; - } - - 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)) { - wrapper.setRunning(false); - updateFailureCounter(wrapper.getRequest(), result.isSuccess()); - startWorker(keyValue.getKey()); - break; - } - } - } - - private void logSyncResult(@NonNull RemoteOperationResult.ResultCode resultCode, @NonNull RemoteOperation callerOperation) { - String fileName; - String operationType; - if (callerOperation instanceof UploadFileOperation) { - operationType = "Upload"; - fileName = ((UploadFileOperation) callerOperation).getSyncedState().getName(); - } else if (callerOperation instanceof DownloadFileOperation) { - operationType = "Download"; - fileName = ((DownloadFileOperation) callerOperation).getRemoteFilePath(); - } else return; - Timber.d("%s operation for %s result in: %s", operationType, fileName, resultCode.name()); - } - - private void updateFailureCounter(SyncRequest request, boolean success) { - final FailedSyncPrefsManager failedPref = FailedSyncPrefsManager.getInstance(getApplicationContext()); - final SyncedFileState fileState = request.getSyncedFileState(); - - final int fileStateId = fileState.getId(); - - if (success) { - failedPref.removeDataForFile(fileStateId); - } else { - - if (request.getOperationType().equals(SyncRequest.Type.UPLOAD)) { - if (fileState.getLocalPath() == null) return; - final File file = new File(fileState.getLocalPath()); - - if (file.length() >= FILE_SIZE_FLOOR_FOR_CHUNKED) { - return; //do not delay future synchronization when it's for a chunked upload - } - } - failedPref.saveFileSyncFailure(fileStateId); - } - } - - @Override - public void onSyncDisabled(int threadId, boolean succeed) { - final SyncWrapper wrapper = syncManager.getStartedRequestOnThread(threadId); - if (wrapper != null) { - final SyncRequest request = wrapper.getRequest(); - Timber.d("%s sync disabled ? %s", request.getSyncedFileState().getLocalPath(), succeed); - wrapper.setRunning(false); - updateFailureCounter(request, succeed); - } - startWorker(threadId); - } - - @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/SyncProxy.kt b/app/src/main/java/foundation/e/drive/synchronization/SyncProxy.kt index 13c458e1ef2d80c3dd8eb4d2119d42ae2e77b038..ef829bda64d950871f3351b844bf423f914da960 100644 --- a/app/src/main/java/foundation/e/drive/synchronization/SyncProxy.kt +++ b/app/src/main/java/foundation/e/drive/synchronization/SyncProxy.kt @@ -9,12 +9,14 @@ package foundation.e.drive.synchronization import android.app.Application import android.content.Context -import android.content.Intent +import androidx.work.ExistingWorkPolicy +import androidx.work.WorkManager 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 foundation.e.drive.work.WorkRequestFactory +import foundation.e.drive.work.WorkRequestFactory.WorkType.ONE_TIME_SYNC import timber.log.Timber import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue @@ -24,7 +26,7 @@ import java.util.concurrent.ConcurrentLinkedQueue * @author Vincent Bourgmayer */ interface SyncRequestCollector { - fun queueSyncRequest(request: SyncRequest, context: Context) + fun queueSyncRequest(request: SyncRequest, context: Context): Boolean fun queueSyncRequests(requests: MutableCollection, context: Context) fun startSynchronization(context: Context) fun onPeriodicScanStart(application: Application): Boolean @@ -37,19 +39,16 @@ interface SyncRequestCollector { */ interface SyncManager { fun pollSyncRequest(): SyncRequest? - fun addStartedRequest(threadId: Int, syncWrapper: SyncWrapper) + fun addStartedRequest(fileLocalPath: String, syncWrapper: SyncWrapper) fun isQueueEmpty(): Boolean + fun getQueueSize(): Int fun clearRequestQueue() - fun getStartedRequestOnThread(threadIndex: Int): SyncWrapper? - fun getStartedRequests(): ConcurrentHashMap - fun isAnySyncRequestRunning(): Boolean - fun removeStartedRequest(threadIndex: Int) + fun removeStartedRequest(fileLocalPath: String) 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 @@ -57,7 +56,7 @@ interface SyncManager { */ object SyncProxy: SyncRequestCollector, SyncManager { private val syncRequestQueue: ConcurrentLinkedQueue = ConcurrentLinkedQueue() //could we use channel instead ? - private val startedRequest: ConcurrentHashMap = ConcurrentHashMap() + private val startedRequests: ConcurrentHashMap = ConcurrentHashMap() /** * Add a SyncRequest into waiting queue if it matches some conditions: @@ -68,17 +67,17 @@ object SyncProxy: SyncRequestCollector, SyncManager { * @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 - } + override fun queueSyncRequest(request: SyncRequest, context: Context): Boolean { + if (startedRequests.containsKey(request.syncedFileState.localPath)) { + Timber.d("A request is already performing for ${request.syncedFileState.name}") + return false } - if (skipBecauseOfPreviousFail(request, context)) return + if (skipBecauseOfPreviousFail(request, context)) return false syncRequestQueue.remove(request) syncRequestQueue.add(request) + return true } /** @@ -91,11 +90,9 @@ object SyncProxy: SyncRequestCollector, SyncManager { * @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 - } + for (syncWrapper in startedRequests.values) { + requests.removeIf { obj: SyncRequest -> + startedRequests.containsKey(obj.syncedFileState.localPath) } } @@ -136,8 +133,10 @@ object SyncProxy: SyncRequestCollector, SyncManager { } if (previousSyncState != SyncState.SYNCHRONIZING) { - val intent = Intent(context, SynchronizationService::class.java) - context.startService(intent) + val workManager = WorkManager.getInstance(context) + val workRequest = WorkRequestFactory.getOneTimeWorkRequest(ONE_TIME_SYNC, null) + + workManager.enqueueUniqueWork(SyncWorker.UNIQUE_WORK_NAME, ExistingWorkPolicy.KEEP, workRequest) } } @@ -206,33 +205,25 @@ object SyncProxy: SyncRequestCollector, SyncManager { return syncRequestQueue.poll() } - override fun addStartedRequest(threadId: Int, syncWrapper: SyncWrapper) { - startedRequest[threadId] = syncWrapper + override fun addStartedRequest(fileLocalPath: String, syncWrapper: SyncWrapper) { + startedRequests[fileLocalPath] = syncWrapper } override fun isQueueEmpty(): Boolean { return syncRequestQueue.isEmpty() } - override fun getStartedRequests(): ConcurrentHashMap { - return startedRequest - } - - override fun isAnySyncRequestRunning(): Boolean { - return startedRequest.isNotEmpty() + override fun getQueueSize(): Int { + return syncRequestQueue.size } 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) + override fun removeStartedRequest(fileLocalPath: String) { + if (startedRequests.containsKey(fileLocalPath)) { + startedRequests.remove(fileLocalPath) } } } \ No newline at end of file diff --git a/app/src/main/java/foundation/e/drive/synchronization/SyncTask.kt b/app/src/main/java/foundation/e/drive/synchronization/SyncTask.kt new file mode 100644 index 0000000000000000000000000000000000000000..d37317e846da5cc1d6433ff03a50f7a35a58ec43 --- /dev/null +++ b/app/src/main/java/foundation/e/drive/synchronization/SyncTask.kt @@ -0,0 +1,161 @@ +/* + * 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.accounts.Account +import android.content.Context +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.HOST_NOT_AVAILABLE +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.NO_NETWORK_CONNECTION +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.WRONG_CONNECTION +import foundation.e.drive.database.DbHelper +import foundation.e.drive.database.FailedSyncPrefsManager +import foundation.e.drive.models.SyncRequest +import foundation.e.drive.models.SyncRequest.Type.* +import foundation.e.drive.models.SyncWrapper +import foundation.e.drive.synchronization.tasks.UploadFileOperation +import foundation.e.drive.utils.CommonUtils +import timber.log.Timber +import java.io.File + +class SyncTask( + val request: SyncRequest, + val client: OwnCloudClient, + val account: Account, + val context: Context, +) : Runnable { + companion object { + private val syncManager = SyncProxy as SyncManager + } + + private val fileName = request.syncedFileState.name + private val fileLocalPath = request.syncedFileState.localPath + + override fun run() { + if (!canStart()) { + return + } + + Timber.d(" starts ${request.operationType.name} for: $fileLocalPath") + + val wrapper = SyncWrapper(request, account, context) + syncManager.addStartedRequest(fileLocalPath, wrapper) + + val succeed = when (request.operationType) { + UPLOAD -> runUpload(wrapper) + DOWNLOAD -> runDownload(wrapper) + DISABLE_SYNCING -> runSyncDisabling() + } + + updateFailureCounter(request, succeed) + syncManager.removeStartedRequest(fileLocalPath) + Timber.d("${request.operationType.name} finished for $fileLocalPath") + } + + private fun canStart(): Boolean { + val isSyncAllowed = CommonUtils.isThisSyncAllowed(account, request.syncedFileState.isMediaType) + if (!isSyncAllowed){ + Timber.d("Sync of the file is not allowed anymore") + return false + } + + if (!isNetworkAvailable()) { + Timber.d("No network available. can't run syncTask") + return false + } + return true + } + + + private fun runUpload(syncWrapper: SyncWrapper): Boolean { + @Suppress("DEPRECATION") val result = syncWrapper.remoteOperation?.execute(client) + if (result == null) { + Timber.d("Error: Upload result for $fileName is null") + return false + } + + val code = result.code + Timber.d("Upload operation for $fileName result in: ${result.code}") + + if (code == RemoteOperationResult.ResultCode.UNKNOWN_ERROR || code == RemoteOperationResult.ResultCode.FORBIDDEN) { + val operation = syncWrapper.remoteOperation as UploadFileOperation + val rowAffected = DbHelper.forceFoldertoBeRescan(operation.syncedState.id, context) + Timber.d("Force folder to be rescan next time (row affected) : $rowAffected") + return false + } + + if (isNetworkLost(code)) { + syncManager.clearRequestQueue() + Timber.d("Network has been lost. SyncRequest queue has been cleared") + return false + } + return result.isSuccess + } + + private fun runDownload(syncWrapper: SyncWrapper): Boolean { + @Suppress("DEPRECATION") val result = syncWrapper.remoteOperation?.execute(client) + if (result == null) { + Timber.d("Error: Download result for $fileName is null") + return false + } + + val code = result.code + Timber.d("Download operation for $fileName result in: ${result.code}") + + if (isNetworkLost(code)) { + syncManager.clearRequestQueue() + Timber.d("Network has been lost. SyncRequest queue has been cleared") + return false + } + + return result.isSuccess + } + + private fun runSyncDisabling(): Boolean { + val fileState = request.syncedFileState + + fileState.disableScanning() + if (DbHelper.manageSyncedFileStateDB(fileState, "UPDATE", context) <= 0) { + Timber.d("Failed to disable sync for $fileState.name from DB") + return false + } + + return true + } + + private fun isNetworkLost(code: RemoteOperationResult.ResultCode): Boolean { + return (code == WRONG_CONNECTION + || code == NO_NETWORK_CONNECTION + || code == HOST_NOT_AVAILABLE) + } + + private fun updateFailureCounter(request: SyncRequest, success: Boolean) { + val failedPrefs = FailedSyncPrefsManager.getInstance(context) + val fileState = request.syncedFileState + val fileStateId = fileState.id + + if (!success) { + if (request.operationType == SyncRequest.Type.UPLOAD) { + val filePath = fileState.localPath + if (filePath.isEmpty()) return; + val file = File(filePath) + if (file.length() >= UploadFileOperation.FILE_SIZE_FLOOR_FOR_CHUNKED) return + } + failedPrefs.saveFileSyncFailure(fileStateId) + return + } + + failedPrefs.removeDataForFile(fileStateId) + } + + private fun isNetworkAvailable(): Boolean { + val isMeteredNetworkAllowed = CommonUtils.isMeteredNetworkAllowed(account) + return CommonUtils.haveNetworkConnection(context, isMeteredNetworkAllowed) + } +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/drive/synchronization/SyncWorker.kt b/app/src/main/java/foundation/e/drive/synchronization/SyncWorker.kt new file mode 100644 index 0000000000000000000000000000000000000000..eda410b26725e604426f23f46127c62c2224f48e --- /dev/null +++ b/app/src/main/java/foundation/e/drive/synchronization/SyncWorker.kt @@ -0,0 +1,159 @@ +/* + * 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.accounts.Account +import android.accounts.AccountManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Context.NOTIFICATION_SERVICE +import androidx.core.app.NotificationCompat +import androidx.work.ForegroundInfo + +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.owncloud.android.lib.common.OwnCloudClient +import foundation.e.drive.EdriveApplication +import foundation.e.drive.R +import foundation.e.drive.utils.AppConstants +import foundation.e.drive.utils.CommonUtils +import foundation.e.drive.utils.DavClientProvider +import timber.log.Timber +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit + +class SyncWorker( + context: Context, + params: WorkerParameters +) : Worker(context, params) { + + companion object { + const val UNIQUE_WORK_NAME = "syncWorker" + const val NOTIF_CHANNEL_ID = "syncChannelId" + const val NOTIFICATION_ID = 2003004 + private const val threadAmount = 2 + private val syncManager = SyncProxy as SyncManager + } + + private var account: Account? = null + private var ocClient: OwnCloudClient? = null + private val notificationManager = applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager + private var executor = Executors.newFixedThreadPool(threadAmount) + + override fun onStopped() { + Timber.d("SyncWorker has been stopped") + notificationManager.cancel(NOTIFICATION_ID) + executor.shutdownNow() + + super.onStopped() + } + + override fun doWork(): Result { + try { + account = loadAccount() + if (account == null) { + Timber.d("Warning : account is null") + syncManager.startListeningFiles(applicationContext as EdriveApplication) + return Result.failure() + } + + ocClient = getOcClient(account!!) + if (ocClient == null) { + Timber.d("Warning : ocClient is null") + syncManager.startListeningFiles(applicationContext as EdriveApplication) + return Result.failure() + } + + createNotificationChannel() + + while (!syncManager.isQueueEmpty()) { + setForegroundAsync(createForegroundInfo()) + executeRequests() + } + + notificationManager.cancel(NOTIFICATION_ID) + syncManager.startListeningFiles(applicationContext as EdriveApplication) + } catch (exception: Exception) { + Timber.w(exception) + } + + return Result.success() + } + + private fun loadAccount(): Account? { + val context = applicationContext + val prefs = context.getSharedPreferences(AppConstants.SHARED_PREFERENCE_NAME, Context.MODE_PRIVATE) + val accountName = prefs.getString(AccountManager.KEY_ACCOUNT_NAME, "") + val accountType = context.getString(R.string.eelo_account_type) + //for above: AS complaint about accountName as String? while it can't be... + return CommonUtils.getAccount(accountName!!, accountType, AccountManager.get(context)) + } + + private fun getOcClient(account: Account): OwnCloudClient? { + return DavClientProvider.getInstance().getClientInstance( + account, + applicationContext + ) + } + + private fun executeRequests() { + val futureList = arrayListOf>() + if (executor.isShutdown || executor.isTerminated) { + executor = Executors.newFixedThreadPool(threadAmount) + } + + while (!syncManager.isQueueEmpty()) { + val request = syncManager.pollSyncRequest()?: break + val task = SyncTask(request, ocClient!!, account!!, applicationContext) + futureList.add(executor!!.submit(task)) + } + + for (future: Future<*> in futureList) { + if (this.isStopped) { + Timber.d("Cancel task before execution") + future.cancel(true) + } else { + future.get() + } + } + + executor.shutdown() + while (!executor.isTerminated && !executor.isShutdown) { + executor.awaitTermination(30, TimeUnit.SECONDS) + } + } + + private fun createForegroundInfo(): ForegroundInfo { + val title = applicationContext.getString(R.string.notif_sync_is_running_title) + val requestCount = syncManager.getQueueSize() + val text = applicationContext.resources + .getQuantityString(R.plurals.notif_sync_is_running_txt, requestCount, requestCount) + + val notification = NotificationCompat.Builder(applicationContext, NOTIF_CHANNEL_ID) + .setOngoing(true) + .setContentTitle(title) + .setContentText(text) + .setSmallIcon(R.drawable.ic_synchronization) + + .build() + + return ForegroundInfo(NOTIFICATION_ID, notification) + } + + private fun createNotificationChannel() { + val channelName = applicationContext.getString(R.string.notif_sync_channel_name) + val importance = NotificationManager.IMPORTANCE_MIN + val channel = NotificationChannel(NOTIF_CHANNEL_ID, channelName, importance) + val channelDescription = applicationContext.getString(R.string.notif_sync_channel_description) + channel.description = channelDescription + + notificationManager.createNotificationChannel(channel) + } +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/drive/operations/DownloadFileOperation.java b/app/src/main/java/foundation/e/drive/synchronization/tasks/DownloadFileOperation.java similarity index 96% rename from app/src/main/java/foundation/e/drive/operations/DownloadFileOperation.java rename to app/src/main/java/foundation/e/drive/synchronization/tasks/DownloadFileOperation.java index 942fd11d7117752f749a63b05bc1735de80bed5d..4874e4609c6c33b24d6f9f76e6a4cc0d14ac8bbd 100644 --- a/app/src/main/java/foundation/e/drive/operations/DownloadFileOperation.java +++ b/app/src/main/java/foundation/e/drive/synchronization/tasks/DownloadFileOperation.java @@ -7,10 +7,11 @@ * http://www.gnu.org/licenses/gpl.html */ -package foundation.e.drive.operations; +package foundation.e.drive.synchronization.tasks; import static com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.FILE_NOT_FOUND; import static com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.FORBIDDEN; +import static com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.HOST_NOT_AVAILABLE; import static com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.INVALID_OVERWRITE; import static com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.NO_NETWORK_CONNECTION; import static com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.OK; @@ -129,7 +130,9 @@ public class DownloadFileOperation extends RemoteOperation { private boolean isNetworkDisconnected(@NonNull final RemoteOperationResult result) { RemoteOperationResult.ResultCode resultCode = result.getCode(); - return resultCode == NO_NETWORK_CONNECTION || resultCode == WRONG_CONNECTION; + return resultCode == NO_NETWORK_CONNECTION + || resultCode == WRONG_CONNECTION + || resultCode == HOST_NOT_AVAILABLE; } private RemoteOperationResult.ResultCode onDownloadSuccess(String tmpTargetFolderPath) { diff --git a/app/src/main/java/foundation/e/drive/operations/UploadFileOperation.java b/app/src/main/java/foundation/e/drive/synchronization/tasks/UploadFileOperation.java similarity index 97% rename from app/src/main/java/foundation/e/drive/operations/UploadFileOperation.java rename to app/src/main/java/foundation/e/drive/synchronization/tasks/UploadFileOperation.java index 47cdd83e1870957d2b005cfa8132ae2d6f638132..e141198c227332893d809b1071251254de005a99 100644 --- a/app/src/main/java/foundation/e/drive/operations/UploadFileOperation.java +++ b/app/src/main/java/foundation/e/drive/synchronization/tasks/UploadFileOperation.java @@ -7,7 +7,7 @@ * http://www.gnu.org/licenses/gpl.html */ -package foundation.e.drive.operations; +package foundation.e.drive.synchronization.tasks; import static foundation.e.drive.utils.FileUtils.getMimeType; @@ -131,6 +131,7 @@ public class UploadFileOperation extends RemoteOperation { handledResultCodes.add(ResultCode.QUOTA_EXCEEDED); handledResultCodes.add(ResultCode.WRONG_CONNECTION); handledResultCodes.add(ResultCode.NO_NETWORK_CONNECTION); + handledResultCodes.add(ResultCode.HOST_NOT_AVAILABLE); return handledResultCodes; } @@ -212,15 +213,17 @@ public class UploadFileOperation extends RemoteOperation { @NonNull public RemoteOperationResult checkAvailableSpace(@NonNull NextcloudClient client, long fileSize) { final RemoteOperationResult ocsResult = readUserInfo(client); - final ResultCode resultCode; + if (!ocsResult.isSuccess()) { + return ocsResult; + } + final Quota quotas = ocsResult.getResultData().getQuota(); - if (ocsResult.isSuccess() && quotas != null && quotas.getFree() < fileSize) { - resultCode = ResultCode.QUOTA_EXCEEDED; - } else { - resultCode = ocsResult.getCode(); + if (quotas != null && quotas.getFree() < fileSize) { + return new RemoteOperationResult(ResultCode.QUOTA_EXCEEDED); } - return new RemoteOperationResult(resultCode); + + return ocsResult; } /** @@ -267,6 +270,7 @@ public class UploadFileOperation extends RemoteOperation { syncedState.getRemotePath(), mimeType, syncedState.getLastEtag(), timeStamp, false); + //noinspection deprecation return uploadOperation.execute(client); } diff --git a/app/src/main/java/foundation/e/drive/work/FileSyncDisabler.java b/app/src/main/java/foundation/e/drive/work/FileSyncDisabler.java deleted file mode 100644 index 76af0fd90d58106bcc8b281eacb9a2489986eae2..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/drive/work/FileSyncDisabler.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright © MURENA 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.work; - -import android.content.Context; -import android.os.Handler; - -import androidx.annotation.NonNull; - -import foundation.e.drive.database.DbHelper; -import foundation.e.drive.models.SyncedFileState; -import timber.log.Timber; - -/** - * New Goal: Disable eDrive Synchronization of the given file - * - * previous goal: This worker is called when a remote file has been detected as removed and that all the - * scanning process lead to choose to delete the local version too. - * - * @author vincent Bourgmayer - */ -public class FileSyncDisabler { - - public interface FileSyncDisablingListener { - void onSyncDisabled(int threadId, boolean succeed); - } - - private final SyncedFileState fileState; - - public FileSyncDisabler(@NonNull SyncedFileState syncedFileState) { - fileState = syncedFileState; - } - - @NonNull - public Runnable getRunnable(@NonNull final Handler handler, final int threadId, @NonNull final Context context, @NonNull final FileSyncDisablingListener listener) { - return () -> { - - fileState.disableScanning(); - if (DbHelper.manageSyncedFileStateDB(fileState, "UPDATE", context) <= 0) { - Timber.d("Failed to remove %s from DB", fileState.getName()); - } - - notifyCompletion(threadId, listener, true, handler); - }; - } - - private void notifyCompletion(final int threadId, final @NonNull FileSyncDisablingListener listener, final boolean success, @NonNull final Handler handler) { - handler.post(() -> listener.onSyncDisabled(threadId, success)); - } -} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/drive/work/WorkRequestFactory.java b/app/src/main/java/foundation/e/drive/work/WorkRequestFactory.java index 324b5deffe149920cc984cd8b3a2f851be78f69e..460ed5761fb8714b00e86b32080691a92bba748b 100644 --- a/app/src/main/java/foundation/e/drive/work/WorkRequestFactory.java +++ b/app/src/main/java/foundation/e/drive/work/WorkRequestFactory.java @@ -40,6 +40,7 @@ import foundation.e.drive.models.SyncedFolder; import foundation.e.drive.periodicScan.FullScanWorker; import foundation.e.drive.periodicScan.ListAppsWorker; import foundation.e.drive.periodicScan.PeriodicScanWorker; +import foundation.e.drive.synchronization.SyncWorker; public class WorkRequestFactory { public enum WorkType { @@ -50,7 +51,8 @@ public class WorkRequestFactory { ONE_TIME_APP_LIST, ONE_TIME_USER_INFO, ONE_TIME_ROOT_FOLDER_SETUP, - ONE_TIME_FINISH_SETUP + ONE_TIME_FINISH_SETUP, + ONE_TIME_SYNC } private final static int PERIODIC_WORK_REPEAT_INTERVAL = 30; @@ -135,11 +137,26 @@ public class WorkRequestFactory { case ONE_TIME_ROOT_FOLDER_SETUP: if (syncedFolder == null) throw new NullPointerException("Synced folder is null"); return createRootFolderSetupWorkRequest(syncedFolder); + case ONE_TIME_SYNC: + return createSyncWorkRequest(); default: throw new InvalidParameterException("Unsupported Work Type: " + type); } } + private static OneTimeWorkRequest createSyncWorkRequest() { + final Constraints constraints = new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .build(); + + final OneTimeWorkRequest.Builder builder = new OneTimeWorkRequest.Builder(SyncWorker.class); + + return builder.setBackoffCriteria(LINEAR, 2, MINUTES).addTag(WORK_GENERIC_TAG) + .setConstraints(constraints) + .build(); + } + /** * Create a workRequest to generate file which contains list of installed apps * @return the workRequest diff --git a/app/src/main/res/drawable/button_background.xml b/app/src/main/res/drawable/button_background.xml index 1c1a64eee77dcdd4c2aa2f6d37208daae639dc62..56a100fd421de25d05e3202ab4de2a76a282a29b 100644 --- a/app/src/main/res/drawable/button_background.xml +++ b/app/src/main/res/drawable/button_background.xml @@ -1,4 +1,21 @@ + + + diff --git a/app/src/main/res/drawable/button_background_light.xml b/app/src/main/res/drawable/button_background_light.xml index 6a146ee343311301cbef4a7ef95b95d943f9c572..98394184d1a3c061d50599c2da358cd56f00df2a 100644 --- a/app/src/main/res/drawable/button_background_light.xml +++ b/app/src/main/res/drawable/button_background_light.xml @@ -1,4 +1,21 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_up.xml b/app/src/main/res/drawable/ic_arrow_up.xml index fa9dfe41a36c6fc01654b409e7e52cd086847685..994851eb46e2cb934da6b73e26ff3ff494cd2f91 100644 --- a/app/src/main/res/drawable/ic_arrow_up.xml +++ b/app/src/main/res/drawable/ic_arrow_up.xml @@ -1,3 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_background.xml b/app/src/main/res/drawable/widget_background.xml index 33f8c8ad8fcb9fcf7d9179a459688791722ef31e..d79697ce59cb6cf3a3c016b3cd8d57ace19360ef 100644 --- a/app/src/main/res/drawable/widget_background.xml +++ b/app/src/main/res/drawable/widget_background.xml @@ -1,4 +1,21 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e8c1cc12fe4e96dac163f0c30d821d8ea497ab3..fda41306fe16f9966de42198a8c4576956b5c720 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,7 +31,13 @@ 99% of your allotted cloud storage is used. Please take action. You\'ve filled your allotted cloud storage up to 90%. You\'ve filled your allotted cloud storage up to 80%. - + File synchronization in progress + + %d file to sync + %d files to sync + + Sync worker\'s channel + Notification about content being synchronized Copied %1$s to clipboard Settings Additional settings for account diff --git a/app/src/test/java/foundation/e/drive/operations/UploadFileOperationTest.java b/app/src/test/java/foundation/e/drive/operations/UploadFileOperationTest.java index e57b66038dd59342bf6c82b0b58e0d9f8d7dcd0a..8bcb28ef1ebabab01c4339723c4e83d2576b33ee 100644 --- a/app/src/test/java/foundation/e/drive/operations/UploadFileOperationTest.java +++ b/app/src/test/java/foundation/e/drive/operations/UploadFileOperationTest.java @@ -41,6 +41,7 @@ import foundation.e.drive.database.DbHelper; import foundation.e.drive.models.SyncedFileState; import foundation.e.drive.models.SyncedFolder; import foundation.e.drive.TestUtils; +import foundation.e.drive.synchronization.tasks.UploadFileOperation; import foundation.e.drive.utils.DavClientProvider; @SuppressWarnings("rawtypes")