diff --git a/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt b/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt index d59256f786c0..3a9c417a32c7 100644 --- a/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt +++ b/app/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt @@ -16,10 +16,13 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.PeriodicWorkRequest +import androidx.work.WorkContinuation import androidx.work.WorkInfo import androidx.work.WorkManager import com.nextcloud.client.account.User import com.nextcloud.client.core.Clock +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.utils.extensions.toByteArray import com.owncloud.android.lib.common.utils.Log_OC import org.apache.commons.io.FileUtils @@ -42,6 +45,7 @@ import org.mockito.kotlin.argThat import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.timeout import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.io.File @@ -64,6 +68,7 @@ import java.util.concurrent.TimeoutException BackgroundJobManagerTest.PeriodicContactsBackup::class, BackgroundJobManagerTest.ImmediateContactsBackup::class, BackgroundJobManagerTest.ImmediateContactsImport::class, + BackgroundJobManagerTest.FilesUpload::class, BackgroundJobManagerTest.Tags::class ) class BackgroundJobManagerTest { @@ -90,6 +95,7 @@ class BackgroundJobManagerTest { internal lateinit var user: User internal lateinit var workManager: WorkManager internal lateinit var clock: Clock + internal lateinit var preferences: AppPreferences internal lateinit var backgroundJobManager: BackgroundJobManagerImpl internal lateinit var context: Context @@ -100,9 +106,10 @@ class BackgroundJobManagerTest { whenever(user.accountName).thenReturn(USER_ACCOUNT_NAME) workManager = mock() clock = mock() + preferences = mock() whenever(clock.currentTime).thenReturn(TIMESTAMP) whenever(clock.currentDate).thenReturn(Date(TIMESTAMP)) - backgroundJobManager = BackgroundJobManagerImpl(workManager, clock, mock()) + backgroundJobManager = BackgroundJobManagerImpl(workManager, clock, preferences) } fun assertHasRequiredTags(tags: Set, jobName: String, user: User? = null) { @@ -385,6 +392,63 @@ class BackgroundJobManagerTest { } } + class FilesUpload : Fixture() { + + @Test + fun start_files_upload_job_enqueues_batches() { + val uploadIds = longArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) + + val continuation: WorkContinuation = mock() + whenever( + workManager.beginUniqueWork(any(), any(), any>()) + ).thenReturn(continuation) + + backgroundJobManager.startFilesUploadJob(user, uploadIds, true) + + val tagCaptor = argumentCaptor() + val requestsCaptor = argumentCaptor>() + + verify(workManager, timeout(1000)).enqueueUniqueWork( + tagCaptor.capture(), + eq(ExistingWorkPolicy.KEEP), + requestsCaptor.capture() + ) + + val tag = tagCaptor.firstValue + assertTrue(tag.startsWith(BackgroundJobManagerImpl.JOB_FILES_UPLOAD + USER_ACCOUNT_NAME + "_")) + + val requests = requestsCaptor.firstValue + assertEquals(6, requests.size) + + // Check first batch [1, 2] + val data1 = requests[0].workSpec.input + assertEquals(true, data1.getBoolean(FileUploadWorker.SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION, false)) + assertEquals(USER_ACCOUNT_NAME, data1.getString(FileUploadWorker.ACCOUNT)) + assertEquals(2, data1.getInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, 0)) + assertTrue(longArrayOf(1, 2).contentEquals(data1.getLongArray(FileUploadWorker.UPLOAD_IDS)!!)) + assertEquals(0, data1.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, -1)) + + // Check second batch [3, 4] + val data2 = requests[1].workSpec.input + assertTrue(longArrayOf(3, 4).contentEquals(data2.getLongArray(FileUploadWorker.UPLOAD_IDS)!!)) + assertEquals(1, data2.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, -1)) + + // Check third batch [5] + val data3 = requests[2].workSpec.input + assertTrue(longArrayOf(5, 6).contentEquals(data3.getLongArray(FileUploadWorker.UPLOAD_IDS)!!)) + assertEquals(2, data3.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, -1)) + } + + @Test + fun start_files_upload_job_does_nothing_when_empty() { + val uploadIds = longArrayOf() + + backgroundJobManager.startFilesUploadJob(user, uploadIds, true) + + verify(workManager, timeout(1000).times(0)).beginUniqueWork(any(), any(), any>()) + } + } + class Tags { @Test fun split_tag_key_and_value() { diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt index 3e67df51fa92..6434300fabf1 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -29,6 +29,7 @@ import com.nextcloud.client.jobs.metadata.MetadataWorker import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.jobs.upload.UploadNotificationManager import com.nextcloud.client.logger.Logger import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.preferences.AppPreferences @@ -39,6 +40,7 @@ import com.owncloud.android.utils.theme.ViewThemeUtils import org.greenrobot.eventbus.EventBus import javax.inject.Inject import javax.inject.Provider +import kotlin.random.Random /** * This factory is responsible for creating all background jobs and for injecting worker dependencies. @@ -238,6 +240,7 @@ class BackgroundJobFactory @Inject constructor( backgroundJobManager.get(), preferences, context, + UploadNotificationManager(context, viewThemeUtils.get(), Random.nextInt()), params ) diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index 0ad01e66c7ad..66040ea31886 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -117,6 +117,11 @@ internal class BackgroundJobManagerImpl( private const val KEEP_LOG_MILLIS = 1000 * 60 * 60 * 24 * 3L + /** + * The maximum number of concurrent parallel uploads + */ + const val MAX_CONCURRENT_UPLOADS = 5 + fun formatNameTag(name: String, user: User? = null): String = if (user == null) { "$TAG_PREFIX_NAME:$name" } else { @@ -657,48 +662,41 @@ internal class BackgroundJobManagerImpl( */ override fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean) { defaultDispatcherScope.launch { - val batchSize = FileUploadHelper.MAX_FILE_COUNT - val batches = uploadIds.toList().chunked(batchSize) - val tag = startFileUploadJobTag(user.accountName) + val chunkSize = (uploadIds.size / MAX_CONCURRENT_UPLOADS).coerceAtLeast(1) + val batches = uploadIds.toList().chunked(chunkSize) + val executionId = System.currentTimeMillis() + val tag = "${startFileUploadJobTag(user.accountName)}_$executionId" val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() - val dataBuilder = Data.Builder() - .putBoolean( - FileUploadWorker.SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION, - showSameFileAlreadyExistsNotification - ) - .putString(FileUploadWorker.ACCOUNT, user.accountName) - .putInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, uploadIds.size) - val workRequests = batches.mapIndexed { index, batch -> - dataBuilder + val data = Data.Builder() + .putBoolean( + FileUploadWorker.SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION, + showSameFileAlreadyExistsNotification + ) + .putString(FileUploadWorker.ACCOUNT, user.accountName) + .putInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, chunkSize) .putLongArray(FileUploadWorker.UPLOAD_IDS, batch.toLongArray()) .putInt(FileUploadWorker.CURRENT_BATCH_INDEX, index) + .build() oneTimeRequestBuilder(FileUploadWorker::class, JOB_FILES_UPLOAD, user) .addTag(tag) - .setInputData(dataBuilder.build()) + .setInputData(data) .setConstraints(constraints) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() } - // Chain the work requests sequentially if (workRequests.isNotEmpty()) { - var workChain = workManager.beginUniqueWork( + workManager.enqueueUniqueWork( tag, - ExistingWorkPolicy.APPEND_OR_REPLACE, - workRequests.first() + ExistingWorkPolicy.KEEP, + workRequests ) - - workRequests.drop(1).forEach { request -> - workChain = workChain.then(request) - } - - workChain.enqueue() } } } diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt index 62b3294b73af..ae79102729b6 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt @@ -20,8 +20,10 @@ 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.notifications.AppWideNotificationManager +import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.activeUploadFileOperations import com.nextcloud.client.network.Connectivity import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.notifications.AppWideNotificationManager import com.nextcloud.utils.extensions.getUploadIds import com.owncloud.android.MainApp import com.owncloud.android.R @@ -372,17 +374,14 @@ class FileUploadHelper { @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 + upload ?: return false + + return activeUploadFileOperations.values.any { operation -> + operation.user?.accountName == upload.accountName && + ( + upload.remotePath == operation.remotePath || + upload.remotePath == operation.oldFile?.remotePath + ) } } diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt index d1906e7c8c79..a67e81ff68ba 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -47,7 +47,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import java.io.File -import kotlin.random.Random +import java.util.concurrent.ConcurrentHashMap @Suppress("LongParameterList", "TooGenericExceptionCaught") class FileUploadWorker( @@ -60,6 +60,7 @@ class FileUploadWorker( private val backgroundJobManager: BackgroundJobManager, val preferences: AppPreferences, val context: Context, + val notificationManager: UploadNotificationManager, params: WorkerParameters ) : CoroutineWorker(context, params), OnDatatransferProgressListener { @@ -75,8 +76,7 @@ class FileUploadWorker( const val TOTAL_UPLOAD_SIZE = "total_upload_size" const val SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION = "show_same_file_already_exists_notification" - var currentUploadFileOperation: UploadFileOperation? = null - + val activeUploadFileOperations = ConcurrentHashMap() private const val UPLOADS_ADDED_MESSAGE = "UPLOADS_ADDED" private const val UPLOAD_START_MESSAGE = "UPLOAD_START" private const val UPLOAD_FINISH_MESSAGE = "UPLOAD_FINISH" @@ -103,20 +103,16 @@ class FileUploadWorker( fun getUploadFinishMessage(): String = FileUploadWorker::class.java.name + UPLOAD_FINISH_MESSAGE fun cancelCurrentUpload(remotePath: String, accountName: String, onCompleted: () -> Unit) { - currentUploadFileOperation?.let { + activeUploadFileOperations.values.forEach { if (it.remotePath == remotePath && it.user.accountName == accountName) { it.cancel(ResultCode.USER_CANCELLED) - onCompleted() } } + onCompleted() } - fun isUploading(remotePath: String?, accountName: String?): Boolean { - currentUploadFileOperation?.let { - return it.remotePath == remotePath && it.user.accountName == accountName - } - - return false + fun isUploading(remotePath: String?, accountName: String?): Boolean = activeUploadFileOperations.values.any { + it.remotePath == remotePath && it.user.accountName == accountName } fun getUploadAction(action: String): Int = when (action) { @@ -127,9 +123,9 @@ class FileUploadWorker( } } - private var lastPercent = 0 - private val notificationId = Random.nextInt() - private val notificationManager = UploadNotificationManager(context, viewThemeUtils, notificationId) + private val lastPercents = ConcurrentHashMap() + private val lastUpdateTimes = ConcurrentHashMap() + private val intents = FileUploaderIntents(context) private val fileUploaderDelegate = FileUploaderDelegate() @@ -171,7 +167,7 @@ class FileUploadWorker( val notification = createNotification(notificationTitle) return ForegroundServiceHelper.createWorkerForegroundInfo( - notificationId, + notificationManager.getId(), notification, ForegroundServiceType.DataSync ) @@ -179,7 +175,7 @@ class FileUploadWorker( private suspend fun updateForegroundInfo(notification: Notification) { val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo( - notificationId, + notificationManager.getId(), notification, ForegroundServiceType.DataSync ) @@ -202,7 +198,7 @@ class FileUploadWorker( Log_OC.e(TAG, "FileUploadWorker stopped") setIdleWorkerState() - currentUploadFileOperation?.cancel(null) + activeUploadFileOperations.values.forEach { it.cancel(null) } notificationManager.dismissNotification() } @@ -211,7 +207,8 @@ class FileUploadWorker( } private fun setIdleWorkerState() { - WorkerStateObserver.send(WorkerState.FileUploadCompleted(currentUploadFileOperation?.file)) + val lastOp = activeUploadFileOperations.values.lastOrNull() + WorkerStateObserver.send(WorkerState.FileUploadCompleted(lastOp?.file)) } @Suppress("ReturnCount", "LongMethod", "DEPRECATION") @@ -271,14 +268,14 @@ class FileUploadWorker( setWorkerState(user) val operation = createUploadFileOperation(upload, user) - currentUploadFileOperation = operation + activeUploadFileOperations[operation.originalStoragePath] = operation val currentIndex = (index + 1) - val currentUploadIndex = (currentIndex + previouslyUploadedFileSize) + notificationManager.prepareForStart( operation, startIntent = intents.openUploadListIntent(operation), - currentUploadIndex = currentUploadIndex, + currentUploadIndex = currentIndex, totalUploadSize = totalUploadSize ) @@ -287,7 +284,7 @@ class FileUploadWorker( } val entity = uploadsStorageManager.uploadDao.getUploadById(upload.uploadId, accountName) uploadsStorageManager.updateStatus(entity, result.isSuccess) - currentUploadFileOperation = null + activeUploadFileOperations.remove(operation.originalStoragePath) if (result.code == ResultCode.QUOTA_EXCEEDED) { Log_OC.w(TAG, "Quota exceeded, stopping uploads") @@ -295,7 +292,7 @@ class FileUploadWorker( break } - sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result) + sendUploadFinishEvent(totalUploadSize, currentIndex, operation, result) } return@withContext Result.success() @@ -410,20 +407,24 @@ class FileUploadWorker( totalToTransfer: Long, fileAbsoluteName: String ) { + val operation = activeUploadFileOperations[fileAbsoluteName] ?: return val percent = getPercent(totalTransferredSoFar, totalToTransfer) val currentTime = System.currentTimeMillis() + val lastPercent = lastPercents[fileAbsoluteName] ?: 0 + val lastUpdateTime = lastUpdateTimes[fileAbsoluteName] ?: 0L + if (percent != lastPercent && (currentTime - lastUpdateTime) >= minProgressUpdateInterval) { notificationManager.run { - val accountName = currentUploadFileOperation?.user?.accountName - val remotePath = currentUploadFileOperation?.remotePath + val accountName = operation.user.accountName + val remotePath = operation.remotePath - updateUploadProgress(percent, currentUploadFileOperation) + updateUploadProgress(percent, operation) if (accountName != null && remotePath != null) { val key: String = FileUploadHelper.buildRemoteName(accountName, remotePath) val boundListener = FileUploadHelper.mBoundListeners[key] - val filename = currentUploadFileOperation?.fileName ?: "" + val filename = operation.fileName ?: "" boundListener?.onTransferProgress( progressRate, @@ -433,11 +434,10 @@ class FileUploadWorker( ) } - dismissOldErrorNotification(currentUploadFileOperation) + dismissOldErrorNotification(operation) } - lastUpdateTime = currentTime + lastUpdateTimes[fileAbsoluteName] = currentTime + lastPercents[fileAbsoluteName] = percent } - - lastPercent = percent } } diff --git a/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadHelperTest.kt b/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadHelperTest.kt new file mode 100644 index 000000000000..78f2a29540b4 --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadHelperTest.kt @@ -0,0 +1,145 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Raphael Vieira raphaelecv.projects@gmail.com + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.jobs.upload + +import com.nextcloud.client.account.User +import com.nextcloud.client.di.AppComponent +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.db.OCUpload +import com.owncloud.android.operations.UploadFileOperation +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class FileUploadHelperTest { + + private lateinit var fileUploadHelper: FileUploadHelper + + @Before + fun setUp() { + mockkStatic(MainApp::class) + val appComponent = mockk(relaxed = true) + every { MainApp.getAppComponent() } returns appComponent + + fileUploadHelper = FileUploadHelper() + FileUploadWorker.activeUploadFileOperations.clear() + } + + @After + fun tearDown() { + unmockkAll() + FileUploadWorker.activeUploadFileOperations.clear() + } + + @Test + fun `isUploadingNow returns false for null upload`() { + assertFalse(fileUploadHelper.isUploadingNow(null)) + } + + @Test + fun `isUploadingNow returns false when no active operations`() { + val upload = mockk() + every { upload.accountName } returns "account" + every { upload.remotePath } returns "/file.txt" + + assertFalse(fileUploadHelper.isUploadingNow(upload)) + } + + @Test + fun `isUploadingNow returns true when remotePath matches`() { + val accountName = "account" + val remotePath = "/file.txt" + + val upload = mockk() + every { upload.accountName } returns accountName + every { upload.remotePath } returns remotePath + + val operation = mockk() + val user = mockk() + every { user.accountName } returns accountName + every { operation.user } returns user + every { operation.remotePath } returns remotePath + every { operation.oldFile } returns null + + FileUploadWorker.activeUploadFileOperations["key"] = operation + + assertTrue(fileUploadHelper.isUploadingNow(upload)) + } + + @Test + fun `isUploadingNow returns true when old remotePath matches`() { + val accountName = "account" + val remotePath = "/file_renamed.txt" + val oldRemotePath = "/file.txt" + + val upload = mockk() + every { upload.accountName } returns accountName + every { upload.remotePath } returns oldRemotePath + + val operation = mockk() + val user = mockk() + every { user.accountName } returns accountName + every { operation.user } returns user + every { operation.remotePath } returns remotePath + + val oldFile = mockk() + every { oldFile.remotePath } returns oldRemotePath + every { operation.oldFile } returns oldFile + + FileUploadWorker.activeUploadFileOperations["key"] = operation + + assertTrue(fileUploadHelper.isUploadingNow(upload)) + } + + @Test + fun `isUploadingNow returns false when accountName does not match`() { + val remotePath = "/file.txt" + + val upload = mockk() + every { upload.accountName } returns "account1" + every { upload.remotePath } returns remotePath + + val operation = mockk() + val user = mockk() + every { user.accountName } returns "account2" + every { operation.user } returns user + every { operation.remotePath } returns remotePath + every { operation.oldFile } returns null + + FileUploadWorker.activeUploadFileOperations["key"] = operation + + assertFalse(fileUploadHelper.isUploadingNow(upload)) + } + + @Test + fun `isUploadingNow returns false when paths do not match`() { + val accountName = "account" + + val upload = mockk() + every { upload.accountName } returns accountName + every { upload.remotePath } returns "/other.txt" + + val operation = mockk() + val user = mockk() + every { user.accountName } returns accountName + every { operation.user } returns user + every { operation.remotePath } returns "/file.txt" + every { operation.oldFile } returns null + + FileUploadWorker.activeUploadFileOperations["key"] = operation + + assertFalse(fileUploadHelper.isUploadingNow(upload)) + } +} diff --git a/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadWorkerTest.kt b/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadWorkerTest.kt new file mode 100644 index 000000000000..0651f10eff33 --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/jobs/upload/FileUploadWorkerTest.kt @@ -0,0 +1,213 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Raphael Vieira raphaelecv.projects@gmail.com + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.client.jobs.upload + +import android.app.NotificationManager +import android.content.Context +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import com.nextcloud.android.common.ui.theme.MaterialSchemes +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.network.Connectivity +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.utils.theme.ViewThemeUtils +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Optional + +class FileUploadWorkerTest { + + private lateinit var worker: FileUploadWorker + private val uploadsStorageManager: UploadsStorageManager = mockk(relaxed = true) + private val connectivityService: ConnectivityService = mockk(relaxed = true) + private val powerManagementService: PowerManagementService = mockk(relaxed = true) + private val userAccountManager: UserAccountManager = mockk(relaxed = true) + private val localBroadcastManager: LocalBroadcastManager = mockk(relaxed = true) + private val backgroundJobManager: BackgroundJobManager = mockk(relaxed = true) + private val preferences: AppPreferences = mockk(relaxed = true) + private val context: Context = mockk(relaxed = true) + private val params: WorkerParameters = mockk(relaxed = true) + private val systemNotificationManager: NotificationManager = mockk(relaxed = true) + private val uploadNotificationManager: UploadNotificationManager = mockk(relaxed = true) + + @Before + fun setUp() { + every { context.getSystemService(Context.NOTIFICATION_SERVICE) } returns systemNotificationManager + + val materialSchemes = mockk(relaxed = true) + val viewThemeUtils = ViewThemeUtils(materialSchemes, mockk(relaxed = true)) + + val connectivity = mockk() + every { connectivity.isConnected } returns true + every { connectivityService.getConnectivity() } returns connectivity + every { connectivityService.isConnected } returns true + every { connectivityService.isInternetWalled } returns false + + worker = FileUploadWorker( + uploadsStorageManager, + connectivityService, + powerManagementService, + userAccountManager, + viewThemeUtils, + localBroadcastManager, + backgroundJobManager, + preferences, + context, + uploadNotificationManager, + params + ) + } + + @After + fun tearDown() { + unmockkAll() + FileUploadWorker.activeUploadFileOperations.clear() + } + + @Test + fun `doWork returns failure when account name is missing`() = runBlocking { + // GIVEN + every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns null + + // WHEN + val result = worker.doWork() + + // THEN + assertEquals(ListenableWorker.Result.failure(), result) + } + + @Test + fun `doWork returns failure when upload ids are missing`() = runBlocking { + // GIVEN + every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns "account" + every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns null + + // WHEN + val result = worker.doWork() + + // THEN + assertEquals(ListenableWorker.Result.failure(), result) + } + + @Test + fun `doWork returns failure when batch index is missing`() = runBlocking { + // GIVEN + every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns "account" + every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns longArrayOf(1L) + every { params.inputData.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, -1) } returns -1 + + // WHEN + val result = worker.doWork() + + // THEN + assertEquals(ListenableWorker.Result.failure(), result) + } + + @Test + fun `doWork returns failure when user is not found`() = runBlocking { + // GIVEN + val accountName = "account" + every { params.inputData.getString(FileUploadWorker.ACCOUNT) } returns accountName + every { params.inputData.getLongArray(FileUploadWorker.UPLOAD_IDS) } returns longArrayOf(1L) + every { params.inputData.getInt(FileUploadWorker.CURRENT_BATCH_INDEX, any()) } returns 0 + every { params.inputData.getInt(FileUploadWorker.TOTAL_UPLOAD_SIZE, any()) } returns 1 + every { userAccountManager.getUser(accountName) } returns Optional.empty() + + // WHEN + val result = worker.doWork() + + // THEN + assertEquals(ListenableWorker.Result.failure(), result) + } + + @Test + fun `onTransferProgress updates notification manager`() { + // GIVEN + val fileName = "testFile" + val operation = mockk(relaxed = true) + FileUploadWorker.activeUploadFileOperations[fileName] = operation + + // WHEN + worker.onTransferProgress(100, 50, 100, fileName) + + // THEN + verify { uploadNotificationManager.updateUploadProgress(50, operation) } + } + + @Test + fun `cancelCurrentUpload cancels matching operations`() { + // GIVEN + val remotePath = "path" + val accountName = "account" + val operation = mockk(relaxed = true) + every { operation.remotePath } returns remotePath + every { operation.user.accountName } returns accountName + FileUploadWorker.activeUploadFileOperations["key"] = operation + + // WHEN + var completed = false + FileUploadWorker.cancelCurrentUpload(remotePath, accountName) { + completed = true + } + + // THEN + verify { operation.cancel(ResultCode.USER_CANCELLED) } + assertTrue(completed) + } + + @Test + fun `isUploading returns true when operation exists`() { + // GIVEN + val remotePath = "path" + val accountName = "account" + val operation = mockk(relaxed = true) + every { operation.remotePath } returns remotePath + every { operation.user.accountName } returns accountName + FileUploadWorker.activeUploadFileOperations["key"] = operation + + // WHEN & THEN + assertTrue(FileUploadWorker.isUploading(remotePath, accountName)) + assertFalse(FileUploadWorker.isUploading("other", accountName)) + } + + @Test + fun `getUploadAction returns correct values`() { + assertEquals( + FileUploadWorker.LOCAL_BEHAVIOUR_FORGET, + FileUploadWorker.getUploadAction("LOCAL_BEHAVIOUR_FORGET") + ) + assertEquals( + FileUploadWorker.LOCAL_BEHAVIOUR_MOVE, + FileUploadWorker.getUploadAction("LOCAL_BEHAVIOUR_MOVE") + ) + assertEquals( + FileUploadWorker.LOCAL_BEHAVIOUR_DELETE, + FileUploadWorker.getUploadAction("LOCAL_BEHAVIOUR_DELETE") + ) + assertEquals( + FileUploadWorker.LOCAL_BEHAVIOUR_FORGET, + FileUploadWorker.getUploadAction("UNKNOWN") + ) + } +}