main branch updated

This commit is contained in:
Fr4nz D13trich 2025-11-20 16:16:40 +01:00
parent 3d33d3fe49
commit 9a05dc1657
353 changed files with 16802 additions and 2995 deletions

View file

@ -17,14 +17,17 @@ import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.core.Clock
import com.nextcloud.client.device.DeviceInfo
import com.nextcloud.client.database.NextcloudDatabase
import com.nextcloud.client.device.PowerManagementService
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.AutoUploadWorker
import com.nextcloud.client.jobs.autoUpload.FileSystemRepository
import com.nextcloud.client.jobs.download.FileDownloadWorker
import com.nextcloud.client.jobs.metadata.MetadataWorker
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker
import com.nextcloud.client.jobs.upload.FileUploadWorker
import com.nextcloud.client.logger.Logger
import com.nextcloud.client.network.ConnectivityService
@ -50,7 +53,6 @@ class BackgroundJobFactory @Inject constructor(
private val clock: Clock,
private val powerManagementService: PowerManagementService,
private val backgroundJobManager: Provider<BackgroundJobManager>,
private val deviceInfo: DeviceInfo,
private val accountManager: UserAccountManager,
private val resources: Resources,
private val arbitraryDataProvider: ArbitraryDataProvider,
@ -62,7 +64,8 @@ class BackgroundJobFactory @Inject constructor(
private val viewThemeUtils: Provider<ViewThemeUtils>,
private val localBroadcastManager: Provider<LocalBroadcastManager>,
private val generatePdfUseCase: GeneratePDFUseCase,
private val syncedFolderProvider: SyncedFolderProvider
private val syncedFolderProvider: SyncedFolderProvider,
private val database: NextcloudDatabase
) : WorkerFactory() {
@SuppressLint("NewApi")
@ -84,7 +87,7 @@ class BackgroundJobFactory @Inject constructor(
when (workerClass) {
ContactsBackupWork::class -> createContactsBackupWork(context, workerParameters)
ContactsImportWork::class -> createContactsImportWork(context, workerParameters)
FilesSyncWork::class -> createFilesSyncWork(context, workerParameters)
AutoUploadWorker::class -> createFilesSyncWork(context, workerParameters)
OfflineSyncWork::class -> createOfflineSyncWork(context, workerParameters)
MediaFoldersDetectionWork::class -> createMediaFoldersDetectionWork(context, workerParameters)
NotificationWork::class -> createNotificationWork(context, workerParameters)
@ -100,6 +103,7 @@ class BackgroundJobFactory @Inject constructor(
OfflineOperationsWorker::class -> createOfflineOperationsWorker(context, workerParameters)
InternalTwoWaySyncWork::class -> createInternalTwoWaySyncWork(context, workerParameters)
MetadataWorker::class -> createMetadataWorker(context, workerParameters)
FolderDownloadWorker::class -> createFolderDownloadWorker(context, workerParameters)
else -> null // caller falls back to default factory
}
}
@ -166,16 +170,16 @@ class BackgroundJobFactory @Inject constructor(
contentResolver
)
private fun createFilesSyncWork(context: Context, params: WorkerParameters): FilesSyncWork = FilesSyncWork(
private fun createFilesSyncWork(context: Context, params: WorkerParameters): AutoUploadWorker = AutoUploadWorker(
context = context,
params = params,
contentResolver = contentResolver,
userAccountManager = accountManager,
uploadsStorageManager = uploadsStorageManager,
connectivityService = connectivityService,
powerManagementService = powerManagementService,
syncedFolderProvider = syncedFolderProvider,
backgroundJobManager = backgroundJobManager.get()
backgroundJobManager = backgroundJobManager.get(),
repository = FileSystemRepository(dao = database.fileSystemDao())
)
private fun createOfflineSyncWork(context: Context, params: WorkerParameters): OfflineSyncWork = OfflineSyncWork(
@ -285,4 +289,12 @@ class BackgroundJobFactory @Inject constructor(
params,
accountManager.user
)
private fun createFolderDownloadWorker(context: Context, params: WorkerParameters): FolderDownloadWorker =
FolderDownloadWorker(
accountManager,
context,
viewThemeUtils.get(),
params
)
}

View file

@ -10,6 +10,7 @@ import androidx.lifecycle.LiveData
import androidx.work.ListenableWorker
import com.nextcloud.client.account.User
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.operations.DownloadType
/**
@ -119,15 +120,12 @@ interface BackgroundJobManager {
fun startImmediateFilesExportJob(files: Collection<OCFile>): LiveData<JobInfo?>
fun schedulePeriodicFilesSyncJob(syncedFolderID: Long)
fun schedulePeriodicFilesSyncJob(syncedFolder: SyncedFolder)
/**
* Immediately start File Sync job for given syncFolderID.
*/
fun startImmediateFilesSyncJob(
syncedFolderID: Long,
fun startAutoUploadImmediately(
syncedFolder: SyncedFolder,
overridePowerSaving: Boolean = false,
changedFiles: Array<String?> = arrayOf<String?>()
contentUris: Array<String?> = arrayOf()
)
fun cancelTwoWaySyncJob()
@ -142,12 +140,10 @@ interface BackgroundJobManager {
fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean)
fun getFileUploads(user: User): LiveData<List<JobInfo>>
fun cancelFilesUploadJob(user: User)
fun isStartFileUploadJobScheduled(user: User): Boolean
fun isStartFileUploadJobScheduled(accountName: String): Boolean
fun cancelFilesDownloadJob(user: User, fileId: Long)
fun isStartFileDownloadJobScheduled(user: User, fileId: Long): Boolean
@Suppress("LongParameterList")
fun startFileDownloadJob(
user: User,
@ -175,4 +171,6 @@ interface BackgroundJobManager {
fun scheduleInternal2WaySync(intervalMinutes: Long)
fun cancelAllFilesDownloadJobs()
fun startMetadataSyncJob(currentDirPath: String)
fun downloadFolder(folder: OCFile, accountName: String)
fun cancelFolderDownload()
}

View file

@ -26,7 +26,9 @@ 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.AutoUploadWorker
import com.nextcloud.client.jobs.download.FileDownloadWorker
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker
import com.nextcloud.client.jobs.metadata.MetadataWorker
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker
import com.nextcloud.client.jobs.upload.FileUploadHelper
@ -35,6 +37,7 @@ import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.utils.extensions.isWorkRunning
import com.nextcloud.utils.extensions.isWorkScheduled
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.operations.DownloadType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -91,6 +94,7 @@ internal class BackgroundJobManagerImpl(
const val JOB_PERIODIC_OFFLINE_OPERATIONS = "periodic_offline_operations"
const val JOB_PERIODIC_HEALTH_STATUS = "periodic_health_status"
const val JOB_IMMEDIATE_HEALTH_STATUS = "immediate_health_status"
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"
@ -472,41 +476,68 @@ internal class BackgroundJobManagerImpl(
)
}
override fun schedulePeriodicFilesSyncJob(syncedFolderID: Long) {
override fun schedulePeriodicFilesSyncJob(syncedFolder: SyncedFolder) {
val syncedFolderID = syncedFolder.id
val arguments = Data.Builder()
.putLong(FilesSyncWork.SYNCED_FOLDER_ID, syncedFolderID)
.putLong(AutoUploadWorker.SYNCED_FOLDER_ID, syncedFolderID)
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresCharging(syncedFolder.isChargingOnly)
.build()
val request = periodicRequestBuilder(
jobClass = FilesSyncWork::class,
jobClass = AutoUploadWorker::class,
jobName = JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID,
intervalMins = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES
intervalMins = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES,
constraints = constraints
)
.setBackoffCriteria(
BackoffPolicy.LINEAR,
DEFAULT_BACKOFF_CRITERIA_DELAY_SEC,
TimeUnit.SECONDS
)
.setInputData(arguments)
.build()
workManager.enqueueUniquePeriodicWork(
JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID,
ExistingPeriodicWorkPolicy.REPLACE,
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
override fun startImmediateFilesSyncJob(
syncedFolderID: Long,
override fun startAutoUploadImmediately(
syncedFolder: SyncedFolder,
overridePowerSaving: Boolean,
changedFiles: Array<String?>
contentUris: Array<String?>
) {
val syncedFolderID = syncedFolder.id
val arguments = Data.Builder()
.putBoolean(FilesSyncWork.OVERRIDE_POWER_SAVING, overridePowerSaving)
.putStringArray(FilesSyncWork.CHANGED_FILES, changedFiles)
.putLong(FilesSyncWork.SYNCED_FOLDER_ID, syncedFolderID)
.putBoolean(AutoUploadWorker.OVERRIDE_POWER_SAVING, overridePowerSaving)
.putStringArray(AutoUploadWorker.CONTENT_URIS, contentUris)
.putLong(AutoUploadWorker.SYNCED_FOLDER_ID, syncedFolderID)
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresCharging(syncedFolder.isChargingOnly)
.build()
val request = oneTimeRequestBuilder(
jobClass = FilesSyncWork::class,
jobClass = AutoUploadWorker::class,
jobName = JOB_IMMEDIATE_FILES_SYNC + "_" + syncedFolderID
)
.setInputData(arguments)
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.LINEAR,
DEFAULT_BACKOFF_CRITERIA_DELAY_SEC,
TimeUnit.SECONDS
)
.build()
workManager.enqueueUniqueWork(
@ -606,10 +637,10 @@ internal class BackgroundJobManagerImpl(
workManager.enqueue(request)
}
private fun startFileUploadJobTag(user: User): String = JOB_FILES_UPLOAD + user.accountName
private fun startFileUploadJobTag(accountName: String): String = JOB_FILES_UPLOAD + accountName
override fun isStartFileUploadJobScheduled(user: User): Boolean =
workManager.isWorkScheduled(startFileUploadJobTag(user))
override fun isStartFileUploadJobScheduled(accountName: String): Boolean =
workManager.isWorkScheduled(startFileUploadJobTag(accountName))
/**
* This method supports initiating uploads for various scenarios, including:
@ -627,7 +658,7 @@ internal class BackgroundJobManagerImpl(
defaultDispatcherScope.launch {
val batchSize = FileUploadHelper.MAX_FILE_COUNT
val batches = uploadIds.toList().chunked(batchSize)
val tag = startFileUploadJobTag(user)
val tag = startFileUploadJobTag(user.accountName)
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
@ -673,9 +704,6 @@ internal class BackgroundJobManagerImpl(
private fun startFileDownloadJobTag(user: User, fileId: Long): String =
JOB_FOLDER_DOWNLOAD + user.accountName + fileId
override fun isStartFileDownloadJobScheduled(user: User, fileId: Long): Boolean =
workManager.isWorkScheduled(startFileDownloadJobTag(user, fileId))
override fun startFileDownloadJob(
user: User,
file: OCFile,
@ -795,4 +823,28 @@ internal class BackgroundJobManagerImpl(
workManager.enqueueUniquePeriodicWork(JOB_INTERNAL_TWO_WAY_SYNC, ExistingPeriodicWorkPolicy.UPDATE, request)
}
override fun downloadFolder(folder: OCFile, accountName: String) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresStorageNotLow(true)
.build()
val data = Data.Builder()
.putLong(FolderDownloadWorker.FOLDER_ID, folder.fileId)
.putString(FolderDownloadWorker.ACCOUNT_NAME, accountName)
.build()
val request = oneTimeRequestBuilder(FolderDownloadWorker::class, JOB_DOWNLOAD_FOLDER)
.addTag(JOB_DOWNLOAD_FOLDER)
.setInputData(data)
.setConstraints(constraints)
.build()
workManager.enqueueUniqueWork(JOB_DOWNLOAD_FOLDER, ExistingWorkPolicy.APPEND_OR_REPLACE, request)
}
override fun cancelFolderDownload() {
workManager.cancelAllWorkByTag(JOB_DOWNLOAD_FOLDER)
}
}

View file

@ -1,18 +1,27 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2019 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
import android.app.Notification
import android.content.Context
import androidx.work.Worker
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.utils.ForegroundServiceHelper
import com.owncloud.android.R
import com.owncloud.android.datamodel.ForegroundServiceType
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.FilesSyncHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* This work is triggered when OS detects change in media folders.
@ -21,53 +30,113 @@ import com.owncloud.android.utils.FilesSyncHelper
*
* This job must not be started on API < 24.
*/
@Suppress("TooGenericExceptionCaught")
class ContentObserverWork(
appContext: Context,
private val context: Context,
private val params: WorkerParameters,
private val syncedFolderProvider: SyncedFolderProvider,
private val powerManagementService: PowerManagementService,
private val backgroundJobManager: BackgroundJobManager
) : Worker(appContext, params) {
) : CoroutineWorker(context, params) {
override fun doWork(): Result {
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
if (params.triggeredContentUris.isNotEmpty()) {
Log_OC.d(TAG, "File-sync Content Observer detected files change")
checkAndStartFileSyncJob()
backgroundJobManager.startMediaFoldersDetectionJob()
} else {
Log_OC.d(TAG, "triggeredContentUris empty")
}
recheduleSelf()
val result = Result.success()
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
return result
companion object {
private const val TAG = "🔍" + "ContentObserverWork"
private const val CHANNEL_ID = NotificationUtils.NOTIFICATION_CHANNEL_CONTENT_OBSERVER
private const val NOTIFICATION_ID = 774
}
private fun recheduleSelf() {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val workerName = BackgroundJobManagerImpl.formatClassTag(this@ContentObserverWork::class)
backgroundJobManager.logStartOfWorker(workerName)
Log_OC.d(TAG, "started")
try {
if (params.triggeredContentUris.isNotEmpty()) {
Log_OC.d(TAG, "📸 content observer detected file changes.")
val notificationTitle = context.getString(R.string.content_observer_work_notification_title)
val notification = createNotification(notificationTitle)
updateForegroundInfo(notification)
checkAndTriggerAutoUpload()
// prevent worker fail because of another worker
try {
backgroundJobManager.startMediaFoldersDetectionJob()
} catch (e: Exception) {
Log_OC.d(TAG, "⚠️ media folder detection job failed :$e")
}
} else {
Log_OC.d(TAG, "⚠️ triggeredContentUris is empty — nothing to sync.")
}
rescheduleSelf()
val result = Result.success()
backgroundJobManager.logEndOfWorker(workerName, result)
Log_OC.d(TAG, "finished")
result
} catch (e: Exception) {
Log_OC.e(TAG, "❌ Exception in ContentObserverWork: ${e.message}", e)
Result.retry()
}
}
private suspend fun updateForegroundInfo(notification: Notification) {
val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
NOTIFICATION_ID,
notification,
ForegroundServiceType.DataSync
)
setForeground(foregroundInfo)
}
private fun createNotification(title: String): Notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setSmallIcon(R.drawable.ic_find_in_page)
.setOngoing(true)
.setSound(null)
.setVibrate(null)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setSilent(true)
.build()
/**
* Re-schedules this observer to ensure continuous monitoring of media changes.
*/
private fun rescheduleSelf() {
Log_OC.d(TAG, "🔁 Rescheduling ContentObserverWork for continued observation.")
backgroundJobManager.scheduleContentObserverJob()
}
private fun checkAndStartFileSyncJob() {
if (!powerManagementService.isPowerSavingEnabled && syncedFolderProvider.countEnabledSyncedFolders() > 0) {
val changedFiles = mutableListOf<String>()
for (uri in params.triggeredContentUris) {
changedFiles.add(uri.toString())
}
FilesSyncHelper.startFilesSyncForAllFolders(
private suspend fun checkAndTriggerAutoUpload() = withContext(Dispatchers.IO) {
if (powerManagementService.isPowerSavingEnabled) {
Log_OC.w(TAG, "⚡ Power saving mode active — skipping file sync.")
return@withContext
}
val enabledFoldersCount = syncedFolderProvider.countEnabledSyncedFolders()
if (enabledFoldersCount <= 0) {
Log_OC.w(TAG, "🚫 No enabled synced folders found — skipping file sync.")
return@withContext
}
val contentUris = params.triggeredContentUris.map { uri ->
// adds uri strings e.g. content://media/external/images/media/2281
uri.toString()
}.toTypedArray()
Log_OC.d(TAG, "📄 Content uris detected")
try {
FilesSyncHelper.startAutoUploadImmediatelyWithContentUris(
syncedFolderProvider,
backgroundJobManager,
false,
changedFiles.toTypedArray()
contentUris
)
} else {
Log_OC.w(TAG, "cant startFilesSyncForAllFolders")
Log_OC.d(TAG, "✅ auto upload triggered successfully for ${contentUris.size} file(s).")
} catch (e: Exception) {
Log_OC.e(TAG, "❌ Failed to start auto upload for changed files: ${e.message}", e)
}
}
companion object {
val TAG: String = ContentObserverWork::class.java.simpleName
}
}

View file

@ -0,0 +1,135 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.autoUpload
import com.nextcloud.utils.extensions.shouldSkipFile
import com.nextcloud.utils.extensions.toLocalPath
import com.owncloud.android.datamodel.FilesystemDataProvider
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.lib.common.utils.Log_OC
import java.io.IOException
import java.nio.file.AccessDeniedException
import java.nio.file.FileVisitOption
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
@Suppress("TooGenericExceptionCaught", "MagicNumber", "ReturnCount")
class AutoUploadHelper {
companion object {
private const val TAG = "AutoUploadHelper"
private const val MAX_DEPTH = 100
}
fun insertCustomFolderIntoDB(folder: SyncedFolder, filesystemDataProvider: FilesystemDataProvider?): Int {
val path = Paths.get(folder.localPath)
if (!Files.exists(path)) {
Log_OC.w(TAG, "Folder does not exist: ${folder.localPath}")
return 0
}
if (!Files.isReadable(path)) {
Log_OC.w(TAG, "Folder is not readable: ${folder.localPath}")
return 0
}
val excludeHidden = folder.isExcludeHidden
var fileCount = 0
var skipCount = 0
var errorCount = 0
try {
Files.walkFileTree(
path,
setOf(FileVisitOption.FOLLOW_LINKS),
MAX_DEPTH,
object : SimpleFileVisitor<Path>() {
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes?): FileVisitResult {
if (excludeHidden && dir != path && dir.toFile().isHidden) {
Log_OC.d(TAG, "Skipping hidden directory: ${dir.fileName}")
skipCount++
return FileVisitResult.SKIP_SUBTREE
}
return FileVisitResult.CONTINUE
}
override fun visitFile(file: Path, attrs: BasicFileAttributes?): FileVisitResult {
try {
val javaFile = file.toFile()
val lastModified = attrs?.lastModifiedTime()?.toMillis() ?: javaFile.lastModified()
val creationTime = attrs?.creationTime()?.toMillis()
if (folder.shouldSkipFile(javaFile, lastModified, creationTime)) {
skipCount++
return FileVisitResult.CONTINUE
}
val localPath = file.toLocalPath()
filesystemDataProvider?.storeOrUpdateFileValue(
localPath,
lastModified,
javaFile.isDirectory,
folder
)
fileCount++
if (fileCount % 100 == 0) {
Log_OC.d(TAG, "Processed $fileCount files so far...")
}
} catch (e: Exception) {
Log_OC.e(TAG, "Error processing file: $file", e)
errorCount++
}
return FileVisitResult.CONTINUE
}
override fun visitFileFailed(file: Path, exc: IOException?): FileVisitResult {
when (exc) {
is AccessDeniedException -> {
Log_OC.w(TAG, "Access denied: $file")
}
else -> {
Log_OC.e(TAG, "Failed to visit file: $file", exc)
}
}
errorCount++
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (exc != null) {
Log_OC.e(TAG, "Error after visiting directory: $dir", exc)
errorCount++
}
return FileVisitResult.CONTINUE
}
}
)
Log_OC.d(
TAG,
"Scan complete for ${folder.localPath}: " +
"$fileCount files processed, $skipCount skipped, $errorCount errors"
)
} catch (e: Exception) {
Log_OC.e(TAG, "Error walking file tree: ${folder.localPath}", e)
}
return fileCount
}
}

View file

@ -0,0 +1,480 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.autoUpload
import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import android.content.res.Resources
import androidx.core.app.NotificationCompat
import androidx.exifinterface.media.ExifInterface
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.database.entity.UploadEntity
import com.nextcloud.client.database.entity.toOCUpload
import com.nextcloud.client.database.entity.toUploadEntity
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.jobs.BackgroundJobManager
import com.nextcloud.client.jobs.upload.FileUploadWorker
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.client.preferences.SubFolderRule
import com.nextcloud.utils.ForegroundServiceHelper
import com.nextcloud.utils.extensions.updateStatus
import com.owncloud.android.R
import com.owncloud.android.datamodel.ArbitraryDataProviderImpl
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.ForegroundServiceType
import com.owncloud.android.datamodel.MediaFolderType
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.datamodel.UploadsStorageManager
import com.owncloud.android.db.OCUpload
import com.owncloud.android.lib.common.OwnCloudAccount
import com.owncloud.android.lib.common.OwnCloudClientManagerFactory
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.UploadFileOperation
import com.owncloud.android.ui.activity.SettingsActivity
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.FileStorageUtils
import com.owncloud.android.utils.FilesSyncHelper
import com.owncloud.android.utils.MimeType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.text.ParsePosition
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
@Suppress("LongParameterList", "TooManyFunctions")
class AutoUploadWorker(
private val context: Context,
params: WorkerParameters,
private val userAccountManager: UserAccountManager,
private val uploadsStorageManager: UploadsStorageManager,
private val connectivityService: ConnectivityService,
private val powerManagementService: PowerManagementService,
private val syncedFolderProvider: SyncedFolderProvider,
private val backgroundJobManager: BackgroundJobManager,
private val repository: FileSystemRepository
) : CoroutineWorker(context, params) {
companion object {
const val TAG = "🔄📤" + "AutoUpload"
const val OVERRIDE_POWER_SAVING = "overridePowerSaving"
const val CONTENT_URIS = "content_uris"
const val SYNCED_FOLDER_ID = "syncedFolderId"
private const val CHANNEL_ID = NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD
private const val NOTIFICATION_ID = 266
}
private val helper = AutoUploadHelper()
private lateinit var syncedFolder: SyncedFolder
private val notificationManager by lazy {
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
@Suppress("TooGenericExceptionCaught", "ReturnCount")
override suspend fun doWork(): Result {
return try {
val syncFolderId = inputData.getLong(SYNCED_FOLDER_ID, -1)
syncedFolder = syncedFolderProvider.getSyncedFolderByID(syncFolderId)
?.takeIf { it.isEnabled } ?: return Result.failure()
// initial notification
val notification = createNotification(context.getString(R.string.upload_files))
updateForegroundInfo(notification)
/**
* Receives from [com.nextcloud.client.jobs.ContentObserverWork.checkAndTriggerAutoUpload]
*/
val contentUris = inputData.getStringArray(CONTENT_URIS)
if (canExitEarly(contentUris, syncFolderId)) {
return Result.retry()
}
collectFileChangesFromContentObserverWork(contentUris)
updateNotification()
uploadFiles(syncedFolder)
Log_OC.d(TAG, "${syncedFolder.remotePath} finished checking files.")
Result.success()
} catch (e: Exception) {
Log_OC.e(TAG, "❌ failed: ${e.message}")
Result.failure()
}
}
private fun updateNotification() {
getStartNotificationTitle()?.let { (localFolderName, remoteFolderName) ->
val startNotification = createNotification(
context.getString(
R.string.auto_upload_worker_start_text,
localFolderName,
remoteFolderName
)
)
notificationManager.notify(NOTIFICATION_ID, startNotification)
}
}
private suspend fun updateForegroundInfo(notification: Notification) {
val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
NOTIFICATION_ID,
notification,
ForegroundServiceType.DataSync
)
setForeground(foregroundInfo)
}
private fun createNotification(title: String): Notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setSmallIcon(R.drawable.uploads)
.setOngoing(true)
.setSound(null)
.setVibrate(null)
.setOnlyAlertOnce(true)
.setSilent(true)
.build()
@Suppress("TooGenericExceptionCaught")
private fun getStartNotificationTitle(): Pair<String, String>? = try {
val localPath = syncedFolder.localPath
val remotePath = syncedFolder.remotePath
if (localPath.isBlank() || remotePath.isBlank()) {
null
} else {
try {
File(localPath).name to File(remotePath).name
} catch (_: Exception) {
null
}
}
} catch (_: Exception) {
null
}
@Suppress("ReturnCount")
private fun canExitEarly(contentUris: Array<String>?, syncedFolderID: Long): Boolean {
val overridePowerSaving = inputData.getBoolean(OVERRIDE_POWER_SAVING, false)
if ((powerManagementService.isPowerSavingEnabled && !overridePowerSaving)) {
Log_OC.w(TAG, "⚡ Skipping: device is in power saving mode")
return true
}
if (syncedFolderID < 0) {
Log_OC.e(TAG, "invalid sync folder id")
return true
}
if (backgroundJobManager.bothFilesSyncJobsRunning(syncedFolderID)) {
Log_OC.w(TAG, "🚧 another worker is already running for $syncedFolderID")
return true
}
val totalScanInterval = syncedFolder.getTotalScanInterval(connectivityService, powerManagementService)
val currentTime = System.currentTimeMillis()
val passedScanInterval = totalScanInterval <= currentTime
Log_OC.d(TAG, "lastScanTimestampMs: " + syncedFolder.lastScanTimestampMs)
Log_OC.d(TAG, "totalScanInterval: $totalScanInterval")
Log_OC.d(TAG, "currentTime: $currentTime")
Log_OC.d(TAG, "passedScanInterval: $passedScanInterval")
if (!passedScanInterval && contentUris.isNullOrEmpty() && !overridePowerSaving) {
Log_OC.w(
TAG,
"skipped since started before scan interval and nothing todo: " + syncedFolder.localPath
)
return true
}
return false
}
/**
* Instead of scanning the entire local folder, optional content URIs can be passed to the worker
* to detect only the relevant changes.
*/
@Suppress("MagicNumber", "TooGenericExceptionCaught")
private suspend fun collectFileChangesFromContentObserverWork(contentUris: Array<String>?) = try {
withContext(Dispatchers.IO) {
if (contentUris.isNullOrEmpty()) {
FilesSyncHelper.insertAllDBEntriesForSyncedFolder(syncedFolder, helper)
} else {
val isContentUrisStored = FilesSyncHelper.insertChangedEntries(syncedFolder, contentUris)
if (!isContentUrisStored) {
Log_OC.w(
TAG,
"changed content uris not stored, fallback to insert all db entries to not lose files"
)
FilesSyncHelper.insertAllDBEntriesForSyncedFolder(syncedFolder, helper)
}
}
syncedFolder.lastScanTimestampMs = System.currentTimeMillis()
syncedFolderProvider.updateSyncFolder(syncedFolder)
}
} catch (e: Exception) {
Log_OC.d(TAG, "Exception collectFileChangesFromContentObserverWork: $e")
}
private fun prepareDateFormat(): SimpleDateFormat {
val currentLocale = context.resources.configuration.locales[0]
return SimpleDateFormat("yyyy:MM:dd HH:mm:ss", currentLocale).apply {
timeZone = TimeZone.getTimeZone(TimeZone.getDefault().id)
}
}
private fun getUserOrReturn(syncedFolder: SyncedFolder): User? {
val optionalUser = userAccountManager.getUser(syncedFolder.account)
if (!optionalUser.isPresent) {
Log_OC.w(TAG, "user not present")
return null
}
return optionalUser.get()
}
@Suppress("DEPRECATION")
private fun getUploadSettings(syncedFolder: SyncedFolder): Triple<Boolean, Boolean, Int> {
val lightVersion = context.resources.getBoolean(R.bool.syncedFolder_light)
val accountName = syncedFolder.account
return if (lightVersion) {
Log_OC.d(TAG, "light version is used")
val arbitraryDataProvider = ArbitraryDataProviderImpl(context)
val needsCharging = context.resources.getBoolean(R.bool.syncedFolder_light_on_charging)
val needsWifi = arbitraryDataProvider.getBooleanValue(
accountName,
SettingsActivity.SYNCED_FOLDER_LIGHT_UPLOAD_ON_WIFI
)
val uploadActionString = context.resources.getString(R.string.syncedFolder_light_upload_behaviour)
val uploadAction = getUploadAction(uploadActionString)
Log_OC.d(TAG, "upload action is: $uploadAction")
Triple(needsCharging, needsWifi, uploadAction)
} else {
Log_OC.d(TAG, "not light version is used")
Triple(syncedFolder.isChargingOnly, syncedFolder.isWifiOnly, syncedFolder.uploadAction)
}
}
@Suppress("LongMethod", "DEPRECATION", "TooGenericExceptionCaught")
private suspend fun uploadFiles(syncedFolder: SyncedFolder) = withContext(Dispatchers.IO) {
val dateFormat = prepareDateFormat()
val user = getUserOrReturn(syncedFolder) ?: return@withContext
val ocAccount = OwnCloudAccount(user.toPlatformAccount(), context)
val client = OwnCloudClientManagerFactory.getDefaultSingleton()
.getClientFor(ocAccount, context)
val lightVersion = context.resources.getBoolean(R.bool.syncedFolder_light)
val currentLocale = context.resources.configuration.locales[0]
var lastId = 0
while (true) {
val filePathsWithIds = repository.getFilePathsWithIds(syncedFolder, lastId)
if (filePathsWithIds.isEmpty()) {
Log_OC.w(TAG, "no more files to upload at lastId: $lastId")
break
}
Log_OC.d(TAG, "Processing batch: lastId=$lastId, count=${filePathsWithIds.size}")
filePathsWithIds.forEach { (path, id) ->
val file = File(path)
val localPath = file.absolutePath
val remotePath = getRemotePath(
file,
syncedFolder,
dateFormat,
lightVersion,
context.resources,
currentLocale
)
try {
var (uploadEntity, upload) = createEntityAndUpload(user, localPath, remotePath)
try {
// Insert/update to IN_PROGRESS state before starting upload
val generatedId = uploadsStorageManager.uploadDao.insertOrReplace(uploadEntity)
uploadEntity = uploadEntity.copy(id = generatedId.toInt())
upload.uploadId = generatedId
val operation = createUploadFileOperation(upload, user)
Log_OC.d(TAG, "🕒 uploading: $localPath, id: $generatedId")
val result = operation.execute(client)
uploadsStorageManager.updateStatus(uploadEntity, result.isSuccess)
if (result.isSuccess) {
repository.markFileAsUploaded(localPath, syncedFolder)
Log_OC.d(TAG, "✅ upload completed: $localPath")
} else {
Log_OC.e(
TAG,
"❌ upload failed $localPath (${upload.accountName}): ${result.logMessage}"
)
}
} catch (e: Exception) {
uploadsStorageManager.updateStatus(
uploadEntity,
UploadsStorageManager.UploadStatus.UPLOAD_FAILED
)
Log_OC.e(
TAG,
"Exception during upload file, localPath: $localPath, remotePath: $remotePath," +
" exception: $e"
)
}
} catch (e: Exception) {
Log_OC.e(
TAG,
"Exception uploadFiles during creating entity and upload, localPath: $localPath, " +
"remotePath: $remotePath, exception: $e"
)
}
// update last id so upload can continue where it left
lastId = id
}
}
}
private fun createEntityAndUpload(user: User, localPath: String, remotePath: String): Pair<UploadEntity, OCUpload> {
val (needsCharging, needsWifi, uploadAction) = getUploadSettings(syncedFolder)
Log_OC.d(TAG, "creating oc upload for ${user.accountName}")
// Get existing upload or create new one
val uploadEntity = uploadsStorageManager.uploadDao.getUploadByAccountAndPaths(
localPath = localPath,
remotePath = remotePath,
accountName = user.accountName
)
val upload = (
uploadEntity?.toOCUpload(null) ?: OCUpload(
localPath,
remotePath,
user.accountName
)
).apply {
uploadStatus = UploadsStorageManager.UploadStatus.UPLOAD_IN_PROGRESS
nameCollisionPolicy = syncedFolder.nameCollisionPolicy
isUseWifiOnly = needsWifi
isWhileChargingOnly = needsCharging
localAction = uploadAction
// Only set these for new uploads
if (uploadEntity == null) {
createdBy = UploadFileOperation.CREATED_AS_INSTANT_PICTURE
isCreateRemoteFolder = true
}
}
return upload.toUploadEntity() to upload
}
private fun createUploadFileOperation(upload: OCUpload, user: User): UploadFileOperation = UploadFileOperation(
uploadsStorageManager,
connectivityService,
powerManagementService,
user,
null,
upload,
upload.nameCollisionPolicy,
upload.localAction,
context,
upload.isUseWifiOnly,
upload.isWhileChargingOnly,
true,
FileDataStorageManager(user, context.contentResolver)
)
private fun getRemotePath(
file: File,
syncedFolder: SyncedFolder,
sFormatter: SimpleDateFormat,
lightVersion: Boolean,
resources: Resources,
currentLocale: Locale
): String {
val lastModificationTime = calculateLastModificationTime(file, syncedFolder, sFormatter)
val (remoteFolder, useSubfolders, subFolderRule) = if (lightVersion) {
Triple(
resources.getString(R.string.syncedFolder_remote_folder),
resources.getBoolean(R.bool.syncedFolder_light_use_subfolders),
SubFolderRule.YEAR_MONTH
)
} else {
Triple(
syncedFolder.remotePath,
syncedFolder.isSubfolderByDate,
syncedFolder.subfolderRule
)
}
return FileStorageUtils.getInstantUploadFilePath(
file,
currentLocale,
remoteFolder,
syncedFolder.localPath,
lastModificationTime,
useSubfolders,
subFolderRule
)
}
private fun hasExif(file: File): Boolean {
val mimeType = FileStorageUtils.getMimeTypeFromName(file.absolutePath)
return MimeType.JPEG.equals(mimeType, ignoreCase = true) || MimeType.TIFF.equals(mimeType, ignoreCase = true)
}
@Suppress("NestedBlockDepth")
private fun calculateLastModificationTime(
file: File,
syncedFolder: SyncedFolder,
formatter: SimpleDateFormat
): Long {
var lastModificationTime = file.lastModified()
if (MediaFolderType.IMAGE == syncedFolder.type && hasExif(file)) {
Log_OC.d(TAG, "calculateLastModificationTime exif found")
@Suppress("TooGenericExceptionCaught")
try {
val exifInterface = ExifInterface(file.absolutePath)
val exifDate = exifInterface.getAttribute(ExifInterface.TAG_DATETIME)
if (!exifDate.isNullOrBlank()) {
val pos = ParsePosition(0)
val dateTime = formatter.parse(exifDate, pos)
if (dateTime != null) {
lastModificationTime = dateTime.time
Log_OC.w(TAG, "calculateLastModificationTime calculatedTime is: $lastModificationTime")
} else {
Log_OC.w(TAG, "calculateLastModificationTime dateTime is empty")
}
} else {
Log_OC.w(TAG, "calculateLastModificationTime exifDate is empty")
}
} catch (e: Exception) {
Log_OC.d(TAG, "Failed to get the proper time " + e.localizedMessage)
}
}
return lastModificationTime
}
private fun getUploadAction(action: String): Int = when (action) {
"LOCAL_BEHAVIOUR_FORGET" -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_FORGET
"LOCAL_BEHAVIOUR_MOVE" -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_MOVE
"LOCAL_BEHAVIOUR_DELETE" -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_DELETE
else -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_FORGET
}
}

View file

@ -0,0 +1,66 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.autoUpload
import com.nextcloud.client.database.dao.FileSystemDao
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.utils.SyncedFolderUtils
import java.io.File
class FileSystemRepository(private val dao: FileSystemDao) {
companion object {
private const val TAG = "FilesystemRepository"
const val BATCH_SIZE = 50
}
@Suppress("NestedBlockDepth")
suspend fun getFilePathsWithIds(syncedFolder: SyncedFolder, lastId: Int): List<Pair<String, Int>> {
val syncedFolderId = syncedFolder.id.toString()
Log_OC.d(TAG, "Fetching candidate files for syncedFolderId = $syncedFolderId")
val entities = dao.getAutoUploadFilesEntities(syncedFolderId, BATCH_SIZE, lastId)
val filtered = mutableListOf<Pair<String, Int>>()
entities.forEach {
it.localPath?.let { path ->
val file = File(path)
if (!file.exists()) {
Log_OC.w(TAG, "Ignoring file for upload (doesn't exist): $path")
} else if (!SyncedFolderUtils.isQualifiedFolder(file.parent)) {
Log_OC.w(TAG, "Ignoring file for upload (unqualified folder): $path")
} else if (!SyncedFolderUtils.isFileNameQualifiedForAutoUpload(file.name)) {
Log_OC.w(TAG, "Ignoring file for upload (unqualified file): $path")
} else {
Log_OC.d(TAG, "Adding path to upload: $path")
if (it.id != null) {
filtered.add(path to it.id)
} else {
Log_OC.w(TAG, "cant adding path to upload, id is null")
}
}
}
}
return filtered
}
@Suppress("TooGenericExceptionCaught")
suspend fun markFileAsUploaded(localPath: String, syncedFolder: SyncedFolder) {
val syncedFolderIdStr = syncedFolder.id.toString()
try {
dao.markFileAsUploaded(localPath, syncedFolderIdStr)
Log_OC.d(TAG, "Marked file as uploaded: $localPath for syncedFolderId=$syncedFolderIdStr")
} catch (e: Exception) {
Log_OC.e(TAG, "Error marking file as uploaded: ${e.message}", e)
}
}
}

View file

@ -9,10 +9,12 @@ package com.nextcloud.client.jobs.download
import com.nextcloud.client.account.User
import com.nextcloud.client.jobs.BackgroundJobManager
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker
import com.owncloud.android.MainApp
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.UploadsStorageManager
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.utils.MimeTypeUtil
@ -29,6 +31,7 @@ class FileDownloadHelper {
companion object {
private var instance: FileDownloadHelper? = null
private const val TAG = "FileDownloadHelper"
fun instance(): FileDownloadHelper = instance ?: synchronized(this) {
instance ?: FileDownloadHelper().also { instance = it }
@ -44,17 +47,11 @@ class FileDownloadHelper {
return false
}
val fileStorageManager = FileDataStorageManager(user, MainApp.getAppContext().contentResolver)
val topParentId = fileStorageManager.getTopParentId(file)
val isJobScheduled = backgroundJobManager.isStartFileDownloadJobScheduled(user, file.fileId)
return isJobScheduled ||
if (file.isFolder) {
FileDownloadWorker.isDownloadingFolder(file.fileId) ||
backgroundJobManager.isStartFileDownloadJobScheduled(user, topParentId)
} else {
FileDownloadWorker.isDownloading(user.accountName, file.fileId)
}
return if (file.isFolder) {
FolderDownloadWorker.isDownloading(file.fileId)
} else {
FileDownloadWorker.isDownloading(user.accountName, file.fileId)
}
}
fun cancelPendingOrCurrentDownloads(user: User?, files: List<OCFile>?) {
@ -141,4 +138,14 @@ class FileDownloadHelper {
conflictUploadId
)
}
fun downloadFolder(folder: OCFile?, accountName: String) {
if (folder == null) {
Log_OC.e(TAG, "folder cannot be null, cant sync")
return
}
backgroundJobManager.downloadFolder(folder, accountName)
}
fun cancelFolderDownload() = backgroundJobManager.cancelFolderDownload()
}

View file

@ -24,7 +24,6 @@ import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.model.WorkerState
import com.nextcloud.model.WorkerStateLiveData
import com.nextcloud.utils.ForegroundServiceHelper
import com.nextcloud.utils.extensions.getParentIdsOfSubfiles
import com.nextcloud.utils.extensions.getPercent
import com.owncloud.android.R
import com.owncloud.android.datamodel.FileDataStorageManager
@ -45,7 +44,6 @@ import com.owncloud.android.utils.theme.ViewThemeUtils
import java.util.AbstractList
import java.util.Optional
import java.util.Vector
import java.util.concurrent.ConcurrentHashMap
import kotlin.random.Random
@Suppress("LongParameterList", "TooManyFunctions")
@ -63,7 +61,6 @@ class FileDownloadWorker(
private val TAG = FileDownloadWorker::class.java.simpleName
private val pendingDownloads = IndexedForest<DownloadFileOperation>()
private val pendingFolderDownloads: MutableSet<Long> = ConcurrentHashMap.newKeySet<Long>()
fun cancelOperation(accountName: String, fileId: Long) {
pendingDownloads.all.forEach {
@ -75,8 +72,6 @@ class FileDownloadWorker(
it.value?.payload?.isMatching(accountName, fileId) == true
}
fun isDownloadingFolder(id: Long): Boolean = pendingFolderDownloads.contains(id)
const val FILE_REMOTE_PATH = "FILE_REMOTE_PATH"
const val ACCOUNT_NAME = "ACCOUNT_NAME"
const val BEHAVIOUR = "BEHAVIOUR"
@ -170,10 +165,6 @@ class FileDownloadWorker(
private fun getRequestDownloads(ocFile: OCFile): AbstractList<String> {
val files = getFiles(ocFile)
val filesPaths = files.map { it.remotePath }
val parentIdsOfSubFiles = fileDataStorageManager?.getParentIdsOfSubfiles(filesPaths) ?: listOf()
pendingFolderDownloads.addAll(parentIdsOfSubFiles)
val downloadType = getDownloadType()
conflictUploadId = inputData.keyValueMap[CONFLICT_UPLOAD_ID] as Long?

View file

@ -0,0 +1,167 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.folderDownload
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.jobs.download.FileDownloadHelper
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
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.helpers.FileOperationsHelper
import com.owncloud.android.utils.theme.ViewThemeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
@Suppress("LongMethod")
class FolderDownloadWorker(
private val accountManager: UserAccountManager,
private val context: Context,
private val viewThemeUtils: ViewThemeUtils,
params: WorkerParameters
) : CoroutineWorker(context, params) {
companion object {
private const val TAG = "📂" + "FolderDownloadWorker"
const val FOLDER_ID = "FOLDER_ID"
const val ACCOUNT_NAME = "ACCOUNT_NAME"
private val pendingDownloads: MutableSet<Long> = ConcurrentHashMap.newKeySet<Long>()
fun isDownloading(id: Long): Boolean = pendingDownloads.contains(id)
}
private var notificationManager: FolderDownloadWorkerNotificationManager? = null
private lateinit var storageManager: FileDataStorageManager
@Suppress("TooGenericExceptionCaught", "ReturnCount", "DEPRECATION")
override suspend fun doWork(): Result {
val folderID = inputData.getLong(FOLDER_ID, -1)
if (folderID == -1L) {
return Result.failure()
}
val accountName = inputData.getString(ACCOUNT_NAME)
if (accountName == null) {
Log_OC.e(TAG, "failed accountName cannot be null")
return Result.failure()
}
val optionalUser = accountManager.getUser(accountName)
if (optionalUser.isEmpty) {
Log_OC.e(TAG, "failed user is not present")
return Result.failure()
}
val user = optionalUser.get()
storageManager = FileDataStorageManager(user, context.contentResolver)
val folder = storageManager.getFileById(folderID)
if (folder == null) {
Log_OC.e(TAG, "failed folder cannot be nul")
return Result.failure()
}
notificationManager = FolderDownloadWorkerNotificationManager(context, viewThemeUtils)
Log_OC.d(TAG, "🕒 started for ${user.accountName} downloading ${folder.fileName}")
val foregroundInfo = notificationManager?.getForegroundInfo(folder) ?: return Result.failure()
setForeground(foregroundInfo)
pendingDownloads.add(folder.fileId)
val downloadHelper = FileDownloadHelper.instance()
return withContext(Dispatchers.IO) {
try {
val files = getFiles(folder, storageManager)
val account = user.toOwnCloudAccount()
val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(account, context)
var result = true
files.forEachIndexed { index, file ->
if (!checkDiskSize(file)) {
return@withContext Result.failure()
}
withContext(Dispatchers.Main) {
notificationManager?.showProgressNotification(
folder.fileName,
file.fileName,
index,
files.size
)
}
val operation = DownloadFileOperation(user, file, context)
val operationResult = operation.execute(client)
if (operationResult?.isSuccess == true && operation.downloadType === DownloadType.DOWNLOAD) {
getOCFile(operation)?.let { ocFile ->
downloadHelper.saveFile(ocFile, operation, storageManager)
}
}
if (!operationResult.isSuccess) {
result = false
}
}
withContext(Dispatchers.Main) {
notificationManager?.showCompletionMessage(folder.fileName, result)
}
if (result) {
Log_OC.d(TAG, "✅ completed")
Result.success()
} else {
Log_OC.d(TAG, "❌ failed")
Result.failure()
}
} catch (e: Exception) {
Log_OC.d(TAG, "❌ failed reason: $e")
Result.failure()
} finally {
pendingDownloads.remove(folder.fileId)
notificationManager?.dismiss()
}
}
}
private fun getOCFile(operation: DownloadFileOperation): OCFile? {
val file = operation.file?.fileId?.let { storageManager.getFileById(it) }
?: storageManager.getFileByDecryptedRemotePath(operation.file?.remotePath)
?: run {
Log_OC.e(TAG, "could not save ${operation.file?.remotePath}")
return null
}
return file
}
private fun getFiles(folder: OCFile, storageManager: FileDataStorageManager): List<OCFile> =
storageManager.getFolderContent(folder, false)
.filter { !it.isFolder && !it.isDown }
private suspend fun checkDiskSize(file: OCFile): Boolean {
val fileSizeInByte = file.fileLength
val availableDiskSpace = FileOperationsHelper.getAvailableSpaceOnDevice()
return if (availableDiskSpace < fileSizeInByte) {
notificationManager?.showNotAvailableDiskSpace()
false
} else {
true
}
}
}

View file

@ -0,0 +1,113 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.folderDownload
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.work.ForegroundInfo
import com.nextcloud.client.jobs.notification.WorkerNotificationManager
import com.nextcloud.utils.ForegroundServiceHelper
import com.owncloud.android.R
import com.owncloud.android.datamodel.ForegroundServiceType
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.theme.ViewThemeUtils
import kotlinx.coroutines.delay
import kotlin.random.Random
class FolderDownloadWorkerNotificationManager(private val context: Context, viewThemeUtils: ViewThemeUtils) :
WorkerNotificationManager(
id = NOTIFICATION_ID,
context,
viewThemeUtils,
tickerId = R.string.folder_download_worker_ticker_id,
channelId = NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD
) {
companion object {
private const val NOTIFICATION_ID = 391
private const val MAX_PROGRESS = 100
private const val DELAY = 1000L
}
private fun getNotification(title: String, description: String? = null, progress: Int? = null): Notification =
notificationBuilder.apply {
setSmallIcon(R.drawable.ic_sync)
setContentTitle(title)
clearActions()
description?.let {
setContentText(description)
}
progress?.let {
setProgress(MAX_PROGRESS, progress, false)
addAction(
android.R.drawable.ic_menu_close_clear_cancel,
context.getString(R.string.common_cancel),
getCancelPendingIntent()
)
}
setAutoCancel(true)
}.build()
private fun getCancelPendingIntent(): PendingIntent {
val intent = Intent(context, FolderDownloadWorkerReceiver::class.java)
return PendingIntent.getBroadcast(
context,
Random.nextInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
fun showProgressNotification(folderName: String, filename: String, currentIndex: Int, totalFileSize: Int) {
val currentFileIndex = (currentIndex + 1)
val description = context.getString(R.string.folder_download_counter, currentFileIndex, totalFileSize, filename)
val progress = (currentFileIndex * MAX_PROGRESS) / totalFileSize
val notification = getNotification(title = folderName, description = description, progress = progress)
notificationManager.notify(NOTIFICATION_ID, notification)
}
suspend fun showCompletionMessage(folderName: String, success: Boolean) {
val title = if (success) {
context.getString(R.string.folder_download_success_notification_title, folderName)
} else {
context.getString(R.string.folder_download_error_notification_title, folderName)
}
val notification = getNotification(title = title)
notificationManager.notify(NOTIFICATION_ID, notification)
delay(DELAY)
dismiss()
}
fun getForegroundInfo(folder: OCFile): ForegroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
NOTIFICATION_ID,
getNotification(folder.fileName, progress = 0),
ForegroundServiceType.DataSync
)
suspend fun showNotAvailableDiskSpace() {
val title = context.getString(R.string.folder_download_insufficient_disk_space_notification_title)
val notification = getNotification(title)
notificationManager.notify(NOTIFICATION_ID, notification)
delay(DELAY)
dismiss()
}
fun dismiss() {
notificationManager.cancel(NOTIFICATION_ID)
}
}

View file

@ -0,0 +1,25 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.folderDownload
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.nextcloud.client.jobs.BackgroundJobManager
import com.owncloud.android.MainApp
import javax.inject.Inject
class FolderDownloadWorkerReceiver : BroadcastReceiver() {
@Inject
lateinit var backgroundJobManager: BackgroundJobManager
override fun onReceive(context: Context, intent: Intent) {
MainApp.getAppComponent().inject(this)
backgroundJobManager.cancelFolderDownload()
}
}

View file

@ -7,11 +7,14 @@
*/
package com.nextcloud.client.jobs.upload
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.database.entity.toOCUpload
import com.nextcloud.client.database.entity.toUploadEntity
import com.nextcloud.client.device.BatteryStatus
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.jobs.BackgroundJobManager
@ -20,6 +23,7 @@ import com.nextcloud.client.network.Connectivity
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.utils.extensions.getUploadIds
import com.owncloud.android.MainApp
import com.owncloud.android.R
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.UploadsStorageManager
@ -35,13 +39,12 @@ import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation
import com.owncloud.android.lib.resources.files.model.RemoteFile
import com.owncloud.android.operations.RemoveFileOperation
import com.owncloud.android.operations.UploadFileOperation
import com.owncloud.android.utils.DisplayUtils
import com.owncloud.android.utils.FileUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutionException
import java.util.concurrent.Semaphore
import javax.inject.Inject
@ -85,18 +88,42 @@ class FileUploadHelper {
fun buildRemoteName(accountName: String, remotePath: String): String = accountName + remotePath
}
/**
* Retries all failed uploads across all user accounts.
*
* This function retrieves all uploads with the status [UploadStatus.UPLOAD_FAILED], including both
* manual uploads and auto uploads. It runs in a background thread (Dispatcher.IO) and ensures
* that only one retry operation runs at a time by using a semaphore to prevent concurrent execution.
*
* Once the failed uploads are retrieved, it calls [retryUploads], which triggers the corresponding
* upload workers for each failed upload.
*
* The function returns `true` if there were any failed uploads to retry and the retry process was
* started, or `false` if no uploads were retried.
*
* @param uploadsStorageManager Provides access to upload data and persistence.
* @param connectivityService Checks the current network connectivity state.
* @param accountManager Handles user account authentication and selection.
* @param powerManagementService Ensures uploads respect power constraints.
* @return `true` if any failed uploads were found and retried; `false` otherwise.
*/
fun retryFailedUploads(
uploadsStorageManager: UploadsStorageManager,
connectivityService: ConnectivityService,
accountManager: UserAccountManager,
powerManagementService: PowerManagementService
) {
if (retryFailedUploadsSemaphore.tryAcquire()) {
try {
val failedUploads = uploadsStorageManager.failedUploads
if (failedUploads == null || failedUploads.isEmpty()) {
Log_OC.d(TAG, "Failed uploads are empty or null")
return
): Boolean {
if (!retryFailedUploadsSemaphore.tryAcquire()) {
Log_OC.d(TAG, "skipping retryFailedUploads, already running")
return true
}
var isUploadStarted = false
try {
getUploadsByStatus(null, UploadStatus.UPLOAD_FAILED) {
if (it.isNotEmpty()) {
isUploadStarted = true
}
retryUploads(
@ -104,14 +131,14 @@ class FileUploadHelper {
connectivityService,
accountManager,
powerManagementService,
failedUploads
uploads = it
)
} finally {
retryFailedUploadsSemaphore.release()
}
} else {
Log_OC.d(TAG, "Skip retryFailedUploads since it is already running")
} finally {
retryFailedUploadsSemaphore.release()
}
return isUploadStarted
}
fun retryCancelledUploads(
@ -120,18 +147,18 @@ class FileUploadHelper {
accountManager: UserAccountManager,
powerManagementService: PowerManagementService
): Boolean {
val cancelledUploads = uploadsStorageManager.cancelledUploadsForCurrentAccount
if (cancelledUploads == null || cancelledUploads.isEmpty()) {
return false
var result = false
getUploadsByStatus(accountManager.user.accountName, UploadStatus.UPLOAD_CANCELLED) {
result = retryUploads(
uploadsStorageManager,
connectivityService,
accountManager,
powerManagementService,
it
)
}
return retryUploads(
uploadsStorageManager,
connectivityService,
accountManager,
powerManagementService,
cancelledUploads
)
return result
}
@Suppress("ComplexCondition")
@ -140,35 +167,32 @@ class FileUploadHelper {
connectivityService: ConnectivityService,
accountManager: UserAccountManager,
powerManagementService: PowerManagementService,
failedUploads: Array<OCUpload>
uploads: Array<OCUpload>
): Boolean {
var showNotExistMessage = false
val isOnline = checkConnectivity(connectivityService)
val connectivity = connectivityService.connectivity
val batteryStatus = powerManagementService.battery
val accountNames = accountManager.accounts.filter { account ->
accountManager.getUser(account.name).isPresent
}.map { account ->
account.name
}.toHashSet()
for (failedUpload in failedUploads) {
if (!accountNames.contains(failedUpload.accountName)) {
uploadsStorageManager.removeUpload(failedUpload)
continue
}
val uploadsToRetry = mutableListOf<Long>()
val uploadResult =
checkUploadConditions(failedUpload, connectivity, batteryStatus, powerManagementService, isOnline)
for (upload in uploads) {
val uploadResult = checkUploadConditions(
upload,
connectivity,
batteryStatus,
powerManagementService,
isOnline
)
if (uploadResult != UploadResult.UPLOADED) {
if (failedUpload.lastResult != uploadResult) {
if (upload.lastResult != uploadResult) {
// Setting Upload status else cancelled uploads will behave wrong, when retrying
// Needs to happen first since lastResult wil be overwritten by setter
failedUpload.uploadStatus = UploadStatus.UPLOAD_FAILED
upload.uploadStatus = UploadStatus.UPLOAD_FAILED
failedUpload.lastResult = uploadResult
uploadsStorageManager.updateUpload(failedUpload)
upload.lastResult = uploadResult
uploadsStorageManager.updateUpload(upload)
}
if (uploadResult == UploadResult.FILE_NOT_FOUND) {
showNotExistMessage = true
@ -176,15 +200,18 @@ class FileUploadHelper {
continue
}
failedUpload.uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
uploadsStorageManager.updateUpload(failedUpload)
// Only uploads that passed checks get marked in progress and are collected for scheduling
upload.uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
uploadsStorageManager.updateUpload(upload)
uploadsToRetry.add(upload.uploadId)
}
accountNames.forEach { accountName ->
val user = accountManager.getUser(accountName)
if (user.isPresent) {
backgroundJobManager.startFilesUploadJob(user.get(), failedUploads.getUploadIds(), false)
}
if (uploadsToRetry.isNotEmpty()) {
backgroundJobManager.startFilesUploadJob(
accountManager.user,
uploadsToRetry.toLongArray(),
false
)
}
return showNotExistMessage
@ -205,7 +232,7 @@ class FileUploadHelper {
showSameFileAlreadyExistsNotification: Boolean = true
) {
val uploads = localPaths.mapIndexed { index, localPath ->
OCUpload(localPath, remotePaths[index], user.accountName).apply {
val result = OCUpload(localPath, remotePaths[index], user.accountName).apply {
this.nameCollisionPolicy = nameCollisionPolicy
isUseWifiOnly = requiresWifi
isWhileChargingOnly = requiresCharging
@ -214,47 +241,54 @@ class FileUploadHelper {
isCreateRemoteFolder = createRemoteFolder
localAction = localBehavior
}
val id = uploadsStorageManager.uploadDao.insertOrReplace(result.toUploadEntity())
result.uploadId = id
result
}
uploadsStorageManager.storeUploads(uploads)
backgroundJobManager.startFilesUploadJob(user, uploads.getUploadIds(), showSameFileAlreadyExistsNotification)
}
fun removeFileUpload(remotePath: String, accountName: String) {
try {
val user = accountManager.getUser(accountName).get()
// need to update now table in mUploadsStorageManager,
// since the operation will not get to be run by FileUploader#uploadFile
uploadsStorageManager.removeUpload(accountName, remotePath)
val uploadIds = uploadsStorageManager.getCurrentUploadIds(user.accountName)
cancelAndRestartUploadJob(user, uploadIds)
} catch (e: NoSuchElementException) {
Log_OC.e(TAG, "Error cancelling current upload because user does not exist!: " + e.message)
}
uploadsStorageManager.uploadDao.deleteByAccountAndRemotePath(accountName, remotePath)
}
fun cancelFileUpload(remotePath: String, accountName: String) {
fun updateUploadStatus(remotePath: String, accountName: String, status: UploadStatus) {
ioScope.launch {
val upload = uploadsStorageManager.getUploadByRemotePath(remotePath)
if (upload != null) {
cancelFileUploads(listOf(upload), accountName)
} else {
Log_OC.e(TAG, "Error cancelling current upload because upload does not exist!")
}
uploadsStorageManager.uploadDao.updateStatus(remotePath, accountName, status.value)
}
}
fun cancelFileUploads(uploads: List<OCUpload>, accountName: String) {
for (upload in uploads) {
upload.uploadStatus = UploadStatus.UPLOAD_CANCELLED
uploadsStorageManager.updateUpload(upload)
}
try {
val user = accountManager.getUser(accountName).get()
cancelAndRestartUploadJob(user, uploads.getUploadIds())
} catch (e: NoSuchElementException) {
Log_OC.e(TAG, "Error restarting upload job because user does not exist!: " + e.message)
/**
* Retrieves uploads filtered by their status, optionally for a specific account.
*
* This function queries the uploads database asynchronously to obtain a list of uploads
* that match the specified [status]. If an [accountName] is provided, only uploads
* belonging to that account are retrieved. If [accountName] is `null`, uploads with the
* given [status] from **all user accounts** are returned.
*
* Once the uploads are fetched, the [onCompleted] callback is invoked with the resulting array.
*
* @param accountName The name of the account to filter uploads by.
* If `null`, uploads matching the given [status] from all accounts are returned.
* @param status The [UploadStatus] to filter uploads by (e.g., `UPLOAD_FAILED`).
* @param nameCollisionPolicy The [NameCollisionPolicy] to filter uploads by (e.g., `SKIP`).
* @param onCompleted A callback invoked with the resulting array of [OCUpload] objects.
*/
fun getUploadsByStatus(
accountName: String?,
status: UploadStatus,
nameCollisionPolicy: NameCollisionPolicy? = null,
onCompleted: (Array<OCUpload>) -> Unit
) {
ioScope.launch {
val dao = uploadsStorageManager.uploadDao
val result = if (accountName != null) {
dao.getUploadsByAccountNameAndStatus(accountName, status.value, nameCollisionPolicy?.serialize())
} else {
dao.getUploadsByStatus(status.value, nameCollisionPolicy?.serialize())
}.map { it.toOCUpload(null) }.toTypedArray()
onCompleted(result)
}
}
@ -266,26 +300,16 @@ class FileUploadHelper {
}
@Suppress("ReturnCount")
fun isUploading(user: User?, file: OCFile?): Boolean {
if (user == null || file == null || !backgroundJobManager.isStartFileUploadJobScheduled(user)) {
fun isUploading(remotePath: String?, accountName: String?): Boolean {
accountName ?: return false
if (!backgroundJobManager.isStartFileUploadJobScheduled(accountName)) {
return false
}
val uploadCompletableFuture = CompletableFuture.supplyAsync {
uploadsStorageManager.getUploadByRemotePath(file.remotePath)
}
return try {
val upload = uploadCompletableFuture.get()
if (upload != null) {
upload.uploadStatus == UploadStatus.UPLOAD_IN_PROGRESS
} else {
false
}
} catch (e: ExecutionException) {
false
} catch (e: InterruptedException) {
false
}
remotePath ?: return false
val upload = uploadsStorageManager.uploadDao.getByRemotePath(remotePath)
return upload?.status == UploadStatus.UPLOAD_IN_PROGRESS.value ||
FileUploadWorker.isUploading(remotePath, accountName)
}
private fun checkConnectivity(connectivityService: ConnectivityService): Boolean {
@ -364,7 +388,7 @@ class FileUploadHelper {
val uploads = existingFiles.map { file ->
file?.let {
OCUpload(file, user).apply {
val result = OCUpload(file, user).apply {
fileSize = file.fileLength
this.nameCollisionPolicy = nameCollisionPolicy
isCreateRemoteFolder = true
@ -373,9 +397,12 @@ class FileUploadHelper {
isWhileChargingOnly = false
uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
}
val id = uploadsStorageManager.uploadDao.insertOrReplace(result.toUploadEntity())
result.uploadId = id
result
}
}
uploadsStorageManager.storeUploads(uploads)
val uploadIds: LongArray = uploads.filterNotNull().map { it.uploadId }.toLongArray()
backgroundJobManager.startFilesUploadJob(user, uploadIds, true)
}
@ -459,6 +486,14 @@ class FileUploadHelper {
return false
}
fun showFileUploadLimitMessage(activity: Activity) {
val message = activity.resources.getQuantityString(
R.plurals.file_upload_limit_message,
MAX_FILE_COUNT
)
DisplayUtils.showSnackMessage(activity, message)
}
class UploadNotificationActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val accountName = intent.getStringExtra(FileUploadWorker.EXTRA_ACCOUNT_NAME)
@ -474,7 +509,9 @@ class FileUploadHelper {
return
}
instance().cancelFileUpload(remotePath, accountName)
FileUploadWorker.cancelCurrentUpload(remotePath, accountName, onCompleted = {
instance().updateUploadStatus(remotePath, accountName, UploadStatus.UPLOAD_CANCELLED)
})
}
}
}

View file

@ -7,10 +7,12 @@
*/
package com.nextcloud.client.jobs.upload
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.Worker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
@ -21,8 +23,12 @@ import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.model.WorkerState
import com.nextcloud.model.WorkerStateLiveData
import com.nextcloud.utils.ForegroundServiceHelper
import com.nextcloud.utils.extensions.getPercent
import com.nextcloud.utils.extensions.updateStatus
import com.owncloud.android.R
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.ForegroundServiceType
import com.owncloud.android.datamodel.ThumbnailsCacheManager
import com.owncloud.android.datamodel.UploadsStorageManager
import com.owncloud.android.db.OCUpload
@ -34,8 +40,12 @@ import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.UploadFileOperation
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.ErrorMessageAdapter
import com.owncloud.android.utils.theme.ViewThemeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.random.Random
@ -51,7 +61,7 @@ class FileUploadWorker(
val preferences: AppPreferences,
val context: Context,
params: WorkerParameters
) : Worker(context, params),
) : CoroutineWorker(context, params),
OnDatatransferProgressListener {
companion object {
@ -91,19 +101,44 @@ class FileUploadWorker(
fun getUploadStartMessage(): String = FileUploadWorker::class.java.name + UPLOAD_START_MESSAGE
fun getUploadFinishMessage(): String = FileUploadWorker::class.java.name + UPLOAD_FINISH_MESSAGE
fun cancelCurrentUpload(remotePath: String, accountName: String, onCompleted: () -> Unit) {
currentUploadFileOperation?.let {
if (it.remotePath == remotePath && it.user.accountName == accountName) {
it.cancel(ResultCode.USER_CANCELLED)
onCompleted()
}
}
}
fun isUploading(remotePath: String?, accountName: String?): Boolean {
currentUploadFileOperation?.let {
return it.remotePath == remotePath && it.user.accountName == accountName
}
return false
}
}
private var lastPercent = 0
private val notificationManager = UploadNotificationManager(context, viewThemeUtils, Random.nextInt())
private val notificationId = Random.nextInt()
private val notificationManager = UploadNotificationManager(context, viewThemeUtils, notificationId)
private val intents = FileUploaderIntents(context)
private val fileUploaderDelegate = FileUploaderDelegate()
@Suppress("TooGenericExceptionCaught")
override fun doWork(): Result = try {
override suspend fun doWork(): Result = try {
Log_OC.d(TAG, "FileUploadWorker started")
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
val workerName = BackgroundJobManagerImpl.formatClassTag(this::class)
backgroundJobManager.logStartOfWorker(workerName)
val notificationTitle = notificationManager.currentOperationTitle
?: context.getString(R.string.foreground_service_upload)
val notification = createNotification(notificationTitle)
updateForegroundInfo(notification)
val result = uploadFiles()
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
backgroundJobManager.logEndOfWorker(workerName, result)
notificationManager.dismissNotification()
if (result == Result.success()) {
setIdleWorkerState()
@ -111,17 +146,37 @@ class FileUploadWorker(
result
} catch (t: Throwable) {
Log_OC.e(TAG, "Error caught at FileUploadWorker $t")
cleanup()
Result.failure()
}
override fun onStopped() {
private suspend fun updateForegroundInfo(notification: Notification) {
val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
notificationId,
notification,
ForegroundServiceType.DataSync
)
setForeground(foregroundInfo)
}
private fun createNotification(title: String): Notification =
NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD)
.setContentTitle(title)
.setSmallIcon(R.drawable.uploads)
.setOngoing(true)
.setSound(null)
.setVibrate(null)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setSilent(true)
.build()
private fun cleanup() {
Log_OC.e(TAG, "FileUploadWorker stopped")
setIdleWorkerState()
currentUploadFileOperation?.cancel(null)
notificationManager.dismissNotification()
super.onStopped()
}
private fun setWorkerState(user: User?) {
@ -133,36 +188,36 @@ class FileUploadWorker(
}
@Suppress("ReturnCount", "LongMethod")
private fun uploadFiles(): Result {
private suspend fun uploadFiles(): Result = withContext(Dispatchers.IO) {
val accountName = inputData.getString(ACCOUNT)
if (accountName == null) {
Log_OC.e(TAG, "accountName is null")
return Result.failure()
return@withContext Result.failure()
}
val uploadIds = inputData.getLongArray(UPLOAD_IDS)
if (uploadIds == null) {
Log_OC.e(TAG, "uploadIds is null")
return Result.failure()
return@withContext Result.failure()
}
val currentBatchIndex = inputData.getInt(CURRENT_BATCH_INDEX, -1)
if (currentBatchIndex == -1) {
Log_OC.e(TAG, "currentBatchIndex is -1, cancelling")
return Result.failure()
return@withContext Result.failure()
}
val totalUploadSize = inputData.getInt(TOTAL_UPLOAD_SIZE, -1)
if (totalUploadSize == -1) {
Log_OC.e(TAG, "totalUploadSize is -1, cancelling")
return Result.failure()
return@withContext Result.failure()
}
// since worker's policy is append or replace and account name comes from there no need check in the loop
val optionalUser = userAccountManager.getUser(accountName)
if (!optionalUser.isPresent) {
Log_OC.e(TAG, "User not found for account: $accountName")
return Result.failure()
return@withContext Result.failure()
}
val user = optionalUser.get()
@ -172,21 +227,19 @@ class FileUploadWorker(
val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context)
for ((index, upload) in uploads.withIndex()) {
ensureActive()
if (preferences.isGlobalUploadPaused) {
Log_OC.d(TAG, "Upload is paused, skip uploading files!")
notificationManager.notifyPaused(
intents.notificationStartIntent(null)
)
return Result.success()
return@withContext Result.success()
}
if (canExitEarly()) {
notificationManager.showConnectionErrorNotification()
return Result.failure()
}
if (isStopped) {
continue
return@withContext Result.failure()
}
setWorkerState(user)
@ -203,12 +256,16 @@ class FileUploadWorker(
totalUploadSize = totalUploadSize
)
val result = upload(operation, user, client)
val result = withContext(Dispatchers.IO) {
upload(operation, user, client)
}
val entity = uploadsStorageManager.uploadDao.getUploadById(upload.uploadId, accountName)
uploadsStorageManager.updateStatus(entity, result.isSuccess)
currentUploadFileOperation = null
sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result)
}
return Result.success()
return@withContext Result.success()
}
private fun sendUploadFinishEvent(
@ -346,6 +403,10 @@ class FileUploadWorker(
return
}
if (uploadResult.code == ResultCode.USER_CANCELLED) {
return
}
notificationManager.run {
val errorMessage = ErrorMessageAdapter.getErrorCauseMessage(
uploadResult,