Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions app/src/main/java/com/nextcloud/client/di/AppComponent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,6 +69,8 @@ interface AppComponent {

fun inject(folderDownloadWorkerReceiver: FolderDownloadWorkerReceiver)

fun inject(synchronizeFolderOperation: SynchronizeFolderOperation)

@Component.Builder
interface Builder {
@BindsInstance
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class InternalTwoWaySyncWork(
user,
fileDataStorageManager,
false,
false
true
)
val operationResult = operation?.execute(context)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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.
* <p>
Expand Down
Loading
Loading