Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.database.dao

import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry
import com.nextcloud.client.database.NextcloudDatabase
import com.nextcloud.client.database.entity.toUploadEntity
import com.nextcloud.client.database.typeConverter.OfflineOperationTypeConverter
import com.nextcloud.utils.extensions.isNonRetryable
import com.owncloud.android.datamodel.UploadsStorageManager.UploadStatus
import com.owncloud.android.db.OCUpload
import com.owncloud.android.db.UploadResult
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class UploadDaoUnfinishedUploadsTest {

private val database = Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
NextcloudDatabase::class.java
)
.addTypeConverter(OfflineOperationTypeConverter())
.allowMainThreadQueries()
.build()
private val uploadDao = database.uploadDao()
private val nonRetryableResults = UploadResult.entries.filter { it.isNonRetryable() }.map { it.value }

@After
fun tearDown() {
database.close()
}

@Test
fun uploadIsUnfinishedWhileTheUploadJobStillProcessesIt() {
val cases = listOf(
Triple(UploadStatus.UPLOAD_IN_PROGRESS, UploadResult.UNKNOWN, true),
Triple(UploadStatus.UPLOAD_FAILED, UploadResult.NETWORK_CONNECTION, true),
Triple(UploadStatus.UPLOAD_FAILED, null, true),
Triple(UploadStatus.UPLOAD_FAILED, UploadResult.FILE_NOT_FOUND, false),
Triple(UploadStatus.UPLOAD_SUCCEEDED, UploadResult.UPLOADED, false)
)

cases.forEachIndexed { index, (status, result, unfinished) ->
val remotePath = "$FILE_PATH.$index"
insert(remotePath, status, result)

assertEquals("$status $result", unfinished, isFileUnfinished(remotePath))
}
}

@Test
fun uploadOfOtherAccountOrPathIsIgnored() {
insert(FILE_PATH, UploadStatus.UPLOAD_IN_PROGRESS, accountName = OTHER_ACCOUNT_NAME)
insert("$FILE_PATH.bak", UploadStatus.UPLOAD_IN_PROGRESS)

assertFalse(isFileUnfinished())
}

@Test
fun unfinishedUploadInsideFolderIsUnfinished() {
insert("${FOLDER_PATH}sub/document.bin", UploadStatus.UPLOAD_FAILED, UploadResult.NETWORK_CONNECTION)

assertTrue(isFolderUnfinished())
}

@Test
fun folderIgnoresFinishedChildrenAndSiblingWithSamePrefix() {
insert("${FOLDER_PATH}document.bin", UploadStatus.UPLOAD_FAILED, UploadResult.FILE_NOT_FOUND)
insert("${FOLDER_PATH.trimEnd('/')}-other/document.bin", UploadStatus.UPLOAD_IN_PROGRESS)

assertFalse(isFolderUnfinished())
}

private fun insert(
remotePath: String,
status: UploadStatus,
result: UploadResult? = UploadResult.UNKNOWN,
accountName: String = ACCOUNT_NAME
) {
val upload = OCUpload(LOCAL_PATH, remotePath, accountName).apply { uploadStatus = status }
uploadDao.insertOrReplace(upload.toUploadEntity().copy(lastResult = result?.value))
}

private fun isFileUnfinished(remotePath: String = FILE_PATH): Boolean =
uploadDao.existsUnfinishedByAccountAndRemotePath(
ACCOUNT_NAME,
remotePath,
UploadStatus.UPLOAD_IN_PROGRESS.value,
UploadStatus.UPLOAD_FAILED.value,
nonRetryableResults
)

private fun isFolderUnfinished(): Boolean = uploadDao.existsUnfinishedByAccountInFolder(
ACCOUNT_NAME,
FOLDER_PATH,
UploadStatus.UPLOAD_IN_PROGRESS.value,
UploadStatus.UPLOAD_FAILED.value,
nonRetryableResults
)

