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 b465446e3f82..eb15648e7a26 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -8,6 +8,7 @@ package com.nextcloud.client.jobs import android.provider.MediaStore import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.map import androidx.work.BackoffPolicy import androidx.work.Constraints @@ -34,10 +35,12 @@ import com.nextcloud.client.jobs.metadata.MetadataWorker import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker import com.nextcloud.client.jobs.upload.FileUploadHelper import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.jobs.worker.WorkerFilesPayload import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.utils.extensions.isWorkScheduled import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.operations.DownloadType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -71,6 +74,8 @@ internal class BackgroundJobManagerImpl( Injectable { companion object { + private const val TAG = "BackgroundJobManagerImpl" + const val TAG_ALL = "*" // This tag allows us to retrieve list of all jobs run by Nextcloud client const val JOB_CONTENT_OBSERVER = "content_observer" const val JOB_PERIODIC_CONTACTS_BACKUP = "periodic_contacts_backup" @@ -359,10 +364,13 @@ internal class BackgroundJobManagerImpl( } override fun startImmediateFilesExportJob(files: Collection): LiveData { - val ids = files.map { it.fileId }.toLongArray() + val path = WorkerFilesPayload.write(files.toList()) ?: run { + Log_OC.w(TAG, "File export was started without any file") + return MutableLiveData(null) + } val data = Data.Builder() - .putLongArray(FilesExportWork.FILES_TO_DOWNLOAD, ids) + .putString(FilesExportWork.FILES_TO_DOWNLOAD, path) .build() val request = oneTimeRequestBuilder(FilesExportWork::class, JOB_IMMEDIATE_FILES_EXPORT) diff --git a/app/src/main/java/com/nextcloud/client/jobs/FilesExportWork.kt b/app/src/main/java/com/nextcloud/client/jobs/FilesExportWork.kt index 812be5cb75c6..de69651c67e0 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/FilesExportWork.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/FilesExportWork.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2022 Tobias Kaminsky * SPDX-FileCopyrightText: 2022 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only @@ -15,153 +16,154 @@ import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import androidx.core.app.NotificationCompat -import androidx.work.Worker +import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.nextcloud.client.account.User -import com.nextcloud.client.jobs.download.FileDownloadHelper +import com.nextcloud.client.jobs.worker.WorkerFilesPayload import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.DownloadFileOperation import com.owncloud.android.operations.DownloadType import com.owncloud.android.ui.notifications.NotificationUtils import com.owncloud.android.utils.FileExportUtils import com.owncloud.android.utils.FileStorageUtils import com.owncloud.android.utils.theme.ViewThemeUtils -import java.security.SecureRandom +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class FilesExportWork( - private val appContext: Context, + private val context: Context, private val user: User, private val contentResolver: ContentResolver, private val viewThemeUtils: ViewThemeUtils, params: WorkerParameters -) : Worker(appContext, params) { +) : CoroutineWorker(context, params) { - private lateinit var storageManager: FileDataStorageManager + companion object { + private const val NOTIFICATION_ID = 179 + const val FILES_TO_DOWNLOAD = "files_to_download" + private val TAG = FilesExportWork::class.simpleName + } - override fun doWork(): Result { - val fileIDs = inputData.getLongArray(FILES_TO_DOWNLOAD) ?: LongArray(0) + private val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (fileIDs.isEmpty()) { - Log_OC.w(this, "File export was started without any file") + override suspend fun doWork(): Result { + val path = inputData.getString(FILES_TO_DOWNLOAD) + val fileIds = WorkerFilesPayload.read(path) + if (fileIds.isEmpty()) { + Log_OC.w(TAG, "file export was started without any file") + WorkerFilesPayload.cleanup(path) return Result.success() } - storageManager = FileDataStorageManager(user, contentResolver) + val storageManager = FileDataStorageManager(user, contentResolver) - val successfulExports = exportFiles(fileIDs) + try { + val (succeeded, failed) = exportFiles(fileIds, storageManager) + notificationManager.cancel(NOTIFICATION_ID) + showSummaryNotification(succeeded, failed) + } finally { + WorkerFilesPayload.cleanup(path) + } - showSuccessNotification(successfulExports) return Result.success() } - private fun exportFiles(fileIDs: LongArray): Int { - val fileDownloadHelper = FileDownloadHelper.instance() - - var successfulExports = 0 - fileIDs - .asSequence() - .map { storageManager.getFileById(it) } - .filterNotNull() - .forEach { ocFile -> - if (!FileStorageUtils.checkIfEnoughSpace(ocFile)) { - showErrorNotification(successfulExports) - return@forEach - } - - if (ocFile.isDown) { - try { - exportFile(ocFile) - } catch (e: IllegalStateException) { - Log_OC.e(TAG, "Error exporting file", e) - showErrorNotification(successfulExports) + @Suppress("DEPRECATION") + private suspend fun exportFiles(fileIDs: List, storageManager: FileDataStorageManager): Pair = + withContext(Dispatchers.IO) { + val client = runCatching { + OwnCloudClientManagerFactory.getDefaultSingleton() + .getClientFor(user.toOwnCloudAccount(), context) + }.onFailure { + Log_OC.e(TAG, "Failed to create OwnCloudClient", it) + }.getOrNull() + + val fileExportUtils = FileExportUtils() + val files = fileIDs.mapNotNull { storageManager.getFileById(it) } + val total = files.size + var succeeded = 0 + var failed = 0 + + files.forEachIndexed { index, ocFile -> + showProgressNotification(index, total, ocFile.fileName) + + val exported = when { + !FileStorageUtils.checkIfEnoughSpace(ocFile) -> false + + ocFile.isDown -> runCatching { + fileExportUtils.exportFile(ocFile.fileName, ocFile.mimeType, contentResolver, ocFile, null) + }.onFailure { Log_OC.e(TAG, "Error exporting file", it) }.isSuccess + + client != null -> downloadFile(ocFile, client) + + else -> { + Log_OC.e(TAG, "Skipping download, client unavailable: ${ocFile.remotePath}") + false } - } else { - fileDownloadHelper.downloadFile( - user, - ocFile, - downloadType = DownloadType.EXPORT - ) } - successfulExports++ + if (exported) succeeded++ else failed++ } - return successfulExports - } - - @Throws(IllegalStateException::class) - private fun exportFile(ocFile: OCFile) { - FileExportUtils().exportFile( - ocFile.fileName, - ocFile.mimeType, - contentResolver, - ocFile, - null - ) - } - private fun showErrorNotification(successfulExports: Int) { - val message = if (successfulExports == 0) { - appContext.resources.getQuantityString(R.plurals.export_failed, successfulExports, successfulExports) - } else { - appContext.resources.getQuantityString( - R.plurals.export_partially_failed, - successfulExports, - successfulExports - ) + return@withContext succeeded to failed } - showNotification(message) - } - private fun showSuccessNotification(successfulExports: Int) { - showNotification( - appContext.resources.getQuantityString( - R.plurals.export_successful, - successfulExports, - successfulExports - ) - ) + @Suppress("DEPRECATION") + private suspend fun downloadFile(file: OCFile, client: OwnCloudClient): Boolean = withContext(Dispatchers.IO) { + val operation = DownloadFileOperation(user, file, context) + operation.downloadType = DownloadType.EXPORT + return@withContext runCatching { + operation.execute(client)?.isSuccess == true + }.onFailure { + Log_OC.e(TAG, "Exception downloading file: ${file.remotePath}", it) + }.getOrDefault(false) } - private fun showNotification(message: String) { - val notificationId = SecureRandom().nextInt() + private fun showProgressNotification(current: Int, total: Int, fileName: String) { + val title = context.getString(R.string.export_in_progress, current + 1, total) - val notificationBuilder = NotificationCompat.Builder( - appContext, - NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD - ) + val notification = NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD) .setSmallIcon(R.drawable.notification_icon) - .setContentTitle(message) - .setAutoCancel(true) - - viewThemeUtils.androidx.themeNotificationCompatBuilder(appContext, notificationBuilder) + .setContentTitle(title) + .setContentText(fileName) + .setProgress(total, current + 1, false) + .setOngoing(true) + .setOnlyAlertOnce(true) + .also { viewThemeUtils.androidx.themeNotificationCompatBuilder(context, it) } + .build() + + notificationManager.notify(NOTIFICATION_ID, notification) + } - val actionIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS).apply { - flags = FLAG_ACTIVITY_NEW_TASK + private fun showSummaryNotification(succeeded: Int, failed: Int) { + val resources = context.resources + val message = when { + failed == 0 -> resources.getQuantityString(R.plurals.export_successful, succeeded, succeeded) + succeeded == 0 -> resources.getQuantityString(R.plurals.export_failed, failed, failed) + else -> resources.getQuantityString(R.plurals.export_partially_failed, succeeded, succeeded) } - val actionPendingIntent = PendingIntent.getActivity( - appContext, - notificationId, - actionIntent, - PendingIntent.FLAG_CANCEL_CURRENT or - PendingIntent.FLAG_IMMUTABLE - ) - notificationBuilder.addAction( - NotificationCompat.Action( - null, - appContext.getString(R.string.locate_folder), - actionPendingIntent - ) + + val pendingIntent = PendingIntent.getActivity( + context, + NOTIFICATION_ID, + Intent(DownloadManager.ACTION_VIEW_DOWNLOADS).apply { flags = FLAG_ACTIVITY_NEW_TASK }, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val notificationManager = appContext - .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.notify(notificationId, notificationBuilder.build()) - } + val notification = NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD) + .setSmallIcon(R.drawable.notification_icon) + .setContentTitle(message) + .setAutoCancel(true) + .addAction(NotificationCompat.Action(null, context.getString(R.string.locate_folder), pendingIntent)) + .also { viewThemeUtils.androidx.themeNotificationCompatBuilder(context, it) } + .build() - companion object { - const val FILES_TO_DOWNLOAD = "files_to_download" - private val TAG = FilesExportWork::class.simpleName + notificationManager.notify(NOTIFICATION_ID, notification) } } diff --git a/app/src/main/java/com/nextcloud/client/jobs/worker/WorkerFilesPayload.kt b/app/src/main/java/com/nextcloud/client/jobs/worker/WorkerFilesPayload.kt new file mode 100644 index 000000000000..1591f9964c3a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/worker/WorkerFilesPayload.kt @@ -0,0 +1,68 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.worker + +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.FileStorageUtils +import java.io.File + +@Suppress("ReturnCount") +object WorkerFilesPayload { + private const val TAG = "WorkerFilesPayload" + private const val FILE_PREFIX = "worker_files_payload_" + private const val FILE_SUFFIX = ".tmp" + private const val SEPARATOR = "," + + fun write(files: List): String? { + val context = MainApp.getAppContext() ?: return null + if (files.isEmpty()) return null + + val dir = File(FileStorageUtils.getAppTempDirectoryPath(context)).also { + if (!it.exists() && !it.mkdirs()) { + Log_OC.e(TAG, "Failed to create temp directory: ${it.absolutePath}") + return null + } + } + + val file = File(dir, "$FILE_PREFIX${System.currentTimeMillis()}$FILE_SUFFIX") + return runCatching { + file.writeText(files.joinToString(SEPARATOR) { it.fileId.toString() }) + file.absolutePath + }.onFailure { + Log_OC.e(TAG, "Failed to write payload file", it) + }.getOrNull() + } + + fun read(path: String?): List { + if (path.isNullOrBlank()) return listOf() + + val file = File(path) + if (!file.exists()) { + Log_OC.e(TAG, "Payload file not found: $path") + return listOf() + } + + val ids = runCatching { + file.readText() + .split(SEPARATOR) + .mapNotNull { it.toLongOrNull() } + }.onFailure { + Log_OC.e(TAG, "Failed to read payload file", it) + }.getOrNull() ?: return listOf() + + return ids + } + + fun cleanup(path: String?) { + if (path.isNullOrBlank()) return + val deleted = File(path).delete() + if (!deleted) Log_OC.w(TAG, "Failed to delete payload file: $path") + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java index d7b50d7d7176..ca0cc8be1b69 100644 --- a/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java @@ -350,4 +350,8 @@ public String getPackageName() { public DownloadType getDownloadType() { return downloadType; } + + public void setDownloadType(DownloadType type) { + downloadType = type; + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 33fac1911ebd..bb49c4f71061 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -373,6 +373,7 @@ You can upload only %d file at once. You can upload up to %d files at once. + Exporting %1$d of %2$d As of version 1.3.16, files uploaded from this device are copied into the local %1$s folder to prevent data loss when a single file is synced with multiple accounts.\n\nDue to this change, all files uploaded with earlier versions of this app were copied into the %2$s folder. However, an error prevented the completion of this operation during account synchronization. You may either leave the file(s) as is and delete the link to %3$s, or move the file(s) into the %1$s folder and retain the link to %4$s.\n\nListed below are the local file(s), and the remote file(s) in %5$s they were linked to. The folder %1$s does not exist anymore Move all