Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
6705afc
feature/1475-delete-uploaded-files: Menu item and popup
daniele-verducci Aug 18, 2026
ded98a8
feature/1475-delete-uploaded-files: WIP proof of concept
daniele-verducci Aug 19, 2026
0f191b2
feature/1475-delete-uploaded-files: working deletion based only on fi…
daniele-verducci Aug 20, 2026
0bb65d7
feature/1475-delete-uploaded-files: multi folder deletion, refactoring
daniele-verducci Aug 20, 2026
b0f9866
feature/1475-delete-uploaded-files: WIP (working folder refresh)
daniele-verducci Aug 20, 2026
7b04805
feature/1475-delete-uploaded-files: Working files checks
daniele-verducci Aug 20, 2026
1f973a0
feature/1475-delete-uploaded-files: Cleanup
daniele-verducci Aug 20, 2026
ffd385a
feature/1475-delete-uploaded-files: refresh subfolders as needed
daniele-verducci Aug 21, 2026
2ac202f
feature/1475-delete-uploaded-files: Added option to system manage spa…
daniele-verducci Aug 21, 2026
96c90f3
feature/1475-delete-uploaded-files: Moved logic to background worker,…
daniele-verducci Aug 21, 2026
06d03da
feature/1475-delete-uploaded-files: Passing needed objects to backgro…
daniele-verducci Aug 21, 2026
034c1ce
feature/1475-delete-uploaded-files: notifications
daniele-verducci Aug 24, 2026
a262a0c
feature/1475-delete-uploaded-files: lint
daniele-verducci Aug 24, 2026
23ed719
feature/1475-delete-uploaded-files: Manage non-writeable folders
daniele-verducci Aug 24, 2026
cd4d928
feature/1475-delete-uploaded-files: Fix user race condition
daniele-verducci Aug 25, 2026
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
Expand Up @@ -23,6 +23,7 @@ import com.nextcloud.client.documentscan.GeneratePDFUseCase
import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
import com.nextcloud.client.integrations.deck.DeckApi
import com.nextcloud.client.jobs.autoUpload.AutoUploadHelper
import com.nextcloud.client.jobs.autoUpload.AutoUploadLocalDeletionWorker
import com.nextcloud.client.jobs.autoUpload.AutoUploadWorker
import com.nextcloud.client.jobs.autoUpload.FileSystemRepository
import com.nextcloud.client.jobs.download.FileDownloadWorker
Expand Down Expand Up @@ -107,6 +108,7 @@ class BackgroundJobFactory @Inject constructor(
InternalTwoWaySyncWork::class -> createInternalTwoWaySyncWork(context, workerParameters)
MetadataWorker::class -> createMetadataWorker(context, workerParameters)
FolderDownloadWorker::class -> createFolderDownloadWorker(context, workerParameters)
AutoUploadLocalDeletionWorker::class -> createAutoUploadLocalDeletionWorker(context, workerParameters)
else -> null // caller falls back to default factory
}
}
Expand Down Expand Up @@ -311,4 +313,15 @@ class BackgroundJobFactory @Inject constructor(
localBroadcastManager.get(),
params
)

