Skip to content
Merged
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,88 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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.ExistenceCheckRemoteOperation
import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation
import com.owncloud.android.lib.resources.files.UploadFileRemoteOperation
import com.owncloud.android.lib.resources.files.model.RemoteFile
import org.junit.Assert.assertTrue
import java.io.File

@Suppress("DEPRECATION")
abstract class AbstractInternalTwoWaySyncIT : AbstractOnServerIT() {

companion object {
const val TWO_WAY_SYNC_ENABLED = 0L
const val BINARY_MIME_TYPE = "application/octet-stream"
const val MILLIS_IN_SECOND = 1000
const val POLL_ATTEMPTS = 10
const val SMALL_DUMMY_FILE = "nonEmpty.txt"
const val LARGE_DUMMY_FILE = "chunkedFile.txt"
const val FILE_NAME = "file.bin"
}

protected fun sync(remotePath: String): RemoteOperationResult<*> =
SynchronizeFolderOperation(targetContext, remotePath, user, storageManager, false, true)
.execute(targetContext)

protected fun syncAndAssertSuccess(remotePath: String) = assertTrue(sync(remotePath).isSuccess)

protected fun setUpTwoWaySyncFolder(remotePath: String): OCFile {
createFolder(remotePath)
syncAndAssertSuccess(remotePath)

return storageManager.getFileByPath(remotePath).apply {
internalFolderSyncTimestamp = TWO_WAY_SYNC_ENABLED
storageManager.saveFile(this)
}
}

protected fun setUpTwoWaySyncFolderWithFile(remotePath: String): OCFile = setUpTwoWaySyncFolder(remotePath).also {
uploadFile(getDummyFile(SMALL_DUMMY_FILE), it.childPath(FILE_NAME))
syncAndAssertSuccess(it.remotePath)
}

protected fun uploadDirectlyToServer(localFile: File, remotePath: String) {
val modificationTimestamp = System.currentTimeMillis() / MILLIS_IN_SECOND
val result = UploadFileRemoteOperation(
localFile.absolutePath,
remotePath,
BINARY_MIME_TYPE,
modificationTimestamp
).execute(client)
assertTrue(result.isSuccess)
}

protected fun existsOnServer(remotePath: String): Boolean =
ExistenceCheckRemoteOperation(remotePath, false).execute(client).isSuccess

protected fun serverEtagOf(remotePath: String): String? = ReadFileRemoteOperation(remotePath)
.execute(client)
.takeIf { it.isSuccess }
?.let { (it.data.first() as RemoteFile).etag }

protected fun waitUntil(condition: () -> Boolean): Boolean = (1..POLL_ATTEMPTS).any {
val fulfilled = condition()
if (!fulfilled) {
shortSleep()
}
fulfilled
}

protected fun waitUntilServerEtagChanges(remotePath: String, previousEtag: String): Boolean = waitUntil {
serverEtagOf(remotePath)?.let { etag -> etag != previousEtag } == true
}

protected fun OCFile.childPath(name: String): String = remotePath + name

protected fun OCFile.childFolderPath(name: String): String = remotePath + name + OCFile.PATH_SEPARATOR
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/*
* 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.lib.resources.files.CreateFolderRemoteOperation
import com.owncloud.android.lib.resources.files.RemoveFileRemoteOperation
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

@Suppress("TooManyFunctions", "DEPRECATION")
class InternalTwoWaySyncIT : AbstractInternalTwoWaySyncIT() {

companion object {
private const val NESTED_FILE_NAME = "nested.bin"
private const val SUB_FOLDER_NAME = "sub"
}

@Test
fun testWhenLocalFileCreatedGivenShouldUploadToServer() {
val folder = setUpTwoWaySyncFolder("/twoWaySyncLocalCreateFile/")
File(folder.storagePath, FILE_NAME).writeText("hello")

syncAndAssertSuccess(folder.remotePath)

val uploaded = storageManager.getFileByPath(folder.childPath(FILE_NAME))
assertNotNull(uploaded)
assertTrue(File(uploaded.storagePath).exists())
assertTrue(existsOnServer(folder.childPath(FILE_NAME)))
}

@Test
fun testWhenLocalFolderWithContentsCreatedGivenShouldCreateFolderAndUploadContents() {
val folder = setUpTwoWaySyncFolder("/twoWaySyncLocalCreateFolder/")
val subFolder = File(folder.storagePath, SUB_FOLDER_NAME).apply { mkdir() }
File(subFolder, NESTED_FILE_NAME).writeText("nested")

syncAndAssertSuccess(folder.remotePath)

val remoteSubFolder = storageManager.getFileByPath(folder.childFolderPath(SUB_FOLDER_NAME))
assertNotNull(remoteSubFolder)
assertTrue(remoteSubFolder.isFolder)

val nested = storageManager.getFileByPath(remoteSubFolder.childPath(NESTED_FILE_NAME))
assertNotNull(nested)
assertTrue(File(nested.storagePath).exists())
}

@Test
fun testWhenLocalFileUpdatedGivenShouldUploadNewVersion() {
val folder = setUpTwoWaySyncFolderWithFile("/twoWaySyncLocalUpdate/")
val before = storageManager.getFileByPath(folder.childPath(FILE_NAME))

shortSleep()
File(before.storagePath).writeText("updated by test")

syncAndAssertSuccess(folder.remotePath)

assertTrue(
"Locally modified file was not uploaded within timeout",
waitUntilServerEtagChanges(folder.childPath(FILE_NAME), before.etag)
)
}

@Test
fun testWhenLocalFileDeletedGivenShouldRemoveFromServer() {
val folder = setUpTwoWaySyncFolderWithFile("/twoWaySyncLocalDelete/")
val file = storageManager.getFileByPath(folder.childPath(FILE_NAME))
assertTrue(File(file.storagePath).delete())

syncAndAssertSuccess(folder.remotePath)

assertNull(storageManager.getFileByPath(folder.childPath(FILE_NAME)))
assertFalse(existsOnServer(folder.childPath(FILE_NAME)))
}

@Test
fun testWhenRemoteFileCreatedGivenShouldDownload() {
val folder = setUpTwoWaySyncFolder("/twoWaySyncRemoteCreateFile/")
uploadDirectlyToServer(getDummyFile(SMALL_DUMMY_FILE), folder.childPath(FILE_NAME))

syncAndAssertSuccess(folder.remotePath)

val downloaded = storageManager.getFileByPath(folder.childPath(FILE_NAME))
assertNotNull(downloaded)
assertTrue(File(downloaded.storagePath).exists())
}

@Test
fun testWhenRemoteFolderWithContentsCreatedGivenShouldDownloadFolderAndContents() {
val folder = setUpTwoWaySyncFolder("/twoWaySyncRemoteCreateFolder/")
val subFolderPath = folder.childFolderPath(SUB_FOLDER_NAME)
assertTrue(CreateFolderRemoteOperation(subFolderPath, true).execute(client).isSuccess)
uploadDirectlyToServer(getDummyFile(SMALL_DUMMY_FILE), subFolderPath + NESTED_FILE_NAME)

syncAndAssertSuccess(folder.remotePath)

val remoteSubFolder = storageManager.getFileByPath(subFolderPath)
assertNotNull(remoteSubFolder)
assertTrue(remoteSubFolder.isFolder)

syncAndAssertSuccess(subFolderPath)

val nested = storageManager.getFileByPath(subFolderPath + NESTED_FILE_NAME)
assertNotNull(nested)
assertTrue(File(nested.storagePath).exists())
}

@Test
fun testWhenRemoteFileUpdatedGivenShouldDownloadNewVersion() {
val folder = setUpTwoWaySyncFolderWithFile("/twoWaySyncRemoteUpdate/")
val before = storageManager.getFileByPath(folder.childPath(FILE_NAME))
val newVersion = getDummyFile(LARGE_DUMMY_FILE)
uploadDirectlyToServer(newVersion, folder.childPath(FILE_NAME))

syncAndAssertSuccess(folder.remotePath)

val after = storageManager.getFileByPath(folder.childPath(FILE_NAME))
assertNotEquals(before.etag, after.etag)
assertEquals(newVersion.length(), File(after.storagePath).length())
}

@Test
fun testWhenRemoteFileDeletedGivenShouldRemoveLocally() {
val folder = setUpTwoWaySyncFolderWithFile("/twoWaySyncRemoteDeleteFile/")
val localFile = File(storageManager.getFileByPath(folder.childPath(FILE_NAME)).storagePath)
assertTrue(localFile.exists())
assertTrue(RemoveFileRemoteOperation(folder.childPath(FILE_NAME)).execute(client).isSuccess)

syncAndAssertSuccess(folder.remotePath)

assertNull(storageManager.getFileByPath(folder.childPath(FILE_NAME)))
assertFalse(localFile.exists())
}

@Test
fun testWhenRemoteFolderWithContentsDeletedGivenShouldRemoveLocally() {
val folder = setUpTwoWaySyncFolder("/twoWaySyncRemoteDeleteFolder/")
val subFolderPath = folder.childFolderPath(SUB_FOLDER_NAME)
val subFolder = File(folder.storagePath, SUB_FOLDER_NAME).apply { mkdir() }
File(subFolder, NESTED_FILE_NAME).writeText("nested")
syncAndAssertSuccess(folder.remotePath)
assertNotNull(storageManager.getFileByPath(subFolderPath))
assertTrue(RemoveFileRemoteOperation(subFolderPath).execute(client).isSuccess)

syncAndAssertSuccess(folder.remotePath)

assertNull(storageManager.getFileByPath(subFolderPath))
assertFalse(subFolder.exists())
}

@Test
fun testWhenBothSidesChangedGivenShouldMarkConflict() {
val folder = setUpTwoWaySyncFolderWithFile("/twoWaySyncConflict/")
val before = storageManager.getFileByPath(folder.childPath(FILE_NAME))

shortSleep()
File(before.storagePath).writeText("local edit")
uploadDirectlyToServer(getDummyFile(LARGE_DUMMY_FILE), folder.childPath(FILE_NAME))

sync(folder.remotePath)

val after = storageManager.getFileByPath(folder.childPath(FILE_NAME))
assertNotNull("Concurrently changed file should have been flagged as conflicting", after.etagInConflict)
}

@Test
fun testWhenNoChangesGivenShouldSecondSyncNotWork() {
val folder = setUpTwoWaySyncFolderWithFile("/twoWaySyncNoop/")
val before = storageManager.getFileByPath(folder.childPath(FILE_NAME))

syncAndAssertSuccess(folder.remotePath)

val after = storageManager.getFileByPath(folder.childPath(FILE_NAME))
assertEquals(before.etag, after.etag)
assertEquals(before.fileId, after.fileId)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

package com.owncloud.android.operations

import android.media.MediaScannerConnection
import android.net.Uri
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference

class InternalTwoWaySyncLocalEditIT : AbstractInternalTwoWaySyncIT() {

companion object {
private const val IMAGE_ASSET = "image.jpg"
private const val IMAGE_NAME = "edited.jpg"
private const val MEDIA_SCAN_TIMEOUT_SECONDS = 10L
private val EDITED_BYTES = byteArrayOf(0, 1, 2, 3)
}

@Test
fun testWhenLocalImageEditedGivenShouldKeepLocalAndServerCopyAcrossSyncs() {
val folder = setUpTwoWaySyncFolder("/twoWaySyncEditedImage/")
val localImage = File(folder.storagePath, IMAGE_NAME)
getFile(IMAGE_ASSET).copyTo(localImage)
syncAndAssertSuccess(folder.remotePath)

val before = storageManager.getFileByPath(folder.childPath(IMAGE_NAME))
assertNotNull(before)
assertEquals(localImage, File(before.storagePath))
scanIntoMediaStore(localImage)

shortSleep()
localImage.appendBytes(EDITED_BYTES)
syncAndAssertSuccess(folder.remotePath)

assertTrue(
"Edited image was not uploaded within timeout",
waitUntil { storageManager.getFileByPath(folder.childPath(IMAGE_NAME))?.etag != before.etag }
)
assertTrue("Edited image was removed locally after upload", localImage.exists())

syncAndAssertSuccess(folder.remotePath)

assertTrue("Edited image was removed locally after next sync", localImage.exists())
assertTrue("Edited image was removed from server after next sync", existsOnServer(before.remotePath))
}

@Test
fun testWhenLocalFileMissingAndServerChangedGivenShouldDownloadInsteadOfDeletingOnServer() {
val folder = setUpTwoWaySyncFolderWithFile("/twoWaySyncMissingLocallyChangedOnServer/")
val localFile = File(storageManager.getFileByPath(folder.childPath(FILE_NAME)).storagePath)
assertTrue(localFile.delete())
val newVersion = getDummyFile(LARGE_DUMMY_FILE)
uploadDirectlyToServer(newVersion, folder.childPath(FILE_NAME))

syncAndAssertSuccess(folder.remotePath)

assertTrue(existsOnServer(folder.childPath(FILE_NAME)))
assertTrue(localFile.exists())
assertEquals(newVersion.length(), localFile.length())
}

private fun scanIntoMediaStore(file: File) {
val latch = CountDownLatch(1)
val scannedUri = AtomicReference<Uri?>()
MediaScannerConnection.scanFile(targetContext, arrayOf(file.absolutePath), null) { _, uri ->
scannedUri.set(uri)
latch.countDown()
}

assertTrue("Media scan did not finish", latch.await(MEDIA_SCAN_TIMEOUT_SECONDS, TimeUnit.SECONDS))
assertNotNull("Image is not indexed by MediaStore", scannedUri.get())
}
}
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.synchronize.TwoWaySyncOperation
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(twoWaySyncOperation: TwoWaySyncOperation)

@Component.Builder
interface Builder {
@BindsInstance
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,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
Loading
Loading