diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt index b9145d7e8769..49fdd9705a2d 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -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 @@ -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 @@ -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, @@ -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 } } @@ -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() + ) } diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt index a859e5808d56..9bfd2c1392a5 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt @@ -171,4 +171,5 @@ interface BackgroundJobManager { fun startMetadataSyncJob(currentDirPath: String) fun downloadFolder(folder: OCFile, accountName: String) fun cancelFolderDownload() + fun locallyDeleteAutoUploadedFiles(syncedFolders: List) } diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index 4a28b1715dea..946813296fa8 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -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 @@ -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" @@ -824,4 +826,33 @@ internal class BackgroundJobManagerImpl( override fun cancelFolderDownload() { workManager.cancelAllWorkByTag(JOB_DOWNLOAD_FOLDER) } + + override fun locallyDeleteAutoUploadedFiles(syncedFolders: List) { + 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 + ) + } } diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadLocalDeletionWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadLocalDeletionWorker.kt new file mode 100644 index 000000000000..4f821a8d73c2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadLocalDeletionWorker.kt @@ -0,0 +1,99 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * 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() +} diff --git a/app/src/main/java/com/owncloud/android/operations/upload/DeleteUploadedFileOperation.kt b/app/src/main/java/com/owncloud/android/operations/upload/DeleteUploadedFileOperation.kt new file mode 100644 index 000000000000..9cf91603c846 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/DeleteUploadedFileOperation.kt @@ -0,0 +1,144 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * 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(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(RemoteOperationResult.ResultCode.METADATA_NOT_FOUND) + } + val refreshedFolders = hashSetOf(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(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(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(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 + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ManageSpaceActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ManageSpaceActivity.kt index b176788af43b..6050426a8393 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ManageSpaceActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/ManageSpaceActivity.kt @@ -16,10 +16,13 @@ import com.google.android.material.snackbar.Snackbar import com.nextcloud.android.common.ui.util.extensions.applyEdgeToEdgeWithSystemBarPadding import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.di.Injectable +import com.nextcloud.client.jobs.BackgroundJobManager import com.nextcloud.client.preferences.AppPreferences import com.owncloud.android.R import com.owncloud.android.databinding.ActivityManageSpaceBinding +import com.owncloud.android.datamodel.SyncedFolderProvider import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.FilesSyncHelper import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -41,6 +44,12 @@ class ManageSpaceActivity : @Inject lateinit var viewThemeUtils: ViewThemeUtils + @Inject + lateinit var syncedFolderProvider: SyncedFolderProvider + + @Inject + lateinit var backgroundJobManager: BackgroundJobManager + private lateinit var binding: ActivityManageSpaceBinding override fun onCreate(savedInstanceState: Bundle?) { @@ -54,6 +63,7 @@ class ManageSpaceActivity : manageActivityToolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() } + generalDescription.text = getString(R.string.manage_space_description, getString(R.string.app_name)) clearDataButton.setOnClickListener { lifecycleScope.launch { @@ -61,6 +71,13 @@ class ManageSpaceActivity : } } viewThemeUtils.material.colorMaterialButtonPrimaryTonal(clearDataButton) + + autoUploadDeleteButton.setOnClickListener { + lifecycleScope.launch { + clearAutoUploadData() + } + } + viewThemeUtils.material.colorMaterialButtonPrimaryTonal(autoUploadDeleteButton) } } @@ -149,6 +166,10 @@ class ManageSpaceActivity : return dir?.delete() ?: false } + private fun clearAutoUploadData() { + FilesSyncHelper.startLocalDeletionForEnabledSyncedFolders(syncedFolderProvider, backgroundJobManager) + } + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { android.R.id.home -> { finish() diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt index 967c1995390d..61d7c7516419 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt @@ -21,6 +21,7 @@ import android.view.View import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog import androidx.drawerlayout.widget.DrawerLayout +import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager @@ -60,6 +61,7 @@ import com.owncloud.android.ui.dialog.SyncedFolderPreferencesDialogFragment import com.owncloud.android.ui.dialog.SyncedFolderPreferencesDialogFragment.OnSyncedFolderPreferenceListener import com.owncloud.android.ui.dialog.extensions.themeButtons import com.owncloud.android.ui.dialog.parcel.SyncedFolderParcelable +import com.owncloud.android.utils.FilesSyncHelper import com.owncloud.android.utils.PermissionUtil import com.owncloud.android.utils.SyncedFolderUtils import kotlinx.coroutines.Dispatchers @@ -81,6 +83,8 @@ class SyncedFoldersActivity : companion object { private const val SYNCED_FOLDER_PREFERENCES_DIALOG_TAG = "SYNCED_FOLDER_PREFERENCES_DIALOG" + private const val SYNCED_FOLDER_DELETE_UPLOADED_DIALOG_TAG = "SYNCED_FOLDER_DELETE_UPLOADED_DIALOG_TAG" + private const val SYNCED_FOLDER_DELETE_ALL_UPLOADED_DIALOG_TAG = "SYNCED_FOLDER_DELETE_ALL_UPLOADED_DIALOG_TAG" private const val SUB_FOLDER_WARNING_DIALOG_TAG = "SUB_FOLDER_WARNING_DIALOG_TAG" // yes, there is a typo in this value @@ -582,6 +586,8 @@ class SyncedFoldersActivity : result = super.onOptionsItemSelected(item) } + R.id.action_auto_upload_all_folders_delete_uploaded -> onAllSyncFolderDeleteUploadedClick() + else -> result = super.onOptionsItemSelected(item) } return result @@ -595,18 +601,72 @@ class SyncedFoldersActivity : section ) - dialogFragment?.let { folderPreferencesDialog -> - if (isDialogFragmentReady(folderPreferencesDialog) && - lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) - ) { - val fragmentTransaction = supportFragmentManager - .beginTransaction() - .addToBackStack(null) + showDialog(dialogFragment!!, SYNCED_FOLDER_PREFERENCES_DIALOG_TAG) + } - folderPreferencesDialog.show(fragmentTransaction, SYNCED_FOLDER_PREFERENCES_DIALOG_TAG) - } else { - Log_OC.d(TAG, "SyncedFolderPreferencesDialogFragment not ready") + private fun onAllSyncFolderDeleteUploadedClick() { + val dialog = ConfirmationDialogFragment.newInstance( + R.string.autoupload_delete_uploaded_all_dialog_description, + null, + R.string.autoupload_delete_uploaded_all_dialog_title, + R.drawable.selector_trashbin, + R.string.common_ok, + R.string.common_cancel, + -1 + ) + dialog.isCancelable = false + dialog.setOnConfirmationListener(object : ConfirmationDialogFragment.ConfirmationDialogFragmentListener { + override fun onConfirmation(callerTag: String?) { + FilesSyncHelper.startLocalDeletionForEnabledSyncedFolders(syncedFolderProvider, backgroundJobManager) + } + + override fun onNeutral(callerTag: String?) { + } + + override fun onCancel(callerTag: String?) { + } + }) + + showDialog(dialog, SYNCED_FOLDER_DELETE_ALL_UPLOADED_DIALOG_TAG) + } + override fun onSyncFolderDeleteUploadedClick(section: Int, syncedFolderDisplayItem: SyncedFolderDisplayItem?) { + syncedFolderDisplayItem ?: return + + val dialog = ConfirmationDialogFragment.newInstance( + R.string.autoupload_delete_uploaded_dialog_description, + null, + R.string.autoupload_delete_uploaded_dialog_title, + R.drawable.selector_trashbin, + R.string.common_ok, + R.string.common_cancel, + -1 + ) + dialog.isCancelable = false + dialog.setOnConfirmationListener(object : ConfirmationDialogFragment.ConfirmationDialogFragmentListener { + override fun onConfirmation(callerTag: String?) { + FilesSyncHelper.startLocalDeletionForSyncedFolder(syncedFolderDisplayItem, backgroundJobManager) + } + + override fun onNeutral(callerTag: String?) { + } + + override fun onCancel(callerTag: String?) { } + }) + showDialog(dialog, SYNCED_FOLDER_DELETE_UPLOADED_DIALOG_TAG) + } + + private fun showDialog(dialog: DialogFragment, tag: String) { + if (isDialogFragmentReady(dialog) && + lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) + ) { + val fragmentTransaction = supportFragmentManager + .beginTransaction() + .addToBackStack(null) + + dialog.show(fragmentTransaction, tag) + } else { + Log_OC.d(TAG, "SyncFolderDeleteUploaded dialog not ready") } } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/SyncedFolderAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/SyncedFolderAdapter.kt index 126af0068702..67b92725c408 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/SyncedFolderAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/SyncedFolderAdapter.kt @@ -40,6 +40,7 @@ import com.owncloud.android.datamodel.SyncedFolderDisplayItem import com.owncloud.android.datamodel.ThumbnailsCacheManager import com.owncloud.android.datamodel.ThumbnailsCacheManager.AsyncMediaThumbnailDrawable import com.owncloud.android.datamodel.ThumbnailsCacheManager.MediaThumbnailGenerationTask +import com.owncloud.android.utils.FileUtil import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -382,17 +383,25 @@ class SyncedFolderAdapter( menu .findItem(R.id.action_auto_upload_folder_toggle_visibility) .setChecked(item.isHidden) + updateDeletionMenuItem( + item, + menu.findItem(R.id.action_auto_upload_folder_delete_uploaded) + ) } popup.show() } private fun optionsItemSelected(menuItem: MenuItem, section: Int, item: SyncedFolderDisplayItem): Boolean { - if (menuItem.itemId == R.id.action_auto_upload_folder_toggle_visibility) { - clickListener.onVisibilityToggleClick(section, item) - } else { - // default: R.id.action_create_custom_folder - clickListener.onSyncFolderSettingsClick(section, item) + when (menuItem.itemId) { + R.id.action_auto_upload_folder_toggle_visibility -> clickListener.onVisibilityToggleClick(section, item) + + R.id.action_auto_upload_folder_settings -> clickListener.onSyncFolderSettingsClick(section, item) + + R.id.action_auto_upload_folder_delete_uploaded -> clickListener.onSyncFolderDeleteUploadedClick( + section, + item + ) } return true } @@ -508,6 +517,7 @@ class SyncedFolderAdapter( interface ClickListener { fun onSyncStatusToggleClick(section: Int, item: SyncedFolderDisplayItem?) fun onSyncFolderSettingsClick(section: Int, syncedFolderDisplayItem: SyncedFolderDisplayItem?) + fun onSyncFolderDeleteUploadedClick(section: Int, syncedFolderDisplayItem: SyncedFolderDisplayItem?) fun onVisibilityToggleClick(section: Int, item: SyncedFolderDisplayItem?) fun showSubFolderWarningDialog() } @@ -538,6 +548,17 @@ class SyncedFolderAdapter( } } + private fun updateDeletionMenuItem(syncedFolder: SyncedFolderDisplayItem, menuItem: MenuItem) { + val folderFile = syncedFolder.localPath?.let { File(it) } + lifecycleScope.launch { + val writable = FileUtil.isFolderWritable(folderFile) + withContext(Dispatchers.Main) { + menuItem.isEnabled = writable + menuItem.isVisible = writable + } + } + } + companion object { private const val VIEW_TYPE_EMPTY = Int.MAX_VALUE private const val VIEW_TYPE_ITEM = 1 diff --git a/app/src/main/java/com/owncloud/android/utils/FilesSyncHelper.kt b/app/src/main/java/com/owncloud/android/utils/FilesSyncHelper.kt index 9b18dea58a5b..f0bc12570be6 100644 --- a/app/src/main/java/com/owncloud/android/utils/FilesSyncHelper.kt +++ b/app/src/main/java/com/owncloud/android/utils/FilesSyncHelper.kt @@ -14,6 +14,7 @@ import com.nextcloud.client.device.PowerManagementService import com.nextcloud.client.jobs.BackgroundJobManager import com.nextcloud.client.jobs.upload.FileUploadHelper.Companion.instance import com.nextcloud.client.network.ConnectivityService +import com.owncloud.android.datamodel.SyncedFolder import com.owncloud.android.datamodel.SyncedFolderProvider import com.owncloud.android.datamodel.UploadsStorageManager import com.owncloud.android.lib.common.utils.Log_OC @@ -52,4 +53,18 @@ object FilesSyncHelper { } } } + + @JvmStatic + fun startLocalDeletionForEnabledSyncedFolders(provider: SyncedFolderProvider, manager: BackgroundJobManager) { + Log_OC.d(TAG, "start local deletion worker for each enabled folder") + + manager.locallyDeleteAutoUploadedFiles(provider.syncedFolders) + } + + @JvmStatic + fun startLocalDeletionForSyncedFolder(folder: SyncedFolder, manager: BackgroundJobManager) { + Log_OC.d(TAG, "start local deletion worker for folder ${folder.localPath}") + + manager.locallyDeleteAutoUploadedFiles(listOf(folder)) + } } diff --git a/app/src/main/res/layout/activity_manage_space.xml b/app/src/main/res/layout/activity_manage_space.xml index 1ef4a0dd1af3..7097badb06ab 100644 --- a/app/src/main/res/layout/activity_manage_space.xml +++ b/app/src/main/res/layout/activity_manage_space.xml @@ -26,7 +26,7 @@ android:background="@color/appbar" /> + + + + + + + + diff --git a/app/src/main/res/menu/activity_synced_folders.xml b/app/src/main/res/menu/activity_synced_folders.xml index af450aa933d2..363c68c06ea6 100644 --- a/app/src/main/res/menu/activity_synced_folders.xml +++ b/app/src/main/res/menu/activity_synced_folders.xml @@ -19,5 +19,9 @@ android:title="@string/autoupload_disable_power_save_check" android:visible="false" android:checkable="true" /> + + diff --git a/app/src/main/res/menu/synced_folders_adapter.xml b/app/src/main/res/menu/synced_folders_adapter.xml index 227769ed905b..f0752530a2ac 100644 --- a/app/src/main/res/menu/synced_folders_adapter.xml +++ b/app/src/main/res/menu/synced_folders_adapter.xml @@ -14,4 +14,8 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b6cb7d99a11..d324f039f2ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -767,6 +767,9 @@ Manage space Settings, database and server certificates from %1$s\'s data will be deleted permanently. \n\nDownloaded files will be kept untouched.\n\nThis process can take a while. Clear data + Locally delete auto uploaded files + All files already uploaded to the server from the Auto Upload folders will be deleted locally to free up space in the device.\n\nThis process can take a while. + Locally delete auto uploaded files File not found in local file system Do you really want to delete the selected items? @@ -878,8 +881,19 @@ Create new custom folder setup Hide folder Configure + Locally delete uploaded files + Local files deletion + Started to locally delete already uploaded files + Locally deleted already uploaded files + Unable to locally delete already uploaded files Configure folders + Locally delete uploaded files + All the files already uploaded to the cloud will be removed from the current device + + Locally delete uploaded files for all auto upload folders + All the files already uploaded to the cloud will be removed from the current device for all the autoupload folders + Test server connection  •