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,255 @@
/*
* 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

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

companion object {
private const val TWO_WAY_SYNC_ENABLED = 0L
private const val BINARY_MIME_TYPE = "application/octet-stream"
private const val MILLIS_IN_SECOND = 1000
private const val SERVER_POLL_ATTEMPTS = 10
private const val SMALL_DUMMY_FILE = "nonEmpty.txt"
private const val LARGE_DUMMY_FILE = "chunkedFile.txt"
private const val FILE_NAME = "file.bin"
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)
}

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

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

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

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

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

private 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)
}

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

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

private fun waitUntilServerEtagChanges(remotePath: String, previousEtag: String): Boolean =
(1..SERVER_POLL_ATTEMPTS).any {
val changed = serverEtagOf(remotePath)?.let { etag -> etag != previousEtag } == true
if (!changed) {
shortSleep()
}
changed
}

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

private fun OCFile.childFolderPath(name: String): String = remotePath + name + OCFile.PATH_SEPARATOR
}
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, folderAlreadySynced: Boolean = false)
fun downloadFolder(folder: OCFile, accountName: String)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,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 @@ -16,10 +16,13 @@ import com.owncloud.android.datamodel.OCFileDepth
import com.owncloud.android.datamodel.OCFileDepth.DeepLevel
import com.owncloud.android.datamodel.OCFileDepth.FirstLevel
import com.owncloud.android.datamodel.OCFileDepth.Root
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.ui.events.EncryptionEvent
import com.owncloud.android.utils.FileStorageUtils
import java.io.File
import java.util.Calendar
import java.util.Date

private const val TAG = "OCFileExtensions"

fun List<OCFile>.filterFilenames(): List<OCFile> = distinctBy { it.fileName }

Expand Down Expand Up @@ -91,3 +94,14 @@ fun OCFile.toEncryptionEvent(encrypt: Boolean): EncryptionEvent = EncryptionEven
remotePath,
encrypt
)

fun OCFile.createStoragePath(accountName: String) {
val savePath = FileStorageUtils.getDefaultSavePathFor(accountName, this)
val localDirectory = File(savePath)
if (!localDirectory.exists() && !localDirectory.mkdirs()) {
Log_OC.e(TAG, "Could not create local directory for internal two-way sync folder: $savePath")
return
}

storagePath = savePath
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
import com.nextcloud.utils.extensions.DateExtensionsKt;
import com.nextcloud.utils.extensions.FileDataStorageManagerExtensionsKt;
import com.nextcloud.utils.extensions.FileExtensionsKt;
import com.nextcloud.utils.extensions.OCFileExtensionsKt;
import com.owncloud.android.MainApp;
import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile;
import com.owncloud.android.db.ProviderMeta.ProviderTableMeta;
Expand Down Expand Up @@ -108,6 +109,7 @@ public class FileDataStorageManager {
public static final int ROOT_PARENT_ID = 0;
private static final String JSON_NULL_STRING = "null";
private static final String JSON_EMPTY_ARRAY = "[]";
private static final long INTERNAL_TWO_WAY_SYNC_NEVER_SYNCED = 0L;

private final ContentResolver contentResolver;
private final ContentProviderClient contentProviderClient;
Expand Down Expand Up @@ -577,6 +579,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 (isInternalTwoWaySyncAndNeverSynced(ocFile)) {
OCFileExtensionsKt.createStoragePath(ocFile, user.getAccountName());
}
}

boolean sameRemotePath = fileExists(ocFile.getRemotePath());
Expand Down Expand Up @@ -622,6 +628,10 @@ public boolean saveFile(OCFile ocFile) {
return overridden;
}

private boolean isInternalTwoWaySyncAndNeverSynced(OCFile folder) {
return folder.getInternalFolderSyncTimestamp() == INTERNAL_TWO_WAY_SYNC_NEVER_SYNCED;
}

/**
* Ensures that an {@link OCFile} and all of its parent folders are stored locally.
* <p>
Expand Down
Loading
Loading