private fun createAutoUploadLocalDeletionWorker(
context: Context,
params: WorkerParameters
): AutoUploadLocalDeletionWorker = AutoUploadLocalDeletionWorker(
context = context,
params = params,
userAccountManager = accountManager,
syncedFolderProvider = syncedFolderProvider,
viewThemeUtils = viewThemeUtils.get()
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,5 @@ interface BackgroundJobManager {
fun startMetadataSyncJob(currentDirPath: String)
fun downloadFolder(folder: OCFile, accountName: String)
fun cancelFolderDownload()
fun locallyDeleteAutoUploadedFiles(syncedFolders: List<SyncedFolder>)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import com.nextcloud.client.account.User
import com.nextcloud.client.core.Clock
import com.nextcloud.client.di.Injectable
import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
import com.nextcloud.client.jobs.autoUpload.AutoUploadLocalDeletionWorker
import com.nextcloud.client.jobs.autoUpload.AutoUploadWorker
import com.nextcloud.client.jobs.download.FileDownloadWorker
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker
Expand Down Expand Up @@ -103,6 +104,7 @@ internal class BackgroundJobManagerImpl(
const val JOB_DOWNLOAD_FOLDER = "download_folder"
const val JOB_METADATA_SYNC = "metadata_sync"
const val JOB_INTERNAL_TWO_WAY_SYNC = "internal_two_way_sync"
const val JOB_AUTO_UPLOAD_LOCAL_DELETION = "auto_upload_local_deletion"

const val JOB_TEST = "test_job"

Expand Down Expand Up @@ -824,4 +826,33 @@ internal class BackgroundJobManagerImpl(
override fun cancelFolderDownload() {
workManager.cancelAllWorkByTag(JOB_DOWNLOAD_FOLDER)
}

override fun locallyDeleteAutoUploadedFiles(syncedFolders: List<SyncedFolder>) {
val syncedFolderIDs = syncedFolders
.filter { it.isEnabled }
.map { it.id }

val arguments = Data.Builder()
.putLongArray(AutoUploadLocalDeletionWorker.SYNCED_FOLDER_IDS, syncedFolderIDs.toLongArray())
.build()

val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()

val jobName = JOB_AUTO_UPLOAD_LOCAL_DELETION + "_" + syncedFolderIDs.joinToString("-")
val request = oneTimeRequestBuilder(
jobClass = AutoUploadLocalDeletionWorker::class,
jobName = jobName
)
.setInputData(arguments)
.setConstraints(constraints)
.build()

workManager.enqueueUniqueWork(
jobName,
ExistingWorkPolicy.KEEP,
request
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Daniele Verducci <daniele.verducci@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

package com.nextcloud.client.jobs.autoUpload

import android.app.Notification
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.jobs.notification.WorkerNotificationManager
import com.owncloud.android.R
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.upload.DeleteUploadedFileOperation
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.FileUtil
import com.owncloud.android.utils.theme.ViewThemeUtils
import java.io.File

class AutoUploadLocalDeletionWorker(
private val context: Context,
params: WorkerParameters,
private val userAccountManager: UserAccountManager,
private val syncedFolderProvider: SyncedFolderProvider,
val viewThemeUtils: ViewThemeUtils
) : CoroutineWorker(context, params) {

companion object {
const val SYNCED_FOLDER_IDS = "synced_folder_IDs"
const val NOTIFICATION_ID = 267

private const val TAG = "\uD83D\uDDD1\uFE0F AutoUploadLocalDeletionWorker"
}

private val notificationManager = WorkerNotificationManager(
NOTIFICATION_ID,
context,
viewThemeUtils,
R.string.autoupload_delete_uploaded_notif_ticker,
NotificationUtils.NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS
)

override suspend fun doWork(): Result {
showNotification(
createNotification(context.getString(R.string.autoupload_delete_uploaded_notif_started_title))
)
Log_OC.d(TAG, "Started")

val syncedFolderIDs = inputData.getLongArray(SYNCED_FOLDER_IDS)
?: throw IllegalArgumentException("$SYNCED_FOLDER_IDS param is mandatory")
val syncedFolders = syncedFolderIDs
.map { syncedFolderProvider.getSyncedFolderByID(it) }

syncedFolders
.filterNotNull()
.filter { it.isEnabled }
.filter { FileUtil.isFolderWritable(File(it.localPath)) }
.forEach {
val sharedFolderOwner = userAccountManager.getUser(it.account).get()
val fileDataStorageManager = FileDataStorageManager(sharedFolderOwner, context.contentResolver)
val op = DeleteUploadedFileOperation(
it,
context,
fileDataStorageManager
)
val res = op.run()
if (res.code != RemoteOperationResult.ResultCode.OK) {
Log_OC.d(TAG, "Failed")
showNotification(
createNotification(context.getString(R.string.autoupload_delete_uploaded_notif_error_title))
)
return Result.failure()
}
}
showNotification(
createNotification(context.getString(R.string.autoupload_delete_uploaded_notif_ended_title))
)
Log_OC.d(TAG, "Success")
return Result.success()
}

private fun createNotification(title: String): Notification = notificationManager.notificationBuilder
.setContentTitle(title)
.setSmallIcon(R.drawable.ic_delete)
.setSound(null)
.setVibrate(null)
.setOnlyAlertOnce(true)
.setSilent(true)
.setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS)
.build()

private fun showNotification(notification: Notification) = notificationManager.showNotification()
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ class SyncFolderHelper(private val context: Context) {
private const val TAG = "SyncFolderHelper"
}

/**
* Computes the auto upload remote path for a given file based on the current syncedFolder folder settings.
* Note that if the user changed the syncedFolder's settings after the file was already uploaded,
* this may not reflect the actual uploaded file's path.
* @param syncedFolder containing the file
* @param file contained in the syncedFolder
* @return the remote path based on the current syncedFolder folder settings
*/
fun getAutoUploadRemotePath(syncedFolder: SyncedFolder, file: File): String {
val resources = context.resources
val isLightVersion = resources.getBoolean(R.bool.syncedFolder_light)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Daniele Verducci <daniele.verducci@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

package com.owncloud.android.operations.upload

import android.content.Context
import com.nextcloud.client.jobs.autoUpload.SyncFolderHelper
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.RefreshFolderOperation
import com.owncloud.android.utils.SyncedFolderUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File

class DeleteUploadedFileOperation(
private val syncedFolder: SyncedFolder,
private val context: Context,
private val storageManager: FileDataStorageManager
) {

companion object {
const val TAG = "DeleteUploadedFileOperation"
}
private val syncFolderHelper = SyncFolderHelper(context)

suspend fun run(): RemoteOperationResult<*> {
Log_OC.d(TAG, "StorageManager user is ${storageManager.user}")
// Obtain synced folder data
val folder = storageManager.getFileByRemotePath(syncedFolder.remotePath)
?: storageManager.getFileByLocalPath(syncedFolder.localPath)
if (folder == null) {
Log_OC.e(TAG, "Unable to obtain remote folder to refresh metadata")
return RemoteOperationResult<Any?>(RemoteOperationResult.ResultCode.METADATA_NOT_FOUND)
}

// Refresh synced folder metadata
val metadataRefreshSuccess = refreshFolder(folder, storageManager)
if (!metadataRefreshSuccess) {
Log_OC.e(TAG, "Unable to refresh folder metadata")
return RemoteOperationResult<Any?>(RemoteOperationResult.ResultCode.METADATA_NOT_FOUND)
}
val refreshedFolders = hashSetOf<String>(folder.remotePath)

val localFolder = File(syncedFolder.localPath)
val files = SyncedFolderUtils.getFileList(localFolder)
files.forEach { localFile ->
val remotePath = syncFolderHelper.getAutoUploadRemotePath(syncedFolder, localFile)
val ocFile = storageManager.getFileByRemotePath(remotePath)
?: storageManager.getFileByLocalPath(localFile.absolutePath)
if (ocFile == null) {
Log_OC.i(TAG, "Unable to compare file ${localFile.name} with its remote counterpart, leaving in place")
return@forEach
}

val parentFolderRemotePath = ocFile.parentRemotePath
if (parentFolderRemotePath !in refreshedFolders) {
// Files are stored in subfolder by date on the server.
// Refresh only subfolders containing one of the files to be checked
val subFolder = storageManager.getFileByRemotePath(parentFolderRemotePath)
if (subFolder == null) {
Log_OC.e(TAG, "Subfolder $parentFolderRemotePath not found on the server")
return RemoteOperationResult<Any?>(RemoteOperationResult.ResultCode.METADATA_NOT_FOUND)
}
val metadataRefreshSuccess = refreshFolder(subFolder, storageManager)
if (!metadataRefreshSuccess) {
Log_OC.e(TAG, "Unable to refresh folder metadata for $parentFolderRemotePath")
return RemoteOperationResult<Any?>(RemoteOperationResult.ResultCode.METADATA_NOT_FOUND)
}
}

// Check the file wasn't modified after uploading
// TODO: Is this redundant?
val localLastMod = localFile.lastModified()
val lastSyncDate = ocFile.lastSyncDateForProperties
if (lastSyncDate < localLastMod) {
Log_OC.i(
TAG,
"File ${localFile.name} has been modified ($localLastMod " +
"after it was synced ($lastSyncDate), leaving in place"
)
return@forEach
}

// Check the file has same mod date. Note that the remote mod date is rounded to the second.
val remoteLastMod = ocFile.modificationTimestamp
if (remoteLastMod / 1000 != localLastMod / 1000) {
Log_OC.i(
TAG,
"Local and remote mod date differs for file file ${localFile.name}: " +
"$localLastMod : $remoteLastMod, leaving in place"
)
return@forEach
}

// Check the file has same size
val localSize = localFile.length()
val remoteSize = ocFile.fileLength
if (localSize != remoteSize) {
Log_OC.d(
TAG,
"Local and remote file sizes differs for file ${localFile.name}: " +
"$localSize : $remoteSize, leaving in place"
)
return@forEach
}

// File deletion
val deleted = true // localFile.delete()
if (deleted) {
Log_OC.i(TAG, "Deleted file ${localFile.name}")
} else {
Log_OC.e(TAG, "Error deleting file ${localFile.name}")
}
}

return RemoteOperationResult<Any?>(RemoteOperationResult.ResultCode.OK)
}

private suspend fun refreshFolder(folder: OCFile, storageManager: FileDataStorageManager): Boolean =
withContext(Dispatchers.IO) {
val operation = RefreshFolderOperation(folder, storageManager, storageManager.user, context)
return@withContext try {
val result = operation.execute(storageManager.user, context)
if (result.isSuccess) {
Log_OC.d(TAG, "Successfully fetched metadata for: ${folder.remotePath}")
true
} else {
Log_OC.e(TAG, "Failed to fetch metadata for: ${folder.remotePath}")
false
}
} catch (e: Exception) {
Log_OC.e(TAG, "Exception refreshing folder ${folder.remotePath}: ${e.message}", e)
false
}
}
}
Loading
Loading