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