repo created

This commit is contained in:
Fr4nz D13trich 2025-09-18 17:54:51 +02:00
commit 1ef725ef20
2483 changed files with 278273 additions and 0 deletions

View file

@ -0,0 +1,213 @@
/*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* @author Chris Narkiewicz
*
* Copyright (C) 2017 Tobias Kaminsky
* Copyright (C) 2017 Nextcloud GmbH.
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs
import android.content.Context
import android.text.TextUtils
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.google.gson.Gson
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.core.Clock
import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.common.NextcloudClient
import com.owncloud.android.MainApp
import com.owncloud.android.R
import com.owncloud.android.datamodel.ArbitraryDataProvider
import com.owncloud.android.datamodel.ArbitraryDataProviderImpl
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.FilesystemDataProvider
import com.owncloud.android.datamodel.PushConfigurationState
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.datamodel.UploadsStorageManager
import com.owncloud.android.lib.common.OwnCloudClient
import com.owncloud.android.lib.common.OwnCloudClientManagerFactory
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.lib.resources.users.DeleteAppPasswordRemoteOperation
import com.owncloud.android.lib.resources.users.RemoteWipeSuccessRemoteOperation
import com.owncloud.android.providers.DocumentsStorageProvider
import com.owncloud.android.ui.activity.ContactsPreferenceActivity
import com.owncloud.android.ui.activity.ManageAccountsActivity
import com.owncloud.android.ui.events.AccountRemovedEvent
import com.owncloud.android.utils.EncryptionUtils
import com.owncloud.android.utils.PushUtils
import org.greenrobot.eventbus.EventBus
import java.util.Optional
/**
* Removes account and all local files
*/
@Suppress("LongParameterList") // legacy code
class AccountRemovalWork(
private val context: Context,
params: WorkerParameters,
private val uploadsStorageManager: UploadsStorageManager,
private val userAccountManager: UserAccountManager,
private val backgroundJobManager: BackgroundJobManager,
private val clock: Clock,
private val eventBus: EventBus,
private val preferences: AppPreferences,
private val syncedFolderProvider: SyncedFolderProvider
) : Worker(context, params) {
companion object {
const val TAG = "AccountRemovalJob"
const val ACCOUNT = "account"
const val REMOTE_WIPE = "remote_wipe"
}
@Suppress("ReturnCount") // legacy code
override fun doWork(): Result {
val accountName = inputData.getString(ACCOUNT) ?: ""
if (TextUtils.isEmpty(accountName)) {
// didn't receive account to delete
return Result.failure()
}
val optionalUser = userAccountManager.getUser(accountName)
if (!optionalUser.isPresent) {
// trying to delete non-existing user
return Result.failure()
}
val remoteWipe = inputData.getBoolean(REMOTE_WIPE, false)
val arbitraryDataProvider: ArbitraryDataProvider = ArbitraryDataProviderImpl(context)
val user = optionalUser.get()
backgroundJobManager.cancelPeriodicContactsBackup(user)
val userRemoved = userAccountManager.removeUser(user)
val storageManager = FileDataStorageManager(user, context.contentResolver)
// disable daily backup
arbitraryDataProvider.storeOrUpdateKeyValue(
user.accountName,
ContactsPreferenceActivity.PREFERENCE_CONTACTS_AUTOMATIC_BACKUP,
"false"
)
// unregister push notifications
unregisterPushNotifications(context, user, arbitraryDataProvider)
// remove pending account removal
arbitraryDataProvider.deleteKeyForAccount(user.accountName, ManageAccountsActivity.PENDING_FOR_REMOVAL)
// remove synced folders set for account
removeSyncedFolders(context, user, clock)
// delete all uploads for account
uploadsStorageManager.removeUserUploads(user)
// delete stored E2E keys and mnemonic
EncryptionUtils.removeE2E(arbitraryDataProvider, user)
// unset default account, if needed
if (preferences.currentAccountName.equals(user.accountName)) {
preferences.currentAccountName = ""
}
// remove all files
storageManager.removeLocalFiles(user, storageManager)
// delete all database entries
storageManager.deleteAllFiles()
if (remoteWipe) {
val optionalClient = createClient(user)
if (optionalClient.isPresent) {
val client = optionalClient.get()
val authToken = client.credentials.authToken
RemoteWipeSuccessRemoteOperation(authToken).execute(client)
}
}
// notify Document Provider
DocumentsStorageProvider.notifyRootsChanged(context)
// delete app password
val deleteAppPasswordRemoteOperation = DeleteAppPasswordRemoteOperation()
val optionNextcloudClient = createNextcloudClient(user)
if (optionNextcloudClient.isPresent) {
deleteAppPasswordRemoteOperation.execute(optionNextcloudClient.get())
}
// delete cached OwncloudClient
OwnCloudClientManagerFactory.getDefaultSingleton().removeClientFor(user.toOwnCloudAccount())
if (userRemoved) {
eventBus.post(AccountRemovedEvent())
}
return Result.success()
}
private fun unregisterPushNotifications(
context: Context,
user: User,
arbitraryDataProvider: ArbitraryDataProvider
) {
val arbitraryDataPushString = arbitraryDataProvider.getValue(user, PushUtils.KEY_PUSH)
val pushServerUrl = context.resources.getString(R.string.push_server_url)
if (!TextUtils.isEmpty(arbitraryDataPushString) && !TextUtils.isEmpty(pushServerUrl)) {
val gson = Gson()
val pushArbitraryData = gson.fromJson(
arbitraryDataPushString,
PushConfigurationState::class.java
)
pushArbitraryData.isShouldBeDeleted = true
arbitraryDataProvider.storeOrUpdateKeyValue(
user.accountName,
PushUtils.KEY_PUSH,
gson.toJson(pushArbitraryData)
)
PushUtils.pushRegistrationToServer(userAccountManager, pushArbitraryData.getPushToken())
}
}
private fun removeSyncedFolders(context: Context, user: User, clock: Clock) {
val syncedFolders = syncedFolderProvider.syncedFolders
val syncedFolderIds: MutableList<Long> = ArrayList()
for (syncedFolder in syncedFolders) {
if (syncedFolder.account == user.accountName) {
syncedFolderIds.add(syncedFolder.id)
}
}
syncedFolderProvider.deleteSyncFoldersForAccount(user)
val filesystemDataProvider = FilesystemDataProvider(context.contentResolver)
for (syncedFolderId in syncedFolderIds) {
filesystemDataProvider.deleteAllEntriesForSyncedFolder(syncedFolderId.toString())
}
}
private fun createClient(user: User): Optional<OwnCloudClient> {
@Suppress("TooGenericExceptionCaught") // needs migration to newer api to get rid of exceptions
return try {
val context = MainApp.getAppContext()
val factory = OwnCloudClientManagerFactory.getDefaultSingleton()
val client = factory.getClientFor(user.toOwnCloudAccount(), context)
Optional.of(client)
} catch (e: Exception) {
Log_OC.e(this, "Could not create client", e)
Optional.empty()
}
}
private fun createNextcloudClient(user: User): Optional<NextcloudClient> {
@Suppress("TooGenericExceptionCaught") // needs migration to newer api to get rid of exceptions
return try {
val context = MainApp.getAppContext()
val factory = OwnCloudClientManagerFactory.getDefaultSingleton()
val client = factory.getNextcloudClientFor(user.toOwnCloudAccount(), context)
Optional.of(client)
} catch (e: Exception) {
Log_OC.e(this, "Could not create client", e)
Optional.empty()
}
}
}

View file

@ -0,0 +1,286 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.ContentResolver
import android.content.Context
import android.content.res.Resources
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.ListenableWorker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.core.Clock
import com.nextcloud.client.device.DeviceInfo
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.documentscan.GeneratePDFUseCase
import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
import com.nextcloud.client.integrations.deck.DeckApi
import com.nextcloud.client.jobs.download.FileDownloadWorker
import com.nextcloud.client.jobs.upload.FileUploadWorker
import com.nextcloud.client.logger.Logger
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.client.preferences.AppPreferences
import com.owncloud.android.datamodel.ArbitraryDataProvider
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.datamodel.UploadsStorageManager
import com.owncloud.android.utils.theme.ViewThemeUtils
import org.greenrobot.eventbus.EventBus
import javax.inject.Inject
import javax.inject.Provider
/**
* This factory is responsible for creating all background jobs and for injecting worker dependencies.
*
* This class is doing too many things and should be split up into smaller factories.
*/
@Suppress("LongParameterList", "TooManyFunctions") // satisfied by DI
class BackgroundJobFactory @Inject constructor(
private val logger: Logger,
private val preferences: AppPreferences,
private val contentResolver: ContentResolver,
private val clock: Clock,
private val powerManagementService: PowerManagementService,
private val backgroundJobManager: Provider<BackgroundJobManager>,
private val deviceInfo: DeviceInfo,
private val accountManager: UserAccountManager,
private val resources: Resources,
private val arbitraryDataProvider: ArbitraryDataProvider,
private val uploadsStorageManager: UploadsStorageManager,
private val connectivityService: ConnectivityService,
private val notificationManager: NotificationManager,
private val eventBus: EventBus,
private val deckApi: DeckApi,
private val viewThemeUtils: Provider<ViewThemeUtils>,
private val localBroadcastManager: Provider<LocalBroadcastManager>,
private val generatePdfUseCase: GeneratePDFUseCase,
private val syncedFolderProvider: SyncedFolderProvider
) : WorkerFactory() {
@SuppressLint("NewApi")
@Suppress("ComplexMethod") // it's just a trivial dispatch
override fun createWorker(
context: Context,
workerClassName: String,
workerParameters: WorkerParameters
): ListenableWorker? {
val workerClass = try {
Class.forName(workerClassName).kotlin
} catch (ex: ClassNotFoundException) {
null
}
return if (workerClass == ContentObserverWork::class) {
createContentObserverJob(context, workerParameters)
} else {
when (workerClass) {
ContactsBackupWork::class -> createContactsBackupWork(context, workerParameters)
ContactsImportWork::class -> createContactsImportWork(context, workerParameters)
FilesSyncWork::class -> createFilesSyncWork(context, workerParameters)
OfflineSyncWork::class -> createOfflineSyncWork(context, workerParameters)
MediaFoldersDetectionWork::class -> createMediaFoldersDetectionWork(context, workerParameters)
NotificationWork::class -> createNotificationWork(context, workerParameters)
AccountRemovalWork::class -> createAccountRemovalWork(context, workerParameters)
CalendarBackupWork::class -> createCalendarBackupWork(context, workerParameters)
CalendarImportWork::class -> createCalendarImportWork(context, workerParameters)
FilesExportWork::class -> createFilesExportWork(context, workerParameters)
FileUploadWorker::class -> createFilesUploadWorker(context, workerParameters)
FileDownloadWorker::class -> createFilesDownloadWorker(context, workerParameters)
GeneratePdfFromImagesWork::class -> createPDFGenerateWork(context, workerParameters)
HealthStatusWork::class -> createHealthStatusWork(context, workerParameters)
TestJob::class -> createTestJob(context, workerParameters)
else -> null // caller falls back to default factory
}
}
}
private fun createFilesExportWork(
context: Context,
params: WorkerParameters
): ListenableWorker {
return FilesExportWork(
context,
accountManager.user,
contentResolver,
viewThemeUtils.get(),
params
)
}
private fun createContentObserverJob(
context: Context,
workerParameters: WorkerParameters
): ListenableWorker {
return ContentObserverWork(
context,
workerParameters,
SyncedFolderProvider(contentResolver, preferences, clock),
powerManagementService,
backgroundJobManager.get()
)
}
private fun createContactsBackupWork(context: Context, params: WorkerParameters): ContactsBackupWork {
return ContactsBackupWork(
context,
params,
resources,
arbitraryDataProvider,
contentResolver,
accountManager
)
}
private fun createContactsImportWork(context: Context, params: WorkerParameters): ContactsImportWork {
return ContactsImportWork(
context,
params,
logger,
contentResolver
)
}
private fun createCalendarBackupWork(context: Context, params: WorkerParameters): CalendarBackupWork {
return CalendarBackupWork(
context,
params,
contentResolver,
accountManager,
preferences
)
}
private fun createCalendarImportWork(context: Context, params: WorkerParameters): CalendarImportWork {
return 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 createOfflineSyncWork(context: Context, params: WorkerParameters): OfflineSyncWork {
return OfflineSyncWork(
context = context,
params = params,
contentResolver = contentResolver,
userAccountManager = accountManager,
connectivityService = connectivityService,
powerManagementService = powerManagementService
)
}
private fun createMediaFoldersDetectionWork(context: Context, params: WorkerParameters): MediaFoldersDetectionWork {
return MediaFoldersDetectionWork(
context,
params,
resources,
contentResolver,
accountManager,
preferences,
clock,
viewThemeUtils.get(),
syncedFolderProvider
)
}
private fun createNotificationWork(context: Context, params: WorkerParameters): NotificationWork {
return NotificationWork(
context,
params,
notificationManager,
accountManager,
deckApi,
viewThemeUtils.get()
)
}
private fun createAccountRemovalWork(context: Context, params: WorkerParameters): AccountRemovalWork {
return AccountRemovalWork(
context,
params,
uploadsStorageManager,
accountManager,
backgroundJobManager.get(),
clock,
eventBus,
preferences,
syncedFolderProvider
)
}
private fun createFilesUploadWorker(context: Context, params: WorkerParameters): FileUploadWorker {
return FileUploadWorker(
uploadsStorageManager,
connectivityService,
powerManagementService,
accountManager,
viewThemeUtils.get(),
localBroadcastManager.get(),
backgroundJobManager.get(),
preferences,
context,
params
)
}
private fun createFilesDownloadWorker(context: Context, params: WorkerParameters): FileDownloadWorker {
return FileDownloadWorker(
viewThemeUtils.get(),
accountManager,
localBroadcastManager.get(),
context,
params
)
}
private fun createPDFGenerateWork(context: Context, params: WorkerParameters): GeneratePdfFromImagesWork {
return GeneratePdfFromImagesWork(
appContext = context,
generatePdfUseCase = generatePdfUseCase,
viewThemeUtils = viewThemeUtils.get(),
notificationManager = notificationManager,
userAccountManager = accountManager,
logger = logger,
params = params
)
}
private fun createHealthStatusWork(context: Context, params: WorkerParameters): HealthStatusWork {
return HealthStatusWork(
context,
params,
accountManager,
arbitraryDataProvider,
backgroundJobManager.get()
)
}
private fun createTestJob(context: Context, params: WorkerParameters): TestJob {
return TestJob(
context,
params,
backgroundJobManager.get()
)
}
}

View file

@ -0,0 +1,166 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs
import androidx.lifecycle.LiveData
import androidx.work.ListenableWorker
import com.nextcloud.client.account.User
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.operations.DownloadType
/**
* This interface allows to control, schedule and monitor all application
* long-running background tasks, such as periodic checks or synchronization.
*/
@Suppress("TooManyFunctions") // we expect this implementation to have rich API
interface BackgroundJobManager {
/**
* Information about all application background jobs.
*/
val jobs: LiveData<List<JobInfo>>
fun logStartOfWorker(workerName: String?)
fun logEndOfWorker(workerName: String?, result: ListenableWorker.Result)
/**
* Start content observer job that monitors changes in media folders
* and launches synchronization when needed.
*
* This call is idempotent - there will be only one scheduled job
* regardless of number of calls.
*/
fun scheduleContentObserverJob()
/**
* Schedule periodic contacts backups job. Operating system will
* decide when to start the job.
*
* This call is idempotent - there can be only one scheduled job
* at any given time.
*
* @param user User for which job will be scheduled.
*/
fun schedulePeriodicContactsBackup(user: User)
/**
* Cancel periodic contacts backup. Existing tasks might finish, but no new
* invocations will occur.
*/
fun cancelPeriodicContactsBackup(user: User)
/**
* Immediately start single contacts backup job.
* This job will launch independently from periodic contacts backup.
*
* @return Job info with current status; status is null if job does not exist
*/
fun startImmediateContactsBackup(user: User): LiveData<JobInfo?>
/**
* Schedule periodic calendar backups job. Operating system will
* decide when to start the job.
*
* This call is idempotent - there can be only one scheduled job
* at any given time.
*
* @param user User for which job will be scheduled.
*/
fun schedulePeriodicCalendarBackup(user: User)
/**
* Cancel periodic calendar backup. Existing tasks might finish, but no new
* invocations will occur.
*/
fun cancelPeriodicCalendarBackup(user: User)
/**
* Immediately start single calendar backup job.
* This job will launch independently from periodic calendar backup.
*
* @return Job info with current status; status is null if job does not exist
*/
fun startImmediateCalendarBackup(user: User): LiveData<JobInfo?>
/**
* Immediately start contacts import job. Import job will be started only once.
* If new job is started while existing job is running - request will be ignored
* and currently running job will continue running.
*
* @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
*
* @return Job info with current status; status is null if job does not exist
*/
fun startImmediateContactsImport(
contactsAccountName: String?,
contactsAccountType: String?,
vCardFilePath: String,
selectedContacts: IntArray
): LiveData<JobInfo?>
/**
* Immediately start calendar import job. Import job will be started only once.
* If new job is started while existing job is running - request will be ignored
* and currently running job will continue running.
*
* @param calendarPaths Array of paths of calendar files to import from
*
* @return Job info with current status; status is null if job does not exist
*/
fun startImmediateCalendarImport(calendarPaths: Map<String, Int>): LiveData<JobInfo?>
fun startImmediateFilesExportJob(files: Collection<OCFile>): LiveData<JobInfo?>
fun schedulePeriodicFilesSyncJob()
fun startImmediateFilesSyncJob(
overridePowerSaving: Boolean = false,
changedFiles: Array<String> = arrayOf<String>()
)
fun scheduleOfflineSync()
fun scheduleMediaFoldersDetectionJob()
fun startMediaFoldersDetectionJob()
fun startNotificationJob(subject: String, signature: String)
fun startAccountRemovalJob(accountName: String, remoteWipe: Boolean)
fun startFilesUploadJob(user: User)
fun getFileUploads(user: User): LiveData<List<JobInfo>>
fun cancelFilesUploadJob(user: User)
fun isStartFileUploadJobScheduled(user: User): Boolean
fun cancelFilesDownloadJob(user: User, fileId: Long)
fun isStartFileDownloadJobScheduled(user: User, fileId: Long): Boolean
@Suppress("LongParameterList")
fun startFileDownloadJob(
user: User,
file: OCFile,
behaviour: String,
downloadType: DownloadType?,
activityName: String,
packageName: String,
conflictUploadId: Long?
)
fun startPdfGenerateAndUploadWork(user: User, uploadFolder: String, imagePaths: List<String>, pdfPath: String)
fun scheduleTestJob()
fun startImmediateTestJob()
fun cancelTestJob()
fun pruneJobs()
fun cancelAllJobs()
fun schedulePeriodicHealthStatus()
fun startHealthStatus()
}

View file

@ -0,0 +1,628 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs
import android.provider.MediaStore
import androidx.lifecycle.LiveData
import androidx.lifecycle.map
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.ListenableWorker
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.Operation
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.nextcloud.client.account.User
import com.nextcloud.client.core.Clock
import com.nextcloud.client.di.Injectable
import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
import com.nextcloud.client.jobs.download.FileDownloadWorker
import com.nextcloud.client.jobs.upload.FileUploadWorker
import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.utils.extensions.isWorkScheduled
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.operations.DownloadType
import java.util.Date
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlin.reflect.KClass
/**
* Note to maintainers
*
* Since [androidx.work.WorkManager] is missing API to easily attach worker metadata,
* we use tags API to attach our custom metadata.
*
* To create new job request, use [BackgroundJobManagerImpl.oneTimeRequestBuilder] and
* [BackgroundJobManagerImpl.periodicRequestBuilder] calls, instead of calling
* platform builders. Those methods will create builders pre-set with mandatory tags.
*
* Since Google is notoriously releasing new background job services, [androidx.work.WorkManager] API is
* considered private implementation detail and should not be leaked through the interface, to minimize
* potential migration cost in the future.
*/
@Suppress("TooManyFunctions") // we expect this implementation to have rich API
internal class BackgroundJobManagerImpl(
private val workManager: WorkManager,
private val clock: Clock,
private val preferences: AppPreferences
) : 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"
const val JOB_IMMEDIATE_CONTACTS_BACKUP = "immediate_contacts_backup"
const val JOB_IMMEDIATE_CONTACTS_IMPORT = "immediate_contacts_import"
const val JOB_PERIODIC_CALENDAR_BACKUP = "periodic_calendar_backup"
const val JOB_IMMEDIATE_CALENDAR_IMPORT = "immediate_calendar_import"
const val JOB_PERIODIC_FILES_SYNC = "periodic_files_sync"
const val JOB_IMMEDIATE_FILES_SYNC = "immediate_files_sync"
const val JOB_PERIODIC_OFFLINE_SYNC = "periodic_offline_sync"
const val JOB_PERIODIC_MEDIA_FOLDER_DETECTION = "periodic_media_folder_detection"
const val JOB_IMMEDIATE_MEDIA_FOLDER_DETECTION = "immediate_media_folder_detection"
const val JOB_NOTIFICATION = "notification"
const val JOB_ACCOUNT_REMOVAL = "account_removal"
const val JOB_FILES_UPLOAD = "files_upload"
const val JOB_FOLDER_DOWNLOAD = "folder_download"
const val JOB_FILES_DOWNLOAD = "files_download"
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_PERIODIC_HEALTH_STATUS = "periodic_health_status"
const val JOB_IMMEDIATE_HEALTH_STATUS = "immediate_health_status"
const val JOB_TEST = "test_job"
const val MAX_CONTENT_TRIGGER_DELAY_MS = 10000L
const val TAG_PREFIX_NAME = "name"
const val TAG_PREFIX_USER = "user"
const val TAG_PREFIX_CLASS = "class"
const val TAG_PREFIX_START_TIMESTAMP = "timestamp"
val PREFIXES = setOf(TAG_PREFIX_NAME, TAG_PREFIX_USER, TAG_PREFIX_START_TIMESTAMP, TAG_PREFIX_CLASS)
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 DEFAULT_IMMEDIATE_JOB_DELAY_SEC = 3L
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 formatUserTag(user: User): String = "$TAG_PREFIX_USER:${user.accountName}"
fun formatClassTag(jobClass: KClass<out ListenableWorker>): String = "$TAG_PREFIX_CLASS:${jobClass.simpleName}"
fun formatTimeTag(startTimestamp: Long): String = "$TAG_PREFIX_START_TIMESTAMP:$startTimestamp"
fun parseTag(tag: String): Pair<String, String>? {
val key = tag.substringBefore(":", "")
val value = tag.substringAfter(":", "")
return if (key in PREFIXES) {
key to value
} else {
null
}
}
fun parseTimestamp(timestamp: String): Date {
return 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 deleteOldLogs(logEntries: MutableList<LogEntry>): MutableList<LogEntry> {
logEntries.removeIf {
return@removeIf (
it.started != null &&
Date(Date().time - KEEP_LOG_MILLIS).after(it.started)
) ||
(
it.finished != null &&
Date(Date().time - KEEP_LOG_MILLIS).after(it.finished)
)
}
return logEntries
}
}
override fun logStartOfWorker(workerName: String?) {
val logs = deleteOldLogs(preferences.readLogEntry().toMutableList())
if (workerName == null) {
logs.add(LogEntry(Date(), null, null, NOT_SET_VALUE))
} else {
logs.add(LogEntry(Date(), null, null, workerName))
}
preferences.saveLogEntry(logs)
}
override fun logEndOfWorker(workerName: String?, result: ListenableWorker.Result) {
val logs = deleteOldLogs(preferences.readLogEntry().toMutableList())
if (workerName == null) {
logs.add(LogEntry(null, Date(), result.toString(), NOT_SET_VALUE))
} else {
logs.add(LogEntry(null, Date(), result.toString(), workerName))
}
preferences.saveLogEntry(logs)
}
/**
* Create [OneTimeWorkRequest.Builder] pre-set with common attributes
*/
private fun oneTimeRequestBuilder(
jobClass: KClass<out ListenableWorker>,
jobName: String,
user: User? = null
): OneTimeWorkRequest.Builder {
val builder = OneTimeWorkRequest.Builder(jobClass.java)
.addTag(TAG_ALL)
.addTag(formatNameTag(jobName, user))
.addTag(formatTimeTag(clock.currentTime))
.addTag(formatClassTag(jobClass))
user?.let { builder.addTag(formatUserTag(it)) }
return builder
}
/**
* Create [PeriodicWorkRequest] pre-set with common attributes
*/
private fun periodicRequestBuilder(
jobClass: KClass<out ListenableWorker>,
jobName: String,
intervalMins: Long = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES,
flexIntervalMins: Long = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES,
user: User? = null
): PeriodicWorkRequest.Builder {
val builder = PeriodicWorkRequest.Builder(
jobClass.java,
intervalMins,
TimeUnit.MINUTES,
flexIntervalMins,
TimeUnit.MINUTES
)
.addTag(TAG_ALL)
.addTag(formatNameTag(jobName, user))
.addTag(formatTimeTag(clock.currentTime))
.addTag(formatClassTag(jobClass))
user?.let { builder.addTag(formatUserTag(it)) }
return builder
}
private fun WorkManager.getJobInfo(id: UUID): LiveData<JobInfo?> {
val workInfo = getWorkInfoByIdLiveData(id)
return workInfo.map { fromWorkInfo(it) }
}
/**
* Cancel work using name tag with optional user scope.
* All work instances will be cancelled.
*/
private fun WorkManager.cancelJob(name: String, user: User? = null): Operation {
val tag = formatNameTag(name, user)
return cancelAllWorkByTag(tag)
}
override val jobs: LiveData<List<JobInfo>>
get() {
val workInfo = workManager.getWorkInfosByTagLiveData("*")
return workInfo.map { it -> it.map { fromWorkInfo(it) ?: JobInfo() }.sortedBy { it.started }.reversed() }
}
override fun scheduleContentObserverJob() {
val constrains = Constraints.Builder()
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
.setTriggerContentMaxDelay(MAX_CONTENT_TRIGGER_DELAY_MS, TimeUnit.MILLISECONDS)
.build()
val request = oneTimeRequestBuilder(ContentObserverWork::class, JOB_CONTENT_OBSERVER)
.setConstraints(constrains)
.build()
workManager.enqueueUniqueWork(JOB_CONTENT_OBSERVER, ExistingWorkPolicy.REPLACE, request)
}
override fun schedulePeriodicContactsBackup(user: User) {
val data = Data.Builder()
.putString(ContactsBackupWork.KEY_ACCOUNT, user.accountName)
.putBoolean(ContactsBackupWork.KEY_FORCE, true)
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = periodicRequestBuilder(
jobClass = ContactsBackupWork::class,
jobName = JOB_PERIODIC_CONTACTS_BACKUP,
intervalMins = PERIODIC_BACKUP_INTERVAL_MINUTES,
user = user
)
.setInputData(data)
.setConstraints(constraints)
.build()
workManager.enqueueUniquePeriodicWork(JOB_PERIODIC_CONTACTS_BACKUP, ExistingPeriodicWorkPolicy.KEEP, request)
}
override fun cancelPeriodicContactsBackup(user: User) {
workManager.cancelJob(JOB_PERIODIC_CONTACTS_BACKUP, user)
}
override fun startImmediateContactsImport(
contactsAccountName: String?,
contactsAccountType: String?,
vCardFilePath: String,
selectedContacts: IntArray
): 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)
.build()
val constraints = Constraints.Builder()
.setRequiresCharging(false)
.build()
val request = oneTimeRequestBuilder(ContactsImportWork::class, JOB_IMMEDIATE_CONTACTS_IMPORT)
.setInputData(data)
.setConstraints(constraints)
.build()
workManager.enqueueUniqueWork(JOB_IMMEDIATE_CONTACTS_IMPORT, ExistingWorkPolicy.KEEP, request)
return workManager.getJobInfo(request.id)
}
override fun startImmediateCalendarImport(calendarPaths: Map<String, Int>): LiveData<JobInfo?> {
val data = Data.Builder()
.putAll(calendarPaths)
.build()
val constraints = Constraints.Builder()
.setRequiresCharging(false)
.build()
val request = oneTimeRequestBuilder(CalendarImportWork::class, JOB_IMMEDIATE_CALENDAR_IMPORT)
.setInputData(data)
.setConstraints(constraints)
.build()
workManager.enqueueUniqueWork(JOB_IMMEDIATE_CALENDAR_IMPORT, ExistingWorkPolicy.KEEP, request)
return workManager.getJobInfo(request.id)
}
override fun startImmediateFilesExportJob(files: Collection<OCFile>): LiveData<JobInfo?> {
val ids = files.map { it.fileId }.toLongArray()
val data = Data.Builder()
.putLongArray(FilesExportWork.FILES_TO_DOWNLOAD, ids)
.build()
val request = oneTimeRequestBuilder(FilesExportWork::class, JOB_IMMEDIATE_FILES_EXPORT)
.setInputData(data)
.build()
workManager.enqueueUniqueWork(JOB_IMMEDIATE_FILES_EXPORT, ExistingWorkPolicy.APPEND_OR_REPLACE, request)
return workManager.getJobInfo(request.id)
}
override fun startImmediateContactsBackup(user: User): LiveData<JobInfo?> {
val data = Data.Builder()
.putString(ContactsBackupWork.KEY_ACCOUNT, user.accountName)
.putBoolean(ContactsBackupWork.KEY_FORCE, true)
.build()
val request = oneTimeRequestBuilder(ContactsBackupWork::class, JOB_IMMEDIATE_CONTACTS_BACKUP, user)
.setInputData(data)
.build()
workManager.enqueueUniqueWork(JOB_IMMEDIATE_CONTACTS_BACKUP, ExistingWorkPolicy.KEEP, request)
return workManager.getJobInfo(request.id)
}
override fun startImmediateCalendarBackup(user: User): LiveData<JobInfo?> {
val data = Data.Builder()
.putString(CalendarBackupWork.ACCOUNT, user.accountName)
.putBoolean(CalendarBackupWork.FORCE, true)
.build()
val request = oneTimeRequestBuilder(CalendarBackupWork::class, JOB_IMMEDIATE_CALENDAR_BACKUP, user)
.setInputData(data)
.build()
workManager.enqueueUniqueWork(JOB_IMMEDIATE_CALENDAR_BACKUP, ExistingWorkPolicy.KEEP, request)
return workManager.getJobInfo(request.id)
}
override fun schedulePeriodicCalendarBackup(user: User) {
val data = Data.Builder()
.putString(CalendarBackupWork.ACCOUNT, user.accountName)
.putBoolean(CalendarBackupWork.FORCE, true)
.build()
val request = periodicRequestBuilder(
jobClass = CalendarBackupWork::class,
jobName = JOB_PERIODIC_CALENDAR_BACKUP,
intervalMins = PERIODIC_BACKUP_INTERVAL_MINUTES,
user = user
).setInputData(data).build()
workManager.enqueueUniquePeriodicWork(JOB_PERIODIC_CALENDAR_BACKUP, ExistingPeriodicWorkPolicy.KEEP, request)
}
override fun cancelPeriodicCalendarBackup(user: User) {
workManager.cancelJob(JOB_PERIODIC_CALENDAR_BACKUP, user)
}
override fun schedulePeriodicFilesSyncJob() {
val request = periodicRequestBuilder(
jobClass = FilesSyncWork::class,
jobName = JOB_PERIODIC_FILES_SYNC,
intervalMins = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES
).build()
workManager.enqueueUniquePeriodicWork(JOB_PERIODIC_FILES_SYNC, ExistingPeriodicWorkPolicy.REPLACE, request)
}
override fun startImmediateFilesSyncJob(
overridePowerSaving: Boolean,
changedFiles: Array<String>
) {
val arguments = Data.Builder()
.putBoolean(FilesSyncWork.OVERRIDE_POWER_SAVING, overridePowerSaving)
.putStringArray(FilesSyncWork.CHANGED_FILES, changedFiles)
.build()
val request = oneTimeRequestBuilder(
jobClass = FilesSyncWork::class,
jobName = JOB_IMMEDIATE_FILES_SYNC
)
.setInputData(arguments)
.build()
workManager.enqueueUniqueWork(JOB_IMMEDIATE_FILES_SYNC, ExistingWorkPolicy.APPEND, request)
}
override fun scheduleOfflineSync() {
val constrains = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.build()
val request = periodicRequestBuilder(OfflineSyncWork::class, JOB_PERIODIC_OFFLINE_SYNC)
.setConstraints(constrains)
.build()
workManager.enqueueUniquePeriodicWork(JOB_PERIODIC_OFFLINE_SYNC, ExistingPeriodicWorkPolicy.KEEP, request)
}
override fun scheduleMediaFoldersDetectionJob() {
val request = periodicRequestBuilder(MediaFoldersDetectionWork::class, JOB_PERIODIC_MEDIA_FOLDER_DETECTION)
.build()
workManager.enqueueUniquePeriodicWork(
JOB_PERIODIC_MEDIA_FOLDER_DETECTION,
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
override fun startMediaFoldersDetectionJob() {
val request = oneTimeRequestBuilder(MediaFoldersDetectionWork::class, JOB_IMMEDIATE_MEDIA_FOLDER_DETECTION)
.build()
workManager.enqueueUniqueWork(
JOB_IMMEDIATE_MEDIA_FOLDER_DETECTION,
ExistingWorkPolicy.KEEP,
request
)
}
override fun startNotificationJob(subject: String, signature: String) {
val data = Data.Builder()
.putString(NotificationWork.KEY_NOTIFICATION_SUBJECT, subject)
.putString(NotificationWork.KEY_NOTIFICATION_SIGNATURE, signature)
.build()
val request = oneTimeRequestBuilder(NotificationWork::class, JOB_NOTIFICATION)
.setInputData(data)
.build()
workManager.enqueue(request)
}
override fun startAccountRemovalJob(accountName: String, remoteWipe: Boolean) {
val data = Data.Builder()
.putString(AccountRemovalWork.ACCOUNT, accountName)
.putBoolean(AccountRemovalWork.REMOTE_WIPE, remoteWipe)
.build()
val request = oneTimeRequestBuilder(AccountRemovalWork::class, JOB_ACCOUNT_REMOVAL)
.setInputData(data)
.build()
workManager.enqueue(request)
}
private fun startFileUploadJobTag(user: User): String {
return JOB_FILES_UPLOAD + user.accountName
}
override fun isStartFileUploadJobScheduled(user: User): Boolean {
return workManager.isWorkScheduled(startFileUploadJobTag(user))
}
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 startFileDownloadJob(
user: User,
file: OCFile,
behaviour: String,
downloadType: DownloadType?,
activityName: String,
packageName: String,
conflictUploadId: Long?
) {
val tag = startFileDownloadJobTag(user, file.fileId)
val data = workDataOf(
FileDownloadWorker.ACCOUNT_NAME to user.accountName,
FileDownloadWorker.FILE_REMOTE_PATH to file.remotePath,
FileDownloadWorker.BEHAVIOUR to behaviour,
FileDownloadWorker.DOWNLOAD_TYPE to downloadType.toString(),
FileDownloadWorker.ACTIVITY_NAME to activityName,
FileDownloadWorker.PACKAGE_NAME to packageName,
FileDownloadWorker.CONFLICT_UPLOAD_ID to conflictUploadId
)
val request = oneTimeRequestBuilder(FileDownloadWorker::class, JOB_FILES_DOWNLOAD, user)
.addTag(tag)
.setInputData(data)
.build()
workManager.enqueueUniqueWork(tag, ExistingWorkPolicy.REPLACE, request)
}
override fun getFileUploads(user: User): LiveData<List<JobInfo>> {
val workInfo = workManager.getWorkInfosByTagLiveData(formatNameTag(JOB_FILES_UPLOAD, user))
return workInfo.map { it -> it.map { fromWorkInfo(it) ?: JobInfo() } }
}
override fun cancelFilesUploadJob(user: User) {
workManager.cancelJob(JOB_FILES_UPLOAD, user)
}
override fun cancelFilesDownloadJob(user: User, fileId: Long) {
workManager.cancelAllWorkByTag(startFileDownloadJobTag(user, fileId))
}
override fun startPdfGenerateAndUploadWork(
user: User,
uploadFolder: String,
imagePaths: List<String>,
pdfPath: String
) {
val data = workDataOf(
GeneratePdfFromImagesWork.INPUT_IMAGE_FILE_PATHS to imagePaths.toTypedArray(),
GeneratePdfFromImagesWork.INPUT_OUTPUT_FILE_PATH to pdfPath,
GeneratePdfFromImagesWork.INPUT_UPLOAD_ACCOUNT to user.accountName,
GeneratePdfFromImagesWork.INPUT_UPLOAD_FOLDER to uploadFolder
)
val request = oneTimeRequestBuilder(GeneratePdfFromImagesWork::class, JOB_PDF_GENERATION)
.setInputData(data)
.build()
workManager.enqueue(request)
}
override fun scheduleTestJob() {
val request = periodicRequestBuilder(TestJob::class, JOB_TEST)
.setInitialDelay(DEFAULT_IMMEDIATE_JOB_DELAY_SEC, TimeUnit.SECONDS)
.build()
workManager.enqueueUniquePeriodicWork(JOB_TEST, ExistingPeriodicWorkPolicy.REPLACE, request)
}
override fun startImmediateTestJob() {
val request = oneTimeRequestBuilder(TestJob::class, JOB_TEST)
.build()
workManager.enqueueUniqueWork(JOB_TEST, ExistingWorkPolicy.REPLACE, request)
}
override fun cancelTestJob() {
workManager.cancelAllWorkByTag(formatNameTag(JOB_TEST))
}
override fun pruneJobs() {
workManager.pruneWork()
}
override fun cancelAllJobs() {
workManager.cancelAllWorkByTag(TAG_ALL)
}
override fun schedulePeriodicHealthStatus() {
val request = periodicRequestBuilder(
jobClass = HealthStatusWork::class,
jobName = JOB_PERIODIC_HEALTH_STATUS,
intervalMins = PERIODIC_BACKUP_INTERVAL_MINUTES
).build()
workManager.enqueueUniquePeriodicWork(JOB_PERIODIC_HEALTH_STATUS, ExistingPeriodicWorkPolicy.KEEP, request)
}
override fun startHealthStatus() {
val request = oneTimeRequestBuilder(HealthStatusWork::class, JOB_IMMEDIATE_HEALTH_STATUS)
.build()
workManager.enqueueUniqueWork(
JOB_IMMEDIATE_HEALTH_STATUS,
ExistingWorkPolicy.KEEP,
request
)
}
}

View file

@ -0,0 +1,68 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2021 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs
import android.content.ContentResolver
import android.content.Context
import android.text.TextUtils
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.preferences.AppPreferences
import com.owncloud.android.lib.common.utils.Log_OC
import third_parties.sufficientlysecure.AndroidCalendar
import third_parties.sufficientlysecure.SaveCalendar
import java.util.Calendar
class CalendarBackupWork(
appContext: Context,
params: WorkerParameters,
private val contentResolver: ContentResolver,
private val accountManager: UserAccountManager,
private val preferences: AppPreferences
) : Worker(appContext, params) {
companion object {
val TAG = CalendarBackupWork::class.java.simpleName
const val ACCOUNT = "account"
const val FORCE = "force"
const val JOB_INTERVAL_MS: Long = 24 * 60 * 60 * 1000
}
override fun doWork(): Result {
val accountName = inputData.getString(ACCOUNT) ?: ""
val optionalUser = accountManager.getUser(accountName)
if (!optionalUser.isPresent || TextUtils.isEmpty(accountName)) {
// no account provided
Log_OC.d(TAG, "User not present")
return Result.failure()
}
val lastExecution = preferences.calendarLastBackup
val force = inputData.getBoolean(FORCE, false)
if (force || lastExecution + JOB_INTERVAL_MS < Calendar.getInstance().timeInMillis) {
val calendars = AndroidCalendar.loadAll(contentResolver)
Log_OC.d(TAG, "Saving ${calendars.size} calendars")
calendars.forEach { calendar ->
SaveCalendar(
applicationContext,
calendar,
preferences,
optionalUser.get()
).start()
}
// store execution date
preferences.calendarLastBackup = Calendar.getInstance().timeInMillis
} else {
Log_OC.d(TAG, "last execution less than 24h ago")
}
return Result.success()
}
}

View file

@ -0,0 +1,64 @@
/*
* Nextcloud - Android Client
*
* 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
*/
package com.nextcloud.client.jobs
import android.content.ContentResolver
import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.nextcloud.client.logger.Logger
import net.fortuna.ical4j.data.CalendarBuilder
import third_parties.sufficientlysecure.AndroidCalendar
import third_parties.sufficientlysecure.CalendarSource
import third_parties.sufficientlysecure.ProcessVEvent
import java.io.File
class CalendarImportWork(
private val appContext: Context,
params: WorkerParameters,
private val logger: Logger,
private val contentResolver: ContentResolver
) : Worker(appContext, params) {
companion object {
const val TAG = "CalendarImportWork"
const val SELECTED_CALENDARS = "selected_contacts_indices"
}
override fun doWork(): Result {
val calendarPaths = inputData.getStringArray(SELECTED_CALENDARS) ?: arrayOf<String>()
val calendars = inputData.keyValueMap as Map<String, AndroidCalendar>
val calendarBuilder = CalendarBuilder()
for ((path, selectedCalendar) in calendars) {
logger.d(TAG, "Import calendar from $path")
val file = File(path)
val calendarSource = CalendarSource(
file.toURI().toURL().toString(),
null,
null,
null,
appContext
)
val calendars = AndroidCalendar.loadAll(contentResolver)[0]
ProcessVEvent(
appContext,
calendarBuilder.build(calendarSource.stream),
selectedCalendar,
true
).run()
}
return Result.success()
}
}

View file

@ -0,0 +1,261 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs
import android.content.ComponentName
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.res.Resources
import android.database.Cursor
import android.net.Uri
import android.os.IBinder
import android.provider.ContactsContract
import android.text.TextUtils
import android.text.format.DateFormat
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.files.UploadRequest
import com.nextcloud.client.jobs.transfer.TransferManagerConnection
import com.nextcloud.client.jobs.upload.PostUploadAction
import com.nextcloud.client.jobs.upload.UploadTrigger
import com.owncloud.android.R
import com.owncloud.android.datamodel.ArbitraryDataProvider
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.files.services.NameCollisionPolicy
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.services.OperationsService
import com.owncloud.android.services.OperationsService.OperationsServiceBinder
import com.owncloud.android.ui.activity.ContactsPreferenceActivity
import ezvcard.Ezvcard
import ezvcard.VCardVersion
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader
import java.util.Calendar
@Suppress("LongParameterList") // legacy code
class ContactsBackupWork(
appContext: Context,
params: WorkerParameters,
private val resources: Resources,
private val arbitraryDataProvider: ArbitraryDataProvider,
private val contentResolver: ContentResolver,
private val accountManager: UserAccountManager
) : Worker(appContext, params) {
companion object {
val TAG = ContactsBackupWork::class.java.simpleName
const val KEY_ACCOUNT = "account"
const val KEY_FORCE = "force"
const val JOB_INTERVAL_MS: Long = 24L * 60L * 60L * 1000L
const val BUFFER_SIZE = 1024
}
private var operationsServiceConnection: OperationsServiceConnection? = null
private var operationsServiceBinder: OperationsServiceBinder? = null
@Suppress("ReturnCount") // pre-existing issue
override fun doWork(): Result {
val accountName = inputData.getString(KEY_ACCOUNT) ?: ""
if (TextUtils.isEmpty(accountName)) {
// no account provided
return Result.failure()
}
val optionalUser = accountManager.getUser(accountName)
if (!optionalUser.isPresent) {
return Result.failure()
}
val user = optionalUser.get()
val lastExecution = arbitraryDataProvider.getLongValue(
user,
ContactsPreferenceActivity.PREFERENCE_CONTACTS_LAST_BACKUP
)
val force = inputData.getBoolean(KEY_FORCE, false)
if (force || lastExecution + JOB_INTERVAL_MS < Calendar.getInstance().timeInMillis) {
Log_OC.d(TAG, "start contacts backup job")
val backupFolder: String = resources.getString(R.string.contacts_backup_folder) + OCFile.PATH_SEPARATOR
val daysToExpire: Int = applicationContext.getResources().getInteger(R.integer.contacts_backup_expire)
backupContact(user, backupFolder)
// bind to Operations Service
operationsServiceConnection = OperationsServiceConnection(
this,
daysToExpire,
backupFolder,
user
)
applicationContext.bindService(
Intent(applicationContext, OperationsService::class.java),
operationsServiceConnection as OperationsServiceConnection,
OperationsService.BIND_AUTO_CREATE
)
// store execution date
arbitraryDataProvider.storeOrUpdateKeyValue(
user.accountName,
ContactsPreferenceActivity.PREFERENCE_CONTACTS_LAST_BACKUP,
Calendar.getInstance().timeInMillis
)
} else {
Log_OC.d(TAG, "last execution less than 24h ago")
}
return Result.success()
}
private fun backupContact(user: User, backupFolder: String) {
val vCard = ArrayList<String>()
val cursor = contentResolver.query(
ContactsContract.Contacts.CONTENT_URI,
null,
null,
null,
null
)
if (cursor != null && cursor.count > 0) {
cursor.moveToFirst()
for (i in 0 until cursor.count) {
vCard.add(getContactFromCursor(cursor))
cursor.moveToNext()
}
}
val filename = DateFormat.format("yyyy-MM-dd_HH-mm-ss", Calendar.getInstance()).toString() + ".vcf"
Log_OC.d(TAG, "Storing: $filename")
val file = File(applicationContext.getCacheDir(), filename)
var fw: FileWriter? = null
try {
fw = FileWriter(file)
for (card in vCard) {
fw.write(card)
}
} catch (e: IOException) {
Log_OC.d(TAG, "Error ", e)
} finally {
cursor?.close()
if (fw != null) {
try {
fw.close()
} catch (e: IOException) {
Log_OC.d(TAG, "Error closing file writer ", e)
}
}
}
val request = UploadRequest.Builder(user, file.absolutePath, backupFolder + file.name)
.setFileSize(file.length())
.setNameConflicPolicy(NameCollisionPolicy.RENAME)
.setCreateRemoteFolder(true)
.setTrigger(UploadTrigger.USER)
.setPostAction(PostUploadAction.MOVE_TO_APP)
.setRequireWifi(false)
.setRequireCharging(false)
.build()
val connection = TransferManagerConnection(applicationContext, user)
connection.enqueue(request)
}
private fun expireFiles(daysToExpire: Int, backupFolderString: String, user: User) {
// -1 disables expiration
if (daysToExpire > -1) {
val storageManager = FileDataStorageManager(
user,
applicationContext.getContentResolver()
)
val backupFolder: OCFile = storageManager.getFileByPath(backupFolderString)
val cal = Calendar.getInstance()
cal.add(Calendar.DAY_OF_YEAR, -daysToExpire)
val timestampToExpire = cal.timeInMillis
if (backupFolder != null) {
Log_OC.d(TAG, "expire: " + daysToExpire + " " + backupFolder.fileName)
}
val backups: List<OCFile> = storageManager.getFolderContent(backupFolder, false)
for (backup in backups) {
if (timestampToExpire > backup.modificationTimestamp) {
Log_OC.d(TAG, "delete " + backup.remotePath)
// delete backups
val service = Intent(applicationContext, OperationsService::class.java)
service.action = OperationsService.ACTION_REMOVE
service.putExtra(OperationsService.EXTRA_ACCOUNT, user.toPlatformAccount())
service.putExtra(OperationsService.EXTRA_REMOTE_PATH, backup.remotePath)
service.putExtra(OperationsService.EXTRA_REMOVE_ONLY_LOCAL, false)
operationsServiceBinder!!.queueNewOperation(service)
}
}
}
operationsServiceConnection?.let {
applicationContext.unbindService(it)
}
}
@Suppress("NestedBlockDepth")
private fun getContactFromCursor(cursor: Cursor): String {
val lookupKey = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.LOOKUP_KEY))
val uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, lookupKey)
var vCard = ""
var inputStream: InputStream? = null
var inputStreamReader: InputStreamReader? = null
try {
inputStream = applicationContext.getContentResolver().openInputStream(uri)
val buffer = CharArray(BUFFER_SIZE)
val stringBuilder = StringBuilder()
if (inputStream != null) {
inputStreamReader = InputStreamReader(inputStream)
while (true) {
val byteCount = inputStreamReader.read(buffer, 0, buffer.size)
if (byteCount > 0) {
stringBuilder.append(buffer, 0, byteCount)
} else {
break
}
}
}
vCard = stringBuilder.toString()
// bump to vCard 3.0 format (min version supported by server) since Android OS exports to 2.1
return Ezvcard.write(Ezvcard.parse(vCard).all()).version(VCardVersion.V3_0).go()
} catch (e: IOException) {
Log_OC.d(TAG, e.message)
} finally {
try {
inputStream?.close()
inputStreamReader?.close()
} catch (e: IOException) {
Log_OC.e(TAG, "failed to close stream")
}
}
return vCard
}
/**
* Implements callback methods for service binding.
*/
private class OperationsServiceConnection internal constructor(
private val worker: ContactsBackupWork,
private val daysToExpire: Int,
private val backupFolder: String,
private val user: User
) : ServiceConnection {
override fun onServiceConnected(component: ComponentName, service: IBinder) {
Log_OC.d(TAG, "service connected")
if (component == ComponentName(worker.applicationContext, OperationsService::class.java)) {
worker.operationsServiceBinder = service as OperationsServiceBinder
worker.expireFiles(daysToExpire, backupFolder, user)
}
}
override fun onServiceDisconnected(component: ComponentName) {
Log_OC.d(TAG, "service disconnected")
if (component == ComponentName(worker.applicationContext, OperationsService::class.java)) {
worker.operationsServiceBinder = null
}
}
}
}

View file

@ -0,0 +1,126 @@
/*
* Nextcloud - Android Client
*
* 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
*/
package com.nextcloud.client.jobs
import android.content.ContentResolver
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.ContactsContract
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.nextcloud.client.logger.Logger
import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment
import com.owncloud.android.ui.fragment.contactsbackup.VCardComparator
import ezvcard.Ezvcard
import ezvcard.VCard
import third_parties.ezvcard_android.ContactOperations
import java.io.BufferedInputStream
import java.io.FileInputStream
import java.io.IOException
import java.util.Collections
import java.util.TreeMap
class ContactsImportWork(
appContext: Context,
params: WorkerParameters,
private val logger: Logger,
private val contentResolver: ContentResolver
) : Worker(appContext, params) {
companion object {
const val TAG = "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"
}
@Suppress("ComplexMethod", "NestedBlockDepth") // 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 inputStream = BufferedInputStream(FileInputStream(vCardFilePath))
val vCards = ArrayList<VCard>()
var cursor: Cursor? = null
@Suppress("TooGenericExceptionCaught") // legacy code
try {
val operations = ContactOperations(applicationContext, contactsAccountName, contactsAccountType)
vCards.addAll(Ezvcard.parse(inputStream).all())
Collections.sort(
vCards,
VCardComparator()
)
cursor = contentResolver.query(
ContactsContract.Contacts.CONTENT_URI,
null,
null,
null,
null
)
val ownContactMap = TreeMap<VCard, Long?>(VCardComparator())
if (cursor != null && cursor.count > 0) {
cursor.moveToFirst()
for (i in 0 until cursor.count) {
val vCard = getContactFromCursor(cursor)
if (vCard != null) {
ownContactMap[vCard] = cursor.getLong(cursor.getColumnIndexOrThrow("NAME_RAW_CONTACT_ID"))
}
cursor.moveToNext()
}
}
for (contactIndex in selectedContactsIndices) {
val vCard = vCards[contactIndex]
if (BackupListFragment.getDisplayName(vCard).isEmpty()) {
if (!ownContactMap.containsKey(vCard)) {
operations.insertContact(vCard)
} else {
operations.updateContact(vCard, ownContactMap[vCard])
}
} else {
operations.insertContact(vCard) // Insert All the contacts without name
}
}
} catch (e: Exception) {
logger.e(TAG, "${e.message}", e)
} finally {
cursor?.close()
}
try {
inputStream.close()
} catch (e: IOException) {
logger.e(TAG, "Error closing vCard stream", e)
}
return Result.success()
}
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)
var vCard: VCard? = null
try {
contentResolver.openInputStream(uri).use { inputStream ->
val vCardList = ArrayList<VCard>()
vCardList.addAll(Ezvcard.parse(inputStream).all())
if (vCardList.size > 0) {
vCard = vCardList[0]
}
}
} catch (e: IOException) {
logger.d(TAG, "${e.message}")
}
return vCard
}
}

View file

@ -0,0 +1,64 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2019 Chris Narkiewicz <hello@ezaquarii.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.device.PowerManagementService
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.lib.common.utils.Log_OC
/**
* This work is triggered when OS detects change in media folders.
*
* It fires media detection job and sync job and finishes immediately.
*
* This job must not be started on API < 24.
*/
class ContentObserverWork(
appContext: Context,
private val params: WorkerParameters,
private val syncerFolderProvider: SyncedFolderProvider,
private val powerManagementService: PowerManagementService,
private val backgroundJobManager: BackgroundJobManager
) : Worker(appContext, params) {
override fun doWork(): Result {
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
if (params.triggeredContentUris.size > 0) {
Log_OC.d(TAG, "File-sync Content Observer detected files change")
checkAndStartFileSyncJob()
backgroundJobManager.startMediaFoldersDetectionJob()
}
recheduleSelf()
val result = Result.success()
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
return result
}
private fun recheduleSelf() {
backgroundJobManager.scheduleContentObserverJob()
}
private fun checkAndStartFileSyncJob() {
val syncFolders = syncerFolderProvider.countEnabledSyncedFolders() > 0
if (!powerManagementService.isPowerSavingEnabled && syncFolders) {
val changedFiles = mutableListOf<String>()
for (uri in params.triggeredContentUris) {
changedFiles.add(uri.toString())
}
backgroundJobManager.startImmediateFilesSyncJob(false, changedFiles.toTypedArray())
}
}
companion object {
val TAG: String = ContentObserverWork::class.java.simpleName
}
}

View file

@ -0,0 +1,169 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2022 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs
import android.app.DownloadManager
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import androidx.core.app.NotificationCompat
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.User
import com.nextcloud.client.jobs.download.FileDownloadHelper
import com.owncloud.android.R
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.DownloadType
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.FileExportUtils
import com.owncloud.android.utils.FileStorageUtils
import com.owncloud.android.utils.theme.ViewThemeUtils
import java.security.SecureRandom
class FilesExportWork(
private val appContext: Context,
private val user: User,
private val contentResolver: ContentResolver,
private val viewThemeUtils: ViewThemeUtils,
params: WorkerParameters
) : Worker(appContext, params) {
private lateinit var storageManager: FileDataStorageManager
override fun doWork(): Result {
val fileIDs = inputData.getLongArray(FILES_TO_DOWNLOAD) ?: LongArray(0)
if (fileIDs.isEmpty()) {
Log_OC.w(this, "File export was started without any file")
return Result.success()
}
storageManager = FileDataStorageManager(user, contentResolver)
val successfulExports = exportFiles(fileIDs)
showSuccessNotification(successfulExports)
return Result.success()
}
private fun exportFiles(fileIDs: LongArray): Int {
var successfulExports = 0
fileIDs
.asSequence()
.map { storageManager.getFileById(it) }
.filterNotNull()
.forEach { ocFile ->
if (!FileStorageUtils.checkIfEnoughSpace(ocFile)) {
showErrorNotification(successfulExports)
return@forEach
}
if (ocFile.isDown) {
try {
exportFile(ocFile)
} catch (e: IllegalStateException) {
Log_OC.e(TAG, "Error exporting file", e)
showErrorNotification(successfulExports)
}
} else {
downloadFile(ocFile)
}
successfulExports++
}
return successfulExports
}
@Throws(IllegalStateException::class)
private fun exportFile(ocFile: OCFile) {
FileExportUtils().exportFile(
ocFile.fileName,
ocFile.mimeType,
contentResolver,
ocFile,
null
)
}
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)
} else {
appContext.resources.getQuantityString(
R.plurals.export_partially_failed,
successfulExports,
successfulExports
)
}
showNotification(message)
}
private fun showSuccessNotification(successfulExports: Int) {
showNotification(
appContext.resources.getQuantityString(
R.plurals.export_successful,
successfulExports,
successfulExports
)
)
}
private fun showNotification(message: String) {
val notificationId = SecureRandom().nextInt()
val notificationBuilder = NotificationCompat.Builder(
appContext,
NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD
)
.setSmallIcon(R.drawable.notification_icon)
.setContentTitle(message)
.setAutoCancel(true)
viewThemeUtils.androidx.themeNotificationCompatBuilder(appContext, notificationBuilder)
val actionIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS).apply {
flags = FLAG_ACTIVITY_NEW_TASK
}
val actionPendingIntent = PendingIntent.getActivity(
appContext,
notificationId,
actionIntent,
PendingIntent.FLAG_CANCEL_CURRENT or
PendingIntent.FLAG_IMMUTABLE
)
notificationBuilder.addAction(
NotificationCompat.Action(
null,
appContext.getString(R.string.locate_folder),
actionPendingIntent
)
)
val notificationManager = appContext
.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(notificationId, notificationBuilder.build())
}
companion object {
const val FILES_TO_DOWNLOAD = "files_to_download"
private val TAG = FilesExportWork::class.simpleName
}
}

View file

@ -0,0 +1,313 @@
/*
* Nextcloud - Android Client
*
* 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
*/
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
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.jobs.upload.FileUploadHelper
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
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
import com.owncloud.android.utils.MimeTypeUtil
import java.io.File
import java.text.ParsePosition
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
@Suppress("LongParameterList") // legacy code
class FilesSyncWork(
private val context: Context,
params: WorkerParameters,
private val contentResolver: ContentResolver,
private val userAccountManager: UserAccountManager,
private val uploadsStorageManager: UploadsStorageManager,
private val connectivityService: ConnectivityService,
private val powerManagementService: PowerManagementService,
private val syncedFolderProvider: SyncedFolderProvider,
private val backgroundJobManager: BackgroundJobManager
) : Worker(context, params) {
companion object {
const val TAG = "FilesSyncJob"
const val OVERRIDE_POWER_SAVING = "overridePowerSaving"
const val CHANGED_FILES = "changedFiles"
const val FOREGROUND_SERVICE_ID = 414
}
@Suppress("MagicNumber")
private fun updateForegroundWorker(progressPercent: Int, useForegroundWorker: Boolean) {
if (!useForegroundWorker) {
return
}
// 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")
override fun doWork(): Result {
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
Log_OC.d(TAG, "File-sync worker started")
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
}
val resources = context.resources
val lightVersion = resources.getBoolean(R.bool.syncedFolder_light)
FilesSyncHelper.restartJobsIfNeeded(
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.")
// 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")
val result = Result.success()
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
return result
}
@Suppress("MagicNumber")
private fun collectChangedFiles(changedFiles: Array<String>?) {
if (!changedFiles.isNullOrEmpty()) {
FilesSyncHelper.insertChangedEntries(syncedFolderProvider, 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)
}
}
@Suppress("LongMethod") // legacy code
private fun syncFolder(
context: Context,
resources: Resources,
lightVersion: Boolean,
filesystemDataProvider: FilesystemDataProvider,
currentLocale: Locale,
sFormatter: SimpleDateFormat,
syncedFolder: SyncedFolder
) {
val uploadAction: Int?
val needsCharging: Boolean
val needsWifi: Boolean
var file: File
val accountName = syncedFolder.account
val optionalUser = userAccountManager.getUser(accountName)
if (!optionalUser.isPresent) {
return
}
val user = optionalUser.get()
val arbitraryDataProvider: ArbitraryDataProvider? = if (lightVersion) {
ArbitraryDataProviderImpl(context)
} else {
null
}
val paths = filesystemDataProvider.getFilesForUpload(
syncedFolder.localPath,
syncedFolder.id.toString()
)
if (paths.size == 0) {
return
}
val pathsAndMimes = paths.map { path ->
file = File(path)
val localPath = file.absolutePath
Triple(
localPath,
getRemotePath(file, syncedFolder, sFormatter, lightVersion, resources, currentLocale),
MimeTypeUtil.getBestMimeTypeByFilename(localPath)
)
}
val localPaths = pathsAndMimes.map { it.first }.toTypedArray()
val remotePaths = pathsAndMimes.map { it.second }.toTypedArray()
if (lightVersion) {
needsCharging = resources.getBoolean(R.bool.syncedFolder_light_on_charging)
needsWifi = arbitraryDataProvider!!.getBooleanValue(
accountName,
SettingsActivity.SYNCED_FOLDER_LIGHT_UPLOAD_ON_WIFI
)
val uploadActionString = resources.getString(R.string.syncedFolder_light_upload_behaviour)
uploadAction = getUploadAction(uploadActionString)
} else {
needsCharging = syncedFolder.isChargingOnly
needsWifi = syncedFolder.isWifiOnly
uploadAction = syncedFolder.uploadAction
}
FileUploadHelper.instance().uploadNewFiles(
user,
localPaths,
remotePaths,
uploadAction!!,
true, // create parent folder if not existent
UploadFileOperation.CREATED_AS_INSTANT_PICTURE,
needsWifi,
needsCharging,
syncedFolder.nameCollisionPolicy
)
for (path in paths) {
// TODO batch update
filesystemDataProvider.updateFilesystemFileAsSentForUpload(
path,
syncedFolder.id.toString()
)
}
}
private fun getRemotePath(
file: File,
syncedFolder: SyncedFolder,
sFormatter: SimpleDateFormat,
lightVersion: Boolean,
resources: Resources,
currentLocale: Locale
): String {
val lastModificationTime = calculateLastModificationTime(file, syncedFolder, sFormatter)
val remoteFolder: String
val useSubfolders: Boolean
val subFolderRule: SubFolderRule
if (lightVersion) {
useSubfolders = resources.getBoolean(R.bool.syncedFolder_light_use_subfolders)
remoteFolder = resources.getString(R.string.syncedFolder_remote_folder)
subFolderRule = SubFolderRule.YEAR_MONTH
} else {
useSubfolders = syncedFolder.isSubfolderByDate
remoteFolder = syncedFolder.remotePath
subFolderRule = syncedFolder.subfolderRule
}
return FileStorageUtils.getInstantUploadFilePath(
file,
currentLocale,
remoteFolder,
syncedFolder.localPath,
lastModificationTime,
useSubfolders,
subFolderRule
)
}
private fun hasExif(file: File): Boolean {
val mimeType = FileStorageUtils.getMimeTypeFromName(file.absolutePath)
return MimeType.JPEG.equals(mimeType, ignoreCase = true) || MimeType.TIFF.equals(mimeType, ignoreCase = true)
}
private fun calculateLastModificationTime(
file: File,
syncedFolder: SyncedFolder,
formatter: SimpleDateFormat
): Long {
var lastModificationTime = file.lastModified()
if (MediaFolderType.IMAGE == syncedFolder.type && hasExif(file)) {
@Suppress("TooGenericExceptionCaught") // legacy code
try {
val exifInterface = ExifInterface(file.absolutePath)
val exifDate = exifInterface.getAttribute(ExifInterface.TAG_DATETIME)
if (!TextUtils.isEmpty(exifDate)) {
val pos = ParsePosition(0)
val dateTime = formatter.parse(exifDate, pos)
lastModificationTime = dateTime.time
}
} catch (e: Exception) {
Log_OC.d(TAG, "Failed to get the proper time " + e.localizedMessage)
}
}
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
}
}
}

View file

@ -0,0 +1,121 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* 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.User
import com.nextcloud.client.account.UserAccountManager
import com.owncloud.android.datamodel.ArbitraryDataProvider
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.UploadsStorageManager
import com.owncloud.android.db.UploadResult
import com.owncloud.android.lib.common.OwnCloudClientManagerFactory
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.lib.resources.status.Problem
import com.owncloud.android.lib.resources.status.SendClientDiagnosticRemoteOperation
import com.owncloud.android.utils.EncryptionUtils
import com.owncloud.android.utils.theme.CapabilityUtils
class HealthStatusWork(
private val context: Context,
params: WorkerParameters,
private val userAccountManager: UserAccountManager,
private val arbitraryDataProvider: ArbitraryDataProvider,
private val backgroundJobManager: BackgroundJobManager
) : Worker(context, params) {
override fun doWork(): Result {
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
for (user in userAccountManager.allUsers) {
// only if security guard is enabled
if (!CapabilityUtils.getCapability(user, context).securityGuard.isTrue) {
continue
}
val syncConflicts = collectSyncConflicts(user)
val problems = mutableListOf<Problem>().apply {
addAll(
collectUploadProblems(
user,
listOf(
UploadResult.CREDENTIAL_ERROR,
UploadResult.CANNOT_CREATE_FILE,
UploadResult.FOLDER_ERROR,
UploadResult.SERVICE_INTERRUPTED
)
)
)
}
val virusDetected = collectUploadProblems(user, listOf(UploadResult.VIRUS_DETECTED)).firstOrNull()
val e2eErrors = EncryptionUtils.readE2eError(arbitraryDataProvider, user)
val nextcloudClient = OwnCloudClientManagerFactory.getDefaultSingleton()
.getNextcloudClientFor(user.toOwnCloudAccount(), context)
val result =
SendClientDiagnosticRemoteOperation(
syncConflicts,
problems,
virusDetected,
e2eErrors
).execute(
nextcloudClient
)
if (!result.isSuccess) {
if (result.exception == null) {
Log_OC.e(TAG, "Update client health NOT successful!")
} else {
Log_OC.e(TAG, "Update client health NOT successful!", result.exception)
}
}
}
val result = Result.success()
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
return result
}
private fun collectSyncConflicts(user: User): Problem? {
val fileDataStorageManager = FileDataStorageManager(user, context.contentResolver)
val conflicts = fileDataStorageManager.getFilesWithSyncConflict(user)
return if (conflicts.isEmpty()) {
null
} else {
Problem("sync_conflicts", conflicts.size, conflicts.minOf { it.lastSyncDateForData })
}
}
private fun collectUploadProblems(user: User, errorCodes: List<UploadResult>): List<Problem> {
val uploadsStorageManager = UploadsStorageManager(userAccountManager, context.contentResolver)
val problems = uploadsStorageManager
.getUploadsForAccount(user.accountName)
.filter {
errorCodes.contains(it.lastResult)
}.groupBy { it.lastResult }
return if (problems.isEmpty()) {
emptyList()
} else {
return problems.map { problem ->
Problem(problem.key.toString(), problem.value.size, problem.value.minOf { it.uploadEndTimestamp })
}
}
}
companion object {
private const val TAG = "Health Status"
}
}

View file

@ -0,0 +1,27 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs
import java.util.Date
import java.util.UUID
data class JobInfo(
val id: UUID = UUID.fromString("00000000-0000-0000-0000-000000000000"),
val state: String = "",
val name: String = "",
val user: String = "",
val workerClass: String = "",
val started: Date = Date(0),
val progress: Int = 0
)
data class LogEntry(
val started: Date? = null,
val finished: Date? = null,
val result: String? = null,
var workerClass: String = BackgroundJobManagerImpl.NOT_SET_VALUE
)

View file

@ -0,0 +1,48 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs
import android.content.Context
import android.content.ContextWrapper
import androidx.work.Configuration
import androidx.work.WorkManager
import com.nextcloud.client.core.Clock
import com.nextcloud.client.preferences.AppPreferences
import dagger.Module
import dagger.Provides
import javax.inject.Singleton
@Module
class JobsModule {
@Provides
@Singleton
fun workManager(context: Context, factory: BackgroundJobFactory): WorkManager {
val configuration = Configuration.Builder()
.setWorkerFactory(factory)
.build()
val contextWrapper = object : ContextWrapper(context) {
override fun getApplicationContext(): Context {
return this
}
}
WorkManager.initialize(contextWrapper, configuration)
return WorkManager.getInstance(context)
}
@Provides
@Singleton
fun backgroundJobManager(
workManager: WorkManager,
clock: Clock,
preferences: AppPreferences
): BackgroundJobManager {
return BackgroundJobManagerImpl(workManager, clock, preferences)
}
}

View file

@ -0,0 +1,277 @@
/*
* Nextcloud Android client application
*
* @author Mario Danic
* @author Andy Scherzinger
* @author Chris Narkiewicz
* Copyright (C) 2018 Mario Danic
* Copyright (C) 2018 Andy Scherzinger
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs
import android.app.Activity
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.graphics.BitmapFactory
import android.media.RingtoneManager
import android.text.TextUtils
import androidx.core.app.NotificationCompat
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.google.gson.Gson
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.core.Clock
import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.client.preferences.AppPreferencesImpl
import com.owncloud.android.MainApp
import com.owncloud.android.R
import com.owncloud.android.datamodel.ArbitraryDataProvider
import com.owncloud.android.datamodel.ArbitraryDataProviderImpl
import com.owncloud.android.datamodel.MediaFolderType
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.SyncedFoldersActivity
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.SyncedFolderUtils
import com.owncloud.android.utils.theme.ViewThemeUtils
import java.util.Random
@Suppress("LongParameterList") // dependencies injection
class MediaFoldersDetectionWork constructor(
private val context: Context,
params: WorkerParameters,
private val resources: Resources,
private val contentResolver: ContentResolver,
private val userAccountManager: UserAccountManager,
private val preferences: AppPreferences,
private val clock: Clock,
private val viewThemeUtils: ViewThemeUtils,
private val syncedFolderProvider: SyncedFolderProvider
) : Worker(context, params) {
companion object {
const val TAG = "MediaFoldersDetectionJob"
const val KEY_MEDIA_FOLDER_PATH = "KEY_MEDIA_FOLDER_PATH"
const val KEY_MEDIA_FOLDER_TYPE = "KEY_MEDIA_FOLDER_TYPE"
private const val ACCOUNT_NAME_GLOBAL = "global"
private const val KEY_MEDIA_FOLDERS = "media_folders"
const val NOTIFICATION_ID = "NOTIFICATION_ID"
private val DISABLE_DETECTION_CLICK = MainApp.getAuthority() + "_DISABLE_DETECTION_CLICK"
}
private val randomIdGenerator = Random(clock.currentTime)
@Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth") // legacy code
override fun doWork(): Result {
val arbitraryDataProvider: ArbitraryDataProvider = ArbitraryDataProviderImpl(context)
val gson = Gson()
val mediaFoldersModel: MediaFoldersModel
val imageMediaFolders = MediaProvider.getImageFolders(
contentResolver,
1,
null,
true,
viewThemeUtils
)
val videoMediaFolders = MediaProvider.getVideoFolders(
contentResolver,
1,
null,
true,
viewThemeUtils
)
val imageMediaFolderPaths: MutableList<String> = ArrayList()
val videoMediaFolderPaths: MutableList<String> = ArrayList()
for (imageMediaFolder in imageMediaFolders) {
imageMediaFolder.absolutePath?.let {
imageMediaFolderPaths.add(it)
}
}
for (videoMediaFolder in videoMediaFolders) {
videoMediaFolder.absolutePath?.let {
imageMediaFolderPaths.add(it)
}
}
val arbitraryDataString = arbitraryDataProvider.getValue(ACCOUNT_NAME_GLOBAL, KEY_MEDIA_FOLDERS)
if (!TextUtils.isEmpty(arbitraryDataString)) {
mediaFoldersModel = gson.fromJson(arbitraryDataString, MediaFoldersModel::class.java)
// merge new detected paths with already notified ones
for (existingImageFolderPath in mediaFoldersModel.imageMediaFolders) {
if (!imageMediaFolderPaths.contains(existingImageFolderPath)) {
imageMediaFolderPaths.add(existingImageFolderPath)
}
}
for (existingVideoFolderPath in mediaFoldersModel.videoMediaFolders) {
if (!videoMediaFolderPaths.contains(existingVideoFolderPath)) {
videoMediaFolderPaths.add(existingVideoFolderPath)
}
}
// Store updated values
arbitraryDataProvider.storeOrUpdateKeyValue(
ACCOUNT_NAME_GLOBAL,
KEY_MEDIA_FOLDERS,
gson.toJson(MediaFoldersModel(imageMediaFolderPaths, videoMediaFolderPaths))
)
if (preferences.isShowMediaScanNotifications) {
imageMediaFolderPaths.removeAll(mediaFoldersModel.imageMediaFolders)
videoMediaFolderPaths.removeAll(mediaFoldersModel.videoMediaFolders)
if (imageMediaFolderPaths.isNotEmpty() || videoMediaFolderPaths.isNotEmpty()) {
val allUsers = userAccountManager.allUsers
val activeUsers: MutableList<User> = ArrayList()
for (user in allUsers) {
if (!arbitraryDataProvider.getBooleanValue(user, PENDING_FOR_REMOVAL)) {
activeUsers.add(user)
}
}
for (user in activeUsers) {
for (imageMediaFolder in imageMediaFolderPaths) {
val folder = syncedFolderProvider.findByLocalPathAndAccount(
imageMediaFolder,
user
)
if (folder == null &&
SyncedFolderUtils.isQualifyingMediaFolder(imageMediaFolder, MediaFolderType.IMAGE)
) {
val contentTitle = String.format(
resources.getString(R.string.new_media_folder_detected),
resources.getString(R.string.new_media_folder_photos)
)
sendNotification(
contentTitle,
imageMediaFolder.substring(imageMediaFolder.lastIndexOf('/') + 1),
user,
imageMediaFolder,
MediaFolderType.IMAGE.id
)
}
}
for (videoMediaFolder in videoMediaFolderPaths) {
val folder = syncedFolderProvider.findByLocalPathAndAccount(
videoMediaFolder,
user
)
if (folder == null) {
val contentTitle = String.format(
context.getString(R.string.new_media_folder_detected),
context.getString(R.string.new_media_folder_videos)
)
sendNotification(
contentTitle,
videoMediaFolder.substring(videoMediaFolder.lastIndexOf('/') + 1),
user,
videoMediaFolder,
MediaFolderType.VIDEO.id
)
}
}
}
}
}
} else {
mediaFoldersModel = MediaFoldersModel(imageMediaFolderPaths, videoMediaFolderPaths)
arbitraryDataProvider.storeOrUpdateKeyValue(
ACCOUNT_NAME_GLOBAL,
KEY_MEDIA_FOLDERS,
gson.toJson(mediaFoldersModel)
)
}
return Result.success()
}
@Suppress("LongMethod")
private fun sendNotification(contentTitle: String, subtitle: String, user: User, path: String, type: Int) {
val notificationId = randomIdGenerator.nextInt()
val context = context
val intent = Intent(context, SyncedFoldersActivity::class.java)
intent.putExtra(NOTIFICATION_ID, notificationId)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.putExtra(NotificationWork.KEY_NOTIFICATION_ACCOUNT, user.accountName)
intent.putExtra(KEY_MEDIA_FOLDER_PATH, path)
intent.putExtra(KEY_MEDIA_FOLDER_TYPE, type)
val pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val notificationBuilder = NotificationCompat.Builder(
context,
NotificationUtils.NOTIFICATION_CHANNEL_GENERAL
)
.setSmallIcon(R.drawable.notification_icon)
.setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.notification_icon))
.setSubText(user.accountName)
.setContentTitle(contentTitle)
.setContentText(subtitle)
.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
.setAutoCancel(true)
.setContentIntent(pendingIntent)
viewThemeUtils.androidx.themeNotificationCompatBuilder(context, notificationBuilder)
val disableDetection = Intent(context, NotificationReceiver::class.java)
disableDetection.putExtra(NOTIFICATION_ID, notificationId)
disableDetection.action = DISABLE_DETECTION_CLICK
val disableIntent = PendingIntent.getBroadcast(
context,
notificationId,
disableDetection,
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
notificationBuilder.addAction(
NotificationCompat.Action(
R.drawable.ic_close,
context.getString(R.string.disable_new_media_folder_detection_notifications),
disableIntent
)
)
val configureIntent = PendingIntent.getActivity(
context,
notificationId,
intent,
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
notificationBuilder.addAction(
NotificationCompat.Action(
R.drawable.ic_settings,
context.getString(R.string.configure_new_media_folder_detection_notifications),
configureIntent
)
)
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(notificationId, notificationBuilder.build())
}
class NotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
val notificationId = intent.getIntExtra(NOTIFICATION_ID, 0)
val preferences = AppPreferencesImpl.fromContext(context)
if (DISABLE_DETECTION_CLICK == action) {
Log_OC.d(this, "Disable media scan notifications")
preferences.isShowMediaScanNotifications = false
cancel(context, notificationId)
}
}
private fun cancel(context: Context, notificationId: Int) {
val notificationManager = context.getSystemService(Activity.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(notificationId)
}
}
}

View file

@ -0,0 +1,343 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs
import android.accounts.AuthenticatorException
import android.accounts.OperationCanceledException
import android.app.Activity
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.media.RingtoneManager
import android.text.TextUtils
import android.util.Base64
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.google.gson.Gson
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
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.OwnCloudClientManagerFactory
import com.owncloud.android.lib.common.operations.RemoteOperation
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.lib.resources.notifications.DeleteNotificationRemoteOperation
import com.owncloud.android.lib.resources.notifications.GetNotificationRemoteOperation
import com.owncloud.android.lib.resources.notifications.models.Notification
import com.owncloud.android.ui.activity.FileDisplayActivity
import com.owncloud.android.ui.activity.NotificationsActivity
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.PushUtils
import com.owncloud.android.utils.theme.ViewThemeUtils
import dagger.android.AndroidInjection
import org.apache.commons.httpclient.HttpMethod
import org.apache.commons.httpclient.HttpStatus
import org.apache.commons.httpclient.methods.DeleteMethod
import org.apache.commons.httpclient.methods.GetMethod
import org.apache.commons.httpclient.methods.PutMethod
import org.apache.commons.httpclient.methods.Utf8PostMethod
import java.io.IOException
import java.security.GeneralSecurityException
import java.security.PrivateKey
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.inject.Inject
@Suppress("LongParameterList")
class NotificationWork constructor(
private val context: Context,
params: WorkerParameters,
private val notificationManager: NotificationManager,
private val accountManager: UserAccountManager,
private val deckApi: DeckApi,
private val viewThemeUtils: ViewThemeUtils
) : Worker(context, params) {
companion object {
const val TAG = "NotificationJob"
const val KEY_NOTIFICATION_ACCOUNT = "KEY_NOTIFICATION_ACCOUNT"
const val KEY_NOTIFICATION_SUBJECT = "subject"
const val KEY_NOTIFICATION_SIGNATURE = "signature"
private const val KEY_NOTIFICATION_ACTION_LINK = "KEY_NOTIFICATION_ACTION_LINK"
private const val KEY_NOTIFICATION_ACTION_TYPE = "KEY_NOTIFICATION_ACTION_TYPE"
private const val PUSH_NOTIFICATION_ID = "PUSH_NOTIFICATION_ID"
private const val NUMERIC_NOTIFICATION_ID = "NUMERIC_NOTIFICATION_ID"
}
@Suppress("TooGenericExceptionCaught", "NestedBlockDepth", "ComplexMethod", "LongMethod") // legacy code
override fun doWork(): Result {
val subject = inputData.getString(KEY_NOTIFICATION_SUBJECT) ?: ""
val signature = inputData.getString(KEY_NOTIFICATION_SIGNATURE) ?: ""
if (!TextUtils.isEmpty(subject) && !TextUtils.isEmpty(signature)) {
try {
val base64DecodedSubject = Base64.decode(subject, Base64.DEFAULT)
val base64DecodedSignature = Base64.decode(signature, Base64.DEFAULT)
val privateKey = PushUtils.readKeyFromFile(false) as PrivateKey
try {
val signatureVerification = PushUtils.verifySignature(
context,
accountManager,
base64DecodedSignature,
base64DecodedSubject
)
if (signatureVerification != null && signatureVerification.signatureValid) {
val cipher = Cipher.getInstance("RSA/None/PKCS1Padding")
cipher.init(Cipher.DECRYPT_MODE, privateKey)
val decryptedSubject = cipher.doFinal(base64DecodedSubject)
val gson = Gson()
val decryptedPushMessage = gson.fromJson(
String(decryptedSubject),
DecryptedPushMessage::class.java
)
if (decryptedPushMessage.delete) {
notificationManager.cancel(decryptedPushMessage.nid)
} else if (decryptedPushMessage.deleteAll) {
notificationManager.cancelAll()
} else {
val user = accountManager.getUser(signatureVerification.account?.name)
.orElseThrow { RuntimeException() }
fetchCompleteNotification(user, decryptedPushMessage)
}
}
} catch (e1: GeneralSecurityException) {
Log_OC.d(TAG, "Error decrypting message ${e1.javaClass.name} ${e1.localizedMessage}")
}
} catch (exception: Exception) {
Log_OC.d(TAG, "Something went very wrong" + exception.localizedMessage)
}
}
return Result.success()
}
@Suppress("LongMethod") // legacy code
private fun sendNotification(notification: Notification, user: User) {
val randomId = SecureRandom()
val file = notification.subjectRichParameters["file"]
val deckActionOverrideIntent = deckApi.createForwardToDeckActionIntent(notification, user)
val pendingIntent: PendingIntent?
if (deckActionOverrideIntent.isPresent) {
pendingIntent = deckActionOverrideIntent.get()
} else {
val intent: Intent
if (file == null) {
intent = Intent(context, NotificationsActivity::class.java)
} else {
intent = Intent(context, FileDisplayActivity::class.java)
intent.action = Intent.ACTION_VIEW
intent.putExtra(FileDisplayActivity.KEY_FILE_ID, file.id)
}
intent.putExtra(KEY_NOTIFICATION_ACCOUNT, user.accountName)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
pendingIntent = PendingIntent.getActivity(
context,
notification.getNotificationId(),
intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
}
val pushNotificationId = randomId.nextInt()
val notificationBuilder = NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_PUSH)
.setSmallIcon(R.drawable.notification_icon)
.setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.notification_icon))
.setShowWhen(true)
.setSubText(user.accountName)
.setContentTitle(notification.getSubject())
.setContentText(notification.getMessage())
.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
.setAutoCancel(true)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setContentIntent(pendingIntent)
viewThemeUtils.androidx.themeNotificationCompatBuilder(context, notificationBuilder)
// Remove
if (notification.getActions().isEmpty()) {
val disableDetection = Intent(context, NotificationReceiver::class.java)
disableDetection.putExtra(NUMERIC_NOTIFICATION_ID, notification.getNotificationId())
disableDetection.putExtra(PUSH_NOTIFICATION_ID, pushNotificationId)
disableDetection.putExtra(KEY_NOTIFICATION_ACCOUNT, user.accountName)
val disableIntent = PendingIntent.getBroadcast(
context,
pushNotificationId,
disableDetection,
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
notificationBuilder.addAction(
NotificationCompat.Action(
R.drawable.ic_close,
context.getString(R.string.remove_push_notification),
disableIntent
)
)
} else {
// Actions
for (action in notification.getActions()) {
val actionIntent = Intent(context, NotificationReceiver::class.java)
actionIntent.putExtra(NUMERIC_NOTIFICATION_ID, notification.getNotificationId())
actionIntent.putExtra(PUSH_NOTIFICATION_ID, pushNotificationId)
actionIntent.putExtra(KEY_NOTIFICATION_ACCOUNT, user.accountName)
actionIntent.putExtra(KEY_NOTIFICATION_ACTION_LINK, action.link)
actionIntent.putExtra(KEY_NOTIFICATION_ACTION_TYPE, action.type)
val actionPendingIntent = PendingIntent.getBroadcast(
context,
randomId.nextInt(),
actionIntent,
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
var icon: Int
icon = if (action.primary) {
R.drawable.ic_check_circle
} else {
R.drawable.ic_check_circle_outline
}
notificationBuilder.addAction(NotificationCompat.Action(icon, action.label, actionPendingIntent))
}
}
notificationBuilder.setPublicVersion(
NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_PUSH)
.setSmallIcon(R.drawable.notification_icon)
.setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.notification_icon))
.setShowWhen(true)
.setSubText(user.accountName)
.setContentTitle(context.getString(R.string.new_notification))
.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
.setAutoCancel(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(pendingIntent)
.also {
viewThemeUtils.androidx.themeNotificationCompatBuilder(context, it)
}
.build()
)
val notificationManager = NotificationManagerCompat.from(context)
notificationManager.notify(notification.getNotificationId(), notificationBuilder.build())
}
@Suppress("TooGenericExceptionCaught") // legacy code
private fun fetchCompleteNotification(account: User, decryptedPushMessage: DecryptedPushMessage) {
val optionalUser = accountManager.getUser(account.accountName)
if (!optionalUser.isPresent) {
Log_OC.e(this, "Account may not be null")
return
}
val user = optionalUser.get()
try {
val client = OwnCloudClientManagerFactory.getDefaultSingleton()
.getClientFor(user.toOwnCloudAccount(), context)
val result = GetNotificationRemoteOperation(decryptedPushMessage.nid)
.execute(client)
if (result.isSuccess) {
val notification = result.resultData
sendNotification(notification, account)
}
} catch (e: Exception) {
Log_OC.e(this, "Error creating account", e)
}
}
class NotificationReceiver : BroadcastReceiver() {
private lateinit var accountManager: UserAccountManager
/**
* This is a workaround for a Dagger compiler bug - it cannot inject
* into a nested Kotlin class for some reason, but the helper
* works.
*/
@Inject
fun inject(accountManager: UserAccountManager) {
this.accountManager = accountManager
}
@Suppress("ComplexMethod") // legacy code
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
val numericNotificationId = intent.getIntExtra(NUMERIC_NOTIFICATION_ID, 0)
val accountName = intent.getStringExtra(KEY_NOTIFICATION_ACCOUNT)
if (numericNotificationId != 0) {
Thread(
Runnable {
val notificationManager = context.getSystemService(
Activity.NOTIFICATION_SERVICE
) as NotificationManager
var oldNotification: android.app.Notification? = null
for (statusBarNotification in notificationManager.activeNotifications) {
if (numericNotificationId == statusBarNotification.id) {
oldNotification = statusBarNotification.notification
break
}
}
cancel(context, numericNotificationId)
try {
val optionalUser = accountManager.getUser(accountName)
if (optionalUser.isPresent) {
val user = optionalUser.get()
val client = OwnCloudClientManagerFactory.getDefaultSingleton()
.getClientFor(user.toOwnCloudAccount(), 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()) {
val resultCode = executeAction(actionType, actionLink, client)
resultCode == HttpStatus.SC_OK || resultCode == HttpStatus.SC_ACCEPTED
} else {
DeleteNotificationRemoteOperation(numericNotificationId)
.execute(client).isSuccess
}
if (success) {
if (oldNotification == null) {
cancel(context, numericNotificationId)
}
} else {
notificationManager.notify(numericNotificationId, oldNotification)
}
}
} catch (e: IOException) {
Log_OC.e(TAG, "Error initializing client", e)
} catch (e: OperationCanceledException) {
Log_OC.e(TAG, "Error initializing client", e)
} catch (e: AuthenticatorException) {
Log_OC.e(TAG, "Error initializing client", e)
}
}
).start()
}
}
@Suppress("ReturnCount") // legacy code
private fun executeAction(actionType: String, actionLink: String, client: OwnCloudClient): Int {
val method: HttpMethod
method = when (actionType) {
"GET" -> GetMethod(actionLink)
"POST" -> Utf8PostMethod(actionLink)
"DELETE" -> DeleteMethod(actionLink)
"PUT" -> PutMethod(actionLink)
else -> return 0 // do nothing
}
method.setRequestHeader(RemoteOperation.OCS_API_HEADER, RemoteOperation.OCS_API_HEADER_VALUE)
try {
return client.executeMethod(method)
} catch (e: IOException) {
Log_OC.e(TAG, "Execution of notification action failed: $e")
}
return 0
}
private fun cancel(context: Context, notificationId: Int) {
val notificationManager = context.getSystemService(Activity.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(notificationId)
}
}
}

View file

@ -0,0 +1,142 @@
/*
* Nextcloud Android client application
*
* @author Mario Danic
* @author Chris Narkiewicz
* Copyright (C) 2018 Mario Danic
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs
import android.content.ContentResolver
import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.network.ConnectivityService
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.lib.resources.files.CheckEtagRemoteOperation
import com.owncloud.android.operations.SynchronizeFileOperation
import com.owncloud.android.utils.FileStorageUtils
import java.io.File
@Suppress("LongParameterList") // Legacy code
class OfflineSyncWork constructor(
private val context: Context,
params: WorkerParameters,
private val contentResolver: ContentResolver,
private val userAccountManager: UserAccountManager,
private val connectivityService: ConnectivityService,
private val powerManagementService: PowerManagementService
) : Worker(context, params) {
companion object {
const val TAG = "OfflineSyncJob"
}
override fun doWork(): Result {
if (!powerManagementService.isPowerSavingEnabled) {
val users = userAccountManager.allUsers
for (user in users) {
val storageManager = FileDataStorageManager(user, contentResolver)
val ocRoot = storageManager.getFileByPath(OCFile.ROOT_PATH)
if (ocRoot.storagePath == null) {
break
}
recursive(File(ocRoot.storagePath), storageManager, user)
}
}
return Result.success()
}
private fun recursive(folder: File, storageManager: FileDataStorageManager, user: User) {
val downloadFolder = FileStorageUtils.getSavePath(user.accountName)
val folderName = folder.absolutePath.replaceFirst(downloadFolder.toRegex(), "") + OCFile.PATH_SEPARATOR
Log_OC.d(TAG, "$folderName: enter")
// exit
if (folder.listFiles() == null) {
return
}
val updatedEtag = checkEtagChanged(folderName, storageManager, user) ?: return
// iterate over downloaded files
val files = folder.listFiles { obj: File -> obj.isFile }
if (files != null) {
for (file in files) {
val ocFile = storageManager.getFileByLocalPath(file.path)
val synchronizeFileOperation = SynchronizeFileOperation(
ocFile?.remotePath,
user,
true,
context,
storageManager
)
synchronizeFileOperation.execute(context)
}
}
// recursive into folder
val subfolders = folder.listFiles { obj: File -> obj.isDirectory }
if (subfolders != null) {
for (subfolder in subfolders) {
recursive(subfolder, storageManager, user)
}
}
// update eTag
@Suppress("TooGenericExceptionCaught") // legacy code
try {
val ocFolder = storageManager.getFileByPath(folderName)
ocFolder.etagOnServer = updatedEtag
storageManager.saveFile(ocFolder)
} catch (e: Exception) {
Log_OC.e(TAG, "Failed to update etag on " + folder.absolutePath, e)
}
}
/**
* @return new etag if changed, `null` otherwise
*/
private fun checkEtagChanged(folderName: String, storageManager: FileDataStorageManager, user: User): String? {
val ocFolder = storageManager.getFileByPath(folderName) ?: return null
Log_OC.d(TAG, "$folderName: currentEtag: ${ocFolder.etag}")
// check for etag change, if false, skip
val checkEtagOperation = CheckEtagRemoteOperation(
ocFolder.remotePath,
ocFolder.etagOnServer
)
val result = checkEtagOperation.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)
if (!removalResult) {
Log_OC.e(TAG, "removal of " + ocFolder.storagePath + " failed: file not found")
}
null
}
ResultCode.ETAG_CHANGED -> {
Log_OC.d(TAG, "$folderName: eTag changed")
result.data[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
}
}
}
}

View file

@ -0,0 +1,41 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs
import android.content.Context
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) {
companion object {
private const val MAX_PROGRESS = 100
private const val DELAY_MS = 1000L
private const val PROGRESS_KEY = "progress"
}
override fun doWork(): Result {
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
for (i in 0..MAX_PROGRESS) {
Thread.sleep(DELAY_MS)
val progress = Data.Builder()
.putInt(PROGRESS_KEY, i)
.build()
setProgressAsync(progress)
}
val result = Result.success()
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
return result
}
}

View file

@ -0,0 +1,145 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
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.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
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)
}
}
notification = notificationBuilder.build()
}
@Suppress("MagicNumber")
fun prepareForStart(operation: DownloadFileOperation) {
notificationBuilder = NotificationUtils.newNotificationBuilder(context, viewThemeUtils).apply {
setSmallIcon(R.drawable.notification_icon)
setOngoing(true)
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()
)
}
}
fun prepareForResult() {
notificationBuilder
.setAutoCancel(true)
.setOngoing(false)
.setProgress(0, 0, false)
}
@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)
}
}
@Suppress("MagicNumber")
fun dismissNotification() {
Handler(Looper.getMainLooper()).postDelayed({
notificationManager.cancel(id)
}, 2000)
}
fun showNewNotification(text: String) {
val notifyId = SecureRandom().nextInt()
notificationBuilder.run {
setProgress(0, 0, false)
setContentTitle(null)
setContentText(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(
context,
System.currentTimeMillis().toInt(),
intent,
flag
)
)
}
fun getId(): Int {
return id
}
fun getNotification(): Notification {
return notificationBuilder.build()
}
}

View file

@ -0,0 +1,96 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.download
import android.content.ContentResolver
import android.content.Context
import com.nextcloud.client.core.IsCancelled
import com.nextcloud.client.files.DownloadRequest
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.common.OwnCloudClient
import com.owncloud.android.operations.DownloadFileOperation
import com.owncloud.android.utils.MimeTypeUtil
import java.io.File
/**
* This runnable object encapsulates file download logic. It has been extracted to wrap
* network operation and storage manager interactions, as those pose testing challenges
* that cannot be addressed due to large number of dependencies.
*
* This design can be regarded as intermediary refactoring step.
*/
class DownloadTask(
private val context: Context,
private val contentResolver: ContentResolver,
private val clientProvider: () -> OwnCloudClient
) {
data class Result(val file: OCFile, val success: Boolean)
/**
* This class is a helper factory to to keep static dependencies
* injection out of the downloader instance.
*
* @param context Context
* @param clientProvider Provide client - this must be called on background thread
* @param contentResolver content resovler used to access file storage
*/
class Factory(
private val context: Context,
private val clientProvider: () -> OwnCloudClient,
private val contentResolver: ContentResolver
) {
fun create(): DownloadTask {
return DownloadTask(context, contentResolver, clientProvider)
}
}
// Unused progress, isCancelled arguments needed for TransferManagerTest
fun download(request: DownloadRequest, progress: (Int) -> Unit, isCancelled: IsCancelled): Result {
val op = DownloadFileOperation(request.user, request.file, context)
val client = clientProvider.invoke()
val result = op.execute(client)
return if (result.isSuccess) {
val storageManager = FileDataStorageManager(
request.user,
contentResolver
)
val file = saveDownloadedFile(op, storageManager)
Result(file, true)
} else {
Result(request.file, false)
}
}
private fun saveDownloadedFile(op: DownloadFileOperation, storageManager: FileDataStorageManager): OCFile {
val file = storageManager.getFileById(op.file.fileId) as OCFile
file.apply {
val syncDate = System.currentTimeMillis()
lastSyncDateForProperties = syncDate
lastSyncDateForData = syncDate
isUpdateThumbnailNeeded = true
modificationTimestamp = op.modificationTimestamp
modificationTimestampAtLastSyncForData = op.modificationTimestamp
etag = op.etag
mimeType = op.mimeType
storagePath = op.savePath
fileLength = File(op.savePath).length()
remoteId = op.file.remoteId
}
storageManager.saveFile(file)
if (MimeTypeUtil.isMedia(op.mimeType)) {
FileDataStorageManager.triggerMediaScan(file.storagePath)
}
return file
}
}

View file

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

View file

@ -0,0 +1,148 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.download
import com.nextcloud.client.account.User
import com.nextcloud.client.jobs.BackgroundJobManager
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.operations.DownloadFileOperation
import com.owncloud.android.operations.DownloadType
import com.owncloud.android.utils.MimeTypeUtil
import java.io.File
import javax.inject.Inject
class FileDownloadHelper {
@Inject
lateinit var backgroundJobManager: BackgroundJobManager
@Inject
lateinit var uploadsStorageManager: UploadsStorageManager
companion object {
private var instance: FileDownloadHelper? = null
fun instance(): FileDownloadHelper {
return instance ?: synchronized(this) {
instance ?: FileDownloadHelper().also { instance = it }
}
}
}
init {
MainApp.getAppComponent().inject(this)
}
fun isDownloading(user: User?, file: OCFile?): Boolean {
if (user == null || file == null) {
return false
}
val fileStorageManager = FileDataStorageManager(user, MainApp.getAppContext().contentResolver)
val topParentId = fileStorageManager.getTopParentId(file)
val isJobScheduled = backgroundJobManager.isStartFileDownloadJobScheduled(user, file.fileId)
return isJobScheduled || if (file.isFolder) {
backgroundJobManager.isStartFileDownloadJobScheduled(user, topParentId)
} else {
FileDownloadWorker.isDownloading(user.accountName, file.fileId)
}
}
fun cancelPendingOrCurrentDownloads(user: User?, files: List<OCFile>?) {
if (user == null || files == null) return
files.forEach { file ->
FileDownloadWorker.cancelOperation(user.accountName, file.fileId)
backgroundJobManager.cancelFilesDownloadJob(user, file.fileId)
}
}
fun cancelAllDownloadsForAccount(accountName: String?, currentDownload: DownloadFileOperation?) {
if (accountName == null || currentDownload == null) return
val currentUser = currentDownload.user
val currentFile = currentDownload.file
if (!currentUser.nameEquals(accountName)) {
return
}
currentDownload.cancel()
FileDownloadWorker.cancelOperation(currentUser.accountName, currentFile.fileId)
backgroundJobManager.cancelFilesDownloadJob(currentUser, currentFile.fileId)
}
fun saveFile(
file: OCFile,
currentDownload: DownloadFileOperation?,
storageManager: FileDataStorageManager?
) {
val syncDate = System.currentTimeMillis()
file.apply {
lastSyncDateForProperties = syncDate
lastSyncDateForData = syncDate
isUpdateThumbnailNeeded = true
modificationTimestamp = currentDownload?.modificationTimestamp ?: 0L
modificationTimestampAtLastSyncForData = currentDownload?.modificationTimestamp ?: 0L
etag = currentDownload?.etag
mimeType = currentDownload?.mimeType
storagePath = currentDownload?.savePath
val savePathFile = currentDownload?.savePath?.let { File(it) }
savePathFile?.let {
fileLength = savePathFile.length()
}
remoteId = currentDownload?.file?.remoteId
}
storageManager?.saveFile(file)
if (MimeTypeUtil.isMedia(currentDownload?.mimeType)) {
FileDataStorageManager.triggerMediaScan(file.storagePath, file)
}
storageManager?.saveConflict(file, null)
}
fun downloadFileIfNotStartedBefore(user: User, file: OCFile) {
if (!isDownloading(user, file)) {
downloadFile(user, file, downloadType = DownloadType.DOWNLOAD)
}
}
fun downloadFile(user: User, file: OCFile) {
downloadFile(user, file, downloadType = DownloadType.DOWNLOAD)
}
@Suppress("LongParameterList")
fun downloadFile(
user: User,
ocFile: OCFile,
behaviour: String = "",
downloadType: DownloadType? = DownloadType.DOWNLOAD,
activityName: String = "",
packageName: String = "",
conflictUploadId: Long? = null
) {
backgroundJobManager.startFileDownloadJob(
user,
ocFile,
behaviour,
downloadType,
activityName,
packageName,
conflictUploadId
)
}
}

View file

@ -0,0 +1,84 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.download
import android.content.Context
import android.content.Intent
import com.nextcloud.client.account.User
import com.owncloud.android.authentication.AuthenticatorActivity
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.operations.DownloadFileOperation
import com.owncloud.android.ui.activity.FileActivity
import com.owncloud.android.ui.activity.FileDisplayActivity
import com.owncloud.android.ui.dialog.SendShareDialog
import com.owncloud.android.ui.fragment.OCFileListFragment
import com.owncloud.android.ui.preview.PreviewImageActivity
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 {
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)
}
}
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 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
}
} else {
Intent()
}
}
}

View file

@ -0,0 +1,466 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.download
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.app.PendingIntent
import android.content.Context
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.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.owncloud.android.R
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.ForegroundServiceType
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.files.services.IndexedForest
import com.owncloud.android.lib.common.OwnCloudAccount
import com.owncloud.android.lib.common.OwnCloudClientManagerFactory
import com.owncloud.android.lib.common.network.OnDatatransferProgressListener
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.DownloadFileOperation
import com.owncloud.android.operations.DownloadType
import com.owncloud.android.utils.theme.ViewThemeUtils
import java.security.SecureRandom
import java.util.AbstractList
import java.util.Optional
import java.util.Vector
@Suppress("LongParameterList", "TooManyFunctions")
class FileDownloadWorker(
viewThemeUtils: ViewThemeUtils,
private val accountManager: UserAccountManager,
private var localBroadcastManager: LocalBroadcastManager,
private val context: Context,
params: WorkerParameters
) : Worker(context, params), OnAccountsUpdateListener, OnDatatransferProgressListener {
companion object {
private val TAG = FileDownloadWorker::class.java.simpleName
private val pendingDownloads = IndexedForest<DownloadFileOperation>()
fun cancelOperation(accountName: String, fileId: Long) {
pendingDownloads.all.forEach {
it.value?.payload?.cancelMatchingOperation(accountName, fileId)
}
}
fun isDownloading(accountName: String, fileId: Long): Boolean {
return pendingDownloads.all.any { it.value?.payload?.isMatching(accountName, fileId) == true }
}
const val FILE_REMOTE_PATH = "FILE_REMOTE_PATH"
const val ACCOUNT_NAME = "ACCOUNT_NAME"
const val BEHAVIOUR = "BEHAVIOUR"
const val DOWNLOAD_TYPE = "DOWNLOAD_TYPE"
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 getDownloadFinishMessage(): String {
return FileDownloadWorker::class.java.name + "DOWNLOAD_FINISH"
}
}
private var currentDownload: DownloadFileOperation? = null
private var conflictUploadId: Long? = null
private var lastPercent = 0
private val intents = FileDownloadIntents(context)
private var notificationManager = DownloadNotificationManager(
SecureRandom().nextInt(),
context,
viewThemeUtils
)
private var downloadProgressListener = FileDownloadProgressListener()
private var user: User? = null
private var currentUser = Optional.empty<User>()
private var currentUserFileStorageManager: FileDataStorageManager? = null
private var fileDataStorageManager: FileDataStorageManager? = null
private var downloadError: FileDownloadError? = null
@Suppress("TooGenericExceptionCaught")
override fun doWork(): Result {
return try {
val requestDownloads = getRequestDownloads()
addAccountUpdateListener()
val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
notificationManager.getId(),
notificationManager.getNotification(),
ForegroundServiceType.DataSync
)
setForegroundAsync(foregroundInfo)
requestDownloads.forEach {
downloadFile(it)
}
downloadError?.let {
showDownloadErrorNotification(it)
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()
}
}
override fun onStopped() {
Log_OC.e(TAG, "FilesDownloadWorker stopped")
notificationManager.dismissNotification()
setIdleWorkerState()
super.onStopped()
}
private fun setWorkerState(user: User?) {
WorkerStateLiveData.instance().setWorkState(WorkerState.Download(user, currentDownload))
}
private fun setIdleWorkerState() {
WorkerStateLiveData.instance().setWorkState(WorkerState.Idle)
}
private fun removePendingDownload(accountName: String?) {
pendingDownloads.remove(accountName)
}
private fun getRequestDownloads(): AbstractList<String> {
setUser()
val files = getFiles()
val downloadType = getDownloadType()
conflictUploadId = inputData.keyValueMap[CONFLICT_UPLOAD_ID] as Long?
val behaviour = inputData.keyValueMap[BEHAVIOUR] as String? ?: ""
val activityName = inputData.keyValueMap[ACTIVITY_NAME] as String? ?: ""
val packageName = inputData.keyValueMap[PACKAGE_NAME] as String? ?: ""
val requestedDownloads: AbstractList<String> = Vector()
return try {
files.forEach { file ->
val operation = DownloadFileOperation(
user,
file,
behaviour,
activityName,
packageName,
context,
downloadType
)
operation.addDownloadDataTransferProgressListener(this)
operation.addDownloadDataTransferProgressListener(downloadProgressListener)
val (downloadKey, linkedToRemotePath) = pendingDownloads.putIfAbsent(
user?.accountName,
file.remotePath,
operation
) ?: Pair(null, null)
downloadKey?.let {
requestedDownloads.add(downloadKey)
}
linkedToRemotePath?.let {
localBroadcastManager.sendBroadcast(intents.newDownloadIntent(operation, linkedToRemotePath))
}
}
requestedDownloads
} catch (e: IllegalArgumentException) {
Log_OC.e(TAG, "Not enough information provided in intent: " + e.message)
requestedDownloads
}
}
private fun setUser() {
val accountName = inputData.keyValueMap[ACCOUNT_NAME] as String
user = accountManager.getUser(accountName).get()
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 getDownloadType(): DownloadType? {
val typeAsString = inputData.keyValueMap[DOWNLOAD_TYPE] as String?
return if (typeAsString != null) {
if (typeAsString == DownloadType.DOWNLOAD.toString()) {
DownloadType.DOWNLOAD
} else {
DownloadType.EXPORT
}
} else {
null
}
}
private fun addAccountUpdateListener() {
val am = AccountManager.get(context)
am.addOnAccountsUpdatedListener(this, null, false)
}
@Suppress("TooGenericExceptionCaught", "DEPRECATION")
private fun downloadFile(downloadKey: String) {
currentDownload = pendingDownloads.get(downloadKey)
if (currentDownload == null) {
return
}
setWorkerState(user)
Log_OC.e(TAG, "FilesDownloadWorker downloading: $downloadKey")
val isAccountExist = accountManager.exists(currentDownload?.user?.toPlatformAccount())
if (!isAccountExist) {
removePendingDownload(currentDownload?.user?.accountName)
return
}
notifyDownloadStart(currentDownload!!)
var downloadResult: RemoteOperationResult<*>? = null
try {
val ocAccount = getOCAccountForDownload()
val downloadClient =
OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context)
downloadResult = currentDownload?.execute(downloadClient)
if (downloadResult?.isSuccess == true && currentDownload?.downloadType === DownloadType.DOWNLOAD) {
getCurrentFile()?.let {
FileDownloadHelper.instance().saveFile(it, currentDownload, currentUserFileStorageManager)
}
}
} catch (e: Exception) {
Log_OC.e(TAG, "Error downloading", e)
downloadResult = RemoteOperationResult<Any?>(e)
} finally {
cleanupDownloadProcess(downloadResult)
}
}
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()
val currentDownloadUser = accountManager.getUser(currentDownloadAccount?.name)
if (currentUser != currentDownloadUser) {
currentUser = currentDownloadUser
currentUserFileStorageManager = FileDataStorageManager(currentUser.get(), context.contentResolver)
}
return currentDownloadUser.get().toOwnCloudAccount()
}
private fun getCurrentFile(): OCFile? {
var file: OCFile? = currentDownload?.file?.fileId?.let { currentUserFileStorageManager?.getFileById(it) }
if (file == null) {
file = currentUserFileStorageManager?.getFileByDecryptedRemotePath(currentDownload?.file?.remotePath)
}
if (file == null) {
Log_OC.e(this, "Could not save " + currentDownload?.file?.remotePath)
return null
}
return file
}
private fun cleanupDownloadProcess(result: RemoteOperationResult<*>?) {
result?.let {
checkDownloadError(it)
}
val removeResult = pendingDownloads.removePayload(
currentDownload?.user?.accountName,
currentDownload?.remotePath
)
val downloadResult = result ?: RemoteOperationResult<Any?>(RuntimeException("Error downloading…"))
currentDownload?.run {
notifyDownloadResult(this, downloadResult)
val downloadFinishedIntent = intents.downloadFinishedIntent(
this,
downloadResult,
removeResult.second
)
localBroadcastManager.sendBroadcast(downloadFinishedIntent)
}
}
private fun checkDownloadError(result: RemoteOperationResult<*>) {
if (result.isSuccess || downloadError != null) {
return
}
downloadError = if (result.isCancelled) {
FileDownloadError.Cancelled
} else {
FileDownloadError.Failed
}
}
private fun showDownloadErrorNotification(downloadError: FileDownloadError) {
val text = when (downloadError) {
FileDownloadError.Cancelled -> {
context.getString(R.string.downloader_file_download_cancelled)
}
FileDownloadError.Failed -> {
context.getString(R.string.downloader_file_download_failed)
}
}
notificationManager.showNewNotification(text)
}
private fun notifyDownloadResult(
download: DownloadFileOperation,
downloadResult: RemoteOperationResult<*>
) {
if (downloadResult.isCancelled) {
return
}
val needsToUpdateCredentials = (ResultCode.UNAUTHORIZED == downloadResult.code)
notificationManager.run {
prepareForResult()
if (needsToUpdateCredentials) {
showNewNotification(context.getString(R.string.downloader_download_failed_credentials_error))
setContentIntent(
intents.credentialContentIntent(download.user),
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
} else {
setContentIntent(intents.detailsIntent(null), PendingIntent.FLAG_IMMUTABLE)
}
}
}
@Suppress("DEPRECATION")
override fun onAccountsUpdated(accounts: Array<out Account>?) {
if (!accountManager.exists(currentDownload?.user?.toPlatformAccount())) {
currentDownload?.cancel()
}
}
@Suppress("MagicNumber")
override fun onTransferProgress(
progressRate: Long,
totalTransferredSoFar: Long,
totalToTransfer: Long,
filePath: String
) {
val percent: Int = (100.0 * totalTransferredSoFar.toDouble() / totalToTransfer.toDouble()).toInt()
if (percent != lastPercent) {
notificationManager.run {
updateDownloadProgress(filePath, percent, totalToTransfer)
}
}
lastPercent = percent
}
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 addDataTransferProgressListener(listener: OnDatatransferProgressListener?, file: OCFile?) {
if (file == null || listener == null) {
return
}
boundListeners[file.fileId] = listener
}
fun removeDataTransferProgressListener(listener: OnDatatransferProgressListener?, file: OCFile?) {
if (file == null || listener == null) {
return
}
val fileId = file.fileId
if (boundListeners[fileId] === listener) {
boundListeners.remove(fileId)
}
}
override fun onTransferProgress(
progressRate: Long,
totalTransferredSoFar: Long,
totalToTransfer: Long,
fileName: String
) {
val listener = boundListeners[currentDownload?.file?.fileId]
listener?.onTransferProgress(
progressRate,
totalTransferredSoFar,
totalToTransfer,
fileName
)
}
}
}

View file

@ -0,0 +1,184 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.transfer
import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleService
import com.nextcloud.client.account.User
import com.nextcloud.client.core.AsyncRunner
import com.nextcloud.client.core.LocalBinder
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.files.Direction
import com.nextcloud.client.files.Request
import com.nextcloud.client.jobs.download.DownloadTask
import com.nextcloud.client.jobs.upload.UploadTask
import com.nextcloud.client.logger.Logger
import com.nextcloud.client.network.ClientFactory
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.client.notifications.AppNotificationManager
import com.nextcloud.utils.ForegroundServiceHelper
import com.nextcloud.utils.extensions.getParcelableArgument
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.ForegroundServiceType
import com.owncloud.android.datamodel.UploadsStorageManager
import dagger.android.AndroidInjection
import javax.inject.Inject
import javax.inject.Named
class FileTransferService : LifecycleService() {
companion object {
const val TAG = "DownloaderService"
const val ACTION_TRANSFER = "transfer"
const val EXTRA_REQUEST = "request"
const val EXTRA_USER = "user"
fun createBindIntent(context: Context, user: User): Intent {
return Intent(context, FileTransferService::class.java).apply {
putExtra(EXTRA_USER, user)
}
}
fun createTransferRequestIntent(context: Context, request: Request): Intent {
return 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),
TransferManager by downloader
@Inject
lateinit var notificationsManager: AppNotificationManager
@Inject
lateinit var clientFactory: ClientFactory
@Inject
@Named("io")
lateinit var runner: AsyncRunner
@Inject
lateinit var logger: Logger
@Inject
lateinit var uploadsStorageManager: UploadsStorageManager
@Inject
lateinit var connectivityService: ConnectivityService
@Inject
lateinit var powerManagementService: PowerManagementService
@Inject
lateinit var fileDataStorageManager: FileDataStorageManager
val isRunning: Boolean get() = downloaders.any { it.value.isRunning }
private val downloaders: MutableMap<String, TransferManagerImpl> = mutableMapOf()
override fun onCreate() {
AndroidInjection.inject(this)
super.onCreate()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
if (intent == null || intent.action != ACTION_TRANSFER) {
return START_NOT_STICKY
}
if (!isRunning && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
ForegroundServiceHelper.startService(
this,
AppNotificationManager.TRANSFER_NOTIFICATION_ID,
notificationsManager.buildDownloadServiceForegroundNotification(),
ForegroundServiceType.DataSync
)
}
val request: Request = intent.getParcelableArgument(EXTRA_REQUEST, Request::class.java)!!
getTransferManager(request.user).run {
enqueue(request)
}
logger.d(TAG, "Enqueued new transfer: ${request.uuid} ${request.file.remotePath}")
return START_NOT_STICKY
}
override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
val user = intent.getParcelableArgument(EXTRA_USER, User::class.java) ?: return null
return Binder(getTransferManager(user), this)
}
private fun onTransferUpdate(transfer: Transfer) {
if (!isRunning) {
logger.d(TAG, "All downloads completed")
notificationsManager.cancelTransferNotification()
stopForeground(STOP_FOREGROUND_DETACH)
stopSelf()
} else if (transfer.direction == Direction.DOWNLOAD) {
notificationsManager.postDownloadTransferProgress(
fileOwner = transfer.request.user,
file = transfer.request.file,
progress = transfer.progress,
allowPreview = !transfer.request.test
)
} else if (transfer.direction == Direction.UPLOAD) {
notificationsManager.postUploadTransferProgress(
fileOwner = transfer.request.user,
file = transfer.request.file,
progress = transfer.progress
)
}
}
override fun onDestroy() {
super.onDestroy()
logger.d(TAG, "Stopping downloader service")
}
private fun getTransferManager(user: User): TransferManagerImpl {
val existingDownloader = downloaders[user.accountName]
return if (existingDownloader != null) {
existingDownloader
} else {
val downloadTaskFactory = DownloadTask.Factory(
applicationContext,
{ clientFactory.create(user) },
contentResolver
)
val uploadTaskFactory = UploadTask.Factory(
applicationContext,
uploadsStorageManager,
connectivityService,
powerManagementService,
{ clientFactory.create(user) },
fileDataStorageManager
)
val newDownloader = TransferManagerImpl(runner, downloadTaskFactory, uploadTaskFactory)
newDownloader.registerTransferListener(this::onTransferUpdate)
downloaders[user.accountName] = newDownloader
newDownloader
}
}
}

View file

@ -0,0 +1,48 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.transfer
import com.nextcloud.client.files.Direction
import com.nextcloud.client.files.DownloadRequest
import com.nextcloud.client.files.Request
import com.nextcloud.client.files.UploadRequest
import com.owncloud.android.datamodel.OCFile
import java.util.UUID
/**
* This class represents current transfer (download or upload) process state.
* This object is immutable by design.
*
* NOTE: Although [OCFile] object is mutable, it is caused by shortcomings
* of legacy design; please behave like an adult and treat it as immutable value.
*
* @property uuid Unique transfer id
* @property state current transfer state
* @property progress transfer progress, 0-100 percent
* @property file transferred file
* @property request initial transfer request
* @property direction transfer direction, download or upload
*/
data class Transfer(
val uuid: UUID,
val state: TransferState,
val progress: Int,
val file: OCFile,
val request: Request
) {
/**
* True if download is no longer running, false if it is still being processed.
*/
val isFinished: Boolean get() = state == TransferState.COMPLETED || state == TransferState.FAILED
val direction: Direction
get() = when (request) {
is DownloadRequest -> Direction.DOWNLOAD
is UploadRequest -> Direction.UPLOAD
}
}

View file

@ -0,0 +1,90 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.transfer
import com.nextcloud.client.files.Request
import com.owncloud.android.datamodel.OCFile
import java.util.UUID
/**
* Transfer manager provides API to upload and download files.
*/
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>
) {
companion object {
val EMPTY = Status(emptyList(), emptyList(), emptyList())
}
}
/**
* True if transfer manager has any pending or running transfers.
*/
val isRunning: Boolean
/**
* Status snapshot of all transfers.
*/
val status: Status
/**
* Register transfer progress listener. Registration is idempotent - a listener will be registered only once.
*/
fun registerTransferListener(listener: (Transfer) -> Unit)
/**
* Removes registered listener if exists.
*/
fun removeTransferListener(listener: (Transfer) -> Unit)
/**
* Register transfer manager status listener. Registration is idempotent - a listener will be registered only once.
*/
fun registerStatusListener(listener: (Status) -> Unit)
/**
* Removes registered listener if exists.
*/
fun removeStatusListener(listener: (Status) -> Unit)
/**
* Adds transfer request to pending queue and returns immediately.
*
* @param request Transfer request
*/
fun enqueue(request: Request)
/**
* Find transfer status by UUID.
*
* @param uuid Download process uuid
* @return transfer status or null if not found
*/
fun getTransfer(uuid: UUID): Transfer?
/**
* Query user's transfer manager for a transfer status. It performs linear search
* of all queues and returns first transfer matching [OCFile.remotePath].
*
* Since there can be multiple transfers with identical file in the queues,
* order of search matters.
*
* It looks for pending transfers first, then running and completed queue last.
*
* @param file Downloaded file
* @return transfer status or null, if transfer does not exist
*/
fun getTransfer(file: OCFile): Transfer?
}

View file

@ -0,0 +1,114 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.transfer
import android.content.Context
import android.content.Intent
import android.os.IBinder
import com.nextcloud.client.account.User
import com.nextcloud.client.core.LocalConnection
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 {
private var transferListeners: MutableSet<(Transfer) -> Unit> = mutableSetOf()
private var statusListeners: MutableSet<(TransferManager.Status) -> Unit> = mutableSetOf()
private var binder: FileTransferService.Binder? = null
private val transfersRequiringStatusRedelivery: MutableSet<UUID> = mutableSetOf()
override val isRunning: Boolean
get() = binder?.isRunning ?: false
override val status: TransferManager.Status
get() = binder?.status ?: TransferManager.Status.EMPTY
override fun getTransfer(uuid: UUID): Transfer? = binder?.getTransfer(uuid)
override fun getTransfer(file: OCFile): Transfer? = binder?.getTransfer(file)
override fun enqueue(request: Request) {
val intent = FileTransferService.createTransferRequestIntent(context, request)
context.startService(intent)
if (!isConnected && transferListeners.size > 0) {
transfersRequiringStatusRedelivery.add(request.uuid)
}
}
override fun registerTransferListener(listener: (Transfer) -> Unit) {
transferListeners.add(listener)
binder?.registerTransferListener(listener)
}
override fun removeTransferListener(listener: (Transfer) -> Unit) {
transferListeners.remove(listener)
binder?.removeTransferListener(listener)
}
override fun registerStatusListener(listener: (TransferManager.Status) -> Unit) {
statusListeners.add(listener)
binder?.registerStatusListener(listener)
}
override fun removeStatusListener(listener: (TransferManager.Status) -> Unit) {
statusListeners.remove(listener)
binder?.removeStatusListener(listener)
}
override fun createBindIntent(): Intent {
return FileTransferService.createBindIntent(context, user)
}
override fun onBound(binder: IBinder) {
super.onBound(binder)
this.binder = binder as FileTransferService.Binder
transferListeners.forEach { listener ->
binder.registerTransferListener(listener)
}
statusListeners.forEach { listener ->
binder.registerStatusListener(listener)
}
deliverMissedUpdates()
}
/**
* Since binding and transfer start are both asynchronous and the order
* is not guaranteed, some transfers might already finish when service is bound,
* resulting in lost notifications.
*
* Deliver all updates for pending transfers that were scheduled
* before service was bound.
*/
private fun deliverMissedUpdates() {
val transferUpdates = transfersRequiringStatusRedelivery.mapNotNull { uuid ->
binder?.getTransfer(uuid)
}
transferListeners.forEach { listener ->
transferUpdates.forEach { update ->
listener.invoke(update)
}
}
transfersRequiringStatusRedelivery.clear()
if (statusListeners.isNotEmpty()) {
binder?.status?.let { status ->
statusListeners.forEach { it.invoke(status) }
}
}
}
override fun onUnbind() {
super.onUnbind()
transferListeners.forEach { binder?.removeTransferListener(it) }
statusListeners.forEach { binder?.removeStatusListener(it) }
}
}

View file

@ -0,0 +1,199 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.transfer
import com.nextcloud.client.core.AsyncRunner
import com.nextcloud.client.core.IsCancelled
import com.nextcloud.client.core.OnProgressCallback
import com.nextcloud.client.core.TaskFunction
import com.nextcloud.client.files.DownloadRequest
import com.nextcloud.client.files.Registry
import com.nextcloud.client.files.Request
import com.nextcloud.client.files.UploadRequest
import com.nextcloud.client.jobs.download.DownloadTask
import com.nextcloud.client.jobs.upload.UploadTask
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.operations.UploadFileOperation
import java.util.UUID
/**
* Per-user file transfer manager.
*
* All notifications are performed on main thread. All transfer processes are run
* in the background.
*
* @param runner Background task runner. It is important to provide runner that is not shared with UI code.
* @param downloadTaskFactory Download task factory
* @param threads maximum number of concurrent transfer processes
*/
@Suppress("LongParameterList") // transfer operations requires those resources
class TransferManagerImpl(
private val runner: AsyncRunner,
private val downloadTaskFactory: DownloadTask.Factory,
private val uploadTaskFactory: UploadTask.Factory,
threads: Int = 1
) : TransferManager {
companion object {
const val PROGRESS_PERCENTAGE_MAX = 100
const val PROGRESS_PERCENTAGE_MIN = 0
const val TEST_DOWNLOAD_PROGRESS_UPDATE_PERIOD_MS = 200L
const val TEST_UPLOAD_PROGRESS_UPDATE_PERIOD_MS = 200L
}
private val registry = Registry(
onStartTransfer = this::onStartTransfer,
onTransferChanged = this::onTransferUpdate,
maxRunning = threads
)
private val transferListeners: MutableSet<(Transfer) -> Unit> = mutableSetOf()
private val statusListeners: MutableSet<(TransferManager.Status) -> Unit> = mutableSetOf()
override val isRunning: Boolean get() = registry.isRunning
override val status: TransferManager.Status
get() = TransferManager.Status(
pending = registry.pending,
running = registry.running,
completed = registry.completed
)
override fun registerTransferListener(listener: (Transfer) -> Unit) {
transferListeners.add(listener)
}
override fun removeTransferListener(listener: (Transfer) -> Unit) {
transferListeners.remove(listener)
}
override fun registerStatusListener(listener: (TransferManager.Status) -> Unit) {
statusListeners.add(listener)
}
override fun removeStatusListener(listener: (TransferManager.Status) -> Unit) {
statusListeners.remove(listener)
}
override fun enqueue(request: Request) {
registry.add(request)
registry.startNext()
}
override fun getTransfer(uuid: UUID): Transfer? = registry.getTransfer(uuid)
override fun getTransfer(file: OCFile): Transfer? = registry.getTransfer(file)
private fun onStartTransfer(uuid: UUID, request: Request) {
if (request is DownloadRequest) {
runner.postTask(
task = createDownloadTask(request),
onProgress = { progress: Int -> registry.progress(uuid, progress) },
onResult = { result ->
registry.complete(uuid, result.success, result.file)
registry.startNext()
},
onError = {
registry.complete(uuid, false)
registry.startNext()
}
)
} else if (request is UploadRequest) {
runner.postTask(
task = createUploadTask(request),
onProgress = { progress: Int -> registry.progress(uuid, progress) },
onResult = { result ->
registry.complete(uuid, result.success, result.file)
registry.startNext()
},
onError = {
registry.complete(uuid, false)
registry.startNext()
}
)
}
}
private fun createDownloadTask(request: DownloadRequest): TaskFunction<DownloadTask.Result, Int> {
return if (request.test) {
{ progress: OnProgressCallback<Int>, isCancelled: IsCancelled ->
testDownloadTask(request.file, progress, isCancelled)
}
} else {
val downloadTask = downloadTaskFactory.create()
val wrapper: TaskFunction<DownloadTask.Result, Int> = { progress: ((Int) -> Unit), isCancelled ->
downloadTask.download(request, progress, isCancelled)
}
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 onTransferUpdate(transfer: Transfer) {
transferListeners.forEach { it.invoke(transfer) }
if (statusListeners.isNotEmpty()) {
val status = this.status
statusListeners.forEach { it.invoke(status) }
}
}
/**
* Test download task is used only to simulate download process without
* any network traffic. It is used for development.
*/
private fun testDownloadTask(
file: OCFile,
onProgress: OnProgressCallback<Int>,
isCancelled: IsCancelled
): DownloadTask.Result {
for (i in PROGRESS_PERCENTAGE_MIN..PROGRESS_PERCENTAGE_MAX) {
onProgress(i)
if (isCancelled()) {
return DownloadTask.Result(file, false)
}
Thread.sleep(TEST_DOWNLOAD_PROGRESS_UPDATE_PERIOD_MS)
}
return DownloadTask.Result(file, true)
}
/**
* Test upload task is used only to simulate upload process without
* any network traffic. It is used for development.
*/
private fun testUploadTask(
file: OCFile,
onProgress: OnProgressCallback<Int>,
isCancelled: IsCancelled
): UploadTask.Result {
for (i in PROGRESS_PERCENTAGE_MIN..PROGRESS_PERCENTAGE_MAX) {
onProgress(i)
if (isCancelled()) {
return UploadTask.Result(file, false)
}
Thread.sleep(TEST_UPLOAD_PROGRESS_UPDATE_PERIOD_MS)
}
return UploadTask.Result(file, true)
}
}

View file

@ -0,0 +1,14 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.transfer
enum class TransferState {
PENDING,
RUNNING,
COMPLETED,
FAILED
}

View file

@ -0,0 +1,340 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.upload
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.jobs.BackgroundJobManager
import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.currentUploadFileOperation
import com.nextcloud.client.network.ConnectivityService
import com.owncloud.android.MainApp
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.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.utils.FileUtil
import java.io.File
import java.util.Optional
import javax.inject.Inject
@Suppress("TooManyFunctions")
class FileUploadHelper {
@Inject
lateinit var backgroundJobManager: BackgroundJobManager
@Inject
lateinit var accountManager: UserAccountManager
@Inject
lateinit var uploadsStorageManager: UploadsStorageManager
init {
MainApp.getAppComponent().inject(this)
}
companion object {
private val TAG = FileUploadWorker::class.java.simpleName
@Suppress("MagicNumber")
const val MAX_FILE_COUNT = 500
val mBoundListeners = HashMap<String, OnDatatransferProgressListener>()
private var instance: FileUploadHelper? = null
fun instance(): FileUploadHelper {
return instance ?: synchronized(this) {
instance ?: FileUploadHelper().also { instance = it }
}
}
fun buildRemoteName(accountName: String, remotePath: String): String {
return accountName + remotePath
}
}
fun retryFailedUploads(
uploadsStorageManager: UploadsStorageManager,
connectivityService: ConnectivityService,
accountManager: UserAccountManager,
powerManagementService: PowerManagementService
) {
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
)
}
fun retryCancelledUploads(
uploadsStorageManager: UploadsStorageManager,
connectivityService: ConnectivityService,
accountManager: UserAccountManager,
powerManagementService: PowerManagementService
): Boolean {
val cancelledUploads = uploadsStorageManager.cancelledUploadsForCurrentAccount
if (cancelledUploads == null || cancelledUploads.isEmpty()) {
return false
}
return retryUploads(
uploadsStorageManager,
connectivityService,
accountManager,
powerManagementService,
cancelledUploads
)
}
@Suppress("ComplexCondition")
private fun retryUploads(
uploadsStorageManager: UploadsStorageManager,
connectivityService: ConnectivityService,
accountManager: UserAccountManager,
powerManagementService: PowerManagementService,
failedUploads: Array<OCUpload>
): Boolean {
var showNotExistMessage = false
val (gotNetwork, _, gotWifi) = connectivityService.connectivity
val batteryStatus = powerManagementService.battery
val charging = batteryStatus.isCharging || batteryStatus.isFull
val isPowerSaving = powerManagementService.isPowerSavingEnabled
var uploadUser = Optional.empty<User>()
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)
}
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
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())
}
}
return showNotExistMessage
}
@Suppress("LongParameterList")
fun uploadNewFiles(
user: User,
localPaths: Array<String>,
remotePaths: Array<String>,
localBehavior: Int,
createRemoteFolder: Boolean,
createdBy: Int,
requiresWifi: Boolean,
requiresCharging: Boolean,
nameCollisionPolicy: NameCollisionPolicy
) {
val uploads = localPaths.mapIndexed { index, localPath ->
OCUpload(localPath, remotePaths[index], user.accountName).apply {
this.nameCollisionPolicy = nameCollisionPolicy
isUseWifiOnly = requiresWifi
isWhileChargingOnly = requiresCharging
uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
this.createdBy = createdBy
isCreateRemoteFolder = createRemoteFolder
localAction = localBehavior
}
}
uploadsStorageManager.storeUploads(uploads)
backgroundJobManager.startFilesUploadJob(user)
}
fun removeFileUpload(remotePath: String, accountName: String) {
try {
val user = accountManager.getUser(accountName).get()
// need to update now table in mUploadsStorageManager,
// since the operation will not get to be run by FileUploader#uploadFile
uploadsStorageManager.removeUpload(accountName, remotePath)
cancelAndRestartUploadJob(user)
} catch (e: NoSuchElementException) {
Log_OC.e(TAG, "Error cancelling current upload because user does not exist!")
}
}
fun cancelFileUpload(remotePath: String, accountName: String) {
uploadsStorageManager.getUploadByRemotePath(remotePath).run {
removeFileUpload(remotePath, accountName)
uploadStatus = UploadStatus.UPLOAD_CANCELLED
uploadsStorageManager.storeUpload(this)
}
}
fun cancelAndRestartUploadJob(user: User) {
backgroundJobManager.run {
cancelFilesUploadJob(user)
startFilesUploadJob(user)
}
}
@Suppress("ReturnCount")
fun isUploading(user: User?, file: OCFile?): Boolean {
if (user == null || file == null || !backgroundJobManager.isStartFileUploadJobScheduled(user)) {
return false
}
val upload: OCUpload = uploadsStorageManager.getUploadByRemotePath(file.remotePath) ?: return false
return upload.uploadStatus == UploadStatus.UPLOAD_IN_PROGRESS
}
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)
}
@Suppress("ReturnCount")
fun isUploadingNow(upload: OCUpload?): Boolean {
val currentUploadFileOperation = currentUploadFileOperation
if (currentUploadFileOperation == null || currentUploadFileOperation.user == null) return false
if (upload == null || upload.accountName != currentUploadFileOperation.user.accountName) return false
return if (currentUploadFileOperation.oldFile != null) {
// For file conflicts check old file remote path
upload.remotePath == currentUploadFileOperation.remotePath ||
upload.remotePath == currentUploadFileOperation.oldFile!!
.remotePath
} else {
upload.remotePath == currentUploadFileOperation.remotePath
}
}
fun uploadUpdatedFile(
user: User,
existingFiles: Array<OCFile?>?,
behaviour: Int,
nameCollisionPolicy: NameCollisionPolicy
) {
if (existingFiles == null) {
return
}
Log_OC.d(this, "upload updated file")
val uploads = existingFiles.map { file ->
file?.let {
OCUpload(file, user).apply {
fileSize = file.fileLength
this.nameCollisionPolicy = nameCollisionPolicy
isCreateRemoteFolder = true
this.localAction = behaviour
isUseWifiOnly = false
isWhileChargingOnly = false
uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
}
}
}
uploadsStorageManager.storeUploads(uploads)
backgroundJobManager.startFilesUploadJob(user)
}
fun retryUpload(upload: OCUpload, user: User) {
Log_OC.d(this, "retry upload")
upload.uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
uploadsStorageManager.updateUpload(upload)
backgroundJobManager.startFilesUploadJob(user)
}
fun cancel(accountName: String) {
uploadsStorageManager.removeUploads(accountName)
cancelAndRestartUploadJob(accountManager.getUser(accountName).get())
}
fun addUploadTransferProgressListener(
listener: OnDatatransferProgressListener,
targetKey: String
) {
mBoundListeners[targetKey] = listener
}
fun removeUploadTransferProgressListener(
listener: OnDatatransferProgressListener,
targetKey: String
) {
if (mBoundListeners[targetKey] === listener) {
mBoundListeners.remove(targetKey)
}
}
@Suppress("MagicNumber")
fun isSameFileOnRemote(user: User, localFile: File, remotePath: String, context: Context): Boolean {
// Compare remote file to local file
val localLastModifiedTimestamp = localFile.lastModified() / 1000 // remote file timestamp in milli not micro sec
val localCreationTimestamp = FileUtil.getCreationTimestamp(localFile)
val localSize: Long = localFile.length()
val operation = ReadFileRemoteOperation(remotePath)
val result: RemoteOperationResult<*> = operation.execute(user, context)
if (result.isSuccess) {
val remoteFile = result.data[0] as RemoteFile
return remoteFile.size == localSize &&
localCreationTimestamp != null &&
localCreationTimestamp == remoteFile.creationTimestamp &&
remoteFile.modifiedTimestamp == localLastModifiedTimestamp * 1000
}
return false
}
class UploadNotificationActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val accountName = intent.getStringExtra(FileUploadWorker.EXTRA_ACCOUNT_NAME)
val remotePath = intent.getStringExtra(FileUploadWorker.EXTRA_REMOTE_PATH)
val action = intent.action
if (FileUploadWorker.ACTION_CANCEL_BROADCAST == action) {
Log_OC.d(
FileUploadWorker.TAG,
"Cancel broadcast received for file " + remotePath + " at " + System.currentTimeMillis()
)
if (accountName == null || remotePath == null) {
return
}
instance().cancelFileUpload(remotePath, accountName)
}
}
}
}