companion object {
private const val ACCOUNT_NAME = "user@server"
private const val OTHER_ACCOUNT_NAME = "other@server"
private const val LOCAL_PATH = "/local/document.bin"
private const val FILE_PATH = "/folder/document.bin"
private const val FOLDER_PATH = "/snapshot/"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import com.owncloud.android.datamodel.UploadsStorageManager.UploadStatus
import com.owncloud.android.db.OCUpload
import com.owncloud.android.db.UploadResult
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.providers.documentstorage.DocumentsProviderUtils.assertExistsOnServer
import com.owncloud.android.providers.documentstorage.DocumentsProviderUtils.assertListFilesEquals
import com.owncloud.android.providers.documentstorage.DocumentsProviderUtils.assertReadEquals
Expand Down Expand Up @@ -48,6 +50,8 @@ private const val MAX_FILE_NAME_LENGTH = 225
// the provider can only enqueue the upload worker, so give it a generous budget before giving up
private const val UPLOAD_POLL_ATTEMPTS = 45

private const val RENAME_AFTER_WRITE_DATA_SIZE = 64 * 1024

class DocumentsStorageProviderIT : AbstractOnServerIT() {

private val context = targetContext
Expand Down Expand Up @@ -281,6 +285,39 @@ class DocumentsStorageProviderIT : AbstractOnServerIT() {
assertEquals(String(content2), String(bytes))
}

@Test
fun testRenameRightAfterWriteKeepsContent() {
val file1 = rootDir.createFile("application/octet-stream", RandomStringGenerator.make())!!
val data = Random.nextBytes(RENAME_AFTER_WRITE_DATA_SIZE)
contentResolver.openOutputStream(file1.uri, "wt").use {
it!!.write(data)
}

DocumentsContract.renameDocument(contentResolver, file1.uri, RandomStringGenerator.make())

val remotePath = file1.getOCFile(storageManager)!!.remotePath
val result = ReadFileRemoteOperation(remotePath).execute(client)
assertTrue(result.isSuccess)
assertEquals(data.size.toLong(), (result.data[0] as RemoteFile).length)
}

@Test
fun testRenameFolderRightAfterWriteKeepsContent() {
val folder = rootDir.createDirectory(RandomStringGenerator.make())!!
val file1 = folder.createFile("application/octet-stream", RandomStringGenerator.make())!!
val data = Random.nextBytes(RENAME_AFTER_WRITE_DATA_SIZE)
contentResolver.openOutputStream(file1.uri, "wt").use {
it!!.write(data)
}

DocumentsContract.renameDocument(contentResolver, folder.uri, RandomStringGenerator.make())

val remotePath = file1.getOCFile(storageManager)!!.remotePath
val result = ReadFileRemoteOperation(remotePath).execute(client)
assertTrue(result.isSuccess)
assertEquals(data.size.toLong(), (result.data[0] as RemoteFile).length)
}

private fun awaitUploadedToServer(file: DocumentFile) {
val remotePath = file.getOCFile(storageManager)!!.remotePath

Expand Down
34 changes: 34 additions & 0 deletions app/src/main/java/com/nextcloud/client/database/dao/UploadDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import androidx.room.Query
import com.nextcloud.client.database.entity.UploadEntity
import com.owncloud.android.db.ProviderMeta.ProviderTableMeta

private const val UNFINISHED_UPLOAD_CONDITION =
"(${ProviderTableMeta.UPLOADS_STATUS} = :inProgressStatus " +
"OR (${ProviderTableMeta.UPLOADS_STATUS} = :failedStatus " +
"AND (${ProviderTableMeta.UPLOADS_LAST_RESULT} IS NULL " +
"OR ${ProviderTableMeta.UPLOADS_LAST_RESULT} NOT IN (:nonRetryableResults))))"

@Dao
interface UploadDao {
@Query(
Expand All @@ -36,6 +42,34 @@ interface UploadDao {
)
fun getByRemotePath(remotePath: String): UploadEntity?

@Query(
"SELECT EXISTS(SELECT 1 FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME} " +
"WHERE ${ProviderTableMeta.UPLOADS_ACCOUNT_NAME} = :accountName " +
"AND ${ProviderTableMeta.UPLOADS_REMOTE_PATH} = :remotePath " +
"AND $UNFINISHED_UPLOAD_CONDITION)"
)
fun existsUnfinishedByAccountAndRemotePath(
accountName: String,
remotePath: String,
inProgressStatus: Int,
failedStatus: Int,
nonRetryableResults: List<Int>
): Boolean

@Query(
"SELECT EXISTS(SELECT 1 FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME} " +
"WHERE ${ProviderTableMeta.UPLOADS_ACCOUNT_NAME} = :accountName " +
"AND substr(${ProviderTableMeta.UPLOADS_REMOTE_PATH}, 1, length(:folderPath)) = :folderPath " +
"AND $UNFINISHED_UPLOAD_CONDITION)"
)
fun existsUnfinishedByAccountInFolder(
accountName: String,
folderPath: String,
inProgressStatus: Int,
failedStatus: Int,
nonRetryableResults: List<Int>
): Boolean

@Query(
"DELETE FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME} " +
"WHERE ${ProviderTableMeta.UPLOADS_ACCOUNT_NAME} = :accountName " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.nextcloud.client.account.User
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.operations.DownloadType
import kotlinx.coroutines.Job

/**
* This interface allows to control, schedule and monitor all application
Expand Down Expand Up @@ -135,12 +136,16 @@ interface BackgroundJobManager {

fun startNotificationJob(subject: String, signature: String)
fun startAccountRemovalJob(accountName: String, remoteWipe: Boolean)

/**
* @return completes once enqueueing the upload job finished or failed
*/
fun startFilesUploadJob(
user: User,
uploadIds: LongArray,
showSameFileAlreadyExistsNotification: Boolean,
skipAutoUploadCheck: Boolean = false
)
): Job
fun startAlbumFilesUploadJob(user: User, uploadIds: LongArray, albumName: String)
fun getFileUploads(user: User): LiveData<List<JobInfo>>
fun cancelFilesUploadJob(user: User)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.await
import androidx.work.workDataOf
import com.nextcloud.client.account.User
import com.nextcloud.client.core.Clock
Expand All @@ -46,8 +47,10 @@ import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.DownloadType
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.time.Duration
import java.util.Date
Expand Down Expand Up @@ -679,8 +682,8 @@ internal class BackgroundJobManagerImpl(
uploadIds: LongArray,
showSameFileAlreadyExistsNotification: Boolean,
skipAutoUploadCheck: Boolean
) {
defaultDispatcherScope.launch {
): Job {
val enqueueJob = defaultDispatcherScope.launch {
val batchSize = FileUploadHelper.MAX_FILE_COUNT
val batches = uploadIds.toList().chunked(batchSize)
val tag = startFileUploadJobTag(user.accountName)
Expand Down Expand Up @@ -721,9 +724,16 @@ internal class BackgroundJobManagerImpl(
workChain = workChain.then(request)
}

workChain.enqueue()
try {
workChain.enqueue().await()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log_OC.e(TAG, "Failed to enqueue upload job", e)
}
}
}
return enqueueJob
}

private fun startAlbumsFileUploadJobTag(accountName: String): String = ALBUM_JOB_FILES_UPLOAD + accountName
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import com.nextcloud.utils.extensions.checkWCFRestrictions
import com.nextcloud.utils.extensions.createOwncloudClient
import com.nextcloud.utils.extensions.getUploadIds
import com.nextcloud.utils.extensions.isLastResultConflictError
import com.nextcloud.utils.extensions.isNonRetryable
import com.nextcloud.utils.extensions.isSame
import com.owncloud.android.MainApp
import com.owncloud.android.R
Expand All @@ -54,6 +55,7 @@ import com.owncloud.android.ui.adapter.uploadList.helper.ConflictHandlingResult
import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterActionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
Expand Down Expand Up @@ -91,6 +93,8 @@ class FileUploadHelper {

const val MAX_UPLOADS_PER_QUERY = 500

private val nonRetryableResults = UploadResult.entries.filter { it.isNonRetryable() }.map { it.value }

val mBoundListeners = HashMap<String, OnDatatransferProgressListener>()

private val retryInProgress = AtomicBoolean(false)
Expand Down Expand Up @@ -429,6 +433,29 @@ class FileUploadHelper {
FileUploadWorker.isUploading(remotePath, accountName)
}

/**
* Mirrors the rows [FileUploadWorker] still processes; rows left behind without a scheduled upload job never
* finish. Rows are matched by path, so a retryable failed row also counts while another batch of the account runs.
*/
fun isUploadPending(file: OCFile, accountName: String): Boolean {
val uploadDao = uploadsStorageManager.uploadDao
val existsUnfinished = if (file.isFolder) {
uploadDao::existsUnfinishedByAccountInFolder
} else {
uploadDao::existsUnfinishedByAccountAndRemotePath
}
return existsUnfinished(
accountName,
file.remotePath,
UploadStatus.UPLOAD_IN_PROGRESS.value,
UploadStatus.UPLOAD_FAILED.value,
nonRetryableResults
) &&
backgroundJobManager.isStartFileUploadJobScheduled(accountName)
}

fun getLastUploadProgress(accountName: String): Long = FileUploadWorker.getLastTransferProgress(accountName)

private fun checkConnectivity(connectivityService: ConnectivityService): Boolean {
// check that connection isn't walled off and that the server is reachable
return connectivityService.connectivity.isConnected && !connectivityService.isInternetWalled()
Expand Down Expand Up @@ -497,7 +524,7 @@ class FileUploadHelper {
behaviour: Int,
nameCollisionPolicy: NameCollisionPolicy,
skipAutoUploadCheck: Boolean = false
) {
): Job {
Log_OC.d(this, "upload updated file")

val uploads = existingFiles.map { file ->
Expand Down Expand Up @@ -534,7 +561,7 @@ class FileUploadHelper {
}
}
val uploadIds: LongArray = uploads.filterNotNull().map { it.uploadId }.toLongArray()
backgroundJobManager.startFilesUploadJob(
return backgroundJobManager.startFilesUploadJob(
user,
uploadIds,
true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package com.nextcloud.client.jobs.upload

import android.app.Notification
import android.content.Context
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.CoroutineWorker
Expand Down Expand Up @@ -133,6 +134,10 @@ class FileUploadWorker(
it.remotePath == remotePath && it.user.accountName == accountName
}

private val lastTransferProgress = ConcurrentHashMap<String, Long>()

fun getLastTransferProgress(accountName: String): Long = lastTransferProgress[accountName] ?: 0L

/**
* [activeOperations] tracks every upload running in this process, not only the ones this worker started.
* Auto upload has to register here too, otherwise a retry pass sees its in-progress row as abandoned and
Expand Down Expand Up @@ -497,6 +502,7 @@ class FileUploadWorker(
totalToTransfer: Long,
fileAbsoluteName: String
) {
inputData.getString(ACCOUNT)?.let { lastTransferProgress[it] = SystemClock.elapsedRealtime() }
val percent = getPercent(totalTransferredSoFar, totalToTransfer)
val currentTime = System.currentTimeMillis()

Expand Down
Loading