Skip to content
Draft
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
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 All @@ -34,6 +35,7 @@ import com.nextcloud.client.logger.Logger
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.client.preferences.AppPreferences
import com.owncloud.android.datamodel.ArbitraryDataProvider
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.datamodel.UploadsStorageManager
import com.owncloud.android.operations.factory.UploadFileOperationFactory
Expand All @@ -59,6 +61,7 @@ class BackgroundJobFactory @Inject constructor(
private val resources: Resources,
private val arbitraryDataProvider: ArbitraryDataProvider,
private val uploadsStorageManager: UploadsStorageManager,
private val fileDataStorageManager: FileDataStorageManager,
private val connectivityService: ConnectivityService,
private val notificationManager: NotificationManager,
private val eventBus: EventBus,
Expand Down Expand Up @@ -107,6 +110,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 +315,16 @@ class BackgroundJobFactory @Inject constructor(
localBroadcastManager.get(),
params
)

private fun createAutoUploadLocalDeletionWorker(
context: Context,
params: WorkerParameters
): AutoUploadLocalDeletionWorker = AutoUploadLocalDeletionWorker(
context = context,
params = params,
userAccountManager = accountManager,
fileDataStorageManager = fileDataStorageManager,
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,99 @@
/*
* 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 fileDataStorageManager: FileDataStorageManager,
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 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
@@ -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