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 b2a70f7ed622..86971010cb9a 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 @@ -301,8 +301,9 @@ class AutoUploadWorker( try { // A camera app can register the file with MediaStore before it finished writing it. - if (file.isFile && file.length() == 0L) { - Log_OC.w(TAG, "skipping empty file, still being written: $localPath") + // Allow empty files unmodified for at least one hour so intentional empty files can be uploaded. + if (EmptyFileUploadPolicy.shouldDefer(file)) { + Log_OC.w(TAG, "deferring empty file until unmodified for one hour: $localPath") return@forEachIndexed } diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/EmptyFileUploadPolicy.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/EmptyFileUploadPolicy.kt new file mode 100644 index 000000000000..87189e3e58e6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/EmptyFileUploadPolicy.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.autoUpload + +import java.io.File +import kotlin.time.Duration.Companion.hours + +internal object EmptyFileUploadPolicy { + private val minimumAgeMillis = 1.hours.inWholeMilliseconds + + fun shouldDefer(file: File, nowMillis: Long = System.currentTimeMillis()): Boolean { + if (!file.isFile || file.length() != 0L) return false + + val lastModified = file.lastModified() + return lastModified <= 0L || nowMillis - lastModified < minimumAgeMillis + } +} diff --git a/app/src/test/java/com/nextcloud/client/jobs/autoUpload/EmptyFileUploadPolicyTest.kt b/app/src/test/java/com/nextcloud/client/jobs/autoUpload/EmptyFileUploadPolicyTest.kt new file mode 100644 index 000000000000..d3ecab65e479 --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/jobs/autoUpload/EmptyFileUploadPolicyTest.kt @@ -0,0 +1,114 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.autoUpload + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import java.io.File +import kotlin.time.Duration.Companion.hours + +class EmptyFileUploadPolicyTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val nowMillis = 2.hours.inWholeMilliseconds + private val oneHourMillis = 1.hours.inWholeMilliseconds + + @Test + fun recentEmptyFileIsDeferred() { + val file = emptyFile(nowMillis - oneHourMillis + 1) + + assertTrue(EmptyFileUploadPolicy.shouldDefer(file, nowMillis)) + } + + @Test + fun emptyFileIsAllowedAtExactlyOneHour() { + val file = emptyFile(nowMillis - oneHourMillis) + + assertFalse(EmptyFileUploadPolicy.shouldDefer(file, nowMillis)) + } + + @Test + fun olderEmptyFileIsAllowed() { + val file = emptyFile(nowMillis - oneHourMillis - 1) + + assertFalse(EmptyFileUploadPolicy.shouldDefer(file, nowMillis)) + } + + @Test + fun modifyingAnOldEmptyFileRestartsTheGracePeriod() { + val file = emptyFile(nowMillis - oneHourMillis) + assertFalse(EmptyFileUploadPolicy.shouldDefer(file, nowMillis)) + + assertTrue(file.setLastModified(nowMillis)) + + assertTrue(EmptyFileUploadPolicy.shouldDefer(file, nowMillis)) + } + + @Test + fun recentNonEmptyFileIsAllowed() { + val file = temporaryFolder.newFile().apply { + writeText("content") + assertTrue(setLastModified(nowMillis)) + } + + assertFalse(EmptyFileUploadPolicy.shouldDefer(file, nowMillis)) + } + + @Test + fun deferredFileIsAllowedAsSoonAsItHasContent() { + val file = emptyFile(nowMillis) + assertTrue(EmptyFileUploadPolicy.shouldDefer(file, nowMillis)) + + file.writeText("content") + assertTrue(file.setLastModified(nowMillis)) + + assertFalse(EmptyFileUploadPolicy.shouldDefer(file, nowMillis)) + } + + @Test + fun emptyFileWithFutureModificationTimeIsDeferred() { + val file = emptyFile(nowMillis + oneHourMillis) + + assertTrue(EmptyFileUploadPolicy.shouldDefer(file, nowMillis)) + } + + @Test + fun emptyFileWithUnknownModificationTimeIsDeferred() { + val file = mock { + on { isFile } doReturn true + on { length() } doReturn 0L + on { lastModified() } doReturn 0L + } + + assertTrue(EmptyFileUploadPolicy.shouldDefer(file, nowMillis)) + } + + @Test + fun missingFileIsLeftToExistingErrorHandling() { + val file = File(temporaryFolder.root, "missing.txt") + + assertFalse(EmptyFileUploadPolicy.shouldDefer(file, nowMillis)) + } + + @Test + fun directoryIsNotTreatedAsAnEmptyFile() { + val directory = temporaryFolder.newFolder() + + assertFalse(EmptyFileUploadPolicy.shouldDefer(directory, nowMillis)) + } + + private fun emptyFile(lastModified: Long): File = temporaryFolder.newFile().apply { + assertTrue(setLastModified(lastModified)) + } +}