diff --git a/app/src/androidTest/java/com/owncloud/android/operations/InternalTwoWaySyncIT.kt b/app/src/androidTest/java/com/owncloud/android/operations/InternalTwoWaySyncIT.kt new file mode 100644 index 000000000000..86e24629f2d1 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/operations/InternalTwoWaySyncIT.kt @@ -0,0 +1,264 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.operations + +import com.owncloud.android.AbstractOnServerIT +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.resources.files.CreateFolderRemoteOperation +import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.lib.resources.files.RemoveFileRemoteOperation +import com.owncloud.android.lib.resources.files.UploadFileRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * CRUD coverage for internal two-way sync, driving [SynchronizeFolderOperation] the same way + * [com.nextcloud.client.jobs.InternalTwoWaySyncWork] does (`syncAll = true`). + */ +class InternalTwoWaySyncIT : AbstractOnServerIT() { + + private fun sync(remotePath: String): RemoteOperationResult<*> = + SynchronizeFolderOperation(targetContext, remotePath, user, getStorageManager(), false, true) + .execute(targetContext) + + /** Creates [remotePath] on the server, downloads it once, then marks it for two-way sync. */ + private fun setUpTwoWaySyncFolder(remotePath: String): OCFile { + createFolder(remotePath) + assertTrue(sync(remotePath).isSuccess) + + val folder = getStorageManager().getFileByPath(remotePath) + folder.internalFolderSyncTimestamp = 0L + getStorageManager().saveFile(folder) + return folder + } + + /** Uploads directly against the server, bypassing the local DB, to simulate a change made elsewhere. */ + private fun uploadDirectlyToServer(localFile: File, remotePath: String) { + assertTrue( + UploadFileRemoteOperation( + localFile.absolutePath, + remotePath, + "text/plain", + System.currentTimeMillis() / 1000 + ).execute(client).isSuccess + ) + } + + private fun existsOnServer(remotePath: String): Boolean = + ExistenceCheckRemoteOperation(remotePath, false).execute(client).isSuccess + + @Test + fun localCreate_file_isUploaded() { + val folder = setUpTwoWaySyncFolder("/twoWaySyncLocalCreateFile/") + + File(folder.storagePath, "newFile.txt").writeText("hello") + + assertTrue(sync(folder.remotePath).isSuccess) + + val uploaded = getStorageManager().getFileByPath(folder.remotePath + "newFile.txt") + assertNotNull(uploaded) + assertTrue(File(uploaded.storagePath).exists()) + assertTrue(existsOnServer(folder.remotePath + "newFile.txt")) + } + + @Test + fun localCreate_folderWithContents_isCreatedAndUploaded() { + val folder = setUpTwoWaySyncFolder("/twoWaySyncLocalCreateFolder/") + + val subFolder = File(folder.storagePath, "sub").apply { mkdir() } + File(subFolder, "nested.txt").writeText("nested") + + // createRemoteFolder() recurses synchronously, so one pass is enough here - unlike the + // remote-create-folder case below, which relies on an async OperationsService intent. + assertTrue(sync(folder.remotePath).isSuccess) + + val remoteSubFolder = getStorageManager().getFileByPath(folder.remotePath + "sub/") + assertNotNull(remoteSubFolder) + assertTrue(remoteSubFolder.isFolder) + + val nested = getStorageManager().getFileByPath(folder.remotePath + "sub/nested.txt") + assertNotNull(nested) + assertTrue(File(nested.storagePath).exists()) + } + + @Test + fun localUpdate_isUploaded() { + val folder = setUpTwoWaySyncFolder("/twoWaySyncLocalUpdate/") + uploadFile(getDummyFile("nonEmpty.txt"), folder.remotePath + "file.txt") + assertTrue(sync(folder.remotePath).isSuccess) + + val before = getStorageManager().getFileByPath(folder.remotePath + "file.txt") + + shortSleep() // makes sure the new mtime is strictly after lastSyncDateForData + File(before.storagePath).writeText("updated by test") + + assertTrue(sync(folder.remotePath).isSuccess) + + // an already-known file that changed locally is uploaded via FileUploadHelper's + // WorkManager job (SynchronizeFileOperation.handleLocalChange), not synchronously - + // poll the server instead of asserting right away + var updated = false + for (i in 0 until 10) { + val remote = ReadFileRemoteOperation(folder.remotePath + "file.txt").execute(client) + if (remote.isSuccess && (remote.data[0] as RemoteFile).etag != before.etag) { + updated = true + break + } + shortSleep() + } + assertTrue("Locally modified file was not uploaded within timeout", updated) + } + + @Test + fun localDelete_isRemovedFromServer() { + val folder = setUpTwoWaySyncFolder("/twoWaySyncLocalDelete/") + uploadFile(getDummyFile("nonEmpty.txt"), folder.remotePath + "file.txt") + assertTrue(sync(folder.remotePath).isSuccess) + + val file = getStorageManager().getFileByPath(folder.remotePath + "file.txt") + assertTrue(File(file.storagePath).delete()) + + assertTrue(sync(folder.remotePath).isSuccess) + + assertNull(getStorageManager().getFileByPath(folder.remotePath + "file.txt")) + assertFalse(existsOnServer(folder.remotePath + "file.txt")) + } + + @Test + fun remoteCreate_file_isDownloaded() { + val folder = setUpTwoWaySyncFolder("/twoWaySyncRemoteCreateFile/") + + uploadDirectlyToServer(getDummyFile("nonEmpty.txt"), folder.remotePath + "remote.txt") + + assertTrue(sync(folder.remotePath).isSuccess) + + val downloaded = getStorageManager().getFileByPath(folder.remotePath + "remote.txt") + assertNotNull(downloaded) + assertTrue(File(downloaded.storagePath).exists()) + } + + @Test + fun remoteCreate_folderWithContents_isDownloaded() { + val folder = setUpTwoWaySyncFolder("/twoWaySyncRemoteCreateFolder/") + val subFolderRemotePath = folder.remotePath + "remoteSub/" + + assertTrue(CreateFolderRemoteOperation(subFolderRemotePath, true).execute(client).isSuccess) + uploadDirectlyToServer(getDummyFile("nonEmpty.txt"), subFolderRemotePath + "nested.txt") + + assertTrue(sync(folder.remotePath).isSuccess) + + val remoteSubFolder = getStorageManager().getFileByPath(subFolderRemotePath) + assertNotNull(remoteSubFolder) + assertTrue(remoteSubFolder.isFolder) + + // descending into a newly discovered subfolder normally happens asynchronously via an + // OperationsService intent (SynchronizeFolderOperation#startSyncFolderOperation) - + // drive it directly here so the assertion below is deterministic + assertTrue(sync(subFolderRemotePath).isSuccess) + + val nested = getStorageManager().getFileByPath(subFolderRemotePath + "nested.txt") + assertNotNull(nested) + assertTrue(File(nested.storagePath).exists()) + } + + @Test + fun remoteUpdate_isDownloaded() { + val folder = setUpTwoWaySyncFolder("/twoWaySyncRemoteUpdate/") + uploadFile(getDummyFile("nonEmpty.txt"), folder.remotePath + "file.txt") + assertTrue(sync(folder.remotePath).isSuccess) + + val before = getStorageManager().getFileByPath(folder.remotePath + "file.txt") + + uploadDirectlyToServer(getDummyFile("chunkedFile.txt"), folder.remotePath + "file.txt") + + assertTrue(sync(folder.remotePath).isSuccess) + + val after = getStorageManager().getFileByPath(folder.remotePath + "file.txt") + assertNotEquals(before.etag, after.etag) + assertEquals(getDummyFile("chunkedFile.txt").length(), File(after.storagePath).length()) + } + + @Test + fun remoteDelete_file_isRemovedLocally() { + val folder = setUpTwoWaySyncFolder("/twoWaySyncRemoteDeleteFile/") + uploadFile(getDummyFile("nonEmpty.txt"), folder.remotePath + "file.txt") + assertTrue(sync(folder.remotePath).isSuccess) + + val file = getStorageManager().getFileByPath(folder.remotePath + "file.txt") + assertTrue(File(file.storagePath).exists()) + + assertTrue(RemoveFileRemoteOperation(folder.remotePath + "file.txt").execute(client).isSuccess) + + assertTrue(sync(folder.remotePath).isSuccess) + + assertNull(getStorageManager().getFileByPath(folder.remotePath + "file.txt")) + assertFalse(File(file.storagePath).exists()) + } + + @Test + fun remoteDelete_folderWithContents_isRemovedLocally() { + val folder = setUpTwoWaySyncFolder("/twoWaySyncRemoteDeleteFolder/") + + val subFolder = File(folder.storagePath, "sub").apply { mkdir() } + File(subFolder, "nested.txt").writeText("nested") + assertTrue(sync(folder.remotePath).isSuccess) + assertNotNull(getStorageManager().getFileByPath(folder.remotePath + "sub/")) + + assertTrue(RemoveFileRemoteOperation(folder.remotePath + "sub/").execute(client).isSuccess) + + assertTrue(sync(folder.remotePath).isSuccess) + + assertNull(getStorageManager().getFileByPath(folder.remotePath + "sub/")) + assertFalse(subFolder.exists()) + } + + @Test + fun conflict_bothSidesChanged_isMarked() { + val folder = setUpTwoWaySyncFolder("/twoWaySyncConflict/") + uploadFile(getDummyFile("nonEmpty.txt"), folder.remotePath + "file.txt") + assertTrue(sync(folder.remotePath).isSuccess) + + val before = getStorageManager().getFileByPath(folder.remotePath + "file.txt") + + shortSleep() + File(before.storagePath).writeText("local edit") + uploadDirectlyToServer(getDummyFile("chunkedFile.txt"), folder.remotePath + "file.txt") + + sync(folder.remotePath) + + val after = getStorageManager().getFileByPath(folder.remotePath + "file.txt") + assertNotNull( + "Concurrently changed file should have been flagged as conflicting", + after.etagInConflict + ) + } + + @Test + fun noChanges_secondSyncIsNoop() { + val folder = setUpTwoWaySyncFolder("/twoWaySyncNoop/") + uploadFile(getDummyFile("nonEmpty.txt"), folder.remotePath + "file.txt") + assertTrue(sync(folder.remotePath).isSuccess) + + val before = getStorageManager().getFileByPath(folder.remotePath + "file.txt") + + assertTrue(sync(folder.remotePath).isSuccess) + + val after = getStorageManager().getFileByPath(folder.remotePath + "file.txt") + assertEquals(before.etag, after.etag) + assertEquals(before.fileId, after.fileId) + } +} diff --git a/app/src/main/java/com/nextcloud/client/di/AppComponent.kt b/app/src/main/java/com/nextcloud/client/di/AppComponent.kt index de3a4edd9688..7030bb5ba8cb 100644 --- a/app/src/main/java/com/nextcloud/client/di/AppComponent.kt +++ b/app/src/main/java/com/nextcloud/client/di/AppComponent.kt @@ -23,6 +23,7 @@ import com.nextcloud.client.onboarding.OnboardingModule import com.nextcloud.client.player.PlayerModule import com.nextcloud.client.preferences.PreferencesModule import com.owncloud.android.MainApp +import com.owncloud.android.operations.SynchronizeFolderOperation import com.owncloud.android.ui.ThemeableSwitchPreference import com.owncloud.android.ui.whatsnew.ProgressIndicator import dagger.BindsInstance @@ -68,6 +69,8 @@ interface AppComponent { fun inject(folderDownloadWorkerReceiver: FolderDownloadWorkerReceiver) + fun inject(synchronizeFolderOperation: SynchronizeFolderOperation) + @Component.Builder interface Builder { @BindsInstance diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt index 4aa562956c40..05974a014228 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt @@ -173,6 +173,7 @@ interface BackgroundJobManager { fun startOfflineOperations() fun startPeriodicallyOfflineOperation() fun scheduleInternal2WaySync(intervalMinutes: Long) + fun runNowInternal2WaySync() fun cancelAllFilesDownloadJobs() fun startMetadataSyncJob(currentDirPath: String) fun downloadFolder(folder: OCFile, accountName: String) diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index daba20131e20..88c6b2da249b 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -883,6 +883,16 @@ internal class BackgroundJobManagerImpl( workManager.enqueueUniquePeriodicWork(JOB_INTERNAL_TWO_WAY_SYNC, ExistingPeriodicWorkPolicy.UPDATE, request) } + override fun runNowInternal2WaySync() { + val request = oneTimeRequestBuilder( + jobClass = InternalTwoWaySyncWork::class, + jobName = JOB_INTERNAL_TWO_WAY_SYNC + ) + .build() + + workManager.enqueueUniqueWork(JOB_INTERNAL_TWO_WAY_SYNC, ExistingWorkPolicy.REPLACE, request) + } + override fun downloadFolder(folder: OCFile, accountName: String) { val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) diff --git a/app/src/main/java/com/nextcloud/client/jobs/InternalTwoWaySyncWork.kt b/app/src/main/java/com/nextcloud/client/jobs/InternalTwoWaySyncWork.kt index 33876e08ecb5..4016d55010f0 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/InternalTwoWaySyncWork.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/InternalTwoWaySyncWork.kt @@ -74,7 +74,7 @@ class InternalTwoWaySyncWork( user, fileDataStorageManager, false, - false + true ) val operationResult = operation?.execute(context) diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index ccad44f05d14..43a39374be9d 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -549,6 +549,10 @@ public boolean saveFile(OCFile ocFile) { // only refresh folder operation must update eTag otherwise content of the folder may stay as outdated cv.remove(ProviderTableMeta.FILE_ETAG); cv.remove(ProviderTableMeta.FILE_STORAGE_PATH); + + if (ocFile.isInternalFolderSync()) { + ensureLocalDirectoryForInternalTwoWaySync(ocFile); + } } boolean sameRemotePath = fileExists(ocFile.getRemotePath()); @@ -594,6 +598,23 @@ public boolean saveFile(OCFile ocFile) { return overridden; } + /** + * A folder flagged for internal two-way sync must have a physical local directory as soon as + * it is flagged, so the user has somewhere to place new files right away instead of waiting + * for the next scheduled {@code InternalTwoWaySyncWork} run. + */ + private void ensureLocalDirectoryForInternalTwoWaySync(OCFile folder) { + String savePath = FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), folder); + File localDir = new File(savePath); + if (localDir.exists() || localDir.mkdirs()) { + // storagePath is never persisted for folders (see cv.remove(FILE_STORAGE_PATH) above); + // it is only kept in-memory so callers holding this OCFile see the directory immediately. + folder.setStoragePath(savePath); + } else { + Log_OC.e(TAG, "Could not create local directory for internal two-way sync folder: " + savePath); + } + } + /** * Ensures that an {@link OCFile} and all of its parent folders are stored locally. *

diff --git a/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation.java b/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation.java index 57edd26105c3..aa99a9ba622f 100644 --- a/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation.java @@ -15,13 +15,20 @@ import android.text.TextUtils; import com.nextcloud.client.account.User; +import com.nextcloud.client.device.PowerManagementService; import com.nextcloud.client.jobs.download.FileDownloadHelper; import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorkerNotificationManager; +import com.nextcloud.client.jobs.upload.FileUploadWorker; +import com.nextcloud.client.network.ConnectivityService; import com.nextcloud.utils.extensions.ExtensionsKt; +import com.owncloud.android.MainApp; import com.owncloud.android.datamodel.FileDataStorageManager; import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.UploadsStorageManager; import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1; import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile; +import com.owncloud.android.db.OCUpload; +import com.owncloud.android.files.services.NameCollisionPolicy; import com.owncloud.android.lib.common.OwnCloudClient; import com.owncloud.android.lib.common.operations.OperationCancelledException; import com.owncloud.android.lib.common.operations.RemoteOperationResult; @@ -37,11 +44,19 @@ import java.io.File; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.Vector; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.inject.Inject; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import kotlin.Unit; @@ -70,6 +85,9 @@ public class SynchronizeFolderOperation extends SyncOperation { /** Locally cached information about folder to synchronize */ private OCFile mLocalFolder; + /** 'True' means the folder to synchronize is part of an internal two-way sync folder tree */ + private boolean mIsPartOfInternalTwoWaySync; + /** Counter of conflicts found between local and remote files */ private int mConflictsFound; @@ -95,6 +113,10 @@ public class SynchronizeFolderOperation extends SyncOperation { final FolderDownloadWorkerNotificationManager notificationManager; + @Inject UploadsStorageManager uploadsStorageManager; + @Inject ConnectivityService connectivityService; + @Inject PowerManagementService powerManagementService; + /** * Creates a new instance of {@link SynchronizeFolderOperation}. * @@ -120,6 +142,7 @@ public SynchronizeFolderOperation(Context context, this.useWorkerWithNotification = useWorkerWithNotification; this.syncAll = syncAll; notificationManager = new FolderDownloadWorkerNotificationManager(context, false,null); + MainApp.getAppComponent().inject(this); } @@ -142,6 +165,12 @@ protected RemoteOperationResult run(OwnCloudClient client) { return new RemoteOperationResult<>(ResultCode.FILE_NOT_FOUND); } + mIsPartOfInternalTwoWaySync = getStorageManager().isPartOfInternalTwoWaySync(mLocalFolder); + + if (mIsPartOfInternalTwoWaySync) { + ensureLocalFolderExists(); + } + result = checkForChanges(client); if (result.isSuccess()) { @@ -234,6 +263,26 @@ private RemoteOperationResult fetchAndSyncRemoteFolder(OwnCloudClient client) th } + /** + * A folder under internal two-way sync must exist on disk even before any content is + * downloaded into it, so the user has somewhere to place new local files/folders. + * {@link CreateFolderOperation} only registers a folder in the local database and never + * creates its physical directory, so {@code storagePath} can still be empty here. + */ + private void ensureLocalFolderExists() { + String storagePath = mLocalFolder.getStoragePath(); + if (TextUtils.isEmpty(storagePath)) { + storagePath = FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), mLocalFolder); + mLocalFolder.setStoragePath(storagePath); + getStorageManager().saveFile(mLocalFolder); + } + + File localDir = new File(storagePath); + if (!localDir.exists() && !localDir.mkdirs()) { + Log_OC.e(TAG, "Could not create local directory for internal two-way sync folder: " + storagePath); + } + } + private void removeLocalFolder() { FileDataStorageManager storageManager = getStorageManager(); if (storageManager.fileExists(mLocalFolder.getFileId())) { @@ -336,9 +385,9 @@ private void synchronizeData(List folderAndFiles) throws OperationCancel boolean encrypted = updatedFile.isEncrypted() || mLocalFolder.isEncrypted(); updatedFile.setEncrypted(encrypted); - syncFileOrFolder(remoteFile, localFile); - - updatedFiles.add(updatedFile); + if (syncFileOrFolder(remoteFile, localFile)) { + updatedFiles.add(updatedFile); + } } // update file name for encrypted files @@ -387,17 +436,21 @@ private void updateLocalStateData(OCFile remoteFile, OCFile localFile, OCFile up * Schedules synchronization for the given remote file or folder. *

* If the remote file is a regular file, a {@link SynchronizeFileOperation} is created - * and added to the list of pending file synchronizations. + * and added to the list of pending file synchronizations. Exception: in an internal two-way + * sync folder, a file that was downloaded before but is missing locally now is treated as + * deleted by the user and removed from the server instead of being re-downloaded. * If the remote file is a folder, the method triggers a folder synchronization operation, * which recursively synchronizes all nested files and subfolders. *

* * @param remoteFile the remote file or folder to synchronize * @param localFile the corresponding local file or folder + * @return {@code false} if {@code remoteFile} was deleted from the server and should no + * longer be tracked locally, {@code true} otherwise * @throws OperationCancelledException if the synchronization was cancelled */ @SuppressFBWarnings("JLM") - private void syncFileOrFolder(OCFile remoteFile, OCFile localFile) throws OperationCancelledException { + private boolean syncFileOrFolder(OCFile remoteFile, OCFile localFile) throws OperationCancelledException { if (remoteFile.isFolder()) { synchronized (mCancellationRequested) { if (mCancellationRequested.get()) { @@ -405,17 +458,57 @@ private void syncFileOrFolder(OCFile remoteFile, OCFile localFile) throws Operat } startSyncFolderOperation(remoteFile.getRemotePath()); } + return true; + } + + if (mIsPartOfInternalTwoWaySync && isLocallyDeletedFile(localFile)) { + deleteRemoteFile(localFile); + return false; + } + + SynchronizeFileOperation operation = new SynchronizeFileOperation( + localFile, + remoteFile, + user, + true, + mContext, + getStorageManager(), + useWorkerWithNotification + ); + mFilesToSyncContents.add(operation); + return true; + } + + /** + * A file counts as "deleted by the user" only if it was downloaded before (has a storage + * path) and that path no longer exists; a file that was never downloaded has an empty + * storage path and must not be mistaken for a local deletion. + */ + private boolean isLocallyDeletedFile(OCFile localFile) { + if (localFile == null || localFile.isFolder()) { + return false; + } + + String storagePath = localFile.getStoragePath(); + return !TextUtils.isEmpty(storagePath) && !new File(storagePath).exists(); + } + + private void deleteRemoteFile(OCFile localFile) { + RemoveFileOperation operation = new RemoveFileOperation( + localFile, + false, + user, + false, + mContext, + getStorageManager() + ); + + var result = operation.execute(getClient()); + + if (result.isSuccess()) { + Log_OC.d(TAG, "Deleted remote file after local removal: " + localFile.getFileName()); } else { - SynchronizeFileOperation operation = new SynchronizeFileOperation( - localFile, - remoteFile, - user, - true, - mContext, - getStorageManager(), - useWorkerWithNotification - ); - mFilesToSyncContents.add(operation); + Log_OC.d(TAG, "Failed to delete remote file after local removal: " + localFile.getFileName()); } } @@ -445,6 +538,7 @@ private void prepareOpsFromLocalKnowledge() throws OperationCancelledException { private void syncContents(OwnCloudClient client) throws OperationCancelledException { startDirectDownloads(); startContentSynchronizations(mFilesToSyncContents); + startUploadNewFiles(); updateETag(client); } @@ -470,6 +564,117 @@ private void updateETag(OwnCloudClient client) { storageManager.saveFile(mLocalFolder); } } + + private void startUploadNewFiles() { + List children = getStorageManager().getFolderContent(mLocalFolder, false); + + if (mLocalFolder.getStoragePath() == null) { + return; + } + + File[] localFiles = new File(mLocalFolder.getStoragePath()).listFiles(); + if (localFiles == null) { + return; + } + + Stream sortedLocalFiles = Arrays.stream(localFiles).sorted(new Comparator() { + @Override + public int compare(File o1, File o2) { + if (o1.isDirectory() && o2.isDirectory()) { + return 0; + } + + if (o1.isFile() && o2.isFile()) { + return 0; + } + + if (o1.isDirectory() && o2.isFile()) { + return -1; + } + + if (o1.isFile() && o2.isDirectory()) { + return 1; + } + + return 0; + } + }); + + + + Set childFileNames = children.stream() + .map(OCFile::getFileName) + .collect(Collectors.toSet()); + + for (Iterator it = sortedLocalFiles.iterator(); it.hasNext(); ) { + File localFile = it.next(); + if (childFileNames.contains(localFile.getName())) { + continue; + } + + if (localFile.isDirectory()) { + createRemoteFolder(localFile); + } + + if (localFile.isFile()) { + uploadNewFile(localFile); + } + } + } + + private void createRemoteFolder(File localFolder) { + String remotePath = mLocalFolder.getRemotePath() + localFolder.getName() + OCFile.PATH_SEPARATOR; + CreateFolderOperation operation = new CreateFolderOperation(remotePath, user, mContext, getStorageManager()); + var result = operation.execute(getClient()); + + if (result.isSuccess()) { + Log_OC.d(TAG, "startUploadNewFiles created remote folder for: " + localFolder.getName()); + + new SynchronizeFolderOperation( + mContext, + remotePath, + user, + getStorageManager(), + false, + true + ).execute(mContext); + + } else { + Log_OC.d(TAG, "startUploadNewFiles failed to create remote folder for: " + localFolder.getName()); + } + } + + private void uploadNewFile(File localFile) { + String remotePath = mLocalFolder.getRemotePath() + localFile.getName(); + OCUpload upload = new OCUpload(localFile.getAbsolutePath(), remotePath, user.getAccountName()); + upload.setNameCollisionPolicy(NameCollisionPolicy.DEFAULT); + // the file already lives at its expected two-way-sync location, so it must stay linked + // as the local copy after upload instead of being forgotten + upload.setLocalAction(FileUploadWorker.LOCAL_BEHAVIOUR_COPY); + + UploadFileOperation operation = new UploadFileOperation( + uploadsStorageManager, + connectivityService, + powerManagementService, + user, + null, + upload, + upload.getNameCollisionPolicy(), + upload.getLocalAction(), + mContext, + false, + false, + getStorageManager() + ); + + var result = operation.execute(getClient()); + + if (result.isSuccess()) { + Log_OC.d(TAG, "startUploadNewFiles completed for: " + localFile.getName()); + } else { + Log_OC.d(TAG, "startUploadNewFiles failed for: " + localFile.getName()); + } + } private void startDirectDownloads() { final var fileDownloadHelper = FileDownloadHelper.Companion.instance(); @@ -527,6 +732,7 @@ private void startContentSynchronizations(List filesTo for (int current = 0; current < filesToSyncContents.size(); current++) { if (mCancellationRequested.get()) { + Log_OC.v(TAG, "cancelled..."); throw new OperationCancelledException(); } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/InternalTwoWaySyncActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/InternalTwoWaySyncActivity.kt index 25cdd59cfe4e..0031211a7027 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/InternalTwoWaySyncActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/InternalTwoWaySyncActivity.kt @@ -23,6 +23,7 @@ import com.nextcloud.client.jobs.download.FileDownloadWorker import com.nextcloud.utils.extensions.hourPlural import com.nextcloud.utils.extensions.minPlural import com.nextcloud.utils.extensions.setVisibleIf +import com.owncloud.android.BuildConfig import com.owncloud.android.R import com.owncloud.android.databinding.InternalTwoWaySyncLayoutBinding import com.owncloud.android.lib.common.utils.Log_OC @@ -202,6 +203,8 @@ class InternalTwoWaySyncActivity : menuInflater.inflate(R.menu.activity_internal_two_way_sync, menu) disableForAllFoldersMenuButton = menu?.findItem(R.id.action_dismiss_two_way_sync) checkDisableForAllFoldersMenuButtonVisibility() + menu?.findItem(R.id.action_run_two_way_sync)?.isVisible = BuildConfig.DEBUG + return super.onCreateOptionsMenu(menu) } @@ -214,6 +217,10 @@ class InternalTwoWaySyncActivity : R.id.action_dismiss_two_way_sync -> { disableTwoWaySyncAndWorkers() } + + R.id.action_run_two_way_sync -> { + backgroundJobManager.runNowInternal2WaySync() + } } return super.onOptionsItemSelected(item) diff --git a/app/src/main/res/menu/activity_internal_two_way_sync.xml b/app/src/main/res/menu/activity_internal_two_way_sync.xml index 57ed52bc5d2d..8f0a86a18860 100644 --- a/app/src/main/res/menu/activity_internal_two_way_sync.xml +++ b/app/src/main/res/menu/activity_internal_two_way_sync.xml @@ -14,4 +14,10 @@ android:orderInCategory="1" android:title="@string/two_way_sync_activity_disable_all_button_title" app:showAsAction="never" /> - \ No newline at end of file + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c1589ad6f91d..7a502672c371 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1638,4 +1638,5 @@ Play/Pause button Next button Random button + Run sync now