added DEV version to repo

This commit is contained in:
Fr4nz D13trich 2025-09-18 18:43:03 +02:00
parent 1ef725ef20
commit 23e673bfdf
2135 changed files with 97033 additions and 21206 deletions

View file

@ -8,7 +8,7 @@
* Copyright (C) 2017 Nextcloud GmbH.
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs

View file

@ -2,7 +2,7 @@
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
@ -23,6 +23,8 @@ import com.nextcloud.client.documentscan.GeneratePDFUseCase
import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
import com.nextcloud.client.integrations.deck.DeckApi
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.upload.FileUploadWorker
import com.nextcloud.client.logger.Logger
import com.nextcloud.client.network.ConnectivityService
@ -95,39 +97,42 @@ class BackgroundJobFactory @Inject constructor(
GeneratePdfFromImagesWork::class -> createPDFGenerateWork(context, workerParameters)
HealthStatusWork::class -> createHealthStatusWork(context, workerParameters)
TestJob::class -> createTestJob(context, workerParameters)
OfflineOperationsWorker::class -> createOfflineOperationsWorker(context, workerParameters)
InternalTwoWaySyncWork::class -> createInternalTwoWaySyncWork(context, workerParameters)
MetadataWorker::class -> createMetadataWorker(context, workerParameters)
else -> null // caller falls back to default factory
}
}
}
private fun createFilesExportWork(
context: Context,
params: WorkerParameters
): ListenableWorker {
return FilesExportWork(
context,
private fun createOfflineOperationsWorker(context: Context, params: WorkerParameters): ListenableWorker =
OfflineOperationsWorker(
accountManager.user,
contentResolver,
context,
connectivityService,
viewThemeUtils.get(),
params
)
}
private fun createContentObserverJob(
context: Context,
workerParameters: WorkerParameters
): ListenableWorker {
return ContentObserverWork(
private fun createFilesExportWork(context: Context, params: WorkerParameters): ListenableWorker = FilesExportWork(
context,
accountManager.user,
contentResolver,
viewThemeUtils.get(),
params
)
private fun createContentObserverJob(context: Context, workerParameters: WorkerParameters): ListenableWorker =
ContentObserverWork(
context,
workerParameters,
SyncedFolderProvider(contentResolver, preferences, clock),
powerManagementService,
backgroundJobManager.get()
)
}
private fun createContactsBackupWork(context: Context, params: WorkerParameters): ContactsBackupWork {
return ContactsBackupWork(
private fun createContactsBackupWork(context: Context, params: WorkerParameters): ContactsBackupWork =
ContactsBackupWork(
context,
params,
resources,
@ -135,63 +140,55 @@ class BackgroundJobFactory @Inject constructor(
contentResolver,
accountManager
)
}
private fun createContactsImportWork(context: Context, params: WorkerParameters): ContactsImportWork {
return ContactsImportWork(
private fun createContactsImportWork(context: Context, params: WorkerParameters): ContactsImportWork =
ContactsImportWork(
context,
params,
logger,
contentResolver
)
}
private fun createCalendarBackupWork(context: Context, params: WorkerParameters): CalendarBackupWork {
return CalendarBackupWork(
private fun createCalendarBackupWork(context: Context, params: WorkerParameters): CalendarBackupWork =
CalendarBackupWork(
context,
params,
contentResolver,
accountManager,
preferences
)
}
private fun createCalendarImportWork(context: Context, params: WorkerParameters): CalendarImportWork {
return CalendarImportWork(
private fun createCalendarImportWork(context: Context, params: WorkerParameters): CalendarImportWork =
CalendarImportWork(
context,
params,
logger,
contentResolver
)
}
private fun createFilesSyncWork(context: Context, params: WorkerParameters): FilesSyncWork {
return FilesSyncWork(
context = context,
params = params,
contentResolver = contentResolver,
userAccountManager = accountManager,
uploadsStorageManager = uploadsStorageManager,
connectivityService = connectivityService,
powerManagementService = powerManagementService,
syncedFolderProvider = syncedFolderProvider,
backgroundJobManager = backgroundJobManager.get()
)
}
private fun createFilesSyncWork(context: Context, params: WorkerParameters): FilesSyncWork = FilesSyncWork(
context = context,
params = params,
contentResolver = contentResolver,
userAccountManager = accountManager,
uploadsStorageManager = uploadsStorageManager,
connectivityService = connectivityService,
powerManagementService = powerManagementService,
syncedFolderProvider = syncedFolderProvider,
backgroundJobManager = backgroundJobManager.get()
)
private fun createOfflineSyncWork(context: Context, params: WorkerParameters): OfflineSyncWork {
return OfflineSyncWork(
context = context,
params = params,
contentResolver = contentResolver,
userAccountManager = accountManager,
connectivityService = connectivityService,
powerManagementService = powerManagementService
)
}
private fun createOfflineSyncWork(context: Context, params: WorkerParameters): OfflineSyncWork = OfflineSyncWork(
context = context,
params = params,
contentResolver = contentResolver,
userAccountManager = accountManager,
connectivityService = connectivityService,
powerManagementService = powerManagementService
)
private fun createMediaFoldersDetectionWork(context: Context, params: WorkerParameters): MediaFoldersDetectionWork {
return MediaFoldersDetectionWork(
private fun createMediaFoldersDetectionWork(context: Context, params: WorkerParameters): MediaFoldersDetectionWork =
MediaFoldersDetectionWork(
context,
params,
resources,
@ -202,21 +199,18 @@ class BackgroundJobFactory @Inject constructor(
viewThemeUtils.get(),
syncedFolderProvider
)
}
private fun createNotificationWork(context: Context, params: WorkerParameters): NotificationWork {
return NotificationWork(
context,
params,
notificationManager,
accountManager,
deckApi,
viewThemeUtils.get()
)
}
private fun createNotificationWork(context: Context, params: WorkerParameters): NotificationWork = NotificationWork(
context,
params,
notificationManager,
accountManager,
deckApi,
viewThemeUtils.get()
)
private fun createAccountRemovalWork(context: Context, params: WorkerParameters): AccountRemovalWork {
return AccountRemovalWork(
private fun createAccountRemovalWork(context: Context, params: WorkerParameters): AccountRemovalWork =
AccountRemovalWork(
context,
params,
uploadsStorageManager,
@ -227,10 +221,9 @@ class BackgroundJobFactory @Inject constructor(
preferences,
syncedFolderProvider
)
}
private fun createFilesUploadWorker(context: Context, params: WorkerParameters): FileUploadWorker {
return FileUploadWorker(
private fun createFilesUploadWorker(context: Context, params: WorkerParameters): FileUploadWorker =
FileUploadWorker(
uploadsStorageManager,
connectivityService,
powerManagementService,
@ -242,20 +235,18 @@ class BackgroundJobFactory @Inject constructor(
context,
params
)
}
private fun createFilesDownloadWorker(context: Context, params: WorkerParameters): FileDownloadWorker {
return FileDownloadWorker(
private fun createFilesDownloadWorker(context: Context, params: WorkerParameters): FileDownloadWorker =
FileDownloadWorker(
viewThemeUtils.get(),
accountManager,
localBroadcastManager.get(),
context,
params
)
}
private fun createPDFGenerateWork(context: Context, params: WorkerParameters): GeneratePdfFromImagesWork {
return GeneratePdfFromImagesWork(
private fun createPDFGenerateWork(context: Context, params: WorkerParameters): GeneratePdfFromImagesWork =
GeneratePdfFromImagesWork(
appContext = context,
generatePdfUseCase = generatePdfUseCase,
viewThemeUtils = viewThemeUtils.get(),
@ -264,23 +255,34 @@ class BackgroundJobFactory @Inject constructor(
logger = logger,
params = params
)
}
private fun createHealthStatusWork(context: Context, params: WorkerParameters): HealthStatusWork {
return HealthStatusWork(
private fun createHealthStatusWork(context: Context, params: WorkerParameters): HealthStatusWork = HealthStatusWork(
context,
params,
accountManager,
arbitraryDataProvider,
backgroundJobManager.get()
)
private fun createTestJob(context: Context, params: WorkerParameters): TestJob = TestJob(
context,
params,
backgroundJobManager.get()
)
private fun createInternalTwoWaySyncWork(context: Context, params: WorkerParameters): InternalTwoWaySyncWork =
InternalTwoWaySyncWork(
context,
params,
accountManager,
arbitraryDataProvider,
backgroundJobManager.get()
powerManagementService,
connectivityService,
preferences
)
}
private fun createTestJob(context: Context, params: WorkerParameters): TestJob {
return TestJob(
context,
params,
backgroundJobManager.get()
)
}
private fun createMetadataWorker(context: Context, params: WorkerParameters): MetadataWorker = MetadataWorker(
context,
params,
accountManager.user
)
}

View file

@ -2,7 +2,7 @@
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
@ -95,7 +95,7 @@ interface BackgroundJobManager {
* @param contactsAccountName Target contacts account name; null for local contacts
* @param contactsAccountType Target contacts account type; null for local contacts
* @param vCardFilePath Path to file containing all contact entries
* @param selectedContacts List of contact indices to import from [vCardFilePath] file
* @param selectedContactsFilePath File path of list of contact indices to import from [vCardFilePath] file
*
* @return Job info with current status; status is null if job does not exist
*/
@ -103,7 +103,7 @@ interface BackgroundJobManager {
contactsAccountName: String?,
contactsAccountType: String?,
vCardFilePath: String,
selectedContacts: IntArray
selectedContactsFilePath: String
): LiveData<JobInfo?>
/**
@ -119,13 +119,19 @@ interface BackgroundJobManager {
fun startImmediateFilesExportJob(files: Collection<OCFile>): LiveData<JobInfo?>
fun schedulePeriodicFilesSyncJob()
fun schedulePeriodicFilesSyncJob(syncedFolderID: Long)
/**
* Immediately start File Sync job for given syncFolderID.
*/
fun startImmediateFilesSyncJob(
syncedFolderID: Long,
overridePowerSaving: Boolean = false,
changedFiles: Array<String> = arrayOf<String>()
changedFiles: Array<String?> = arrayOf<String?>()
)
fun cancelTwoWaySyncJob()
fun scheduleOfflineSync()
fun scheduleMediaFoldersDetectionJob()
@ -133,7 +139,7 @@ interface BackgroundJobManager {
fun startNotificationJob(subject: String, signature: String)
fun startAccountRemovalJob(accountName: String, remoteWipe: Boolean)
fun startFilesUploadJob(user: User)
fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean)
fun getFileUploads(user: User): LiveData<List<JobInfo>>
fun cancelFilesUploadJob(user: User)
fun isStartFileUploadJobScheduled(user: User): Boolean
@ -163,4 +169,10 @@ interface BackgroundJobManager {
fun cancelAllJobs()
fun schedulePeriodicHealthStatus()
fun startHealthStatus()
fun bothFilesSyncJobsRunning(syncedFolderID: Long): Boolean
fun startOfflineOperations()
fun startPeriodicallyOfflineOperation()
fun scheduleInternal2WaySync(intervalMinutes: Long)
fun cancelAllFilesDownloadJobs()
fun startMetadataSyncJob(currentDirPath: String)
}

View file

@ -2,13 +2,14 @@
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
import android.provider.MediaStore
import androidx.lifecycle.LiveData
import androidx.lifecycle.map
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
@ -26,11 +27,18 @@ import com.nextcloud.client.core.Clock
import com.nextcloud.client.di.Injectable
import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
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.upload.FileUploadHelper
import com.nextcloud.client.jobs.upload.FileUploadWorker
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.operations.DownloadType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.Date
import java.util.UUID
import java.util.concurrent.TimeUnit
@ -55,10 +63,10 @@ internal class BackgroundJobManagerImpl(
private val workManager: WorkManager,
private val clock: Clock,
private val preferences: AppPreferences
) : BackgroundJobManager, Injectable {
) : BackgroundJobManager,
Injectable {
companion object {
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"
@ -79,9 +87,12 @@ internal class BackgroundJobManagerImpl(
const val JOB_PDF_GENERATION = "pdf_generation"
const val JOB_IMMEDIATE_CALENDAR_BACKUP = "immediate_calendar_backup"
const val JOB_IMMEDIATE_FILES_EXPORT = "immediate_files_export"
const val JOB_OFFLINE_OPERATIONS = "offline_operations"
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_METADATA_SYNC = "metadata_sync"
const val JOB_INTERNAL_TWO_WAY_SYNC = "internal_two_way_sync"
const val JOB_TEST = "test_job"
@ -95,16 +106,16 @@ internal class BackgroundJobManagerImpl(
const val NOT_SET_VALUE = "not set"
const val PERIODIC_BACKUP_INTERVAL_MINUTES = 24 * 60L
const val DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES = 15L
const val OFFLINE_OPERATIONS_PERIODIC_JOB_INTERVAL_MINUTES = 5L
const val DEFAULT_IMMEDIATE_JOB_DELAY_SEC = 3L
const val DEFAULT_BACKOFF_CRITERIA_DELAY_SEC = 300L
private const val KEEP_LOG_MILLIS = 1000 * 60 * 60 * 24 * 3L
fun formatNameTag(name: String, user: User? = null): String {
return if (user == null) {
"$TAG_PREFIX_NAME:$name"
} else {
"$TAG_PREFIX_NAME:$name ${user.accountName}"
}
fun formatNameTag(name: String, user: User? = null): String = if (user == null) {
"$TAG_PREFIX_NAME:$name"
} else {
"$TAG_PREFIX_NAME:$name ${user.accountName}"
}
fun formatUserTag(user: User): String = "$TAG_PREFIX_USER:${user.accountName}"
@ -121,36 +132,32 @@ internal class BackgroundJobManagerImpl(
}
}
fun parseTimestamp(timestamp: String): Date {
return try {
val ms = timestamp.toLong()
Date(ms)
} catch (ex: NumberFormatException) {
Date(0)
}
fun parseTimestamp(timestamp: String): Date = try {
val ms = timestamp.toLong()
Date(ms)
} catch (ex: NumberFormatException) {
Date(0)
}
/**
* Convert platform [androidx.work.WorkInfo] object into application-specific [JobInfo] model.
* Conversion extracts work metadata from tags.
*/
fun fromWorkInfo(info: WorkInfo?): JobInfo? {
return if (info != null) {
val metadata = mutableMapOf<String, String>()
info.tags.forEach { parseTag(it)?.let { metadata[it.first] = it.second } }
val timestamp = parseTimestamp(metadata.get(TAG_PREFIX_START_TIMESTAMP) ?: "0")
JobInfo(
id = info.id,
state = info.state.toString(),
name = metadata.get(TAG_PREFIX_NAME) ?: NOT_SET_VALUE,
user = metadata.get(TAG_PREFIX_USER) ?: NOT_SET_VALUE,
started = timestamp,
progress = info.progress.getInt("progress", -1),
workerClass = metadata.get(TAG_PREFIX_CLASS) ?: NOT_SET_VALUE
)
} else {
null
}
fun fromWorkInfo(info: WorkInfo?): JobInfo? = if (info != null) {
val metadata = mutableMapOf<String, String>()
info.tags.forEach { parseTag(it)?.let { metadata[it.first] = it.second } }
val timestamp = parseTimestamp(metadata.get(TAG_PREFIX_START_TIMESTAMP) ?: "0")
JobInfo(
id = info.id,
state = info.state.toString(),
name = metadata.get(TAG_PREFIX_NAME) ?: NOT_SET_VALUE,
user = metadata.get(TAG_PREFIX_USER) ?: NOT_SET_VALUE,
started = timestamp,
progress = info.progress.getInt("progress", -1),
workerClass = metadata.get(TAG_PREFIX_CLASS) ?: NOT_SET_VALUE
)
} else {
null
}
fun deleteOldLogs(logEntries: MutableList<LogEntry>): MutableList<LogEntry> {
@ -168,6 +175,8 @@ internal class BackgroundJobManagerImpl(
}
}
private val defaultDispatcherScope = CoroutineScope(Dispatchers.Default)
override fun logStartOfWorker(workerName: String?) {
val logs = deleteOldLogs(preferences.readLogEntry().toMutableList())
@ -195,13 +204,15 @@ internal class BackgroundJobManagerImpl(
private fun oneTimeRequestBuilder(
jobClass: KClass<out ListenableWorker>,
jobName: String,
user: User? = null
user: User? = null,
constraints: Constraints = Constraints.Builder().build()
): OneTimeWorkRequest.Builder {
val builder = OneTimeWorkRequest.Builder(jobClass.java)
.addTag(TAG_ALL)
.addTag(formatNameTag(jobName, user))
.addTag(formatTimeTag(clock.currentTime))
.addTag(formatClassTag(jobClass))
.setConstraints(constraints)
user?.let { builder.addTag(formatUserTag(it)) }
return builder
}
@ -214,7 +225,8 @@ internal class BackgroundJobManagerImpl(
jobName: String,
intervalMins: Long = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES,
flexIntervalMins: Long = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES,
user: User? = null
user: User? = null,
constraints: Constraints = Constraints.Builder().build()
): PeriodicWorkRequest.Builder {
val builder = PeriodicWorkRequest.Builder(
jobClass.java,
@ -227,6 +239,7 @@ internal class BackgroundJobManagerImpl(
.addTag(formatNameTag(jobName, user))
.addTag(formatTimeTag(clock.currentTime))
.addTag(formatClassTag(jobClass))
.setConstraints(constraints)
user?.let { builder.addTag(formatUserTag(it)) }
return builder
}
@ -298,13 +311,13 @@ internal class BackgroundJobManagerImpl(
contactsAccountName: String?,
contactsAccountType: String?,
vCardFilePath: String,
selectedContacts: IntArray
selectedContactsFilePath: String
): LiveData<JobInfo?> {
val data = Data.Builder()
.putString(ContactsImportWork.ACCOUNT_NAME, contactsAccountName)
.putString(ContactsImportWork.ACCOUNT_TYPE, contactsAccountType)
.putString(ContactsImportWork.VCARD_FILE_PATH, vCardFilePath)
.putIntArray(ContactsImportWork.SELECTED_CONTACTS_INDICES, selectedContacts)
.putString(ContactsImportWork.SELECTED_CONTACTS_FILE_PATH, selectedContactsFilePath)
.build()
val constraints = Constraints.Builder()
@ -403,32 +416,134 @@ internal class BackgroundJobManagerImpl(
workManager.cancelJob(JOB_PERIODIC_CALENDAR_BACKUP, user)
}
override fun schedulePeriodicFilesSyncJob() {
override fun bothFilesSyncJobsRunning(syncedFolderID: Long): Boolean =
workManager.isWorkRunning(JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID) &&
workManager.isWorkRunning(JOB_IMMEDIATE_FILES_SYNC + "_" + syncedFolderID)
override fun startPeriodicallyOfflineOperation() {
val inputData = Data.Builder()
.putString(OfflineOperationsWorker.JOB_NAME, JOB_PERIODIC_OFFLINE_OPERATIONS)
.build()
val request = periodicRequestBuilder(
jobClass = OfflineOperationsWorker::class,
jobName = JOB_PERIODIC_OFFLINE_OPERATIONS,
intervalMins = OFFLINE_OPERATIONS_PERIODIC_JOB_INTERVAL_MINUTES
)
.setInputData(inputData)
.build()
workManager.enqueueUniquePeriodicWork(
JOB_PERIODIC_OFFLINE_OPERATIONS,
ExistingPeriodicWorkPolicy.UPDATE,
request
)
}
override fun startOfflineOperations() {
val inputData = Data.Builder()
.putString(OfflineOperationsWorker.JOB_NAME, JOB_OFFLINE_OPERATIONS)
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
// Backoff criteria define how the system should retry the task if it fails.
// LINEAR means each retry will be delayed linearly (e.g., 10s, 20s, 30s...)
// DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES is used as the initial delay duration.
val backoffCriteriaPolicy = BackoffPolicy.LINEAR
val backoffCriteriaDelay = DEFAULT_BACKOFF_CRITERIA_DELAY_SEC
val request =
oneTimeRequestBuilder(OfflineOperationsWorker::class, JOB_OFFLINE_OPERATIONS, constraints = constraints)
.setBackoffCriteria(
backoffCriteriaPolicy,
backoffCriteriaDelay,
TimeUnit.SECONDS
)
.setInputData(inputData)
.build()
workManager.enqueueUniqueWork(
JOB_OFFLINE_OPERATIONS,
ExistingWorkPolicy.KEEP,
request
)
}
override fun schedulePeriodicFilesSyncJob(syncedFolderID: Long) {
val arguments = Data.Builder()
.putLong(FilesSyncWork.SYNCED_FOLDER_ID, syncedFolderID)
.build()
val request = periodicRequestBuilder(
jobClass = FilesSyncWork::class,
jobName = JOB_PERIODIC_FILES_SYNC,
jobName = JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID,
intervalMins = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES
).build()
workManager.enqueueUniquePeriodicWork(JOB_PERIODIC_FILES_SYNC, ExistingPeriodicWorkPolicy.REPLACE, request)
)
.setInputData(arguments)
.build()
workManager.enqueueUniquePeriodicWork(
JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID,
ExistingPeriodicWorkPolicy.REPLACE,
request
)
}
override fun startImmediateFilesSyncJob(
syncedFolderID: Long,
overridePowerSaving: Boolean,
changedFiles: Array<String>
changedFiles: Array<String?>
) {
val arguments = Data.Builder()
.putBoolean(FilesSyncWork.OVERRIDE_POWER_SAVING, overridePowerSaving)
.putStringArray(FilesSyncWork.CHANGED_FILES, changedFiles)
.putLong(FilesSyncWork.SYNCED_FOLDER_ID, syncedFolderID)
.build()
val request = oneTimeRequestBuilder(
jobClass = FilesSyncWork::class,
jobName = JOB_IMMEDIATE_FILES_SYNC
jobName = JOB_IMMEDIATE_FILES_SYNC + "_" + syncedFolderID
)
.setInputData(arguments)
.build()
workManager.enqueueUniqueWork(JOB_IMMEDIATE_FILES_SYNC, ExistingWorkPolicy.APPEND, request)
workManager.enqueueUniqueWork(
JOB_IMMEDIATE_FILES_SYNC + "_" + syncedFolderID,
ExistingWorkPolicy.APPEND,
request
)
}
override fun cancelTwoWaySyncJob() {
workManager.cancelJob(JOB_INTERNAL_TWO_WAY_SYNC)
}
override fun cancelAllFilesDownloadJobs() {
workManager.cancelAllWorkByTag(formatClassTag(FileDownloadWorker::class))
}
override fun startMetadataSyncJob(currentDirPath: String) {
val inputData = Data.Builder()
.putString(MetadataWorker.FILE_PATH, currentDirPath)
.build()
val constrains = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val request = oneTimeRequestBuilder(MetadataWorker::class, JOB_METADATA_SYNC)
.setConstraints(constrains)
.setInputData(inputData)
.build()
workManager.enqueueUniqueWork(
JOB_METADATA_SYNC,
ExistingWorkPolicy.REPLACE,
request
)
}
override fun scheduleOfflineSync() {
@ -491,34 +606,75 @@ internal class BackgroundJobManagerImpl(
workManager.enqueue(request)
}
private fun startFileUploadJobTag(user: User): String {
return JOB_FILES_UPLOAD + user.accountName
private fun startFileUploadJobTag(user: User): String = JOB_FILES_UPLOAD + user.accountName
override fun isStartFileUploadJobScheduled(user: User): Boolean =
workManager.isWorkScheduled(startFileUploadJobTag(user))
/**
* This method supports initiating uploads for various scenarios, including:
* - New upload batches
* - Failed uploads
* - FilesSyncWork
* - ...
*
* @param user The user for whom the upload job is being created.
* @param uploadIds Array of upload IDs to be processed. These IDs originate from multiple sources
* and cannot be determined directly from the account name or a single function
* within the worker.
*/
override fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean) {
defaultDispatcherScope.launch {
val batchSize = FileUploadHelper.MAX_FILE_COUNT
val batches = uploadIds.toList().chunked(batchSize)
val tag = startFileUploadJobTag(user)
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val dataBuilder = Data.Builder()
.putBoolean(
FileUploadWorker.SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION,
showSameFileAlreadyExistsNotification
)
.putString(FileUploadWorker.ACCOUNT, user.accountName)
.putInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, uploadIds.size)
val workRequests = batches.mapIndexed { index, batch ->
dataBuilder
.putLongArray(FileUploadWorker.UPLOAD_IDS, batch.toLongArray())
.putInt(FileUploadWorker.CURRENT_BATCH_INDEX, index)
oneTimeRequestBuilder(FileUploadWorker::class, JOB_FILES_UPLOAD, user)
.addTag(tag)
.setInputData(dataBuilder.build())
.setConstraints(constraints)
.build()
}
// Chain the work requests sequentially
if (workRequests.isNotEmpty()) {
var workChain = workManager.beginUniqueWork(
tag,
ExistingWorkPolicy.APPEND_OR_REPLACE,
workRequests.first()
)
workRequests.drop(1).forEach { request ->
workChain = workChain.then(request)
}
workChain.enqueue()
}
}
}
override fun isStartFileUploadJobScheduled(user: User): Boolean {
return workManager.isWorkScheduled(startFileUploadJobTag(user))
}
private fun startFileDownloadJobTag(user: User, fileId: Long): String =
JOB_FOLDER_DOWNLOAD + user.accountName + fileId
override fun startFilesUploadJob(user: User) {
val data = workDataOf(FileUploadWorker.ACCOUNT to user.accountName)
val tag = startFileUploadJobTag(user)
val request = oneTimeRequestBuilder(FileUploadWorker::class, JOB_FILES_UPLOAD, user)
.addTag(tag)
.setInputData(data)
.build()
workManager.enqueueUniqueWork(tag, ExistingWorkPolicy.KEEP, request)
}
private fun startFileDownloadJobTag(user: User, fileId: Long): String {
return JOB_FOLDER_DOWNLOAD + user.accountName + fileId
}
override fun isStartFileDownloadJobScheduled(user: User, fileId: Long): Boolean {
return workManager.isWorkScheduled(startFileDownloadJobTag(user, fileId))
}
override fun isStartFileDownloadJobScheduled(user: User, fileId: Long): Boolean =
workManager.isWorkScheduled(startFileDownloadJobTag(user, fileId))
override fun startFileDownloadJob(
user: User,
@ -546,7 +702,9 @@ internal class BackgroundJobManagerImpl(
.setInputData(data)
.build()
workManager.enqueueUniqueWork(tag, ExistingWorkPolicy.REPLACE, request)
// Since for each file new FileDownloadWorker going to be scheduled,
// better to use ExistingWorkPolicy.KEEP policy.
workManager.enqueueUniqueWork(tag, ExistingWorkPolicy.KEEP, request)
}
override fun getFileUploads(user: User): LiveData<List<JobInfo>> {
@ -625,4 +783,16 @@ internal class BackgroundJobManagerImpl(
request
)
}
override fun scheduleInternal2WaySync(intervalMinutes: Long) {
val request = periodicRequestBuilder(
jobClass = InternalTwoWaySyncWork::class,
jobName = JOB_INTERNAL_TWO_WAY_SYNC,
intervalMins = intervalMinutes
)
.setInitialDelay(intervalMinutes, TimeUnit.MINUTES)
.build()
workManager.enqueueUniquePeriodicWork(JOB_INTERNAL_TWO_WAY_SYNC, ExistingPeriodicWorkPolicy.UPDATE, request)
}
}

View file

@ -3,7 +3,7 @@
*
* SPDX-FileCopyrightText: 2021 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs

View file

@ -1,10 +1,11 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-FileCopyrightText: 2017 Tobias Kaminsky
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
@ -13,6 +14,7 @@ import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.nextcloud.client.logger.Logger
import com.owncloud.android.lib.common.utils.Log_OC
import net.fortuna.ical4j.data.CalendarBuilder
import third_parties.sufficientlysecure.AndroidCalendar
import third_parties.sufficientlysecure.CalendarSource
@ -28,37 +30,56 @@ class CalendarImportWork(
companion object {
const val TAG = "CalendarImportWork"
const val SELECTED_CALENDARS = "selected_contacts_indices"
}
@Suppress("TooGenericExceptionCaught")
override fun doWork(): Result {
val calendarPaths = inputData.getStringArray(SELECTED_CALENDARS) ?: arrayOf<String>()
val calendars = inputData.keyValueMap as Map<String, AndroidCalendar>
val calendars = inputData.keyValueMap as? Map<*, *>
if (calendars == null) {
logger.d(TAG, "CalendarImportWork cancelled due to null empty input data")
return Result.failure()
}
val calendarBuilder = CalendarBuilder()
for ((path, selectedCalendar) in calendars) {
logger.d(TAG, "Import calendar from $path")
for ((path, selectedCalendarIndex) in calendars) {
try {
if (path !is String || selectedCalendarIndex !is Int) {
logger.d(TAG, "Skipping wrong input data types: $path - $selectedCalendarIndex")
continue
}
val file = File(path)
val calendarSource = CalendarSource(
file.toURI().toURL().toString(),
null,
null,
null,
appContext
)
logger.d(TAG, "Import calendar from $path")
val calendars = AndroidCalendar.loadAll(contentResolver)[0]
val file = File(path)
val calendarSource = CalendarSource(
file.toURI().toURL().toString(),
null,
null,
null,
appContext
)
ProcessVEvent(
appContext,
calendarBuilder.build(calendarSource.stream),
selectedCalendar,
true
).run()
val calendarList = AndroidCalendar.loadAll(contentResolver)
if (selectedCalendarIndex >= calendarList.size) {
logger.d(TAG, "Skipping selectedCalendarIndex out of bound")
continue
}
val selectedCalendar = calendarList[selectedCalendarIndex]
ProcessVEvent(
appContext,
calendarBuilder.build(calendarSource.stream),
selectedCalendar,
true
).run()
} catch (e: Exception) {
Log_OC.e(TAG, "skipping calendarIndex: $selectedCalendarIndex due to: $e")
}
}
logger.d(TAG, "CalendarImportWork successfully completed")
return Result.success()
}
}

View file

@ -2,7 +2,7 @@
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs

View file

@ -4,7 +4,7 @@
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-FileCopyrightText: 2017 Tobias Kaminsky
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
@ -16,12 +16,16 @@ import android.provider.ContactsContract
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.nextcloud.client.logger.Logger
import com.nextcloud.utils.extensions.toIntArray
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment
import com.owncloud.android.ui.fragment.contactsbackup.VCardComparator
import ezvcard.Ezvcard
import ezvcard.VCard
import org.apache.commons.io.FileUtils
import third_parties.ezvcard_android.ContactOperations
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.util.Collections
@ -39,15 +43,27 @@ class ContactsImportWork(
const val ACCOUNT_TYPE = "account_type"
const val ACCOUNT_NAME = "account_name"
const val VCARD_FILE_PATH = "vcard_file_path"
const val SELECTED_CONTACTS_INDICES = "selected_contacts_indices"
const val SELECTED_CONTACTS_FILE_PATH = "selected_contacts_file_path"
}
@Suppress("ComplexMethod", "NestedBlockDepth") // legacy code
@Suppress("ComplexMethod", "NestedBlockDepth", "LongMethod", "ReturnCount") // legacy code
override fun doWork(): Result {
val vCardFilePath = inputData.getString(VCARD_FILE_PATH) ?: ""
val contactsAccountName = inputData.getString(ACCOUNT_NAME)
val contactsAccountType = inputData.getString(ACCOUNT_TYPE)
val selectedContactsIndices = inputData.getIntArray(SELECTED_CONTACTS_INDICES) ?: IntArray(0)
val selectedContactsFilePath = inputData.getString(SELECTED_CONTACTS_FILE_PATH)
if (selectedContactsFilePath == null) {
Log_OC.d(TAG, "selectedContactsFilePath is null")
return Result.failure()
}
val selectedContactsFile = File(selectedContactsFilePath)
if (!selectedContactsFile.exists()) {
Log_OC.d(TAG, "selectedContactsFile not exists")
return Result.failure()
}
val selectedContactsIndices = readCheckedContractsFromFile(selectedContactsFile)
val inputStream = BufferedInputStream(FileInputStream(vCardFilePath))
val vCards = ArrayList<VCard>()
@ -79,16 +95,21 @@ class ContactsImportWork(
cursor.moveToNext()
}
}
for (contactIndex in selectedContactsIndices) {
val vCard = vCards[contactIndex]
if (BackupListFragment.getDisplayName(vCard).isEmpty()) {
if (!ownContactMap.containsKey(vCard)) {
operations.insertContact(vCard)
try {
val vCard = vCards[contactIndex]
if (BackupListFragment.getDisplayName(vCard).isEmpty()) {
if (!ownContactMap.containsKey(vCard)) {
operations.insertContact(vCard)
} else {
operations.updateContact(vCard, ownContactMap[vCard])
}
} else {
operations.updateContact(vCard, ownContactMap[vCard])
operations.insertContact(vCard) // Insert All the contacts without name
}
} else {
operations.insertContact(vCard) // Insert All the contacts without name
} catch (t: Throwable) {
Log_OC.e(TAG, "skipping contactIndex: $contactIndex due to: $t")
}
}
} catch (e: Exception) {
@ -103,9 +124,20 @@ class ContactsImportWork(
logger.e(TAG, "Error closing vCard stream", e)
}
Log_OC.d(TAG, "ContractsImportWork successfully completed")
selectedContactsFile.delete()
return Result.success()
}
@Suppress("TooGenericExceptionCaught")
fun readCheckedContractsFromFile(file: File): IntArray = try {
val fileData = FileUtils.readFileToByteArray(file)
fileData.toIntArray()
} catch (e: Exception) {
Log_OC.e(TAG, "Exception readCheckedContractsFromFile: $e")
intArrayOf()
}
private fun getContactFromCursor(cursor: Cursor): VCard? {
val lookupKey = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.LOOKUP_KEY))
val uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, lookupKey)

View file

@ -2,7 +2,7 @@
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2019 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
@ -12,6 +12,7 @@ import androidx.work.WorkerParameters
import com.nextcloud.client.device.PowerManagementService
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.utils.FilesSyncHelper
/**
* This work is triggered when OS detects change in media folders.
@ -23,7 +24,7 @@ import com.owncloud.android.lib.common.utils.Log_OC
class ContentObserverWork(
appContext: Context,
private val params: WorkerParameters,
private val syncerFolderProvider: SyncedFolderProvider,
private val syncedFolderProvider: SyncedFolderProvider,
private val powerManagementService: PowerManagementService,
private val backgroundJobManager: BackgroundJobManager
) : Worker(appContext, params) {
@ -31,10 +32,12 @@ class ContentObserverWork(
override fun doWork(): Result {
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
if (params.triggeredContentUris.size > 0) {
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()
@ -48,13 +51,19 @@ class ContentObserverWork(
}
private fun checkAndStartFileSyncJob() {
val syncFolders = syncerFolderProvider.countEnabledSyncedFolders() > 0
if (!powerManagementService.isPowerSavingEnabled && syncFolders) {
if (!powerManagementService.isPowerSavingEnabled && syncedFolderProvider.countEnabledSyncedFolders() > 0) {
val changedFiles = mutableListOf<String>()
for (uri in params.triggeredContentUris) {
changedFiles.add(uri.toString())
}
backgroundJobManager.startImmediateFilesSyncJob(false, changedFiles.toTypedArray())
FilesSyncHelper.startFilesSyncForAllFolders(
syncedFolderProvider,
backgroundJobManager,
false,
changedFiles.toTypedArray()
)
} else {
Log_OC.w(TAG, "cant startFilesSyncForAllFolders")
}
}

View file

@ -3,7 +3,7 @@
*
* SPDX-FileCopyrightText: 2022 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
@ -57,6 +57,8 @@ class FilesExportWork(
}
private fun exportFiles(fileIDs: LongArray): Int {
val fileDownloadHelper = FileDownloadHelper.instance()
var successfulExports = 0
fileIDs
.asSequence()
@ -76,7 +78,11 @@ class FilesExportWork(
showErrorNotification(successfulExports)
}
} else {
downloadFile(ocFile)
fileDownloadHelper.downloadFile(
user,
ocFile,
downloadType = DownloadType.EXPORT
)
}
successfulExports++
@ -95,14 +101,6 @@ class FilesExportWork(
)
}
private fun downloadFile(ocFile: OCFile) {
FileDownloadHelper.instance().downloadFile(
user,
ocFile,
downloadType = DownloadType.EXPORT
)
}
private fun showErrorNotification(successfulExports: Int) {
val message = if (successfulExports == 0) {
appContext.resources.getQuantityString(R.plurals.export_failed, successfulExports, successfulExports)

View file

@ -1,21 +1,19 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Jonas Mayer <jonas.mayer@nextcloud.com>
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
import android.content.ContentResolver
import android.content.Context
import android.content.res.Resources
import android.os.Build
import android.text.TextUtils
import androidx.core.app.NotificationCompat
import androidx.exifinterface.media.ExifInterface
import androidx.work.ForegroundInfo
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.UserAccountManager
@ -25,10 +23,8 @@ import com.nextcloud.client.jobs.upload.FileUploadWorker
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.client.preferences.SubFolderRule
import com.owncloud.android.R
import com.owncloud.android.datamodel.ArbitraryDataProvider
import com.owncloud.android.datamodel.ArbitraryDataProviderImpl
import com.owncloud.android.datamodel.FilesystemDataProvider
import com.owncloud.android.datamodel.ForegroundServiceType
import com.owncloud.android.datamodel.MediaFolderType
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.datamodel.SyncedFolderProvider
@ -36,7 +32,6 @@ import com.owncloud.android.datamodel.UploadsStorageManager
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
@ -64,106 +59,172 @@ class FilesSyncWork(
const val TAG = "FilesSyncJob"
const val OVERRIDE_POWER_SAVING = "overridePowerSaving"
const val CHANGED_FILES = "changedFiles"
const val FOREGROUND_SERVICE_ID = 414
const val SYNCED_FOLDER_ID = "syncedFolderId"
}
@Suppress("MagicNumber")
private fun updateForegroundWorker(progressPercent: Int, useForegroundWorker: Boolean) {
if (!useForegroundWorker) {
return
}
private lateinit var syncedFolder: SyncedFolder
// update throughout worker execution to give use feedback how far worker is
val notification = NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_FILE_SYNC)
.setTicker(context.getString(R.string.autoupload_worker_foreground_info))
.setContentText(context.getString(R.string.autoupload_worker_foreground_info))
.setSmallIcon(R.drawable.notification_icon)
.setContentTitle(context.getString(R.string.autoupload_worker_foreground_info))
.setOngoing(true)
.setProgress(100, progressPercent, false)
.build()
val foregroundInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(FOREGROUND_SERVICE_ID, notification, ForegroundServiceType.DataSync.getId())
} else {
ForegroundInfo(FOREGROUND_SERVICE_ID, notification)
}
setForegroundAsync(foregroundInfo)
}
@Suppress("MagicNumber")
@Suppress("MagicNumber", "ReturnCount")
override fun doWork(): Result {
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
Log_OC.d(TAG, "File-sync worker started")
val syncFolderId = inputData.getLong(SYNCED_FOLDER_ID, -1)
val changedFiles = inputData.getStringArray(CHANGED_FILES)
val overridePowerSaving = inputData.getBoolean(OVERRIDE_POWER_SAVING, false)
// If we are in power save mode, better to postpone upload
if (powerManagementService.isPowerSavingEnabled && !overridePowerSaving) {
val result = Result.success()
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
return result
}
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class) + "_" + syncFolderId)
Log_OC.d(TAG, "AutoUpload started folder ID: $syncFolderId")
// Create all the providers we'll need
val resources = context.resources
val lightVersion = resources.getBoolean(R.bool.syncedFolder_light)
FilesSyncHelper.restartJobsIfNeeded(
val filesystemDataProvider = FilesystemDataProvider(contentResolver)
val currentLocale = resources.configuration.locale
val dateFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", currentLocale)
dateFormat.timeZone = TimeZone.getTimeZone(TimeZone.getDefault().id)
if (!setSyncedFolder(syncFolderId)) {
Log_OC.w(TAG, "AutoUpload skipped since syncedFolder ($syncFolderId) is not enabled!")
return logEndOfWorker(syncFolderId)
}
// Always first try to schedule uploads to make sure files are uploaded even if worker was killed to early
uploadFilesFromFolder(
context,
resources,
lightVersion,
filesystemDataProvider,
currentLocale,
dateFormat,
syncedFolder
)
if (canExitEarly(changedFiles, syncFolderId)) {
Log_OC.w(TAG, "AutoUpload skipped canExit conditions are met")
return logEndOfWorker(syncFolderId)
}
val user = userAccountManager.getUser(syncedFolder.account)
if (user.isPresent) {
var uploadIds = uploadsStorageManager.getCurrentUploadIds(user.get().accountName)
backgroundJobManager.startFilesUploadJob(user.get(), uploadIds, false)
}
// Get changed files from ContentObserverWork (only images and videos) or by scanning filesystem
Log_OC.d(
TAG,
"AutoUpload (${syncedFolder.remotePath}) changed files from observer: " +
changedFiles.contentToString()
)
collectChangedFiles(changedFiles)
Log_OC.d(TAG, "AutoUpload (${syncedFolder.remotePath}) finished checking files.")
uploadFilesFromFolder(
context,
resources,
lightVersion,
filesystemDataProvider,
currentLocale,
dateFormat,
syncedFolder
)
FilesSyncHelper.restartUploadsIfNeeded(
uploadsStorageManager,
userAccountManager,
connectivityService,
powerManagementService
)
// Get changed files from ContentObserverWork (only images and videos) or by scanning filesystem
val changedFiles = inputData.getStringArray(CHANGED_FILES)
Log_OC.d(TAG, "File-sync worker changed files from observer: " + changedFiles.contentToString())
collectChangedFiles(changedFiles)
Log_OC.d(TAG, "File-sync worker finished checking files.")
return logEndOfWorker(syncFolderId)
}
// Create all the providers we'll need
val filesystemDataProvider = FilesystemDataProvider(contentResolver)
val currentLocale = resources.configuration.locale
val dateFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", currentLocale)
dateFormat.timeZone = TimeZone.getTimeZone(TimeZone.getDefault().id)
// start upload of changed / new files
val syncedFolders = syncedFolderProvider.syncedFolders
for ((index, syncedFolder) in syncedFolders.withIndex()) {
updateForegroundWorker(
(50 + (index.toDouble() / syncedFolders.size.toDouble()) * 50).toInt(),
changedFiles.isNullOrEmpty()
)
if (syncedFolder.isEnabled) {
syncFolder(
context,
resources,
lightVersion,
filesystemDataProvider,
currentLocale,
dateFormat,
syncedFolder
)
}
}
Log_OC.d(TAG, "File-sync worker finished")
private fun logEndOfWorker(syncFolderId: Long): Result {
Log_OC.d(TAG, "AutoUpload worker (${syncedFolder.remotePath}) finished")
val result = Result.success()
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
backgroundJobManager.logEndOfWorker(
BackgroundJobManagerImpl.formatClassTag(this::class) +
"_" + syncFolderId,
result
)
return result
}
private fun setSyncedFolder(syncedFolderID: Long): Boolean {
val syncedFolderTmp = syncedFolderProvider.getSyncedFolderByID(syncedFolderID)
if (syncedFolderTmp == null || !syncedFolderTmp.isEnabled) {
return false
}
syncedFolder = syncedFolderTmp
return true
}
@Suppress("ReturnCount")
private fun canExitEarly(changedFiles: Array<String>?, syncedFolderID: Long): Boolean {
// If we are in power save mode better to postpone scan and upload
val overridePowerSaving = inputData.getBoolean(OVERRIDE_POWER_SAVING, false)
if ((powerManagementService.isPowerSavingEnabled && !overridePowerSaving)) {
Log_OC.w(TAG, "AutoUpload skipped powerSaving is enabled!")
return true
}
if (syncedFolderID < 0) {
Log_OC.w(TAG, "AutoUpload skipped no valid syncedFolderID provided")
return true
}
// or sync worker already running
if (backgroundJobManager.bothFilesSyncJobsRunning(syncedFolderID)) {
Log_OC.w(TAG, "AutoUpload skipped another worker instance is running for $syncedFolderID")
return true
}
val calculatedScanInterval =
FilesSyncHelper.calculateScanInterval(syncedFolder, connectivityService, powerManagementService)
val totalScanInterval = (syncedFolder.lastScanTimestampMs + calculatedScanInterval)
val currentTime = System.currentTimeMillis()
val passedScanInterval = totalScanInterval <= currentTime
Log_OC.d(TAG, "AutoUpload lastScanTimestampMs: " + syncedFolder.lastScanTimestampMs)
Log_OC.d(TAG, "AutoUpload calculatedScanInterval: $calculatedScanInterval")
Log_OC.d(TAG, "AutoUpload totalScanInterval: $totalScanInterval")
Log_OC.d(TAG, "AutoUpload currentTime: $currentTime")
Log_OC.d(TAG, "AutoUpload passedScanInterval: $passedScanInterval")
if (!passedScanInterval && changedFiles.isNullOrEmpty() && !overridePowerSaving) {
Log_OC.w(
TAG,
"AutoUpload skipped since started before scan interval and nothing todo: " + syncedFolder.localPath
)
return true
}
if (syncedFolder.isChargingOnly &&
!powerManagementService.battery.isCharging &&
!powerManagementService.battery.isFull
) {
Log_OC.w(
TAG,
"AutoUpload skipped since phone is not charging: " + syncedFolder.localPath
)
return true
}
return false
}
@Suppress("MagicNumber")
private fun collectChangedFiles(changedFiles: Array<String>?) {
if (!changedFiles.isNullOrEmpty()) {
FilesSyncHelper.insertChangedEntries(syncedFolderProvider, changedFiles)
FilesSyncHelper.insertChangedEntries(syncedFolder, changedFiles)
} else {
// Check every file in every synced folder for changes and update
// filesystemDataProvider database (potentially needs a long time so use foreground worker)
updateForegroundWorker(5, true)
FilesSyncHelper.insertAllDBEntries(syncedFolderProvider)
updateForegroundWorker(50, true)
// Check every file in synced folder for changes and update
// filesystemDataProvider database (potentially needs a long time)
FilesSyncHelper.insertAllDBEntriesForSyncedFolder(syncedFolder)
}
syncedFolder.lastScanTimestampMs = System.currentTimeMillis()
syncedFolderProvider.updateSyncFolder(syncedFolder)
}
@Suppress("LongMethod") // legacy code
private fun syncFolder(
private fun uploadFilesFromFolder(
context: Context,
resources: Resources,
lightVersion: Boolean,
@ -175,66 +236,90 @@ class FilesSyncWork(
val uploadAction: Int?
val needsCharging: Boolean
val needsWifi: Boolean
var file: File
val accountName = syncedFolder.account
val optionalUser = userAccountManager.getUser(accountName)
if (!optionalUser.isPresent) {
Log_OC.w(TAG, "AutoUpload:uploadFilesFromFolder skipped user not present")
return
}
val user = optionalUser.get()
val arbitraryDataProvider: ArbitraryDataProvider? = if (lightVersion) {
val arbitraryDataProvider = if (lightVersion) {
ArbitraryDataProviderImpl(context)
} else {
null
}
// Ensure only new files are processed for upload.
// Files that have been previously uploaded cannot be re-uploaded,
// even if they have been deleted or moved from the target folder,
// as they are already marked as uploaded in the database.
val paths = filesystemDataProvider.getFilesForUpload(
syncedFolder.localPath,
syncedFolder.id.toString()
)
if (paths.size == 0) {
if (paths.isEmpty()) {
Log_OC.w(TAG, "AutoUpload:uploadFilesFromFolder skipped paths is empty")
return
}
val pathsAndMimes = paths.map { path ->
file = File(path)
val file = File(path)
val localPath = file.absolutePath
val remotePath = getRemotePath(file, syncedFolder, sFormatter, lightVersion, resources, currentLocale)
val mimeType = MimeTypeUtil.getBestMimeTypeByFilename(localPath)
Log_OC.d(TAG, "AutoUpload:pathsAndMimes file.path: ${file.path}")
Log_OC.d(TAG, "AutoUpload:pathsAndMimes localPath: $localPath")
Log_OC.d(TAG, "AutoUpload:pathsAndMimes remotePath: $remotePath")
Log_OC.d(TAG, "AutoUpload:pathsAndMimes mimeType: $mimeType")
Triple(
localPath,
getRemotePath(file, syncedFolder, sFormatter, lightVersion, resources, currentLocale),
MimeTypeUtil.getBestMimeTypeByFilename(localPath)
remotePath,
mimeType
)
}
val localPaths = pathsAndMimes.map { it.first }.toTypedArray()
val remotePaths = pathsAndMimes.map { it.second }.toTypedArray()
if (lightVersion) {
Log_OC.d(TAG, "AutoUpload:uploadFilesFromFolder light version is used")
needsCharging = resources.getBoolean(R.bool.syncedFolder_light_on_charging)
needsWifi = arbitraryDataProvider!!.getBooleanValue(
needsWifi = arbitraryDataProvider?.getBooleanValue(
accountName,
SettingsActivity.SYNCED_FOLDER_LIGHT_UPLOAD_ON_WIFI
)
) ?: true
val uploadActionString = resources.getString(R.string.syncedFolder_light_upload_behaviour)
uploadAction = getUploadAction(uploadActionString)
Log_OC.d(TAG, "AutoUpload upload action is: $uploadAction")
} else {
Log_OC.d(TAG, "AutoUpload:uploadFilesFromFolder not light version is used")
needsCharging = syncedFolder.isChargingOnly
needsWifi = syncedFolder.isWifiOnly
uploadAction = syncedFolder.uploadAction
}
FileUploadHelper.instance().uploadNewFiles(
user,
localPaths,
remotePaths,
uploadAction!!,
true, // create parent folder if not existent
uploadAction,
// create parent folder if not existent
true,
UploadFileOperation.CREATED_AS_INSTANT_PICTURE,
needsWifi,
needsCharging,
syncedFolder.nameCollisionPolicy
syncedFolder.nameCollisionPolicy,
false
)
for (path in paths) {
// TODO batch update
filesystemDataProvider.updateFilesystemFileAsSentForUpload(
path,
syncedFolder.id.toString()
@ -255,10 +340,14 @@ class FilesSyncWork(
val useSubfolders: Boolean
val subFolderRule: SubFolderRule
if (lightVersion) {
Log_OC.d(TAG, "AutoUpload:getRemotePath light version is used")
useSubfolders = resources.getBoolean(R.bool.syncedFolder_light_use_subfolders)
remoteFolder = resources.getString(R.string.syncedFolder_remote_folder)
subFolderRule = SubFolderRule.YEAR_MONTH
} else {
Log_OC.d(TAG, "AutoUpload:getRemotePath not light version is used")
useSubfolders = syncedFolder.isSubfolderByDate
remoteFolder = syncedFolder.remotePath
subFolderRule = syncedFolder.subfolderRule
@ -286,6 +375,8 @@ class FilesSyncWork(
): Long {
var lastModificationTime = file.lastModified()
if (MediaFolderType.IMAGE == syncedFolder.type && hasExif(file)) {
Log_OC.d(TAG, "AutoUpload:calculateLastModificationTime exif found")
@Suppress("TooGenericExceptionCaught") // legacy code
try {
val exifInterface = ExifInterface(file.absolutePath)
@ -294,6 +385,9 @@ class FilesSyncWork(
val pos = ParsePosition(0)
val dateTime = formatter.parse(exifDate, pos)
lastModificationTime = dateTime.time
Log_OC.w(TAG, "AutoUpload:calculateLastModificationTime calculatedTime is: $lastModificationTime")
} else {
Log_OC.w(TAG, "AutoUpload:calculateLastModificationTime exifDate is empty")
}
} catch (e: Exception) {
Log_OC.d(TAG, "Failed to get the proper time " + e.localizedMessage)
@ -302,12 +396,10 @@ class FilesSyncWork(
return lastModificationTime
}
private fun getUploadAction(action: String): Int? {
return when (action) {
"LOCAL_BEHAVIOUR_FORGET" -> FileUploadWorker.LOCAL_BEHAVIOUR_FORGET
"LOCAL_BEHAVIOUR_MOVE" -> FileUploadWorker.LOCAL_BEHAVIOUR_MOVE
"LOCAL_BEHAVIOUR_DELETE" -> FileUploadWorker.LOCAL_BEHAVIOUR_DELETE
else -> FileUploadWorker.LOCAL_BEHAVIOUR_FORGET
}
private fun getUploadAction(action: String): Int = when (action) {
"LOCAL_BEHAVIOUR_FORGET" -> FileUploadWorker.LOCAL_BEHAVIOUR_FORGET
"LOCAL_BEHAVIOUR_MOVE" -> FileUploadWorker.LOCAL_BEHAVIOUR_MOVE
"LOCAL_BEHAVIOUR_DELETE" -> FileUploadWorker.LOCAL_BEHAVIOUR_DELETE
else -> FileUploadWorker.LOCAL_BEHAVIOUR_FORGET
}
}

View file

@ -3,7 +3,7 @@
*
* SPDX-FileCopyrightText: 2023 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs

View file

@ -0,0 +1,136 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Tobias Kaminsky <tobias.kaminsky@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs
import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.client.preferences.AppPreferences
import com.owncloud.android.MainApp
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.SynchronizeFolderOperation
import com.owncloud.android.utils.FileStorageUtils
import java.io.File
@Suppress("Detekt.NestedBlockDepth", "ReturnCount", "LongParameterList")
class InternalTwoWaySyncWork(
private val context: Context,
params: WorkerParameters,
private val userAccountManager: UserAccountManager,
private val powerManagementService: PowerManagementService,
private val connectivityService: ConnectivityService,
private val appPreferences: AppPreferences
) : Worker(context, params) {
private var shouldRun = true
private var operation: SynchronizeFolderOperation? = null
override fun doWork(): Result {
Log_OC.d(TAG, "Worker started!")
var result = true
@Suppress("ComplexCondition")
if (!appPreferences.isTwoWaySyncEnabled ||
powerManagementService.isPowerSavingEnabled ||
!connectivityService.isConnected ||
connectivityService.isInternetWalled ||
!connectivityService.connectivity.isWifi
) {
Log_OC.d(TAG, "Not starting due to constraints!")
return Result.success()
}
val users = userAccountManager.allUsers
for (user in users) {
val fileDataStorageManager = FileDataStorageManager(user, context.contentResolver)
val folders = fileDataStorageManager.getInternalTwoWaySyncFolders(user)
for (folder in folders) {
if (!shouldRun) {
Log_OC.d(TAG, "Worker was stopped!")
return Result.failure()
}
checkFreeSpace(folder)?.let { checkFreeSpaceResult ->
return checkFreeSpaceResult
}
Log_OC.d(TAG, "Folder ${folder.remotePath}: started!")
operation = SynchronizeFolderOperation(context, folder.remotePath, user, fileDataStorageManager, true)
val operationResult = operation?.execute(context)
if (operationResult?.isSuccess == true) {
Log_OC.d(TAG, "Folder ${folder.remotePath}: finished!")
} else {
Log_OC.d(TAG, "Folder ${folder.remotePath} failed!")
result = false
}
folder.apply {
operationResult?.let {
internalFolderSyncResult = it.code.toString()
}
internalFolderSyncTimestamp = System.currentTimeMillis()
}
fileDataStorageManager.saveFile(folder)
}
}
return if (result) {
Log_OC.d(TAG, "Worker finished with success!")
Result.success()
} else {
Log_OC.d(TAG, "Worker finished with failure!")
Result.failure()
}
}
override fun onStopped() {
Log_OC.d(TAG, "OnStopped of worker called!")
operation?.cancel()
shouldRun = false
super.onStopped()
}
@Suppress("TooGenericExceptionCaught")
private fun checkFreeSpace(folder: OCFile): Result? {
val storagePath = folder.storagePath ?: MainApp.getStoragePath()
val file = File(storagePath)
if (!file.exists()) return null
return try {
val freeSpaceLeft = file.freeSpace
val localFolder = File(storagePath, MainApp.getDataFolder())
val localFolderSize = FileStorageUtils.getFolderSize(localFolder)
val remoteFolderSize = folder.fileLength
if (freeSpaceLeft < (remoteFolderSize - localFolderSize)) {
Log_OC.d(TAG, "Not enough space left!")
Result.failure()
} else {
null
}
} catch (e: Exception) {
Log_OC.d(TAG, "Error caught at checkFreeSpace: $e")
null
}
}
companion object {
const val TAG = "InternalTwoWaySyncWork"
}
}

View file

@ -2,7 +2,7 @@
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs

View file

@ -2,7 +2,7 @@
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
@ -27,9 +27,7 @@ class JobsModule {
.build()
val contextWrapper = object : ContextWrapper(context) {
override fun getApplicationContext(): Context {
return this
}
override fun getApplicationContext(): Context = this
}
WorkManager.initialize(contextWrapper, configuration)
@ -42,7 +40,5 @@ class JobsModule {
workManager: WorkManager,
clock: Clock,
preferences: AppPreferences
): BackgroundJobManager {
return BackgroundJobManagerImpl(workManager, clock, preferences)
}
): BackgroundJobManager = BackgroundJobManagerImpl(workManager, clock, preferences)
}

View file

@ -8,7 +8,7 @@
* Copyright (C) 2018 Andy Scherzinger
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
@ -41,7 +41,7 @@ import com.owncloud.android.datamodel.MediaFoldersModel
import com.owncloud.android.datamodel.MediaProvider
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.ui.activity.ManageAccountsActivity.PENDING_FOR_REMOVAL
import com.owncloud.android.ui.activity.ManageAccountsActivity
import com.owncloud.android.ui.activity.SyncedFoldersActivity
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.SyncedFolderUtils
@ -73,7 +73,7 @@ class MediaFoldersDetectionWork constructor(
private val randomIdGenerator = Random(clock.currentTime)
@Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth") // legacy code
@Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth", "ReturnCount") // legacy code
override fun doWork(): Result {
val arbitraryDataProvider: ArbitraryDataProvider = ArbitraryDataProviderImpl(context)
val gson = Gson()
@ -134,7 +134,7 @@ class MediaFoldersDetectionWork constructor(
val allUsers = userAccountManager.allUsers
val activeUsers: MutableList<User> = ArrayList()
for (user in allUsers) {
if (!arbitraryDataProvider.getBooleanValue(user, PENDING_FOR_REMOVAL)) {
if (!arbitraryDataProvider.getBooleanValue(user, ManageAccountsActivity.PENDING_FOR_REMOVAL)) {
activeUsers.add(user)
}
}
@ -190,6 +190,7 @@ class MediaFoldersDetectionWork constructor(
gson.toJson(mediaFoldersModel)
)
}
return Result.success()
}

View file

@ -2,10 +2,11 @@
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
import android.Manifest
import android.accounts.AuthenticatorException
import android.accounts.OperationCanceledException
import android.app.Activity
@ -14,10 +15,12 @@ import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.media.RingtoneManager
import android.text.TextUtils
import android.util.Base64
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.Worker
@ -29,6 +32,7 @@ import com.nextcloud.client.integrations.deck.DeckApi
import com.owncloud.android.R
import com.owncloud.android.datamodel.DecryptedPushMessage
import com.owncloud.android.lib.common.OwnCloudClient
import com.owncloud.android.lib.common.OwnCloudClientFactory
import com.owncloud.android.lib.common.OwnCloudClientManagerFactory
import com.owncloud.android.lib.common.operations.RemoteOperation
import com.owncloud.android.lib.common.utils.Log_OC
@ -223,8 +227,17 @@ class NotificationWork constructor(
}
.build()
)
val notificationManager = NotificationManagerCompat.from(context)
notificationManager.notify(notification.getNotificationId(), notificationBuilder.build())
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
Log_OC.w(this, "Missing permission to post notifications")
} else {
val notificationManager = NotificationManagerCompat.from(context)
notificationManager.notify(notification.getNotificationId(), notificationBuilder.build())
}
}
@Suppress("TooGenericExceptionCaught") // legacy code
@ -236,8 +249,7 @@ class NotificationWork constructor(
}
val user = optionalUser.get()
try {
val client = OwnCloudClientManagerFactory.getDefaultSingleton()
.getClientFor(user.toOwnCloudAccount(), context)
val client = OwnCloudClientFactory.createNextcloudClient(user, context)
val result = GetNotificationRemoteOperation(decryptedPushMessage.nid)
.execute(client)
if (result.isSuccess) {
@ -287,6 +299,7 @@ class NotificationWork constructor(
val user = optionalUser.get()
val client = OwnCloudClientManagerFactory.getDefaultSingleton()
.getClientFor(user.toOwnCloudAccount(), context)
val nextcloudClient = OwnCloudClientFactory.createNextcloudClient(user, context)
val actionType = intent.getStringExtra(KEY_NOTIFICATION_ACTION_TYPE)
val actionLink = intent.getStringExtra(KEY_NOTIFICATION_ACTION_LINK)
val success: Boolean = if (!actionType.isNullOrEmpty() && !actionLink.isNullOrEmpty()) {
@ -294,7 +307,7 @@ class NotificationWork constructor(
resultCode == HttpStatus.SC_OK || resultCode == HttpStatus.SC_ACCEPTED
} else {
DeleteNotificationRemoteOperation(numericNotificationId)
.execute(client).isSuccess
.execute(nextcloudClient).isSuccess
}
if (success) {
if (oldNotification == null) {

View file

@ -6,7 +6,7 @@
* Copyright (C) 2018 Mario Danic
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
@ -28,7 +28,7 @@ import com.owncloud.android.utils.FileStorageUtils
import java.io.File
@Suppress("LongParameterList") // Legacy code
class OfflineSyncWork constructor(
class OfflineSyncWork(
private val context: Context,
params: WorkerParameters,
private val contentResolver: ContentResolver,
@ -65,7 +65,7 @@ class OfflineSyncWork constructor(
return
}
val updatedEtag = checkEtagChanged(folderName, storageManager, user) ?: return
val updatedEtag = checkETagChanged(folderName, storageManager, user) ?: return
// iterate over downloaded files
val files = folder.listFiles { obj: File -> obj.isFile }
@ -77,7 +77,9 @@ class OfflineSyncWork constructor(
user,
true,
context,
storageManager
storageManager,
true,
false
)
synchronizeFileOperation.execute(context)
}
@ -101,41 +103,39 @@ class OfflineSyncWork constructor(
}
/**
* @return new etag if changed, `null` otherwise
* @return new eTag if changed, `null` otherwise
*/
private fun checkEtagChanged(folderName: String, storageManager: FileDataStorageManager, user: User): String? {
val ocFolder = storageManager.getFileByPath(folderName) ?: return null
private fun checkETagChanged(folderName: String, storageManager: FileDataStorageManager, user: User): String? {
val folder = storageManager.getFileByEncryptedRemotePath(folderName) ?: return null
Log_OC.d(TAG, "$folderName: currentEtag: ${ocFolder.etag}")
Log_OC.d(TAG, "$folderName: current eTag: ${folder.etag}")
// check for etag change, if false, skip
val checkEtagOperation = CheckEtagRemoteOperation(
ocFolder.remotePath,
ocFolder.etagOnServer
)
val result = checkEtagOperation.execute(user, context)
val operation = CheckEtagRemoteOperation(folder.remotePath, folder.etagOnServer)
val result = operation.execute(user, context)
return when (result.code) {
ResultCode.ETAG_UNCHANGED -> {
Log_OC.d(TAG, "$folderName: eTag unchanged")
null
}
ResultCode.FILE_NOT_FOUND -> {
val removalResult = storageManager.removeFolder(ocFolder, true, true)
val removalResult = storageManager.removeFolder(folder, true, true)
if (!removalResult) {
Log_OC.e(TAG, "removal of " + ocFolder.storagePath + " failed: file not found")
Log_OC.e(TAG, "removal of " + folder.storagePath + " failed: file not found")
}
null
}
ResultCode.ETAG_CHANGED -> {
Log_OC.d(TAG, "$folderName: eTag changed")
result.data[0] as String
result?.data?.get(0) as? String
}
else -> if (connectivityService.isInternetWalled) {
Log_OC.d(TAG, "No connectivity, skipping sync")
null
} else {
Log_OC.d(TAG, "$folderName: eTag changed")
result.data[0] as String
result?.data?.get(0) as? String
}
}
}

View file

@ -2,7 +2,7 @@
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
@ -11,11 +11,8 @@ import androidx.work.Data
import androidx.work.Worker
import androidx.work.WorkerParameters
class TestJob(
appContext: Context,
params: WorkerParameters,
private val backgroundJobManager: BackgroundJobManager
) : Worker(appContext, params) {
class TestJob(appContext: Context, params: WorkerParameters, private val backgroundJobManager: BackgroundJobManager) :
Worker(appContext, params) {
companion object {
private const val MAX_PROGRESS = 100

View file

@ -0,0 +1,49 @@
/*
* 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.clipboard
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.owncloud.android.lib.common.utils.Log_OC
class ClipboardClearWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val tag = ClipboardClearWorker::class.java.name
companion object {
const val CLIPBOARD_TEXT = "clipboard_text"
}
@Suppress("TooGenericExceptionCaught", "ReturnCount")
override fun doWork(): Result {
try {
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val currentClip = clipboardManager.primaryClip ?: return Result.success()
val clipboardText = currentClip.getItemAt(0).text?.toString() ?: return Result.success()
val copiedText = inputData.getString(CLIPBOARD_TEXT)
if (copiedText != clipboardText) {
return Result.success()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
clipboardManager.clearPrimaryClip()
} else {
val newEmptyClip = ClipData.newPlainText("EmptyClipContent", "")
clipboardManager.setPrimaryClip(newEmptyClip)
}
return Result.success()
} catch (e: Exception) {
Log_OC.e(tag, "Error in clipboard clear worker", e)
return Result.retry()
}
}
}

View file

@ -1,77 +1,49 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.download
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.app.NotificationCompat
import com.nextcloud.client.jobs.notification.WorkerNotificationManager
import com.nextcloud.utils.numberFormatter.NumberFormatter
import com.owncloud.android.R
import com.owncloud.android.lib.resources.files.FileUtils
import com.owncloud.android.operations.DownloadFileOperation
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.theme.ViewThemeUtils
import java.io.File
import java.security.SecureRandom
@Suppress("TooManyFunctions")
class DownloadNotificationManager(
private val id: Int,
private val context: Context,
private val viewThemeUtils: ViewThemeUtils
) {
private var notification: Notification
private var notificationBuilder: NotificationCompat.Builder
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
class DownloadNotificationManager(id: Int, private val context: Context, viewThemeUtils: ViewThemeUtils) :
WorkerNotificationManager(id, context, viewThemeUtils, R.string.downloader_download_in_progress_ticker) {
private var lastPercent = -1
init {
notificationBuilder = NotificationUtils.newNotificationBuilder(context, viewThemeUtils).apply {
setContentTitle(context.getString(R.string.downloader_download_in_progress_ticker))
setTicker(context.getString(R.string.downloader_download_in_progress_ticker))
setSmallIcon(R.drawable.notification_icon)
setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.notification_icon))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD)
}
notificationBuilder.apply {
setSound(null)
setVibrate(null)
setOnlyAlertOnce(true)
setSilent(true)
}
notification = notificationBuilder.build()
}
@Suppress("MagicNumber")
fun prepareForStart(operation: DownloadFileOperation) {
notificationBuilder = NotificationUtils.newNotificationBuilder(context, viewThemeUtils).apply {
setSmallIcon(R.drawable.notification_icon)
setOngoing(true)
currentOperationTitle = File(operation.savePath).name
notificationBuilder.run {
setContentTitle(currentOperationTitle)
setOngoing(false)
setProgress(100, 0, operation.size < 0)
setContentText(
String.format(
context.getString(R.string.downloader_download_in_progress), 0,
File(operation.savePath).name
)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD)
}
notificationManager.notify(
id,
this.build()
)
}
showNotification()
}
fun prepareForResult() {
@ -82,23 +54,21 @@ class DownloadNotificationManager(
}
@Suppress("MagicNumber")
fun updateDownloadProgress(filePath: String, percent: Int, totalToTransfer: Long) {
notificationBuilder.run {
setProgress(100, percent, totalToTransfer < 0)
val fileName: String = filePath.substring(filePath.lastIndexOf(FileUtils.PATH_SEPARATOR) + 1)
val text =
String.format(context.getString(R.string.downloader_download_in_progress), percent, fileName)
val title =
context.getString(R.string.downloader_download_in_progress_ticker)
updateNotificationText(title, text)
fun updateDownloadProgress(percent: Int, totalToTransfer: Long) {
// If downloads are so fast, no need to notify again.
if (percent == lastPercent) {
return
}
lastPercent = percent
val progressText = NumberFormatter.getPercentageText(percent)
setProgress(percent, progressText, totalToTransfer < 0)
showNotification()
}
@Suppress("MagicNumber")
fun dismissNotification() {
Handler(Looper.getMainLooper()).postDelayed({
notificationManager.cancel(id)
}, 2000)
dismissNotification(2000)
}
fun showNewNotification(text: String) {
@ -106,24 +76,12 @@ class DownloadNotificationManager(
notificationBuilder.run {
setProgress(0, 0, false)
setContentTitle(null)
setContentText(text)
setContentTitle(text)
setOngoing(false)
notificationManager.notify(notifyId, this.build())
}
}
private fun updateNotificationText(title: String?, text: String) {
notificationBuilder.run {
title?.let {
setContentTitle(title)
}
setContentText(text)
notificationManager.notify(id, this.build())
}
}
fun setContentIntent(intent: Intent, flag: Int) {
notificationBuilder.setContentIntent(
PendingIntent.getActivity(
@ -134,12 +92,4 @@ class DownloadNotificationManager(
)
)
}
fun getId(): Int {
return id
}
fun getNotification(): Notification {
return notificationBuilder.build()
}
}

View file

@ -2,7 +2,7 @@
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.download
@ -45,9 +45,7 @@ class DownloadTask(
private val clientProvider: () -> OwnCloudClient,
private val contentResolver: ContentResolver
) {
fun create(): DownloadTask {
return DownloadTask(context, contentResolver, clientProvider)
}
fun create(): DownloadTask = DownloadTask(context, contentResolver, clientProvider)
}
// Unused progress, isCancelled arguments needed for TransferManagerTest

View file

@ -1,12 +1,13 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.download
enum class FileDownloadError {
Failed, Cancelled
Failed,
Cancelled
}

View file

@ -1,9 +1,9 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.download
@ -30,10 +30,8 @@ class FileDownloadHelper {
companion object {
private var instance: FileDownloadHelper? = null
fun instance(): FileDownloadHelper {
return instance ?: synchronized(this) {
instance ?: FileDownloadHelper().also { instance = it }
}
fun instance(): FileDownloadHelper = instance ?: synchronized(this) {
instance ?: FileDownloadHelper().also { instance = it }
}
}
@ -50,11 +48,13 @@ class FileDownloadHelper {
val topParentId = fileStorageManager.getTopParentId(file)
val isJobScheduled = backgroundJobManager.isStartFileDownloadJobScheduled(user, file.fileId)
return isJobScheduled || if (file.isFolder) {
backgroundJobManager.isStartFileDownloadJobScheduled(user, topParentId)
} else {
FileDownloadWorker.isDownloading(user.accountName, file.fileId)
}
return isJobScheduled ||
if (file.isFolder) {
FileDownloadWorker.isDownloadingFolder(file.fileId) ||
backgroundJobManager.isStartFileDownloadJobScheduled(user, topParentId)
} else {
FileDownloadWorker.isDownloading(user.accountName, file.fileId)
}
}
fun cancelPendingOrCurrentDownloads(user: User?, files: List<OCFile>?) {
@ -81,11 +81,7 @@ class FileDownloadHelper {
backgroundJobManager.cancelFilesDownloadJob(currentUser, currentFile.fileId)
}
fun saveFile(
file: OCFile,
currentDownload: DownloadFileOperation?,
storageManager: FileDataStorageManager?
) {
fun saveFile(file: OCFile, currentDownload: DownloadFileOperation?, storageManager: FileDataStorageManager?) {
val syncDate = System.currentTimeMillis()
file.apply {

View file

@ -1,9 +1,9 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.download
@ -22,63 +22,53 @@ import com.owncloud.android.ui.preview.PreviewImageFragment
class FileDownloadIntents(private val context: Context) {
fun newDownloadIntent(
download: DownloadFileOperation,
linkedToRemotePath: String
): Intent {
return Intent(FileDownloadWorker.getDownloadAddedMessage()).apply {
fun newDownloadIntent(download: DownloadFileOperation, linkedToRemotePath: String): Intent =
Intent(FileDownloadWorker.getDownloadAddedMessage()).apply {
putExtra(FileDownloadWorker.EXTRA_ACCOUNT_NAME, download.user.accountName)
putExtra(FileDownloadWorker.EXTRA_REMOTE_PATH, download.remotePath)
putExtra(FileDownloadWorker.EXTRA_LINKED_TO_PATH, linkedToRemotePath)
setPackage(context.packageName)
}
}
fun downloadFinishedIntent(
download: DownloadFileOperation,
downloadResult: RemoteOperationResult<*>,
unlinkedFromRemotePath: String?
): Intent {
return Intent(FileDownloadWorker.getDownloadFinishMessage()).apply {
putExtra(FileDownloadWorker.EXTRA_DOWNLOAD_RESULT, downloadResult.isSuccess)
putExtra(FileDownloadWorker.EXTRA_ACCOUNT_NAME, download.user.accountName)
putExtra(FileDownloadWorker.EXTRA_REMOTE_PATH, download.remotePath)
putExtra(OCFileListFragment.DOWNLOAD_BEHAVIOUR, download.behaviour)
putExtra(SendShareDialog.ACTIVITY_NAME, download.activityName)
putExtra(SendShareDialog.PACKAGE_NAME, download.packageName)
if (unlinkedFromRemotePath != null) {
putExtra(FileDownloadWorker.EXTRA_LINKED_TO_PATH, unlinkedFromRemotePath)
}
setPackage(context.packageName)
): Intent = Intent(FileDownloadWorker.getDownloadFinishMessage()).apply {
putExtra(FileDownloadWorker.EXTRA_DOWNLOAD_RESULT, downloadResult.isSuccess)
putExtra(FileDownloadWorker.EXTRA_ACCOUNT_NAME, download.user.accountName)
putExtra(FileDownloadWorker.EXTRA_REMOTE_PATH, download.remotePath)
putExtra(OCFileListFragment.DOWNLOAD_BEHAVIOUR, download.behaviour)
putExtra(SendShareDialog.ACTIVITY_NAME, download.activityName)
putExtra(SendShareDialog.PACKAGE_NAME, download.packageName)
if (unlinkedFromRemotePath != null) {
putExtra(FileDownloadWorker.EXTRA_LINKED_TO_PATH, unlinkedFromRemotePath)
}
setPackage(context.packageName)
}
fun credentialContentIntent(user: User): Intent {
return Intent(context, AuthenticatorActivity::class.java).apply {
putExtra(AuthenticatorActivity.EXTRA_ACCOUNT, user.toPlatformAccount())
putExtra(
AuthenticatorActivity.EXTRA_ACTION,
AuthenticatorActivity.ACTION_UPDATE_EXPIRED_TOKEN
)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
addFlags(Intent.FLAG_FROM_BACKGROUND)
}
fun credentialContentIntent(user: User): Intent = Intent(context, AuthenticatorActivity::class.java).apply {
putExtra(AuthenticatorActivity.EXTRA_ACCOUNT, user.toPlatformAccount())
putExtra(
AuthenticatorActivity.EXTRA_ACTION,
AuthenticatorActivity.ACTION_UPDATE_EXPIRED_TOKEN
)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
addFlags(Intent.FLAG_FROM_BACKGROUND)
}
fun detailsIntent(operation: DownloadFileOperation?): Intent {
return if (operation != null) {
if (PreviewImageFragment.canBePreviewed(operation.file)) {
Intent(context, PreviewImageActivity::class.java)
} else {
Intent(context, FileDisplayActivity::class.java)
}.apply {
putExtra(FileActivity.EXTRA_FILE, operation.file)
putExtra(FileActivity.EXTRA_USER, operation.user)
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
}
fun detailsIntent(operation: DownloadFileOperation?): Intent = if (operation != null) {
if (PreviewImageFragment.canBePreviewed(operation.file)) {
Intent(context, PreviewImageActivity::class.java)
} else {
Intent()
Intent(context, FileDisplayActivity::class.java)
}.apply {
putExtra(FileActivity.EXTRA_FILE, operation.file)
putExtra(FileActivity.EXTRA_USER, operation.user)
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
}
} else {
Intent()
}
}

View file

@ -1,9 +1,9 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.download
@ -16,13 +16,16 @@ import android.util.Pair
import androidx.core.util.component1
import androidx.core.util.component2
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.Worker
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import com.nextcloud.client.account.User
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
import com.owncloud.android.datamodel.ForegroundServiceType
@ -36,11 +39,14 @@ import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCo
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.events.EventBusFactory
import com.owncloud.android.ui.events.FileDownloadProgressEvent
import com.owncloud.android.utils.theme.ViewThemeUtils
import java.security.SecureRandom
import java.util.AbstractList
import java.util.Optional
import java.util.Vector
import java.util.concurrent.ConcurrentHashMap
import kotlin.random.Random
@Suppress("LongParameterList", "TooManyFunctions")
class FileDownloadWorker(
@ -49,12 +55,15 @@ class FileDownloadWorker(
private var localBroadcastManager: LocalBroadcastManager,
private val context: Context,
params: WorkerParameters
) : Worker(context, params), OnAccountsUpdateListener, OnDatatransferProgressListener {
) : CoroutineWorker(context, params),
OnAccountsUpdateListener,
OnDatatransferProgressListener {
companion object {
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 {
@ -62,10 +71,12 @@ class FileDownloadWorker(
}
}
fun isDownloading(accountName: String, fileId: Long): Boolean {
return pendingDownloads.all.any { it.value?.payload?.isMatching(accountName, fileId) == true }
fun isDownloading(accountName: String, fileId: Long): Boolean = pendingDownloads.all.any {
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"
@ -73,19 +84,14 @@ class FileDownloadWorker(
const val ACTIVITY_NAME = "ACTIVITY_NAME"
const val PACKAGE_NAME = "PACKAGE_NAME"
const val CONFLICT_UPLOAD_ID = "CONFLICT_UPLOAD_ID"
const val EXTRA_DOWNLOAD_RESULT = "EXTRA_DOWNLOAD_RESULT"
const val EXTRA_REMOTE_PATH = "EXTRA_REMOTE_PATH"
const val EXTRA_LINKED_TO_PATH = "EXTRA_LINKED_TO_PATH"
const val EXTRA_ACCOUNT_NAME = "EXTRA_ACCOUNT_NAME"
fun getDownloadAddedMessage(): String {
return FileDownloadWorker::class.java.name + "DOWNLOAD_ADDED"
}
fun getDownloadAddedMessage(): String = FileDownloadWorker::class.java.name + "DOWNLOAD_ADDED"
fun getDownloadFinishMessage(): String {
return FileDownloadWorker::class.java.name + "DOWNLOAD_FINISH"
}
fun getDownloadFinishMessage(): String = FileDownloadWorker::class.java.name + "DOWNLOAD_FINISH"
}
private var currentDownload: DownloadFileOperation? = null
@ -95,7 +101,7 @@ class FileDownloadWorker(
private val intents = FileDownloadIntents(context)
private var notificationManager = DownloadNotificationManager(
SecureRandom().nextInt(),
Random.nextInt(),
context,
viewThemeUtils
)
@ -110,18 +116,17 @@ class FileDownloadWorker(
private var downloadError: FileDownloadError? = null
@Suppress("TooGenericExceptionCaught")
override fun doWork(): Result {
return try {
val requestDownloads = getRequestDownloads()
addAccountUpdateListener()
@Suppress("TooGenericExceptionCaught", "ReturnCount")
override suspend fun doWork(): Result {
val foregroundInfo = createWorkerForegroundInfo()
setForeground(foregroundInfo)
val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
notificationManager.getId(),
notificationManager.getNotification(),
ForegroundServiceType.DataSync
)
setForegroundAsync(foregroundInfo)
return try {
setUser()
val remotePath = inputData.keyValueMap[FILE_REMOTE_PATH] as String? ?: return Result.failure()
val ocFile = fileDataStorageManager?.getFileByEncryptedRemotePath(remotePath) ?: return Result.failure()
val requestDownloads = getRequestDownloads(ocFile)
addAccountUpdateListener()
requestDownloads.forEach {
downloadFile(it)
@ -132,43 +137,43 @@ class FileDownloadWorker(
notificationManager.dismissNotification()
}
setIdleWorkerState()
Log_OC.e(TAG, "FilesDownloadWorker successfully completed")
Result.success()
} catch (t: Throwable) {
notificationManager.dismissNotification()
notificationManager.showNewNotification(context.getString(R.string.downloader_unexpected_error))
Log_OC.e(TAG, "Error caught at FilesDownloadWorker(): " + t.localizedMessage)
setIdleWorkerState()
Result.failure()
} finally {
Log_OC.e(TAG, "FilesDownloadWorker cleanup")
notificationManager.dismissNotification()
setIdleWorkerState()
}
}
override fun onStopped() {
Log_OC.e(TAG, "FilesDownloadWorker stopped")
notificationManager.dismissNotification()
setIdleWorkerState()
super.onStopped()
}
private fun createWorkerForegroundInfo(): ForegroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
notificationManager.getId(),
notificationManager.getNotification(),
ForegroundServiceType.DataSync
)
private fun setWorkerState(user: User?) {
WorkerStateLiveData.instance().setWorkState(WorkerState.Download(user, currentDownload))
WorkerStateLiveData.instance().setWorkState(WorkerState.DownloadStarted(user, currentDownload))
}
private fun setIdleWorkerState() {
WorkerStateLiveData.instance().setWorkState(WorkerState.Idle)
WorkerStateLiveData.instance().setWorkState(WorkerState.DownloadFinished(getCurrentFile()))
}
private fun removePendingDownload(accountName: String?) {
pendingDownloads.remove(accountName)
}
private fun getRequestDownloads(): AbstractList<String> {
setUser()
val files = getFiles()
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?
@ -221,15 +226,10 @@ class FileDownloadWorker(
fileDataStorageManager = FileDataStorageManager(user, context.contentResolver)
}
private fun getFiles(): List<OCFile> {
val remotePath = inputData.keyValueMap[FILE_REMOTE_PATH] as String?
val file = fileDataStorageManager?.getFileByEncryptedRemotePath(remotePath) ?: return listOf()
return if (file.isFolder) {
fileDataStorageManager?.getAllFilesRecursivelyInsideFolder(file) ?: listOf()
} else {
listOf(file)
}
private fun getFiles(file: OCFile): List<OCFile> = if (file.isFolder) {
fileDataStorageManager?.getAllFilesRecursivelyInsideFolder(file) ?: listOf()
} else {
listOf(file)
}
private fun getDownloadType(): DownloadType? {
@ -267,7 +267,12 @@ class FileDownloadWorker(
return
}
notifyDownloadStart(currentDownload!!)
lastPercent = 0
notificationManager.run {
prepareForStart(currentDownload!!)
setContentIntent(intents.detailsIntent(currentDownload!!), PendingIntent.FLAG_IMMUTABLE)
}
var downloadResult: RemoteOperationResult<*>? = null
try {
val ocAccount = getOCAccountForDownload()
@ -288,15 +293,6 @@ class FileDownloadWorker(
}
}
private fun notifyDownloadStart(download: DownloadFileOperation) {
lastPercent = 0
notificationManager.run {
prepareForStart(download)
setContentIntent(intents.detailsIntent(download), PendingIntent.FLAG_IMMUTABLE)
}
}
@Suppress("DEPRECATION")
private fun getOCAccountForDownload(): OwnCloudAccount {
val currentDownloadAccount = currentDownload?.user?.toPlatformAccount()
@ -350,6 +346,7 @@ class FileDownloadWorker(
private fun checkDownloadError(result: RemoteOperationResult<*>) {
if (result.isSuccess || downloadError != null) {
notificationManager.dismissNotification()
return
}
@ -365,6 +362,7 @@ class FileDownloadWorker(
FileDownloadError.Cancelled -> {
context.getString(R.string.downloader_file_download_cancelled)
}
FileDownloadError.Failed -> {
context.getString(R.string.downloader_file_download_failed)
}
@ -373,10 +371,7 @@ class FileDownloadWorker(
notificationManager.showNewNotification(text)
}
private fun notifyDownloadResult(
download: DownloadFileOperation,
downloadResult: RemoteOperationResult<*>
) {
private fun notifyDownloadResult(download: DownloadFileOperation, downloadResult: RemoteOperationResult<*>) {
if (downloadResult.isCancelled) {
return
}
@ -404,6 +399,10 @@ class FileDownloadWorker(
}
}
@Suppress("MagicNumber")
private val minProgressUpdateInterval = 750
private var lastUpdateTime = 0L
@Suppress("MagicNumber")
override fun onTransferProgress(
progressRate: Long,
@ -411,23 +410,25 @@ class FileDownloadWorker(
totalToTransfer: Long,
filePath: String
) {
val percent: Int = (100.0 * totalTransferredSoFar.toDouble() / totalToTransfer.toDouble()).toInt()
val percent: Int = downloadProgressListener.getPercent(totalTransferredSoFar, totalToTransfer)
val currentTime = System.currentTimeMillis()
if (percent != lastPercent) {
if (percent != lastPercent && (currentTime - lastUpdateTime) >= minProgressUpdateInterval) {
notificationManager.run {
updateDownloadProgress(filePath, percent, totalToTransfer)
updateDownloadProgress(percent, totalToTransfer)
}
lastUpdateTime = currentTime
}
lastPercent = percent
EventBusFactory.downloadProgressEventBus.post(FileDownloadProgressEvent(percent))
}
// CHECK: Is this class still needed after conversion from Foreground Services to Worker?
inner class FileDownloadProgressListener : OnDatatransferProgressListener {
private val boundListeners: MutableMap<Long, OnDatatransferProgressListener> = HashMap()
fun isDownloading(user: User?, file: OCFile?): Boolean {
return FileDownloadHelper.instance().isDownloading(user, file)
}
fun isDownloading(user: User?, file: OCFile?): Boolean = FileDownloadHelper.instance().isDownloading(user, file)
fun addDataTransferProgressListener(listener: OnDatatransferProgressListener?, file: OCFile?) {
if (file == null || listener == null) {

View file

@ -0,0 +1,83 @@
/*
* 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.metadata
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.User
import com.nextcloud.utils.extensions.getNonEncryptedSubfolders
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.RefreshFolderOperation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class MetadataWorker(private val context: Context, params: WorkerParameters, private val user: User) :
CoroutineWorker(context, params) {
companion object {
private const val TAG = "MetadataWorker"
const val FILE_PATH = "file_path"
}
@Suppress("DEPRECATION", "ReturnCount")
override suspend fun doWork(): Result {
val storageManager = FileDataStorageManager(user, context.contentResolver)
val filePath = inputData.getString(FILE_PATH)
if (filePath == null) {
Log_OC.e(TAG, "❌ Invalid folder path. Aborting metadata sync. $filePath")
return Result.failure()
}
val currentDir = storageManager.getFileByDecryptedRemotePath(filePath)
if (currentDir == null) {
Log_OC.e(TAG, "❌ Current directory is null. Aborting metadata sync. $filePath")
return Result.failure()
}
Log_OC.d(TAG, "🕒 Starting metadata sync for folder: $filePath")
// first check current dir
refreshFolder(currentDir, storageManager)
// then get up-to-date subfolders
val subfolders = storageManager.getNonEncryptedSubfolders(currentDir.fileId, user.accountName)
subfolders.forEach { subFolder ->
refreshFolder(subFolder, storageManager)
}
Log_OC.d(TAG, "🏁 Metadata sync completed for folder: $filePath")
return Result.success()
}
@Suppress("DEPRECATION")
private suspend fun refreshFolder(folder: OCFile, storageManager: FileDataStorageManager) =
withContext(Dispatchers.IO) {
Log_OC.d(
TAG,
"📂 eTag check\n" +
" Path: " + folder.remotePath + "\n" +
" eTag: " + folder.etag + "\n" +
" eTagOnServer: " + folder.etagOnServer
)
if (!folder.isEtagChanged) {
Log_OC.d(TAG, "Skipping ${folder.remotePath}, eTag didn't change")
return@withContext
}
Log_OC.d(TAG, "⏳ Fetching metadata for: ${folder.remotePath}")
val operation = RefreshFolderOperation(folder, storageManager, user, context)
val result = operation.execute(user, context)
if (result.isSuccess) {
Log_OC.d(TAG, "✅ Successfully fetched metadata for: ${folder.remotePath}")
} else {
Log_OC.e(TAG, "❌ Failed to fetch metadata for: ${folder.remotePath}")
}
}
}

View file

@ -0,0 +1,70 @@
/*
* 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.notification
import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import android.graphics.BitmapFactory
import android.os.Handler
import android.os.Looper
import androidx.core.app.NotificationCompat
import com.owncloud.android.R
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.theme.ViewThemeUtils
open class WorkerNotificationManager(
private val id: Int,
private val context: Context,
viewThemeUtils: ViewThemeUtils,
private val tickerId: Int,
private val channelId: String = NotificationUtils.NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS
) {
var currentOperationTitle: String? = null
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
var notificationBuilder: NotificationCompat.Builder =
NotificationUtils.newNotificationBuilder(
context,
channelId,
viewThemeUtils
).apply {
setTicker(context.getString(tickerId))
setSmallIcon(R.drawable.notification_icon)
setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.notification_icon))
setStyle(NotificationCompat.BigTextStyle())
priority = NotificationCompat.PRIORITY_LOW
}
fun showNotification() {
notificationManager.notify(id, notificationBuilder.build())
}
@Suppress("MagicNumber")
fun setProgress(percent: Int, progressText: String?, indeterminate: Boolean) {
notificationBuilder.run {
setProgress(100, percent, indeterminate)
setContentTitle(currentOperationTitle)
progressText?.let {
setContentText(progressText)
}
}
}
fun dismissNotification(delay: Long = 0) {
Handler(Looper.getMainLooper()).postDelayed({
notificationManager.cancel(id)
}, delay)
}
fun getId(): Int = id
fun getNotification(): Notification = notificationBuilder.build()
}

View file

@ -0,0 +1,177 @@
/*
* 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.offlineOperations
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import com.nextcloud.client.database.entity.OfflineOperationEntity
import com.nextcloud.client.jobs.notification.WorkerNotificationManager
import com.nextcloud.client.jobs.offlineOperations.receiver.OfflineOperationReceiver
import com.nextcloud.utils.extensions.getErrorMessage
import com.owncloud.android.R
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.common.operations.RemoteOperation
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.ui.activity.ConflictsResolveActivity
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.theme.ViewThemeUtils
class OfflineOperationsNotificationManager(private val context: Context, viewThemeUtils: ViewThemeUtils) :
WorkerNotificationManager(
ID,
context,
viewThemeUtils,
tickerId = R.string.offline_operations_worker_notification_manager_ticker,
channelId = NotificationUtils.NOTIFICATION_CHANNEL_OFFLINE_OPERATIONS
) {
companion object {
private const val ID = 121
const val ERROR_ID = 122
private const val ONE_HUNDRED_PERCENT = 100
}
init {
notificationBuilder.apply {
setSound(null)
setVibrate(null)
setOnlyAlertOnce(true)
setSilent(true)
}
}
fun start() {
notificationBuilder.run {
setContentTitle(context.getString(R.string.offline_operations_worker_notification_start_text))
setProgress(ONE_HUNDRED_PERCENT, 0, false)
}
showNotification()
}
fun update(totalOperationSize: Int, currentOperationIndex: Int, filename: String) {
val title = if (totalOperationSize > 1) {
String.format(
context.getString(R.string.offline_operations_worker_progress_text),
currentOperationIndex,
totalOperationSize,
filename
)
} else {
filename
}
val progress = (currentOperationIndex * ONE_HUNDRED_PERCENT) / totalOperationSize
notificationBuilder.run {
setContentTitle(title)
setProgress(ONE_HUNDRED_PERCENT, progress, false)
}
showNotification()
}
fun showNewNotification(id: Int?, result: RemoteOperationResult<*>, operation: RemoteOperation<*>) {
val reason = (result to operation).getErrorMessage()
val text = context.getString(R.string.offline_operations_worker_notification_error_text, reason)
val cancelOfflineOperationAction = id?.let { getCancelOfflineOperationAction(it) }
notificationBuilder.run {
cancelOfflineOperationAction?.let {
addAction(it)
}
setContentTitle(text)
setOngoing(false)
setProgress(0, 0, false)
notificationManager.notify(ERROR_ID, this.build())
}
}
fun showConflictNotificationForDeleteOrRemoveOperation(entity: OfflineOperationEntity?) {
val id = entity?.id
if (id == null) {
return
}
val title = entity.getConflictText(context)
notificationBuilder
.setProgress(0, 0, false)
.setOngoing(false)
.clearActions()
.setContentTitle(title)
notificationManager.notify(id, notificationBuilder.build())
}
fun showConflictResolveNotification(file: OCFile, entity: OfflineOperationEntity?) {
val path = entity?.path
val id = entity?.id
if (path == null || id == null) {
return
}
val resolveConflictAction = getResolveConflictAction(file, id, path)
val title = entity.getConflictText(context)
notificationBuilder
.setProgress(0, 0, false)
.setOngoing(false)
.clearActions()
.setContentTitle(title)
.setContentIntent(resolveConflictAction.actionIntent)
.addAction(resolveConflictAction)
notificationManager.notify(id, notificationBuilder.build())
}
private fun getResolveConflictAction(file: OCFile, id: Int, path: String): NotificationCompat.Action {
val intent = ConflictsResolveActivity.createIntent(file, path, context)
val pendingIntent = PendingIntent.getActivity(
context,
id,
intent,
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Action(
R.drawable.ic_cloud_upload,
context.getString(R.string.upload_list_resolve_conflict),
pendingIntent
)
}
private fun getCancelOfflineOperationAction(id: Int): NotificationCompat.Action {
val intent = Intent(context, OfflineOperationReceiver::class.java).apply {
putExtra(OfflineOperationReceiver.ID, id)
}
val pendingIntent = PendingIntent.getBroadcast(
context,
id,
intent,
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Action(
R.drawable.ic_delete,
context.getString(R.string.common_cancel),
pendingIntent
)
}
fun dismissNotification(id: Int?) {
if (id == null) return
notificationManager.cancel(id)
}
}

View file

@ -0,0 +1,301 @@
/*
* 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.offlineOperations
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.User
import com.nextcloud.client.database.entity.OfflineOperationEntity
import com.nextcloud.client.jobs.offlineOperations.repository.OfflineOperationsRepository
import com.nextcloud.client.network.ClientFactoryImpl
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.model.OfflineOperationType
import com.nextcloud.model.WorkerState
import com.nextcloud.model.WorkerStateLiveData
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.operations.RemoteOperation
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation
import com.owncloud.android.lib.resources.files.ReadFolderRemoteOperation
import com.owncloud.android.lib.resources.files.UploadFileRemoteOperation
import com.owncloud.android.lib.resources.files.model.RemoteFile
import com.owncloud.android.operations.CreateFolderOperation
import com.owncloud.android.operations.RemoveFileOperation
import com.owncloud.android.operations.RenameFileOperation
import com.owncloud.android.utils.MimeTypeUtil
import com.owncloud.android.utils.theme.ViewThemeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
private typealias OfflineOperationResult = Pair<RemoteOperationResult<*>?, RemoteOperation<*>?>?
class OfflineOperationsWorker(
private val user: User,
private val context: Context,
private val connectivityService: ConnectivityService,
viewThemeUtils: ViewThemeUtils,
params: WorkerParameters
) : CoroutineWorker(context, params) {
companion object {
private val TAG = OfflineOperationsWorker::class.java.simpleName
const val JOB_NAME = "JOB_NAME"
private const val ONE_SECOND = 1000L
}
private val fileDataStorageManager = FileDataStorageManager(user, context.contentResolver)
private val clientFactory = ClientFactoryImpl(context)
private val notificationManager = OfflineOperationsNotificationManager(context, viewThemeUtils)
private var repository = OfflineOperationsRepository(fileDataStorageManager)
@Suppress("TooGenericExceptionCaught")
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
try {
val jobName = inputData.getString(JOB_NAME)
Log_OC.d(TAG, "[$jobName] OfflineOperationsWorker started for user: ${user.accountName}")
// check network connection
if (!isNetworkAndServerAvailable()) {
Log_OC.w(TAG, "⚠️ No internet/server connection. Retrying later...")
return@withContext Result.retry()
}
// check offline operations
val operations = fileDataStorageManager.offlineOperationDao.getAll()
if (operations.isEmpty()) {
Log_OC.d(TAG, "Skipping, no offline operation found")
return@withContext Result.success()
}
// process offline operations
notificationManager.start()
val client = clientFactory.create(user)
processOperations(operations, client)
// finish
WorkerStateLiveData.instance().setWorkState(WorkerState.OfflineOperationsCompleted)
Log_OC.d(TAG, "🏁 Worker finished with result")
return@withContext Result.success()
} catch (e: Exception) {
Log_OC.e(TAG, "💥 ProcessOperations failed: ${e.message}")
return@withContext Result.failure()
} finally {
notificationManager.dismissNotification()
}
}
// region Handle offline operations
@Suppress("TooGenericExceptionCaught")
private suspend fun processOperations(operations: List<OfflineOperationEntity>, client: OwnCloudClient) {
val totalOperationSize = operations.size
operations.forEachIndexed { index, operation ->
try {
Log_OC.d(TAG, "Processing operation, path: ${operation.path}")
val result = executeOperation(operation, client)
handleResult(operation, totalOperationSize, index, result)
} catch (e: Exception) {
Log_OC.e(TAG, "💥 Exception while processing operation id=${operation.id}: ${e.message}")
}
}
}
private fun handleResult(
operation: OfflineOperationEntity,
totalOperations: Int,
currentSuccessfulOperationIndex: Int,
result: OfflineOperationResult
) {
val operationResult = result?.first ?: return
val logMessage = if (operationResult.isSuccess) "Operation completed" else "Operation failed"
Log_OC.d(TAG, "$logMessage filename: ${operation.filename}, type: ${operation.type}")
return if (result.first?.isSuccess == true) {
handleSuccessResult(operation, totalOperations, currentSuccessfulOperationIndex)
} else {
handleErrorResult(operation.id, result)
}
}
private fun handleSuccessResult(
operation: OfflineOperationEntity,
totalOperations: Int,
currentSuccessfulOperationIndex: Int
) {
if (operation.type is OfflineOperationType.RemoveFile) {
val operationType = operation.type as OfflineOperationType.RemoveFile
fileDataStorageManager.getFileByDecryptedRemotePath(operationType.path)?.let { ocFile ->
repository.deleteOperation(ocFile)
}
} else {
repository.updateNextOperations(operation)
}
fileDataStorageManager.offlineOperationDao.delete(operation)
notificationManager.update(totalOperations, currentSuccessfulOperationIndex + 1, operation.filename ?: "")
}
private fun handleErrorResult(id: Int?, result: OfflineOperationResult) {
val operationResult = result?.first ?: return
val operation = result.second ?: return
Log_OC.e(TAG, "❌ Operation failed [id=$id]: code=${operationResult.code}, message=${operationResult.message}")
val excludedErrorCodes =
listOf(RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS, RemoteOperationResult.ResultCode.LOCKED)
if (!excludedErrorCodes.contains(operationResult.code)) {
notificationManager.showNewNotification(id, operationResult, operation)
} else {
Log_OC.d(TAG, " Ignored error: ${operationResult.code}")
}
}
// endregion
private suspend fun isNetworkAndServerAvailable(): Boolean = suspendCoroutine { continuation ->
connectivityService.isNetworkAndServerAvailable { result ->
continuation.resume(result)
}
}
// region Operation Execution
@Suppress("ComplexCondition", "LongMethod")
private suspend fun executeOperation(
operation: OfflineOperationEntity,
client: OwnCloudClient
): OfflineOperationResult? = withContext(Dispatchers.IO) {
var path = (operation.path)
if (path == null) {
Log_OC.w(TAG, "⚠️ Skipped: path is null for operation id=${operation.id}")
return@withContext null
}
if (operation.type is OfflineOperationType.CreateFile && path.endsWith(OCFile.PATH_SEPARATOR)) {
Log_OC.w(
TAG,
"Create file operation should not ends with path separator removing suffix, " +
"operation id=${operation.id}"
)
path = path.removeSuffix(OCFile.PATH_SEPARATOR)
}
val remoteFile = getRemoteFile(path)
val ocFile = fileDataStorageManager.getFileByDecryptedRemotePath(path)
if (remoteFile != null && ocFile != null && isFileChanged(remoteFile, ocFile)) {
Log_OC.w(TAG, "⚠️ Conflict detected: File already exists on server. Skipping operation id=${operation.id}")
if (operation.isRenameOrRemove()) {
Log_OC.d(TAG, "🗑 Removing conflicting rename/remove operation id=${operation.id}")
fileDataStorageManager.offlineOperationDao.delete(operation)
notificationManager.showConflictNotificationForDeleteOrRemoveOperation(operation)
} else {
Log_OC.d(TAG, "📌 Showing conflict resolution for operation id=${operation.id}")
notificationManager.showConflictResolveNotification(ocFile, operation)
}
return@withContext null
}
if (operation.isRenameOrRemove() && ocFile == null) {
Log_OC.d(TAG, "Skipping, attempting to delete or rename non-existing file")
fileDataStorageManager.offlineOperationDao.delete(operation)
return@withContext null
}
if (operation.isCreate() && remoteFile != null && ocFile != null && !isFileChanged(remoteFile, ocFile)) {
Log_OC.d(TAG, "Skipping, attempting to create same file creation")
fileDataStorageManager.offlineOperationDao.delete(operation)
return@withContext null
}
return@withContext when (val type = operation.type) {
is OfflineOperationType.CreateFolder -> {
Log_OC.d(TAG, "📂 Creating folder at ${type.path}")
createFolder(operation, client)
}
is OfflineOperationType.CreateFile -> {
Log_OC.d(TAG, "📤 Uploading file: local=${type.localPath} → remote=${type.remotePath}")
createFile(operation, client)
}
is OfflineOperationType.RenameFile -> {
Log_OC.d(TAG, "✏️ Renaming ${operation.path}${type.newName}")
renameFile(operation, client)
}
is OfflineOperationType.RemoveFile -> {
Log_OC.d(TAG, "🗑 Removing file: ${operation.path}")
ocFile?.let { removeFile(it, client) }
}
else -> {
Log_OC.d(TAG, "⚠️ Unsupported operation type: $type")
null
}
}
}
@Suppress("DEPRECATION")
private fun createFolder(operation: OfflineOperationEntity, client: OwnCloudClient): OfflineOperationResult {
val operationType = (operation.type as OfflineOperationType.CreateFolder)
val createFolderOperation = CreateFolderOperation(operationType.path, user, context, fileDataStorageManager)
return createFolderOperation.execute(client) to createFolderOperation
}
@Suppress("DEPRECATION")
private fun createFile(operation: OfflineOperationEntity, client: OwnCloudClient): OfflineOperationResult {
val operationType = (operation.type as OfflineOperationType.CreateFile)
val lastModificationDate = System.currentTimeMillis() / ONE_SECOND
val createFileOperation = UploadFileRemoteOperation(
operationType.localPath,
operationType.remotePath,
operationType.mimeType,
"",
operation.modifiedAt ?: lastModificationDate,
operation.createdAt ?: System.currentTimeMillis(),
true
)
return createFileOperation.execute(client) to createFileOperation
}
@Suppress("DEPRECATION")
private fun renameFile(operation: OfflineOperationEntity, client: OwnCloudClient): OfflineOperationResult {
val operationType = (operation.type as OfflineOperationType.RenameFile)
val renameFileOperation = RenameFileOperation(operation.path, operationType.newName, fileDataStorageManager)
return renameFileOperation.execute(client) to renameFileOperation
}
@Suppress("DEPRECATION")
private fun removeFile(ocFile: OCFile, client: OwnCloudClient): OfflineOperationResult {
val removeFileOperation = RemoveFileOperation(ocFile, false, user, true, context, fileDataStorageManager)
return removeFileOperation.execute(client) to removeFileOperation
}
// endregion
@Suppress("DEPRECATION")
private fun getRemoteFile(remotePath: String): RemoteFile? {
val mimeType = MimeTypeUtil.getMimeTypeFromPath(remotePath)
val isFolder = MimeTypeUtil.isFolder(mimeType)
val client = ClientFactoryImpl(context).create(user)
val result = if (isFolder) {
ReadFolderRemoteOperation(remotePath).execute(client)
} else {
ReadFileRemoteOperation(remotePath).execute(client)
}
return if (result.isSuccess) {
result.data[0] as? RemoteFile
} else {
null
}
}
private fun isFileChanged(remoteFile: RemoteFile, ocFile: OCFile): Boolean = remoteFile.etag != ocFile.etagOnServer
}

View file

@ -0,0 +1,41 @@
/*
* 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.offlineOperations.receiver
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsNotificationManager
import com.owncloud.android.MainApp
import com.owncloud.android.datamodel.FileDataStorageManager
import javax.inject.Inject
class OfflineOperationReceiver : BroadcastReceiver() {
companion object {
const val ID = "id"
}
@Inject
lateinit var storageManager: FileDataStorageManager
override fun onReceive(context: Context, intent: Intent) {
MainApp.getAppComponent().inject(this)
val id = intent.getIntExtra(ID, -1)
if (id == -1) {
return
}
storageManager.offlineOperationDao.deleteById(id)
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(
OfflineOperationsNotificationManager.ERROR_ID
)
}
}

View file

@ -0,0 +1,114 @@
/*
* 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.offlineOperations.repository
import com.nextcloud.client.database.entity.OfflineOperationEntity
import com.nextcloud.model.OfflineOperationType
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.utils.MimeType
import com.owncloud.android.utils.MimeTypeUtil
class OfflineOperationsRepository(private val fileDataStorageManager: FileDataStorageManager) :
OfflineOperationsRepositoryType {
private val dao = fileDataStorageManager.offlineOperationDao
private val pathSeparator = '/'
@Suppress("NestedBlockDepth")
override fun getAllSubEntities(fileId: Long): List<OfflineOperationEntity> {
val result = mutableListOf<OfflineOperationEntity>()
val queue = ArrayDeque<Long>()
queue.add(fileId)
val processedIds = mutableSetOf<Long>()
while (queue.isNotEmpty()) {
val currentFileId = queue.removeFirst()
if (currentFileId in processedIds || currentFileId == 1L) continue
processedIds.add(currentFileId)
val subDirectories = dao.getSubEntitiesByParentOCFileId(currentFileId)
result.addAll(subDirectories)
subDirectories.forEach {
val ocFile = fileDataStorageManager.getFileByDecryptedRemotePath(it.path)
ocFile?.fileId?.let { newFileId ->
if (newFileId != 1L && newFileId !in processedIds) {
queue.add(newFileId)
}
}
}
}
return result
}
override fun deleteOperation(file: OCFile) {
if (file.isFolder) {
getAllSubEntities(file.fileId).forEach {
dao.delete(it)
}
}
file.decryptedRemotePath?.let {
dao.deleteByPath(it)
}
fileDataStorageManager.removeFile(file, true, true)
}
override fun updateNextOperations(operation: OfflineOperationEntity) {
val ocFile = fileDataStorageManager.getFileByDecryptedRemotePath(operation.path)
val fileId = ocFile?.fileId ?: return
getAllSubEntities(fileId)
.mapNotNull { nextOperation ->
nextOperation.parentOCFileId?.let { parentId ->
fileDataStorageManager.getFileById(parentId)?.let { ocFile ->
ocFile.decryptedRemotePath?.let { updatedPath ->
val newPath = updatedPath + nextOperation.filename + pathSeparator
if (newPath != nextOperation.path) {
nextOperation.apply {
type = when (type) {
is OfflineOperationType.CreateFile ->
(type as OfflineOperationType.CreateFile).copy(
remotePath = newPath
)
is OfflineOperationType.CreateFolder ->
(type as OfflineOperationType.CreateFolder).copy(
path = newPath
)
else -> type
}
path = newPath
}
} else {
null
}
}
}
}
}
.forEach { dao.update(it) }
}
override fun convertToOCFiles(fileId: Long): List<OCFile> =
dao.getSubEntitiesByParentOCFileId(fileId).map { entity ->
OCFile(entity.path).apply {
mimeType = if (entity.type is OfflineOperationType.CreateFolder) {
MimeType.DIRECTORY
} else {
MimeTypeUtil.getMimeTypeFromPath(entity.path)
}
}
}
}

View file

@ -0,0 +1,18 @@
/*
* 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.offlineOperations.repository
import com.nextcloud.client.database.entity.OfflineOperationEntity
import com.owncloud.android.datamodel.OCFile
interface OfflineOperationsRepositoryType {
fun getAllSubEntities(fileId: Long): List<OfflineOperationEntity>
fun deleteOperation(file: OCFile)
fun updateNextOperations(operation: OfflineOperationEntity)
fun convertToOCFiles(fileId: Long): List<OCFile>
}

View file

@ -0,0 +1,66 @@
/*
* 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.operation
import android.content.Context
import com.nextcloud.client.account.User
import com.nextcloud.utils.extensions.getErrorMessage
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.utils.Log_OC
import com.owncloud.android.operations.RemoveFileOperation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
class FileOperationHelper(
private val user: User,
private val context: Context,
private val fileDataStorageManager: FileDataStorageManager
) {
companion object {
private val TAG = FileOperationHelper::class.java.simpleName
}
@Suppress("TooGenericExceptionCaught", "Deprecation")
suspend fun removeFile(
file: OCFile,
onlyLocalCopy: Boolean,
inBackground: Boolean,
client: OwnCloudClient
): Boolean {
return withContext(Dispatchers.IO) {
try {
val operation = async {
RemoveFileOperation(
file,
onlyLocalCopy,
user,
inBackground,
context,
fileDataStorageManager
)
}
val operationResult = operation.await()
val result = operationResult.execute(client)
return@withContext if (result.isSuccess) {
true
} else {
val reason = (result to operationResult).getErrorMessage()
Log_OC.e(TAG, "Error occurred while removing file: $reason")
false
}
} catch (e: Exception) {
Log_OC.e(TAG, "Error occurred while removing file: $e")
false
}
}
}
}

View file

@ -1,9 +1,9 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.transfer
@ -41,27 +41,23 @@ class FileTransferService : LifecycleService() {
const val EXTRA_REQUEST = "request"
const val EXTRA_USER = "user"
fun createBindIntent(context: Context, user: User): Intent {
return Intent(context, FileTransferService::class.java).apply {
fun createBindIntent(context: Context, user: User): Intent =
Intent(context, FileTransferService::class.java).apply {
putExtra(EXTRA_USER, user)
}
}
fun createTransferRequestIntent(context: Context, request: Request): Intent {
return Intent(context, FileTransferService::class.java).apply {
fun createTransferRequestIntent(context: Context, request: Request): Intent =
Intent(context, FileTransferService::class.java).apply {
action = ACTION_TRANSFER
putExtra(EXTRA_REQUEST, request)
}
}
}
/**
* Binder forwards [TransferManager] API calls to selected instance of downloader.
*/
class Binder(
downloader: TransferManagerImpl,
service: FileTransferService
) : LocalBinder<FileTransferService>(service),
class Binder(downloader: TransferManagerImpl, service: FileTransferService) :
LocalBinder<FileTransferService>(service),
TransferManager by downloader
@Inject

View file

@ -1,9 +1,9 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.transfer

View file

@ -1,9 +1,9 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.transfer
@ -19,11 +19,7 @@ interface TransferManager {
/**
* Snapshot of transfer manager status. All data is immutable and can be safely shared.
*/
data class Status(
val pending: List<Transfer>,
val running: List<Transfer>,
val completed: List<Transfer>
) {
data class Status(val pending: List<Transfer>, val running: List<Transfer>, val completed: List<Transfer>) {
companion object {
val EMPTY = Status(emptyList(), emptyList(), emptyList())
}

View file

@ -1,9 +1,9 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.transfer
@ -16,10 +16,9 @@ import com.nextcloud.client.files.Request
import com.owncloud.android.datamodel.OCFile
import java.util.UUID
class TransferManagerConnection(
context: Context,
val user: User
) : LocalConnection<FileTransferService>(context), TransferManager {
class TransferManagerConnection(context: Context, val user: User) :
LocalConnection<FileTransferService>(context),
TransferManager {
private var transferListeners: MutableSet<(Transfer) -> Unit> = mutableSetOf()
private var statusListeners: MutableSet<(TransferManager.Status) -> Unit> = mutableSetOf()
@ -64,9 +63,7 @@ class TransferManagerConnection(
binder?.removeStatusListener(listener)
}
override fun createBindIntent(): Intent {
return FileTransferService.createBindIntent(context, user)
}
override fun createBindIntent(): Intent = FileTransferService.createBindIntent(context, user)
override fun onBound(binder: IBinder) {
super.onBound(binder)

View file

@ -1,9 +1,9 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.transfer
@ -118,8 +118,8 @@ class TransferManagerImpl(
}
}
private fun createDownloadTask(request: DownloadRequest): TaskFunction<DownloadTask.Result, Int> {
return if (request.test) {
private fun createDownloadTask(request: DownloadRequest): TaskFunction<DownloadTask.Result, Int> =
if (request.test) {
{ progress: OnProgressCallback<Int>, isCancelled: IsCancelled ->
testDownloadTask(request.file, progress, isCancelled)
}
@ -130,25 +130,22 @@ class TransferManagerImpl(
}
wrapper
}
}
private fun createUploadTask(request: UploadRequest): TaskFunction<UploadTask.Result, Int> {
return if (request.test) {
{ progress: OnProgressCallback<Int>, isCancelled: IsCancelled ->
val file = UploadFileOperation.obtainNewOCFileToUpload(
request.upload.remotePath,
request.upload.localPath,
request.upload.mimeType
)
testUploadTask(file, progress, isCancelled)
}
} else {
val uploadTask = uploadTaskFactory.create()
val wrapper: TaskFunction<UploadTask.Result, Int> = { _: ((Int) -> Unit), _ ->
uploadTask.upload(request.user, request.upload)
}
wrapper
private fun createUploadTask(request: UploadRequest): TaskFunction<UploadTask.Result, Int> = if (request.test) {
{ progress: OnProgressCallback<Int>, isCancelled: IsCancelled ->
val file = UploadFileOperation.obtainNewOCFileToUpload(
request.upload.remotePath,
request.upload.localPath,
request.upload.mimeType
)
testUploadTask(file, progress, isCancelled)
}
} else {
val uploadTask = uploadTaskFactory.create()
val wrapper: TaskFunction<UploadTask.Result, Int> = { _: ((Int) -> Unit), _ ->
uploadTask.upload(request.user, request.upload)
}
wrapper
}
private fun onTransferUpdate(transfer: Transfer) {

View file

@ -2,7 +2,7 @@
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.transfer

View file

@ -0,0 +1,48 @@
/*
* 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.upload
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.owncloud.android.MainApp
import com.owncloud.android.datamodel.UploadsStorageManager
import com.owncloud.android.ui.notifications.NotificationUtils
import javax.inject.Inject
class FileUploadBroadcastReceiver : BroadcastReceiver() {
@Inject
lateinit var uploadsStorageManager: UploadsStorageManager
companion object {
const val UPLOAD_ID = "UPLOAD_ID"
const val REMOTE_PATH = "REMOTE_PATH"
const val STORAGE_PATH = "STORAGE_PATH"
}
@Suppress("ReturnCount")
override fun onReceive(context: Context, intent: Intent) {
MainApp.getAppComponent().inject(this)
val remotePath = intent.getStringExtra(REMOTE_PATH) ?: return
val storagePath = intent.getStringExtra(STORAGE_PATH) ?: return
val uploadId = intent.getLongExtra(UPLOAD_ID, -1L)
if (uploadId == -1L) {
return
}
uploadsStorageManager.removeUpload(uploadId)
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(
NotificationUtils.createUploadNotificationTag(remotePath, storagePath),
FileUploadWorker.NOTIFICATION_ERROR_ID
)
}
}

View file

@ -1,9 +1,9 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.upload
@ -12,25 +12,37 @@ import android.content.Context
import android.content.Intent
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.device.BatteryStatus
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.jobs.BackgroundJobManager
import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.currentUploadFileOperation
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.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.UploadsStorageManager
import com.owncloud.android.datamodel.UploadsStorageManager.UploadStatus
import com.owncloud.android.db.OCUpload
import com.owncloud.android.db.UploadResult
import com.owncloud.android.files.services.NameCollisionPolicy
import com.owncloud.android.lib.common.OwnCloudClient
import com.owncloud.android.lib.common.network.OnDatatransferProgressListener
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.common.utils.Log_OC
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.FileUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
import java.util.Optional
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutionException
import java.util.concurrent.Semaphore
import javax.inject.Inject
@Suppress("TooManyFunctions")
@ -45,6 +57,11 @@ class FileUploadHelper {
@Inject
lateinit var uploadsStorageManager: UploadsStorageManager
@Inject
lateinit var fileStorageManager: FileDataStorageManager
private val ioScope = CoroutineScope(Dispatchers.IO)
init {
MainApp.getAppComponent().inject(this)
}
@ -59,15 +76,13 @@ class FileUploadHelper {
private var instance: FileUploadHelper? = null
fun instance(): FileUploadHelper {
return instance ?: synchronized(this) {
instance ?: FileUploadHelper().also { instance = it }
}
private val retryFailedUploadsSemaphore = Semaphore(1)
fun instance(): FileUploadHelper = instance ?: synchronized(this) {
instance ?: FileUploadHelper().also { instance = it }
}
fun buildRemoteName(accountName: String, remotePath: String): String {
return accountName + remotePath
}
fun buildRemoteName(accountName: String, remotePath: String): String = accountName + remotePath
}
fun retryFailedUploads(
@ -76,19 +91,27 @@ class FileUploadHelper {
accountManager: UserAccountManager,
powerManagementService: PowerManagementService
) {
val failedUploads = uploadsStorageManager.failedUploads
if (failedUploads == null || failedUploads.isEmpty()) {
Log_OC.d(TAG, "Failed uploads are empty or null")
return
}
if (retryFailedUploadsSemaphore.tryAcquire()) {
try {
val failedUploads = uploadsStorageManager.failedUploads
if (failedUploads == null || failedUploads.isEmpty()) {
Log_OC.d(TAG, "Failed uploads are empty or null")
return
}
retryUploads(
uploadsStorageManager,
connectivityService,
accountManager,
powerManagementService,
failedUploads
)
retryUploads(
uploadsStorageManager,
connectivityService,
accountManager,
powerManagementService,
failedUploads
)
} finally {
retryFailedUploadsSemaphore.release()
}
} else {
Log_OC.d(TAG, "Skip retryFailedUploads since it is already running")
}
}
fun retryCancelledUploads(
@ -120,37 +143,54 @@ class FileUploadHelper {
failedUploads: Array<OCUpload>
): Boolean {
var showNotExistMessage = false
val (gotNetwork, _, gotWifi) = connectivityService.connectivity
val isOnline = checkConnectivity(connectivityService)
val connectivity = connectivityService.connectivity
val batteryStatus = powerManagementService.battery
val charging = batteryStatus.isCharging || batteryStatus.isFull
val isPowerSaving = powerManagementService.isPowerSavingEnabled
var uploadUser = Optional.empty<User>()
val accountNames = accountManager.accounts.filter { account ->
accountManager.getUser(account.name).isPresent
}.map { account ->
account.name
}.toHashSet()
for (failedUpload in failedUploads) {
// 1. extract failed upload owner account and cache it between loops (expensive query)
if (!uploadUser.isPresent || !uploadUser.get().nameEquals(failedUpload.accountName)) {
uploadUser = accountManager.getUser(failedUpload.accountName)
if (!accountNames.contains(failedUpload.accountName)) {
uploadsStorageManager.removeUpload(failedUpload)
continue
}
val isDeleted = !File(failedUpload.localPath).exists()
if (isDeleted) {
showNotExistMessage = true
// 2A. for deleted files, mark as permanently failed
if (failedUpload.lastResult != UploadResult.FILE_NOT_FOUND) {
failedUpload.lastResult = UploadResult.FILE_NOT_FOUND
val uploadResult =
checkUploadConditions(failedUpload, connectivity, batteryStatus, powerManagementService, isOnline)
if (uploadResult != UploadResult.UPLOADED) {
if (failedUpload.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
failedUpload.lastResult = uploadResult
uploadsStorageManager.updateUpload(failedUpload)
}
} else if (!isPowerSaving && gotNetwork &&
canUploadBeRetried(failedUpload, gotWifi, charging) && !connectivityService.isInternetWalled
) {
// 2B. for existing local files, try restarting it if possible
retryUpload(failedUpload, uploadUser.get())
if (uploadResult == UploadResult.FILE_NOT_FOUND) {
showNotExistMessage = true
}
continue
}
failedUpload.uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
uploadsStorageManager.updateUpload(failedUpload)
}
accountNames.forEach { accountName ->
val user = accountManager.getUser(accountName)
if (user.isPresent) {
backgroundJobManager.startFilesUploadJob(user.get(), failedUploads.getUploadIds(), false)
}
}
return showNotExistMessage
}
@JvmOverloads
@Suppress("LongParameterList")
fun uploadNewFiles(
user: User,
@ -161,7 +201,8 @@ class FileUploadHelper {
createdBy: Int,
requiresWifi: Boolean,
requiresCharging: Boolean,
nameCollisionPolicy: NameCollisionPolicy
nameCollisionPolicy: NameCollisionPolicy,
showSameFileAlreadyExistsNotification: Boolean = true
) {
val uploads = localPaths.mapIndexed { index, localPath ->
OCUpload(localPath, remotePaths[index], user.accountName).apply {
@ -175,7 +216,7 @@ class FileUploadHelper {
}
}
uploadsStorageManager.storeUploads(uploads)
backgroundJobManager.startFilesUploadJob(user)
backgroundJobManager.startFilesUploadJob(user, uploads.getUploadIds(), showSameFileAlreadyExistsNotification)
}
fun removeFileUpload(remotePath: String, accountName: String) {
@ -185,25 +226,42 @@ class FileUploadHelper {
// need to update now table in mUploadsStorageManager,
// since the operation will not get to be run by FileUploader#uploadFile
uploadsStorageManager.removeUpload(accountName, remotePath)
cancelAndRestartUploadJob(user)
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!")
Log_OC.e(TAG, "Error cancelling current upload because user does not exist!: " + e.message)
}
}
fun cancelFileUpload(remotePath: String, accountName: String) {
uploadsStorageManager.getUploadByRemotePath(remotePath).run {
removeFileUpload(remotePath, accountName)
uploadStatus = UploadStatus.UPLOAD_CANCELLED
uploadsStorageManager.storeUpload(this)
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!")
}
}
}
fun cancelAndRestartUploadJob(user: User) {
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)
}
}
fun cancelAndRestartUploadJob(user: User, uploadIds: LongArray) {
backgroundJobManager.run {
cancelFilesUploadJob(user)
startFilesUploadJob(user)
startFilesUploadJob(user, uploadIds, false)
}
}
@ -213,15 +271,67 @@ class FileUploadHelper {
return false
}
val upload: OCUpload = uploadsStorageManager.getUploadByRemotePath(file.remotePath) ?: return false
return upload.uploadStatus == UploadStatus.UPLOAD_IN_PROGRESS
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
}
}
private fun canUploadBeRetried(upload: OCUpload, gotWifi: Boolean, isCharging: Boolean): Boolean {
val file = File(upload.localPath)
val needsWifi = upload.isUseWifiOnly
val needsCharging = upload.isWhileChargingOnly
return file.exists() && (!needsWifi || gotWifi) && (!needsCharging || isCharging)
private fun checkConnectivity(connectivityService: ConnectivityService): Boolean {
// check that connection isn't walled off and that the server is reachable
return connectivityService.getConnectivity().isConnected && !connectivityService.isInternetWalled()
}
/**
* Dupe of [UploadFileOperation.checkConditions], needed to check if the upload should even be scheduled
* @return [UploadResult.UPLOADED] if the upload should be scheduled, otherwise the reason why it shouldn't
*/
private fun checkUploadConditions(
upload: OCUpload,
connectivity: Connectivity,
battery: BatteryStatus,
powerManagementService: PowerManagementService,
hasGeneralConnection: Boolean
): UploadResult {
var conditions = UploadResult.UPLOADED
// check that internet is available
if (!hasGeneralConnection) {
conditions = UploadResult.NETWORK_CONNECTION
}
// check that local file exists; skip the upload otherwise
if (!File(upload.localPath).exists()) {
conditions = UploadResult.FILE_NOT_FOUND
}
// check that connectivity conditions are met; delay upload otherwise
if (upload.isUseWifiOnly && (!connectivity.isWifi || connectivity.isMetered)) {
conditions = UploadResult.DELAYED_FOR_WIFI
}
// check if charging conditions are met; delay upload otherwise
if (upload.isWhileChargingOnly && !battery.isCharging && !battery.isFull) {
conditions = UploadResult.DELAYED_FOR_CHARGING
}
// check that device is not in power save mode; delay upload otherwise
if (powerManagementService.isPowerSavingEnabled) {
conditions = UploadResult.DELAYED_IN_POWER_SAVE_MODE
}
return conditions
}
@Suppress("ReturnCount")
@ -266,7 +376,43 @@ class FileUploadHelper {
}
}
uploadsStorageManager.storeUploads(uploads)
backgroundJobManager.startFilesUploadJob(user)
val uploadIds: LongArray = uploads.filterNotNull().map { it.uploadId }.toLongArray()
backgroundJobManager.startFilesUploadJob(user, uploadIds, true)
}
/**
* Removes any existing file in the same directory that has the same name as the provided new file.
*
* This function checks the parent directory of the given `newFile` for any file with the same name.
* If such a file is found, it is removed using the `RemoveFileOperation`.
*
* @param duplicatedFile File to be deleted
* @param client Needed for executing RemoveFileOperation
* @param user Needed for creating client
*/
fun removeDuplicatedFile(duplicatedFile: OCFile, client: OwnCloudClient, user: User, onCompleted: () -> Unit) {
val job = CoroutineScope(Dispatchers.IO)
job.launch {
val removeFileOperation = RemoveFileOperation(
duplicatedFile,
false,
user,
true,
MainApp.getAppContext(),
fileStorageManager
)
val result = removeFileOperation.execute(client)
if (result.isSuccess) {
Log_OC.d(TAG, "Replaced file successfully removed")
launch(Dispatchers.Main) {
onCompleted()
}
}
}
}
fun retryUpload(upload: OCUpload, user: User) {
@ -275,25 +421,20 @@ class FileUploadHelper {
upload.uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
uploadsStorageManager.updateUpload(upload)
backgroundJobManager.startFilesUploadJob(user)
backgroundJobManager.startFilesUploadJob(user, longArrayOf(upload.uploadId), false)
}
fun cancel(accountName: String) {
uploadsStorageManager.removeUploads(accountName)
cancelAndRestartUploadJob(accountManager.getUser(accountName).get())
val uploadIds = uploadsStorageManager.getCurrentUploadIds(accountName)
cancelAndRestartUploadJob(accountManager.getUser(accountName).get(), uploadIds)
}
fun addUploadTransferProgressListener(
listener: OnDatatransferProgressListener,
targetKey: String
) {
fun addUploadTransferProgressListener(listener: OnDatatransferProgressListener, targetKey: String) {
mBoundListeners[targetKey] = listener
}
fun removeUploadTransferProgressListener(
listener: OnDatatransferProgressListener,
targetKey: String
) {
fun removeUploadTransferProgressListener(listener: OnDatatransferProgressListener, targetKey: String) {
if (mBoundListeners[targetKey] === listener) {
mBoundListeners.remove(targetKey)
}

View file

@ -1,9 +1,9 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.upload
@ -21,11 +21,13 @@ 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.extensions.getPercent
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.ThumbnailsCacheManager
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.OwnCloudClient
import com.owncloud.android.lib.common.OwnCloudClientManagerFactory
import com.owncloud.android.lib.common.network.OnDatatransferProgressListener
import com.owncloud.android.lib.common.operations.RemoteOperationResult
@ -35,6 +37,7 @@ import com.owncloud.android.operations.UploadFileOperation
import com.owncloud.android.utils.ErrorMessageAdapter
import com.owncloud.android.utils.theme.ViewThemeUtils
import java.io.File
import kotlin.random.Random
@Suppress("LongParameterList")
class FileUploadWorker(
@ -48,20 +51,28 @@ class FileUploadWorker(
val preferences: AppPreferences,
val context: Context,
params: WorkerParameters
) : Worker(context, params), OnDatatransferProgressListener {
) : Worker(context, params),
OnDatatransferProgressListener {
companion object {
val TAG: String = FileUploadWorker::class.java.simpleName
const val NOTIFICATION_ERROR_ID: Int = 413
private const val MAX_PROGRESS: Int = 100
const val ACCOUNT = "data_account"
const val UPLOAD_IDS = "uploads_ids"
const val CURRENT_BATCH_INDEX = "batch_index"
const val TOTAL_UPLOAD_SIZE = "total_upload_size"
const val SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION = "show_same_file_already_exists_notification"
var currentUploadFileOperation: UploadFileOperation? = null
private const val UPLOADS_ADDED_MESSAGE = "UPLOADS_ADDED"
private const val UPLOAD_START_MESSAGE = "UPLOAD_START"
private const val UPLOAD_FINISH_MESSAGE = "UPLOAD_FINISH"
private const val BATCH_SIZE = 100
const val EXTRA_UPLOAD_RESULT = "RESULT"
const val EXTRA_REMOTE_PATH = "REMOTE_PATH"
const val EXTRA_OLD_REMOTE_PATH = "OLD_REMOTE_PATH"
@ -75,35 +86,32 @@ class FileUploadWorker(
const val LOCAL_BEHAVIOUR_FORGET = 2
const val LOCAL_BEHAVIOUR_DELETE = 3
fun getUploadsAddedMessage(): String {
return FileUploadWorker::class.java.name + UPLOADS_ADDED_MESSAGE
}
fun getUploadsAddedMessage(): String = FileUploadWorker::class.java.name + UPLOADS_ADDED_MESSAGE
fun getUploadStartMessage(): String {
return FileUploadWorker::class.java.name + UPLOAD_START_MESSAGE
}
fun getUploadStartMessage(): String = FileUploadWorker::class.java.name + UPLOAD_START_MESSAGE
fun getUploadFinishMessage(): String {
return FileUploadWorker::class.java.name + UPLOAD_FINISH_MESSAGE
}
fun getUploadFinishMessage(): String = FileUploadWorker::class.java.name + UPLOAD_FINISH_MESSAGE
}
private var lastPercent = 0
private val notificationManager = UploadNotificationManager(context, viewThemeUtils)
private val notificationManager = UploadNotificationManager(context, viewThemeUtils, Random.nextInt())
private val intents = FileUploaderIntents(context)
private val fileUploaderDelegate = FileUploaderDelegate()
@Suppress("TooGenericExceptionCaught")
override fun doWork(): Result {
return try {
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
val result = retrievePagesBySortingUploadsByID()
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
result
} catch (t: Throwable) {
Log_OC.e(TAG, "Error caught at FileUploadWorker " + t.localizedMessage)
Result.failure()
override fun doWork(): Result = try {
Log_OC.d(TAG, "FileUploadWorker started")
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
val result = uploadFiles()
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
notificationManager.dismissNotification()
if (result == Result.success()) {
setIdleWorkerState()
}
result
} catch (t: Throwable) {
Log_OC.e(TAG, "Error caught at FileUploadWorker $t")
Result.failure()
}
override fun onStopped() {
@ -111,27 +119,59 @@ class FileUploadWorker(
setIdleWorkerState()
currentUploadFileOperation?.cancel(null)
notificationManager.dismissWorkerNotifications()
notificationManager.dismissNotification()
super.onStopped()
}
private fun setWorkerState(user: User?, uploads: List<OCUpload>) {
WorkerStateLiveData.instance().setWorkState(WorkerState.Upload(user, uploads))
private fun setWorkerState(user: User?) {
WorkerStateLiveData.instance().setWorkState(WorkerState.UploadStarted(user))
}
private fun setIdleWorkerState() {
WorkerStateLiveData.instance().setWorkState(WorkerState.Idle)
WorkerStateLiveData.instance().setWorkState(WorkerState.UploadFinished(currentUploadFileOperation?.file))
}
@Suppress("ReturnCount")
private fun retrievePagesBySortingUploadsByID(): Result {
val accountName = inputData.getString(ACCOUNT) ?: return Result.failure()
var currentPage = uploadsStorageManager.getCurrentAndPendingUploadsForAccountPageAscById(-1, accountName)
@Suppress("ReturnCount", "LongMethod")
private fun uploadFiles(): Result {
val accountName = inputData.getString(ACCOUNT)
if (accountName == null) {
Log_OC.e(TAG, "accountName is null")
return Result.failure()
}
notificationManager.dismissWorkerNotifications()
val uploadIds = inputData.getLongArray(UPLOAD_IDS)
if (uploadIds == null) {
Log_OC.e(TAG, "uploadIds is null")
return Result.failure()
}
while (currentPage.isNotEmpty() && !isStopped) {
val currentBatchIndex = inputData.getInt(CURRENT_BATCH_INDEX, -1)
if (currentBatchIndex == -1) {
Log_OC.e(TAG, "currentBatchIndex is -1, cancelling")
return Result.failure()
}
val totalUploadSize = inputData.getInt(TOTAL_UPLOAD_SIZE, -1)
if (totalUploadSize == -1) {
Log_OC.e(TAG, "totalUploadSize is -1, cancelling")
return 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()
}
val user = optionalUser.get()
val previouslyUploadedFileSize = currentBatchIndex * FileUploadHelper.MAX_FILE_COUNT
val uploads = uploadsStorageManager.getUploadsByIds(uploadIds, accountName)
val ocAccount = OwnCloudAccount(user.toPlatformAccount(), context)
val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context)
for ((index, upload) in uploads.withIndex()) {
if (preferences.isGlobalUploadPaused) {
Log_OC.d(TAG, "Upload is paused, skip uploading files!")
notificationManager.notifyPaused(
@ -140,86 +180,101 @@ class FileUploadWorker(
return Result.success()
}
Log_OC.d(TAG, "Handling ${currentPage.size} uploads for account $accountName")
val lastId = currentPage.last().uploadId
uploadFiles(currentPage, accountName)
currentPage =
uploadsStorageManager.getCurrentAndPendingUploadsForAccountPageAscById(lastId, accountName)
if (canExitEarly()) {
notificationManager.showConnectionErrorNotification()
return Result.failure()
}
if (isStopped) {
continue
}
setWorkerState(user)
val operation = createUploadFileOperation(upload, user)
currentUploadFileOperation = operation
val currentIndex = (index + 1)
val currentUploadIndex = (currentIndex + previouslyUploadedFileSize)
notificationManager.prepareForStart(
operation,
cancelPendingIntent = intents.startIntent(operation),
startIntent = intents.notificationStartIntent(operation),
currentUploadIndex = currentUploadIndex,
totalUploadSize = totalUploadSize
)
val result = upload(operation, user, client)
currentUploadFileOperation = null
sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result)
}
if (isStopped) {
Log_OC.d(TAG, "FileUploadWorker for account $accountName was stopped")
} else {
Log_OC.d(TAG, "No more pending uploads for account $accountName, stopping work")
}
return Result.success()
}
private fun uploadFiles(uploads: List<OCUpload>, accountName: String) {
val user = userAccountManager.getUser(accountName)
setWorkerState(user.get(), uploads)
private fun sendUploadFinishEvent(
totalUploadSize: Int,
currentUploadIndex: Int,
operation: UploadFileOperation,
result: RemoteOperationResult<*>
) {
val shouldBroadcast =
(totalUploadSize > BATCH_SIZE && currentUploadIndex > 0) && currentUploadIndex % BATCH_SIZE == 0
for (upload in uploads) {
if (isStopped) {
break
}
if (user.isPresent) {
val uploadFileOperation = createUploadFileOperation(upload, user.get())
currentUploadFileOperation = uploadFileOperation
val result = upload(uploadFileOperation, user.get())
currentUploadFileOperation = null
fileUploaderDelegate.sendBroadcastUploadFinished(
uploadFileOperation,
result,
uploadFileOperation.oldFile?.storagePath,
context,
localBroadcastManager
)
} else {
uploadsStorageManager.removeUpload(upload.uploadId)
}
if (shouldBroadcast) {
// delay broadcast
fileUploaderDelegate.sendBroadcastUploadFinished(
operation,
result,
operation.oldFile?.storagePath,
context,
localBroadcastManager
)
}
}
private fun createUploadFileOperation(upload: OCUpload, user: User): UploadFileOperation {
return UploadFileOperation(
uploadsStorageManager,
connectivityService,
powerManagementService,
user,
null,
upload,
upload.nameCollisionPolicy,
upload.localAction,
context,
upload.isUseWifiOnly,
upload.isWhileChargingOnly,
true,
FileDataStorageManager(user, context.contentResolver)
).apply {
addDataTransferProgressListener(this@FileUploadWorker)
private fun canExitEarly(): Boolean {
val result = !connectivityService.isConnected ||
connectivityService.isInternetWalled ||
isStopped
if (result) {
Log_OC.d(TAG, "No internet connection, stopping worker.")
} else {
notificationManager.dismissErrorNotification()
}
return result
}
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)
).apply {
addDataTransferProgressListener(this@FileUploadWorker)
}
@Suppress("TooGenericExceptionCaught", "DEPRECATION")
private fun upload(uploadFileOperation: UploadFileOperation, user: User): RemoteOperationResult<Any?> {
private fun upload(
uploadFileOperation: UploadFileOperation,
user: User,
client: OwnCloudClient
): RemoteOperationResult<Any?> {
lateinit var result: RemoteOperationResult<Any?>
notificationManager.prepareForStart(
uploadFileOperation,
cancelPendingIntent = intents.startIntent(uploadFileOperation),
intents.notificationStartIntent(uploadFileOperation)
)
try {
val storageManager = uploadFileOperation.storageManager
val ocAccount = OwnCloudAccount(user.toPlatformAccount(), context)
val uploadClient = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context)
result = uploadFileOperation.execute(uploadClient)
result = uploadFileOperation.execute(client)
val task = ThumbnailsCacheManager.ThumbnailGenerationTask(storageManager, user)
val file = File(uploadFileOperation.originalStoragePath)
val remoteId: String? = uploadFileOperation.file.remoteId
@ -238,16 +293,17 @@ class FileUploadWorker(
if (!isStopped || !result.isCancelled) {
uploadsStorageManager.updateDatabaseUploadResult(result, uploadFileOperation)
notifyUploadResult(uploadFileOperation, result)
notificationManager.dismissWorkerNotifications()
}
}
@Suppress("ReturnCount")
@Suppress("ReturnCount", "LongMethod")
private fun notifyUploadResult(
uploadFileOperation: UploadFileOperation,
uploadResult: RemoteOperationResult<Any?>
) {
Log_OC.d(TAG, "NotifyUploadResult with resultCode: " + uploadResult.code)
val showSameFileAlreadyExistsNotification =
inputData.getBoolean(SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION, false)
if (uploadResult.isSuccess) {
notificationManager.dismissOldErrorNotification(uploadFileOperation)
@ -259,10 +315,19 @@ class FileUploadWorker(
}
// Only notify if it is not same file on remote that causes conflict
if (uploadResult.code == ResultCode.SYNC_CONFLICT && FileUploadHelper().isSameFileOnRemote(
uploadFileOperation.user, File(uploadFileOperation.storagePath), uploadFileOperation.remotePath, context
if (uploadResult.code == ResultCode.SYNC_CONFLICT &&
FileUploadHelper().isSameFileOnRemote(
uploadFileOperation.user,
File(uploadFileOperation.storagePath),
uploadFileOperation.remotePath,
context
)
) {
if (showSameFileAlreadyExistsNotification) {
notificationManager.showSameFileAlreadyExistsNotification(uploadFileOperation.fileName)
}
uploadFileOperation.handleLocalBehaviour()
return
}
@ -300,31 +365,51 @@ class FileUploadWorker(
null
}
notifyForFailedResult(uploadResult.code, conflictResolveIntent, credentialIntent, errorMessage)
showNewNotification(uploadFileOperation)
val cancelUploadActionIntent = if (conflictResolveIntent != null) {
intents.cancelUploadActionIntent(uploadFileOperation)
} else {
null
}
notifyForFailedResult(
uploadFileOperation,
uploadResult.code,
conflictResolveIntent,
cancelUploadActionIntent,
credentialIntent,
errorMessage
)
}
}
@Suppress("MagicNumber")
private val minProgressUpdateInterval = 750
private var lastUpdateTime = 0L
/**
* Receives from [com.owncloud.android.operations.UploadFileOperation.normalUpload]
*/
@Suppress("MagicNumber")
override fun onTransferProgress(
progressRate: Long,
totalTransferredSoFar: Long,
totalToTransfer: Long,
fileAbsoluteName: String
) {
val percent = (MAX_PROGRESS * totalTransferredSoFar.toDouble() / totalToTransfer.toDouble()).toInt()
val percent = getPercent(totalTransferredSoFar, totalToTransfer)
val currentTime = System.currentTimeMillis()
if (percent != lastPercent) {
if (percent != lastPercent && (currentTime - lastUpdateTime) >= minProgressUpdateInterval) {
notificationManager.run {
val accountName = currentUploadFileOperation?.user?.accountName
val remotePath = currentUploadFileOperation?.remotePath
val filename = currentUploadFileOperation?.fileName ?: ""
updateUploadProgress(filename, percent, currentUploadFileOperation)
updateUploadProgress(percent, currentUploadFileOperation)
if (accountName != null && remotePath != null) {
val key: String =
FileUploadHelper.buildRemoteName(accountName, remotePath)
val key: String = FileUploadHelper.buildRemoteName(accountName, remotePath)
val boundListener = FileUploadHelper.mBoundListeners[key]
val filename = currentUploadFileOperation?.fileName ?: ""
boundListener?.onTransferProgress(
progressRate,
@ -336,6 +421,7 @@ class FileUploadWorker(
dismissOldErrorNotification(currentUploadFileOperation)
}
lastUpdateTime = currentTime
}
lastPercent = percent

View file

@ -1,9 +1,9 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.upload

View file

@ -1,9 +1,9 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.upload
@ -12,7 +12,6 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import com.owncloud.android.authentication.AuthenticatorActivity
import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
import com.owncloud.android.operations.UploadFileOperation
import com.owncloud.android.ui.activity.ConflictsResolveActivity.Companion.createIntent
import com.owncloud.android.ui.activity.UploadListActivity
@ -57,32 +56,6 @@ class FileUploaderIntents(private val context: Context) {
)
}
fun resultIntent(resultCode: ResultCode, operation: UploadFileOperation): PendingIntent {
val intent = if (resultCode == ResultCode.SYNC_CONFLICT) {
createIntent(
operation.file,
operation.user,
operation.ocUploadId,
Intent.FLAG_ACTIVITY_CLEAR_TOP,
context
)
} else {
UploadListActivity.createIntent(
operation.file,
operation.user,
Intent.FLAG_ACTIVITY_CLEAR_TOP,
context
)
}
return PendingIntent.getActivity(
context,
System.currentTimeMillis().toInt(),
intent,
PendingIntent.FLAG_IMMUTABLE
)
}
fun notificationStartIntent(operation: UploadFileOperation?): PendingIntent {
val intent = UploadListActivity.createIntent(
operation?.file,
@ -119,4 +92,19 @@ class FileUploaderIntents(private val context: Context) {
)
}
}
fun cancelUploadActionIntent(uploadFileOperation: UploadFileOperation): PendingIntent {
val intent = Intent(context, FileUploadBroadcastReceiver::class.java).apply {
putExtra(FileUploadBroadcastReceiver.UPLOAD_ID, uploadFileOperation.ocUploadId)
putExtra(FileUploadBroadcastReceiver.REMOTE_PATH, uploadFileOperation.file.remotePath)
putExtra(FileUploadBroadcastReceiver.STORAGE_PATH, uploadFileOperation.file.storagePath)
}
return PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
}

View file

@ -4,7 +4,7 @@
* @author Chris Narkiewicz
* Copyright (C) 2021 Chris Narkiewicz <hello@ezaquarii.com>
*
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.upload

View file

@ -1,65 +1,52 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.upload
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.graphics.BitmapFactory
import android.os.Build
import androidx.core.app.NotificationCompat
import com.nextcloud.client.jobs.notification.WorkerNotificationManager
import com.nextcloud.utils.extensions.isFileSpecificError
import com.nextcloud.utils.numberFormatter.NumberFormatter
import com.owncloud.android.R
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.operations.UploadFileOperation
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.theme.ViewThemeUtils
class UploadNotificationManager(private val context: Context, viewThemeUtils: ViewThemeUtils) {
companion object {
private const val ID = 411
}
private var notification: Notification? = null
private var notificationBuilder: NotificationCompat.Builder =
NotificationUtils.newNotificationBuilder(context, viewThemeUtils).apply {
setContentTitle(context.getString(R.string.foreground_service_upload))
setSmallIcon(R.drawable.notification_icon)
setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.notification_icon))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD)
}
}
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
init {
notification = notificationBuilder.build()
}
class UploadNotificationManager(private val context: Context, viewThemeUtils: ViewThemeUtils, id: Int) :
WorkerNotificationManager(id, context, viewThemeUtils, R.string.foreground_service_upload) {
@Suppress("MagicNumber")
fun prepareForStart(
uploadFileOperation: UploadFileOperation,
cancelPendingIntent: PendingIntent,
startIntent: PendingIntent
startIntent: PendingIntent,
currentUploadIndex: Int,
totalUploadSize: Int
) {
notificationBuilder.run {
setContentTitle(context.getString(R.string.uploader_upload_in_progress_ticker))
setContentText(
String.format(
context.getString(R.string.uploader_upload_in_progress),
0,
uploadFileOperation.fileName
)
currentOperationTitle = if (totalUploadSize > 1) {
String.format(
context.getString(R.string.upload_notification_manager_start_text),
currentUploadIndex,
totalUploadSize,
uploadFileOperation.fileName
)
setTicker(context.getString(R.string.foreground_service_upload))
} else {
uploadFileOperation.fileName
}
val progressText = NumberFormatter.getPercentageText(0)
notificationBuilder.run {
setProgress(100, 0, false)
setOngoing(true)
setContentTitle(currentOperationTitle)
setContentText(progressText)
setOngoing(false)
clearActions()
addAction(
@ -76,13 +63,27 @@ class UploadNotificationManager(private val context: Context, viewThemeUtils: Vi
}
}
@Suppress("MagicNumber")
fun updateUploadProgress(percent: Int, currentOperation: UploadFileOperation?) {
val progressText = NumberFormatter.getPercentageText(percent)
setProgress(percent, progressText, false)
showNotification()
dismissOldErrorNotification(currentOperation)
}
fun notifyForFailedResult(
uploadFileOperation: UploadFileOperation,
resultCode: RemoteOperationResult.ResultCode,
conflictsResolveIntent: PendingIntent?,
cancelUploadActionIntent: PendingIntent?,
credentialIntent: PendingIntent?,
errorMessage: String
) {
val textId = resultTitle(resultCode)
if (uploadFileOperation.isMissingPermissionThrown) {
return
}
val textId = getFailedResultTitleId(resultCode)
notificationBuilder.run {
setTicker(context.getString(textId))
@ -100,15 +101,29 @@ class UploadNotificationManager(private val context: Context, viewThemeUtils: Vi
)
}
cancelUploadActionIntent?.let {
addAction(
R.drawable.ic_delete,
R.string.upload_list_cancel_upload,
cancelUploadActionIntent
)
}
credentialIntent?.let {
setContentIntent(it)
}
setContentText(errorMessage)
}
if (resultCode.isFileSpecificError()) {
showNewNotification(uploadFileOperation)
} else {
showNotification()
}
}
private fun resultTitle(resultCode: RemoteOperationResult.ResultCode): Int {
private fun getFailedResultTitleId(resultCode: RemoteOperationResult.ResultCode): Int {
val needsToUpdateCredentials = (resultCode == RemoteOperationResult.ResultCode.UNAUTHORIZED)
return if (needsToUpdateCredentials) {
@ -128,7 +143,7 @@ class UploadNotificationManager(private val context: Context, viewThemeUtils: Vi
)
}
fun showNewNotification(operation: UploadFileOperation) {
private fun showNewNotification(operation: UploadFileOperation) {
notificationManager.notify(
NotificationUtils.createUploadNotificationTag(operation.file),
FileUploadWorker.NOTIFICATION_ERROR_ID,
@ -136,20 +151,35 @@ class UploadNotificationManager(private val context: Context, viewThemeUtils: Vi
)
}
private fun showNotification() {
notificationManager.notify(ID, notificationBuilder.build())
fun showSameFileAlreadyExistsNotification(filename: String) {
notificationBuilder.run {
setAutoCancel(true)
clearActions()
setContentText("")
setProgress(0, 0, false)
setContentTitle(context.getString(R.string.file_upload_worker_same_file_already_exists, filename))
}
val notificationId = filename.hashCode()
notificationManager.notify(
notificationId,
notificationBuilder.build()
)
}
@Suppress("MagicNumber")
fun updateUploadProgress(filename: String, percent: Int, currentOperation: UploadFileOperation?) {
notificationBuilder.run {
setProgress(100, percent, false)
val text = String.format(context.getString(R.string.uploader_upload_in_progress), percent, filename)
setContentText(text)
fun showConnectionErrorNotification() {
notificationManager.cancel(getId())
showNotification()
dismissOldErrorNotification(currentOperation)
notificationBuilder.run {
setContentTitle(context.getString(R.string.file_upload_worker_error_notification_title))
setContentText("")
}
notificationManager.notify(
FileUploadWorker.NOTIFICATION_ERROR_ID,
notificationBuilder.build()
)
}
fun dismissOldErrorNotification(operation: UploadFileOperation?) {
@ -164,6 +194,8 @@ class UploadNotificationManager(private val context: Context, viewThemeUtils: Vi
}
}
fun dismissErrorNotification() = notificationManager.cancel(FileUploadWorker.NOTIFICATION_ERROR_ID)
fun dismissOldErrorNotification(remotePath: String, localPath: String) {
notificationManager.cancel(
NotificationUtils.createUploadNotificationTag(remotePath, localPath),
@ -171,15 +203,11 @@ class UploadNotificationManager(private val context: Context, viewThemeUtils: Vi
)
}
fun dismissWorkerNotifications() {
notificationManager.cancel(ID)
}
fun notifyPaused(intent: PendingIntent) {
notificationBuilder.apply {
notificationBuilder.run {
setContentTitle(context.getString(R.string.upload_global_pause_title))
setTicker(context.getString(R.string.upload_global_pause_title))
setOngoing(true)
setOngoing(false)
setAutoCancel(false)
setProgress(0, 0, false)
clearActions()

View file

@ -1,9 +1,9 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.upload
@ -44,16 +44,14 @@ class UploadTask(
private val clientProvider: () -> OwnCloudClient,
private val fileDataStorageManager: FileDataStorageManager
) {
fun create(): UploadTask {
return UploadTask(
applicationContext,
uploadsStorageManager,
connectivityService,
powerManagementService,
clientProvider,
fileDataStorageManager
)
}
fun create(): UploadTask = UploadTask(
applicationContext,
uploadsStorageManager,
connectivityService,
powerManagementService,
clientProvider,
fileDataStorageManager
)
}
fun upload(user: User, upload: OCUpload): Result {

View file

@ -1,9 +1,9 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs.upload