repo created
This commit is contained in:
commit
1ef725ef20
2483 changed files with 278273 additions and 0 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
169
app/src/main/java/com/nextcloud/client/jobs/FilesExportWork.kt
Normal file
169
app/src/main/java/com/nextcloud/client/jobs/FilesExportWork.kt
Normal 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
|
||||
}
|
||||
}
|
||||
313
app/src/main/java/com/nextcloud/client/jobs/FilesSyncWork.kt
Normal file
313
app/src/main/java/com/nextcloud/client/jobs/FilesSyncWork.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
121
app/src/main/java/com/nextcloud/client/jobs/HealthStatusWork.kt
Normal file
121
app/src/main/java/com/nextcloud/client/jobs/HealthStatusWork.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
27
app/src/main/java/com/nextcloud/client/jobs/JobInfo.kt
Normal file
27
app/src/main/java/com/nextcloud/client/jobs/JobInfo.kt
Normal 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
|
||||
)
|
||||
48
app/src/main/java/com/nextcloud/client/jobs/JobsModule.kt
Normal file
48
app/src/main/java/com/nextcloud/client/jobs/JobsModule.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
343
app/src/main/java/com/nextcloud/client/jobs/NotificationWork.kt
Normal file
343
app/src/main/java/com/nextcloud/client/jobs/NotificationWork.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
142
app/src/main/java/com/nextcloud/client/jobs/OfflineSyncWork.kt
Normal file
142
app/src/main/java/com/nextcloud/client/jobs/OfflineSyncWork.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/src/main/java/com/nextcloud/client/jobs/TestJob.kt
Normal file
41
app/src/main/java/com/nextcloud/client/jobs/TestJob.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue