diff --git a/app/src/androidTest/java/com/owncloud/android/GrantFolderExistenceTests.kt b/app/src/androidTest/java/com/owncloud/android/GrantFolderExistenceTests.kt new file mode 100644 index 000000000000..a5c296340fd4 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/GrantFolderExistenceTests.kt @@ -0,0 +1,148 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android + +import com.nextcloud.client.account.UserAccountManagerImpl +import com.nextcloud.client.device.BatteryStatus +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation +import com.owncloud.android.lib.resources.files.RemoveFileRemoteOperation +import com.owncloud.android.operations.UploadFileOperation +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException + +class GrantFolderExistenceTests : AbstractOnServerIT() { + + private val root = "/autoupload/" + private val yearFolder = root + "2026/" + private val monthFolder = yearFolder + "07/" + + private val uploadsStorageManager = UploadsStorageManager( + UserAccountManagerImpl.fromContext(targetContext), + targetContext.contentResolver + ) + + private val powerManagementServiceMock = object : PowerManagementService { + override val isPowerSavingEnabled = false + override val isIgnoringOptimization = true + override val battery = BatteryStatus(false, 0) + } + + @Before + @Throws(IOException::class) + fun before() { + createDummyFiles() + } + + @Test + fun testUploadFileThenDeleteYearFolderOnServerOnlyThenUploadAgainShouldRecreateMonthFolderAndReturnOk() { + uploadAndAssertSuccess("first.txt") + + assertTrue("month folder should exist on server", existsOnServer(monthFolder)) + assertNotNull("month folder should be cached locally", storageManager.getFileByDecryptedRemotePath(monthFolder)) + + removeYearFolderOnServerOnly() + + assertFalse("month folder should be deleted", existsOnServer(monthFolder)) + assertNotNull( + "app still has a cached entry for the removed folder", + storageManager.getFileByDecryptedRemotePath(monthFolder) + ) + + // attempting upload to the same folder + val result = upload("nonEmpty.txt", monthFolder + "nonEmpty.txt") + + assertEquals( + "upload must not fail with a conflict, the missing folder has to be recreated", + RemoteOperationResult.ResultCode.OK, + result.code + ) + assertTrue("month folder should be recreated on server", existsOnServer(monthFolder)) + assertTrue("uploaded file should exist on server", existsOnServer(monthFolder + "nonEmpty.txt")) + } + + @Test + fun testUploadFileThenDeleteRootOnServerOnlyThenUploadAgainShouldRecreateAllFolderLevelsAndReturnOk() { + uploadAndAssertSuccess("first.txt") + + assertTrue( + "root folder should be removed", + RemoveFileRemoteOperation(root).execute(client).isSuccess + ) + assertFalse(existsOnServer(root)) + + val result = upload("nonEmpty.txt", monthFolder + "nonEmpty.txt") + + assertEquals( + "every missing folder level has to be recreated", + RemoteOperationResult.ResultCode.OK, + result.code + ) + assertTrue(existsOnServer(yearFolder)) + assertTrue(existsOnServer(monthFolder)) + assertTrue(existsOnServer(monthFolder + "nonEmpty.txt")) + } + + private fun uploadAndAssertSuccess(filename: String) { + val result = upload(filename, monthFolder + filename) + assertTrue(result.logMessage, result.isSuccess) + assertTrue("uploaded file should exist on server", existsOnServer(monthFolder + filename)) + } + + private fun removeYearFolderOnServerOnly() { + assertTrue( + "year folder should be removed", + RemoveFileRemoteOperation(yearFolder).execute(client).isSuccess + ) + } + + private fun existsOnServer(remotePath: String): Boolean = + ExistenceCheckRemoteOperation(remotePath, false).execute(client).isSuccess + + private fun upload(localFileName: String, remotePath: String): RemoteOperationResult<*> { + val localFile = createFile(localFileName, FILE_LINE_COUNT) + assertTrue("local file must exist before uploading", localFile.exists()) + + val ocUpload = OCUpload( + localFile.absolutePath, + remotePath, + account.name + ).apply { + isCreateRemoteFolder = true + createdBy = UploadFileOperation.CREATED_AS_INSTANT_PICTURE + } + + return UploadFileOperation( + uploadsStorageManager, + connectivityServiceMock, + powerManagementServiceMock, + user, + null, + ocUpload, + NameCollisionPolicy.ASK_USER, + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + targetContext, + false, + false, + storageManager + ).execute(client) + } + + companion object { + private const val FILE_LINE_COUNT = 100 + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt index ff897db233d6..f74e2fd7a98b 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt @@ -424,10 +424,11 @@ class AutoUploadWorker( isWhileChargingOnly = needsCharging localAction = uploadAction + isCreateRemoteFolder = true + // Only set these for new uploads if (uploadEntity == null) { createdBy = UploadFileOperation.CREATED_AS_INSTANT_PICTURE - isCreateRemoteFolder = true } } diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt index 714a7d0eb382..64ace5403da4 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt @@ -134,7 +134,16 @@ class FileUploadHelper { appScope.launch { try { - val uploads = getUploadsByStatus(null, UploadStatus.UPLOAD_FAILED, capability) + /** + * Rows are marked `UPLOAD_IN_PROGRESS` before upload starts. + * Only `updateDatabaseUploadResult()` writes status. + * If the process dies first, the row remains `UPLOAD_IN_PROGRESS` + * and is treated as a failed upload eligible for retry. + * + * e.g. AutoUploadWorker killed before completed + */ + val uploads = getUploadsByStatus(null, UploadStatus.UPLOAD_FAILED, capability) + + getUploadsByStatus(null, UploadStatus.UPLOAD_IN_PROGRESS, capability) retryUploads( uploadsStorageManager, @@ -186,6 +195,10 @@ class FileUploadHelper { val client = accountManager.createOwncloudClient() for (upload in uploads) { + if (FileUploadWorker.isUploading(upload.remotePath, upload.accountName)) { + continue + } + if (upload.isLastResultConflictError()) { client?.let { conflictHandlingResult = diff --git a/app/src/main/java/com/nextcloud/utils/extensions/UploadResultExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/UploadResultExtensions.kt index 4ca297751522..674256cd034b 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/UploadResultExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/UploadResultExtensions.kt @@ -14,7 +14,6 @@ import com.owncloud.android.db.UploadResult fun UploadResult.isNonRetryable(): Boolean = when (this) { UploadResult.FILE_NOT_FOUND, UploadResult.FILE_ERROR, - UploadResult.FOLDER_ERROR, UploadResult.CANNOT_CREATE_FILE, UploadResult.SYNC_CONFLICT, UploadResult.CONFLICT_ERROR, diff --git a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java index dd7f88d33588..5b1cef57f58f 100644 --- a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java @@ -467,10 +467,9 @@ protected RemoteOperationResult run(OwnCloudClient client) { Log_OC.d(TAG, "parent lookup for path: " + remoteParentPath + " → " + (parent == null ? "not found in DB" : "found, id=" + parent.getFileId())); - // in case of a fresh upload with subfolder, where parent does not exist yet - if (parent == null && (mFolderUnlockToken == null || mFolderUnlockToken.isEmpty())) { - Log_OC.d(TAG, "parent not in DB and no unlock token, attempting to grant folder existence: " - + remoteParentPath); + final boolean isResumingEncryptedUpload = (mFolderUnlockToken != null && !mFolderUnlockToken.isEmpty()); + if (!isResumingEncryptedUpload && (parent == null || mRemoteFolderToBeCreated)) { + Log_OC.d(TAG, "verifying remote parent folder exists: " + remoteParentPath); final var result = grantFolderExistence(remoteParentPath, client); if (!result.isSuccess()) { @@ -485,8 +484,7 @@ protected RemoteOperationResult run(OwnCloudClient client) { return new RemoteOperationResult<>(ResultCode.UNKNOWN_ERROR); } - Log_OC.d(TAG, "parent created and retrieved successfully: " + remoteParentPath + ", id=" + - parent.getFileId()); + Log_OC.d(TAG, "remote parent folder confirmed: " + remoteParentPath + ", id=" + parent.getFileId()); } if (parent == null) {