View file

@ -0,0 +1,343 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.upload
import android.app.PendingIntent
import android.content.Context
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.jobs.BackgroundJobManager
import com.nextcloud.client.jobs.BackgroundJobManagerImpl
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.model.WorkerState
import com.nextcloud.model.WorkerStateLiveData
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.OwnCloudClientManagerFactory
import com.owncloud.android.lib.common.network.OnDatatransferProgressListener
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.UploadFileOperation
import com.owncloud.android.utils.ErrorMessageAdapter
import com.owncloud.android.utils.theme.ViewThemeUtils
import java.io.File
@Suppress("LongParameterList")
class FileUploadWorker(
val uploadsStorageManager: UploadsStorageManager,
val connectivityService: ConnectivityService,
val powerManagementService: PowerManagementService,
val userAccountManager: UserAccountManager,
val viewThemeUtils: ViewThemeUtils,
val localBroadcastManager: LocalBroadcastManager,
private val backgroundJobManager: BackgroundJobManager,
val preferences: AppPreferences,
val context: Context,
params: WorkerParameters
) : 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"
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"
const val EXTRA_UPLOAD_RESULT = "RESULT"
const val EXTRA_REMOTE_PATH = "REMOTE_PATH"
const val EXTRA_OLD_REMOTE_PATH = "OLD_REMOTE_PATH"
const val EXTRA_OLD_FILE_PATH = "OLD_FILE_PATH"
const val EXTRA_LINKED_TO_PATH = "LINKED_TO"
const val ACCOUNT_NAME = "ACCOUNT_NAME"
const val EXTRA_ACCOUNT_NAME = "ACCOUNT_NAME"
const val ACTION_CANCEL_BROADCAST = "CANCEL"
const val LOCAL_BEHAVIOUR_COPY = 0
const val LOCAL_BEHAVIOUR_MOVE = 1
const val LOCAL_BEHAVIOUR_FORGET = 2
const val LOCAL_BEHAVIOUR_DELETE = 3
fun getUploadsAddedMessage(): String {
return FileUploadWorker::class.java.name + UPLOADS_ADDED_MESSAGE
}
fun getUploadStartMessage(): String {
return FileUploadWorker::class.java.name + UPLOAD_START_MESSAGE
}
fun getUploadFinishMessage(): String {
return FileUploadWorker::class.java.name + UPLOAD_FINISH_MESSAGE
}
}
private var lastPercent = 0
private val notificationManager = UploadNotificationManager(context, viewThemeUtils)
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 onStopped() {
Log_OC.e(TAG, "FileUploadWorker stopped")
setIdleWorkerState()
currentUploadFileOperation?.cancel(null)
notificationManager.dismissWorkerNotifications()
super.onStopped()
}
private fun setWorkerState(user: User?, uploads: List<OCUpload>) {
WorkerStateLiveData.instance().setWorkState(WorkerState.Upload(user, uploads))
}
private fun setIdleWorkerState() {
WorkerStateLiveData.instance().setWorkState(WorkerState.Idle)
}
@Suppress("ReturnCount")
private fun retrievePagesBySortingUploadsByID(): Result {
val accountName = inputData.getString(ACCOUNT) ?: return Result.failure()
var currentPage = uploadsStorageManager.getCurrentAndPendingUploadsForAccountPageAscById(-1, accountName)
notificationManager.dismissWorkerNotifications()
while (currentPage.isNotEmpty() && !isStopped) {
if (preferences.isGlobalUploadPaused) {
Log_OC.d(TAG, "Upload is paused, skip uploading files!")
notificationManager.notifyPaused(
intents.notificationStartIntent(null)
)
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 (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)
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)
}
}
}
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)
}
}
@Suppress("TooGenericExceptionCaught", "DEPRECATION")
private fun upload(uploadFileOperation: UploadFileOperation, user: User): 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)
val task = ThumbnailsCacheManager.ThumbnailGenerationTask(storageManager, user)
val file = File(uploadFileOperation.originalStoragePath)
val remoteId: String? = uploadFileOperation.file.remoteId
task.execute(ThumbnailsCacheManager.ThumbnailGenerationTaskObject(file, remoteId))
} catch (e: Exception) {
Log_OC.e(TAG, "Error uploading", e)
result = RemoteOperationResult<Any?>(e)
} finally {
cleanupUploadProcess(result, uploadFileOperation)
}
return result
}
private fun cleanupUploadProcess(result: RemoteOperationResult<Any?>, uploadFileOperation: UploadFileOperation) {
if (!isStopped || !result.isCancelled) {
uploadsStorageManager.updateDatabaseUploadResult(result, uploadFileOperation)
notifyUploadResult(uploadFileOperation, result)
notificationManager.dismissWorkerNotifications()
}
}
@Suppress("ReturnCount")
private fun notifyUploadResult(
uploadFileOperation: UploadFileOperation,
uploadResult: RemoteOperationResult<Any?>
) {
Log_OC.d(TAG, "NotifyUploadResult with resultCode: " + uploadResult.code)
if (uploadResult.isSuccess) {
notificationManager.dismissOldErrorNotification(uploadFileOperation)
return
}
if (uploadResult.isCancelled) {
return
}
// 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
)
) {
return
}
val notDelayed = uploadResult.code !in setOf(
ResultCode.DELAYED_FOR_WIFI,
ResultCode.DELAYED_FOR_CHARGING,
ResultCode.DELAYED_IN_POWER_SAVE_MODE
)
val isValidFile = uploadResult.code !in setOf(
ResultCode.LOCAL_FILE_NOT_FOUND,
ResultCode.LOCK_FAILED
)
if (!notDelayed || !isValidFile) {
return
}
notificationManager.run {
val errorMessage = ErrorMessageAdapter.getErrorCauseMessage(
uploadResult,
uploadFileOperation,
context.resources
)
val conflictResolveIntent = if (uploadResult.code == ResultCode.SYNC_CONFLICT) {
intents.conflictResolveActionIntents(context, uploadFileOperation)
} else {
null
}
val credentialIntent: PendingIntent? = if (uploadResult.code == ResultCode.UNAUTHORIZED) {
intents.credentialIntent(uploadFileOperation)
} else {
null
}
notifyForFailedResult(uploadResult.code, conflictResolveIntent, credentialIntent, errorMessage)
showNewNotification(uploadFileOperation)
}
}
override fun onTransferProgress(
progressRate: Long,
totalTransferredSoFar: Long,
totalToTransfer: Long,
fileAbsoluteName: String
) {
val percent = (MAX_PROGRESS * totalTransferredSoFar.toDouble() / totalToTransfer.toDouble()).toInt()
if (percent != lastPercent) {
notificationManager.run {
val accountName = currentUploadFileOperation?.user?.accountName
val remotePath = currentUploadFileOperation?.remotePath
val filename = currentUploadFileOperation?.fileName ?: ""
updateUploadProgress(filename, percent, currentUploadFileOperation)
if (accountName != null && remotePath != null) {
val key: String =
FileUploadHelper.buildRemoteName(accountName, remotePath)
val boundListener = FileUploadHelper.mBoundListeners[key]
boundListener?.onTransferProgress(
progressRate,
totalTransferredSoFar,
totalToTransfer,
filename
)
}
dismissOldErrorNotification(currentUploadFileOperation)
}
}
lastPercent = percent
}
}

View file

@ -0,0 +1,80 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.upload
import android.content.Context
import android.content.Intent
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.operations.UploadFileOperation
class FileUploaderDelegate {
/**
* Sends a broadcast in order to the interested activities can update their view
*
* TODO - no more broadcasts, replace with a callback to subscribed listeners once we drop FileUploader
*/
fun sendBroadcastUploadsAdded(context: Context, localBroadcastManager: LocalBroadcastManager) {
val start = Intent(FileUploadWorker.getUploadsAddedMessage())
// nothing else needed right now
start.setPackage(context.packageName)
localBroadcastManager.sendBroadcast(start)
}
/**
* Sends a broadcast in order to the interested activities can update their view
*
* TODO - no more broadcasts, replace with a callback to subscribed listeners once we drop FileUploader
*
* @param upload Finished upload operation
*/
fun sendBroadcastUploadStarted(
upload: UploadFileOperation,
context: Context,
localBroadcastManager: LocalBroadcastManager
) {
val start = Intent(FileUploadWorker.getUploadStartMessage())
start.putExtra(FileUploadWorker.EXTRA_REMOTE_PATH, upload.remotePath) // real remote
start.putExtra(FileUploadWorker.EXTRA_OLD_FILE_PATH, upload.originalStoragePath)
start.putExtra(FileUploadWorker.ACCOUNT_NAME, upload.user.accountName)
start.setPackage(context.packageName)
localBroadcastManager.sendBroadcast(start)
}
/**
* Sends a broadcast in order to the interested activities can update their view
*
* TODO - no more broadcasts, replace with a callback to subscribed listeners once we drop FileUploader
*
* @param upload Finished upload operation
* @param uploadResult Result of the upload operation
* @param unlinkedFromRemotePath Path in the uploads tree where the upload was unlinked from
*/
fun sendBroadcastUploadFinished(
upload: UploadFileOperation,
uploadResult: RemoteOperationResult<*>,
unlinkedFromRemotePath: String?,
context: Context,
localBroadcastManager: LocalBroadcastManager
) {
val end = Intent(FileUploadWorker.getUploadFinishMessage())
// real remote path, after possible automatic renaming
end.putExtra(FileUploadWorker.EXTRA_REMOTE_PATH, upload.remotePath)
if (upload.wasRenamed()) {
end.putExtra(FileUploadWorker.EXTRA_OLD_REMOTE_PATH, upload.oldFile!!.remotePath)
}
end.putExtra(FileUploadWorker.EXTRA_OLD_FILE_PATH, upload.originalStoragePath)
end.putExtra(FileUploadWorker.ACCOUNT_NAME, upload.user.accountName)
end.putExtra(FileUploadWorker.EXTRA_UPLOAD_RESULT, uploadResult.isSuccess)
if (unlinkedFromRemotePath != null) {
end.putExtra(FileUploadWorker.EXTRA_LINKED_TO_PATH, unlinkedFromRemotePath)
}
end.setPackage(context.packageName)
localBroadcastManager.sendBroadcast(end)
}
}

View file

@ -0,0 +1,122 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.upload
import android.app.PendingIntent
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
import java.security.SecureRandom
class FileUploaderIntents(private val context: Context) {
private val secureRandomGenerator = SecureRandom()
fun startIntent(operation: UploadFileOperation): PendingIntent {
val intent = Intent(
context,
FileUploadHelper.UploadNotificationActionReceiver::class.java
).apply {
putExtra(FileUploadWorker.EXTRA_ACCOUNT_NAME, operation.user.accountName)
putExtra(FileUploadWorker.EXTRA_REMOTE_PATH, operation.remotePath)
action = FileUploadWorker.ACTION_CANCEL_BROADCAST
}
return PendingIntent.getBroadcast(
context,
secureRandomGenerator.nextInt(),
intent,
PendingIntent.FLAG_IMMUTABLE
)
}
fun credentialIntent(operation: UploadFileOperation): PendingIntent {
val intent = Intent(context, AuthenticatorActivity::class.java).apply {
putExtra(AuthenticatorActivity.EXTRA_ACCOUNT, operation.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)
}
return PendingIntent.getActivity(
context,
System.currentTimeMillis().toInt(),
intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
}
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,
operation?.user,
Intent.FLAG_ACTIVITY_CLEAR_TOP,
context
)
return PendingIntent.getActivity(
context,
System.currentTimeMillis().toInt(),
intent,
PendingIntent.FLAG_IMMUTABLE
)
}
fun conflictResolveActionIntents(context: Context, uploadFileOperation: UploadFileOperation): PendingIntent {
val intent = createIntent(
uploadFileOperation.file,
uploadFileOperation.user,
uploadFileOperation.ocUploadId,
Intent.FLAG_ACTIVITY_CLEAR_TOP,
context
)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.getActivity(context, SecureRandom().nextInt(), intent, PendingIntent.FLAG_MUTABLE)
} else {
PendingIntent.getActivity(
context,
SecureRandom().nextInt(),
intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
}
}
}

View file

@ -0,0 +1,16 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2021 Chris Narkiewicz <hello@ezaquarii.com>
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.upload
enum class PostUploadAction(val value: Int) {
NONE(FileUploadWorker.LOCAL_BEHAVIOUR_FORGET),
COPY_TO_APP(FileUploadWorker.LOCAL_BEHAVIOUR_COPY),
MOVE_TO_APP(FileUploadWorker.LOCAL_BEHAVIOUR_MOVE),
DELETE_SOURCE(FileUploadWorker.LOCAL_BEHAVIOUR_DELETE)
}

View file

@ -0,0 +1,191 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
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.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()
}
@Suppress("MagicNumber")
fun prepareForStart(
uploadFileOperation: UploadFileOperation,
cancelPendingIntent: PendingIntent,
startIntent: PendingIntent
) {
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
)
)
setTicker(context.getString(R.string.foreground_service_upload))
setProgress(100, 0, false)
setOngoing(true)
clearActions()
addAction(
R.drawable.ic_action_cancel_grey,
context.getString(R.string.common_cancel),
cancelPendingIntent
)
setContentIntent(startIntent)
}
if (!uploadFileOperation.isInstantPicture && !uploadFileOperation.isInstantVideo) {
showNotification()
}
}
fun notifyForFailedResult(
resultCode: RemoteOperationResult.ResultCode,
conflictsResolveIntent: PendingIntent?,
credentialIntent: PendingIntent?,
errorMessage: String
) {
val textId = resultTitle(resultCode)
notificationBuilder.run {
setTicker(context.getString(textId))
setContentTitle(context.getString(textId))
setAutoCancel(false)
setOngoing(false)
setProgress(0, 0, false)
clearActions()
conflictsResolveIntent?.let {
addAction(
R.drawable.ic_cloud_upload,
R.string.upload_list_resolve_conflict,
it
)
}
credentialIntent?.let {
setContentIntent(it)
}
setContentText(errorMessage)
}
}
private fun resultTitle(resultCode: RemoteOperationResult.ResultCode): Int {
val needsToUpdateCredentials = (resultCode == RemoteOperationResult.ResultCode.UNAUTHORIZED)
return if (needsToUpdateCredentials) {
R.string.uploader_upload_failed_credentials_error
} else if (resultCode == RemoteOperationResult.ResultCode.SYNC_CONFLICT) {
R.string.uploader_upload_failed_sync_conflict_error
} else {
R.string.uploader_upload_failed_ticker
}
}
fun addAction(icon: Int, textId: Int, intent: PendingIntent) {
notificationBuilder.addAction(
icon,
context.getString(textId),
intent
)
}
fun showNewNotification(operation: UploadFileOperation) {
notificationManager.notify(
NotificationUtils.createUploadNotificationTag(operation.file),
FileUploadWorker.NOTIFICATION_ERROR_ID,
notificationBuilder.build()
)
}
private fun showNotification() {
notificationManager.notify(ID, 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)
showNotification()
dismissOldErrorNotification(currentOperation)
}
}
fun dismissOldErrorNotification(operation: UploadFileOperation?) {
if (operation == null) {
return
}
dismissOldErrorNotification(operation.file.remotePath, operation.file.storagePath)
operation.oldFile?.let {
dismissOldErrorNotification(it.remotePath, it.storagePath)
}
}
fun dismissOldErrorNotification(remotePath: String, localPath: String) {
notificationManager.cancel(
NotificationUtils.createUploadNotificationTag(remotePath, localPath),
FileUploadWorker.NOTIFICATION_ERROR_ID
)
}
fun dismissWorkerNotifications() {
notificationManager.cancel(ID)
}
fun notifyPaused(intent: PendingIntent) {
notificationBuilder.apply {
setContentTitle(context.getString(R.string.upload_global_pause_title))
setTicker(context.getString(R.string.upload_global_pause_title))
setOngoing(true)
setAutoCancel(false)
setProgress(0, 0, false)
clearActions()
setContentIntent(intent)
}
showNotification()
}
}

View file

@ -0,0 +1,86 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.upload
import android.content.Context
import com.nextcloud.client.account.User
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.network.ConnectivityService
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.UploadsStorageManager
import com.owncloud.android.db.OCUpload
import com.owncloud.android.files.services.NameCollisionPolicy
import com.owncloud.android.lib.common.OwnCloudClient
import com.owncloud.android.operations.UploadFileOperation
@Suppress("LongParameterList")
class UploadTask(
private val applicationContext: Context,
private val uploadsStorageManager: UploadsStorageManager,
private val connectivityService: ConnectivityService,
private val powerManagementService: PowerManagementService,
private val clientProvider: () -> OwnCloudClient,
private val fileDataStorageManager: FileDataStorageManager
) {
data class Result(val file: OCFile, val success: Boolean)
/**
* This class is a helper factory to to keep static dependencies
* injection out of the upload task instance.
*/
@Suppress("LongParameterList")
class Factory(
private val applicationContext: Context,
private val uploadsStorageManager: UploadsStorageManager,
private val connectivityService: ConnectivityService,
private val powerManagementService: PowerManagementService,
private val clientProvider: () -> OwnCloudClient,
private val fileDataStorageManager: FileDataStorageManager
) {
fun create(): UploadTask {
return UploadTask(
applicationContext,
uploadsStorageManager,
connectivityService,
powerManagementService,
clientProvider,
fileDataStorageManager
)
}
}
fun upload(user: User, upload: OCUpload): Result {
val file = UploadFileOperation.obtainNewOCFileToUpload(
upload.remotePath,
upload.localPath,
upload.mimeType
)
val op = UploadFileOperation(
uploadsStorageManager,
connectivityService,
powerManagementService,
user,
file,
upload,
NameCollisionPolicy.ASK_USER,
upload.localAction,
applicationContext,
upload.isUseWifiOnly,
upload.isWhileChargingOnly,
false,
fileDataStorageManager
)
val client = clientProvider()
uploadsStorageManager.updateDatabaseUploadStart(op)
val result = op.execute(client)
uploadsStorageManager.updateDatabaseUploadResult(result, op)
return Result(file, result.isSuccess)
}
}

View file

@ -0,0 +1,41 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper_ozturk@proton.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.upload
import com.owncloud.android.operations.UploadFileOperation
/**
* Upload transfer trigger.
*/
enum class UploadTrigger(val value: Int) {
/**
* Transfer triggered manually by the user.
*/
USER(UploadFileOperation.CREATED_BY_USER),
/**
* Transfer triggered automatically by taking a photo.
*/
PHOTO(UploadFileOperation.CREATED_AS_INSTANT_PICTURE),
/**
* Transfer triggered automatically by making a video.
*/
VIDEO(UploadFileOperation.CREATED_AS_INSTANT_VIDEO);
companion object {
@JvmStatic
fun fromValue(value: Int) = when (value) {
UploadFileOperation.CREATED_BY_USER -> USER
UploadFileOperation.CREATED_AS_INSTANT_PICTURE -> PHOTO
UploadFileOperation.CREATED_AS_INSTANT_VIDEO -> VIDEO
else -> USER
}
}
}