From e446b1293fab47d3b647391fa17bb273d455f46e Mon Sep 17 00:00:00 2001 From: Felix Winterhalter Date: Thu, 29 Oct 2026 09:15:44 +0100 Subject: [PATCH 1/4] fix(auto-upload): clear scan cache when a folder is disabled or deleted Disabling or deleting an auto upload folder removed its upload entries but kept its filesystem entities, which stay marked as sent for upload. When the folder is enabled again, FileSystemRepository finds no upload entry for them and treats them as finished, so every file the folder had seen is skipped forever, even with "also upload existing files" set. Remove the folder's filesystem entities together with its upload entries, so enabling the folder again starts from a fresh scan. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Felix Winterhalter --- .../com/nextcloud/client/database/dao/FileSystemDao.kt | 8 ++++++++ .../com/nextcloud/client/jobs/upload/FileUploadHelper.kt | 8 +++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.kt index 068b819e8c4b..8cf51e8a2744 100644 --- a/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.kt +++ b/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.kt @@ -36,6 +36,14 @@ interface FileSystemDao { ) suspend fun getBySyncedFolderId(syncedFolderId: String): List + @Query( + """ + DELETE FROM ${ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME} + WHERE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID} = :syncedFolderId + """ + ) + suspend fun deleteBySyncedFolderId(syncedFolderId: String) + @Query( """ SELECT COUNT(*) > 0 FROM ${ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME} 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 8d404bdf8f82..2388356b4d23 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 @@ -653,9 +653,14 @@ class FileUploadHelper { * created them, causing FileUploadWorker to keep retrying uploads for a folder that no longer * exists or is intentionally turned off, and AutoUploadWorker to re-queue already handled files * on its next scan via FileSystemRepository.getFilePathsWithIds. + * + * The folder's filesystem entities are removed as well. They are marked as sent for upload, and once their + * upload entries are gone FileSystemRepository treats them as finished, so every file the folder had seen + * would be skipped forever after re-enabling it, even with "also upload existing files". */ suspend fun removeEntityFromUploadEntities(id: Long) { - uploadsStorageManager.fileSystemDao.getBySyncedFolderId(id.toString()) + val fileSystemDao = uploadsStorageManager.fileSystemDao + fileSystemDao.getBySyncedFolderId(id.toString()) .filter { it.localPath != null && it.remotePath != null } .forEach { Log_OC.d( @@ -667,6 +672,7 @@ class FileUploadHelper { remotePath = it.remotePath!! ) } + fileSystemDao.deleteBySyncedFolderId(id.toString()) } /** From e937c25306b90ef704ad69f3dfaa24d6d76e7bf1 Mon Sep 17 00:00:00 2001 From: Felix Winterhalter Date: Thu, 29 Oct 2026 09:15:44 +0100 Subject: [PATCH 2/4] fix(auto-upload): keep enabled timestamp when saving folder settings Saving a folder's settings, and toggling its visibility, stored the current time as its enabled timestamp, because list items were built with the current time and the settings dialog always reset it. Files created before that save which were not scanned yet then count as existing files and are skipped when "also upload existing files" is off. Keep the stored timestamp and only start a new one when the folder is switched on. Also read the column as Long in SyncedFolderEntity, a millisecond timestamp does not fit into an Int. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Felix Winterhalter --- .../database/entity/SyncedFolderEntity.kt | 4 +- .../data/AutoUploadDisplayItemFactory.kt | 8 +- .../data/AutoUploadDisplayItemFactoryTest.kt | 104 ++++++++++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/com/owncloud/android/ui/fragment/autoUpload/data/AutoUploadDisplayItemFactoryTest.kt diff --git a/app/src/main/java/com/nextcloud/client/database/entity/SyncedFolderEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/SyncedFolderEntity.kt index 18b0911209ed..584fbe2ed022 100644 --- a/app/src/main/java/com/nextcloud/client/database/entity/SyncedFolderEntity.kt +++ b/app/src/main/java/com/nextcloud/client/database/entity/SyncedFolderEntity.kt @@ -33,7 +33,7 @@ data class SyncedFolderEntity( @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_ENABLED) val enabled: Int?, @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_ENABLED_TIMESTAMP_MS) - val enabledTimestampMs: Int?, + val enabledTimestampMs: Long?, @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_SUBFOLDER_BY_DATE) val subfolderByDate: Int?, @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_ACCOUNT) @@ -78,7 +78,7 @@ fun SyncedFolderEntity.toSyncedFolder(): SyncedFolder = SyncedFolder( // enabled this.enabled == 1, // timestampMs - (this.enabledTimestampMs ?: SyncedFolder.EMPTY_ENABLED_TIMESTAMP_MS).toLong(), + this.enabledTimestampMs ?: SyncedFolder.EMPTY_ENABLED_TIMESTAMP_MS, // type MediaFolderType.getById(this.type ?: MediaFolderType.CUSTOM.id), // hidden diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/data/AutoUploadDisplayItemFactory.kt b/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/data/AutoUploadDisplayItemFactory.kt index 26efc5256347..002322023496 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/data/AutoUploadDisplayItemFactory.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/data/AutoUploadDisplayItemFactory.kt @@ -122,7 +122,11 @@ class AutoUploadDisplayItemFactory( isSubfolderByDate = syncedFolder.isSubfolderByDate uploadAction = syncedFolder.uploadAction setNameCollisionPolicy(syncedFolder.nameCollisionPolicy.serialize()) - setEnabled(syncedFolder.isEnabled, clock.currentTime) + // only a folder being switched on starts a new "enabled since" period, files from before it count as + // existing files and are skipped unless "also upload existing files" is set + if (syncedFolder.isEnabled != isEnabled) { + setEnabled(syncedFolder.isEnabled, clock.currentTime) + } setSubFolderRule(syncedFolder.subFolderRule) setExcludeHidden(syncedFolder.isExcludeHidden) } @@ -146,7 +150,7 @@ class AutoUploadDisplayItemFactory( syncedFolder.uploadAction, syncedFolder.nameCollisionPolicyInt, syncedFolder.isEnabled, - clock.currentTime, + syncedFolder.enabledTimestampMs, filePaths, folderName, numberOfFiles, diff --git a/app/src/test/java/com/owncloud/android/ui/fragment/autoUpload/data/AutoUploadDisplayItemFactoryTest.kt b/app/src/test/java/com/owncloud/android/ui/fragment/autoUpload/data/AutoUploadDisplayItemFactoryTest.kt new file mode 100644 index 000000000000..0163fa4b7837 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/ui/fragment/autoUpload/data/AutoUploadDisplayItemFactoryTest.kt @@ -0,0 +1,104 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.fragment.autoUpload.data + +import com.nextcloud.client.core.Clock +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.preferences.SubFolderRule +import com.owncloud.android.datamodel.MediaFolder +import com.owncloud.android.datamodel.MediaFolderType +import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.datamodel.SyncedFolderDisplayItem +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.ui.dialog.parcel.SyncedFolderParcelable +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@Suppress("MagicNumber") +class AutoUploadDisplayItemFactoryTest { + + private val enabledAt = 1_000_000_000_000L + private val now = 2_000_000_000_000L + + private val clock: Clock = mockk() + private lateinit var factory: AutoUploadDisplayItemFactory + + @Before + fun setup() { + every { clock.currentTime } returns now + factory = AutoUploadDisplayItemFactory(clock, 4, "/InstantUpload") + } + + private fun createItem(enabled: Boolean, timestampMs: Long = enabledAt) = SyncedFolderDisplayItem( + 1L, + "/storage/emulated/0/DCIM/Camera", + "/InstantUpload/Camera", + true, + false, + false, + false, + "account", + FileUploadWorker.LOCAL_BEHAVIOUR_FORGET, + NameCollisionPolicy.ASK_USER.serialize(), + enabled, + timestampMs, + "Camera", + MediaFolderType.CUSTOM, + false, + SubFolderRule.YEAR_MONTH, + false, + SyncedFolder.NOT_SCANNED_YET + ) + + @Test + fun `saving settings of an enabled folder keeps its enabled timestamp`() { + val item = createItem(enabled = true) + val parcelable = SyncedFolderParcelable(item, 0).apply { isWifiOnly = false } + + factory.updateAutoUploadFolderItem(item, parcelable) + + assertEquals(enabledAt, item.enabledTimestampMs) + } + + @Test + fun `enabling a folder through its settings starts a new enabled period`() { + val item = createItem(enabled = false) + val parcelable = SyncedFolderParcelable(item, 0).apply { isEnabled = true } + + factory.updateAutoUploadFolderItem(item, parcelable) + + assertEquals(now, item.enabledTimestampMs) + } + + @Test + fun `disabling a folder through its settings clears its enabled timestamp`() { + val item = createItem(enabled = true) + val parcelable = SyncedFolderParcelable(item, 0).apply { isEnabled = false } + + factory.updateAutoUploadFolderItem(item, parcelable) + + assertEquals(SyncedFolder.EMPTY_ENABLED_TIMESTAMP_MS, item.enabledTimestampMs) + } + + @Test + fun `display item of a stored folder keeps the stored enabled timestamp`() { + val stored = createItem(enabled = true) + val mediaFolder = MediaFolder().apply { + folderName = "Camera" + absolutePath = stored.localPath + filePaths = emptyList() + type = MediaFolderType.IMAGE + } + + val item = factory.createAutoUploadFolder(stored, mediaFolder) + + assertEquals(enabledAt, item.enabledTimestampMs) + } +} From 6758178485d288ac4de3b32198a99f6da256e88a Mon Sep 17 00:00:00 2001 From: Felix Winterhalter Date: Thu, 29 Oct 2026 20:31:12 +0100 Subject: [PATCH 3/4] fix(auto-upload): clear scan cache when a folder is enabled again Review feedback: - Clear the folder's filesystem entities right before it is enabled again and its scan starts, instead of at the end of the disable cleanup. Cleanup and re-enabling run on one sequential dispatcher, so switching a folder off and on quickly cannot scan before the cleanup finished or have the fresh scan removed by it. - Enabling and disabling a folder in its settings dialog now does the same as the toggle on the list. - Revert the SyncedFolderEntity type change, it is not needed for the fix. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Felix Winterhalter --- .../database/entity/SyncedFolderEntity.kt | 4 +-- .../client/jobs/upload/FileUploadHelper.kt | 19 +++++++---- .../handler/AutoUploadClickHandler.kt | 34 ++++++++++++++++--- 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/database/entity/SyncedFolderEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/SyncedFolderEntity.kt index 584fbe2ed022..18b0911209ed 100644 --- a/app/src/main/java/com/nextcloud/client/database/entity/SyncedFolderEntity.kt +++ b/app/src/main/java/com/nextcloud/client/database/entity/SyncedFolderEntity.kt @@ -33,7 +33,7 @@ data class SyncedFolderEntity( @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_ENABLED) val enabled: Int?, @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_ENABLED_TIMESTAMP_MS) - val enabledTimestampMs: Long?, + val enabledTimestampMs: Int?, @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_SUBFOLDER_BY_DATE) val subfolderByDate: Int?, @ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_ACCOUNT) @@ -78,7 +78,7 @@ fun SyncedFolderEntity.toSyncedFolder(): SyncedFolder = SyncedFolder( // enabled this.enabled == 1, // timestampMs - this.enabledTimestampMs ?: SyncedFolder.EMPTY_ENABLED_TIMESTAMP_MS, + (this.enabledTimestampMs ?: SyncedFolder.EMPTY_ENABLED_TIMESTAMP_MS).toLong(), // type MediaFolderType.getById(this.type ?: MediaFolderType.CUSTOM.id), // hidden 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 2388356b4d23..2710a823bc6a 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 @@ -653,14 +653,9 @@ class FileUploadHelper { * created them, causing FileUploadWorker to keep retrying uploads for a folder that no longer * exists or is intentionally turned off, and AutoUploadWorker to re-queue already handled files * on its next scan via FileSystemRepository.getFilePathsWithIds. - * - * The folder's filesystem entities are removed as well. They are marked as sent for upload, and once their - * upload entries are gone FileSystemRepository treats them as finished, so every file the folder had seen - * would be skipped forever after re-enabling it, even with "also upload existing files". */ suspend fun removeEntityFromUploadEntities(id: Long) { - val fileSystemDao = uploadsStorageManager.fileSystemDao - fileSystemDao.getBySyncedFolderId(id.toString()) + uploadsStorageManager.fileSystemDao.getBySyncedFolderId(id.toString()) .filter { it.localPath != null && it.remotePath != null } .forEach { Log_OC.d( @@ -672,7 +667,17 @@ class FileUploadHelper { remotePath = it.remotePath!! ) } - fileSystemDao.deleteBySyncedFolderId(id.toString()) + } + + /** + * Forgets which files of a synced folder were already seen, so its next scan starts from scratch. + * + * Must run before a folder is enabled again: its filesystem entities stay marked as sent for upload, and once + * [removeEntityFromUploadEntities] removed their upload entities, FileSystemRepository.wasUploadFinished treats + * them as finished, so these files would be skipped forever, even with "also upload existing files". + */ + suspend fun clearAutoUploadScanCache(id: Long) { + uploadsStorageManager.fileSystemDao.deleteBySyncedFolderId(id.toString()) } /** diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadClickHandler.kt b/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadClickHandler.kt index d6f32116f547..7271de8036d6 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadClickHandler.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadClickHandler.kt @@ -40,6 +40,10 @@ class AutoUploadClickHandler( private val fileUploadHelper = FileUploadHelper.instance() + // runs cleanup and re-enabling of folders in the order they were requested, so a folder switched off and on + // quickly cannot be scanned before its cleanup finished or have its fresh scan removed by that cleanup + private val folderStateDispatcher = Dispatchers.IO.limitedParallelism(1) + fun onCreateCustomFolderClick(activity: AppCompatActivity, accountName: String) { Log_OC.d(TAG, "Show custom folder dialog") if (!PermissionUtil.checkStoragePermission(activity)) { @@ -60,14 +64,21 @@ class AutoUploadClickHandler( if (item.isEnabled) { Log_OC.d(TAG, "auto-upload configuration sync status is enabled: " + item.remotePath) - backgroundJobManager.startAutoUpload(item, overridePowerSaving = false) + fragment.lifecycleScope.launch(folderStateDispatcher) { + fileUploadHelper.clearAutoUploadScanCache(item.id) + backgroundJobManager.startAutoUpload(item, overridePowerSaving = false) + } dialogs.showBatteryOptimizationDialogIfNeeded() return } Log_OC.d(TAG, "auto-upload configuration sync status is disabled: " + item.remotePath) - fragment.lifecycleScope.launch(Dispatchers.IO) { - fileUploadHelper.removeEntityFromUploadEntities(item.id) + removeUploadEntities(item.id) + } + + private fun removeUploadEntities(syncedFolderId: Long) { + fragment.lifecycleScope.launch(folderStateDispatcher) { + fileUploadHelper.removeEntityFromUploadEntities(syncedFolderId) } } @@ -135,8 +146,23 @@ class AutoUploadClickHandler( private fun updateExistingFolder(syncedFolder: SyncedFolderParcelable): Boolean { val adapter = getAdapter() val item = adapter.get(syncedFolder.section) ?: return false + val wasEnabled = item.isEnabled itemFactory.updateAutoUploadFolderItem(item, syncedFolder) - persistence.saveOrUpdateAutoUploadFolder(item) + + when { + !wasEnabled && item.isEnabled -> fragment.lifecycleScope.launch(folderStateDispatcher) { + fileUploadHelper.clearAutoUploadScanCache(item.id) + persistence.saveOrUpdateAutoUploadFolder(item) + } + + wasEnabled && !item.isEnabled -> { + persistence.saveOrUpdateAutoUploadFolder(item) + removeUploadEntities(item.id) + } + + else -> persistence.saveOrUpdateAutoUploadFolder(item) + } + adapter.notifyItemChanged(adapter.getSectionHeaderIndex(syncedFolder.section)) return true } From e209985a5c913c66cadac8daa17cbe485b537414 Mon Sep 17 00:00:00 2001 From: Felix Winterhalter Date: Fri, 30 Oct 2026 22:59:53 +0100 Subject: [PATCH 4/4] chore(auto-upload): remove comments Co-Authored-By: Claude Opus 5.5 Signed-off-by: Felix Winterhalter --- .../com/nextcloud/client/jobs/upload/FileUploadHelper.kt | 7 ------- .../autoUpload/data/AutoUploadDisplayItemFactory.kt | 2 -- .../fragment/autoUpload/handler/AutoUploadClickHandler.kt | 2 -- 3 files changed, 11 deletions(-) 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 2710a823bc6a..026b4b9b809d 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 @@ -669,13 +669,6 @@ class FileUploadHelper { } } - /** - * Forgets which files of a synced folder were already seen, so its next scan starts from scratch. - * - * Must run before a folder is enabled again: its filesystem entities stay marked as sent for upload, and once - * [removeEntityFromUploadEntities] removed their upload entities, FileSystemRepository.wasUploadFinished treats - * them as finished, so these files would be skipped forever, even with "also upload existing files". - */ suspend fun clearAutoUploadScanCache(id: Long) { uploadsStorageManager.fileSystemDao.deleteBySyncedFolderId(id.toString()) } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/data/AutoUploadDisplayItemFactory.kt b/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/data/AutoUploadDisplayItemFactory.kt index 002322023496..50d287e4dd7a 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/data/AutoUploadDisplayItemFactory.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/data/AutoUploadDisplayItemFactory.kt @@ -122,8 +122,6 @@ class AutoUploadDisplayItemFactory( isSubfolderByDate = syncedFolder.isSubfolderByDate uploadAction = syncedFolder.uploadAction setNameCollisionPolicy(syncedFolder.nameCollisionPolicy.serialize()) - // only a folder being switched on starts a new "enabled since" period, files from before it count as - // existing files and are skipped unless "also upload existing files" is set if (syncedFolder.isEnabled != isEnabled) { setEnabled(syncedFolder.isEnabled, clock.currentTime) } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadClickHandler.kt b/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadClickHandler.kt index 7271de8036d6..30e665d920e6 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadClickHandler.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadClickHandler.kt @@ -40,8 +40,6 @@ class AutoUploadClickHandler( private val fileUploadHelper = FileUploadHelper.instance() - // runs cleanup and re-enabling of folders in the order they were requested, so a folder switched off and on - // quickly cannot be scanned before its cleanup finished or have its fresh scan removed by that cleanup private val folderStateDispatcher = Dispatchers.IO.limitedParallelism(1) fun onCreateCustomFolderClick(activity: AppCompatActivity, accountName: String) {