diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4c27ccc2c8..f7a501eb23 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -48,6 +48,7 @@
+
@@ -327,6 +328,14 @@
+
+
+
+ // uploadReferenceToWorkId is session-local - after an app restart it's empty, then the work is
+ // found by its reference id. That lookup blocks, so it runs off the main thread.
+ // The placeholder below is removed whether or not a work is found.
+ // Both jobs run in appScope: leaving the chat right after the tap must not drop the cancellation.
+ val workId = uploadReferenceToWorkId.remove(referenceId)
+ appScope.launch(Dispatchers.IO) {
UploadAndShareFilesWorker.cancelUpload(referenceId, workId)
}
- viewModelScope.launch {
+ appScope.launch {
chatRepository.deleteTempMessageByReferenceId(referenceId)
}
_uploadProgressMap.update { it - referenceId }
@@ -2573,7 +2573,11 @@ class ChatViewModel @AssistedInject constructor(
if (progress >= 0) {
_uploadProgressMap.update { it + (referenceId to progress) }
}
- if (workInfo.state == WorkInfo.State.FAILED) {
+ // The worker ends a failed upload as SUCCEEDED (so the uploads queued behind it still run) and reports
+ // the failure in its output data; FAILED stays for a worker that crashed.
+ val failed = workInfo.state == WorkInfo.State.FAILED ||
+ workInfo.outputData.getBoolean(UploadAndShareFilesWorker.KEY_UPLOAD_FAILED, false)
+ if (failed) {
viewModelScope.launch { chatRepository.markUploadPlaceholderFailed(referenceId) }
}
if (workInfo.state.isFinished) {
diff --git a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt
index 0fdda7cb24..cdd9282cbc 100644
--- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt
+++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt
@@ -12,6 +12,7 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.app.NotificationManager
import android.content.Context
+import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Build
import android.os.SystemClock
@@ -22,12 +23,14 @@ import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
+import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkRequest
import androidx.work.Worker
import androidx.work.WorkerParameters
+import androidx.work.workDataOf
import autodagger.AutoInjector
import com.nextcloud.talk.R
import com.nextcloud.talk.api.NcApi
@@ -41,6 +44,12 @@ import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachm
import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ProbeConversationAttachmentRequestDto
import com.nextcloud.talk.upload.chunked.ChunkedFileUploader
import com.nextcloud.talk.upload.chunked.OnDataTransferProgressListener
+import com.nextcloud.talk.upload.UploadNotification
+import com.nextcloud.talk.upload.UploadProgressThrottle
+import com.nextcloud.talk.upload.UploadRetryPolicy
+import com.nextcloud.talk.upload.Unavailable
+import com.nextcloud.talk.upload.UploadWorkspace
+import com.nextcloud.talk.upload.PreparedUpload
import com.nextcloud.talk.upload.normal.FileUploader
import com.nextcloud.talk.users.UserManager
import com.nextcloud.talk.utils.ApiUtils
@@ -59,17 +68,17 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.runBlocking
+import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import java.io.File
import java.io.IOException
-import java.util.Collections
import java.util.UUID
-import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import javax.inject.Inject
+@Suppress("TooManyFunctions")
@AutoInjector(NextcloudTalkApplication::class)
class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerParameters) :
Worker(context, workerParameters),
@@ -107,20 +116,27 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
lateinit var currentUser: User
private var isChunkedUploading = false
private var file: File? = null
+
+ @Volatile
private var chunkedFileUploader: ChunkedFileUploader? = null
+
private var referenceId: String? = null
private var internalConversationId: String? = null
private var uploadDisposable: Disposable? = null
private var uploadLatch: CountDownLatch? = null
+ private lateinit var workspace: UploadWorkspace
+ private val progressThrottle = UploadProgressThrottle()
+ private var isForeground = false
+ private var keepWorkspace = false
+ private val notificationId = id.hashCode()
/**
- * WorkManager's own `isStopped`/`onStopped()` only becomes true/fires after
- * cancelUniqueWork() round-trips through WorkManager's internal executor and Room DB - for a
- * small/compressed image the whole upload+share can finish faster than that round-trip, so
- * isStopped alone arrives too late. [cancelledReferenceIds] is set synchronously by the UI
- * before cancelUniqueWork() is even called, so it's visible to doWork() immediately.
+ * The user cancelled this upload. The cancel is a flag file in the workspace, not a cancellation in
+ * WorkManager: a cancelled work left in the queue of the conversation makes the APPEND_OR_REPLACE of the next
+ * upload delete the other, still living works of that queue. The worker looks at the flag at the start, between
+ * its stages and in the progress callback, and then aborts itself, see [cancelledResult].
*/
- private fun isCancelled(): Boolean = referenceId?.let { cancelledReferenceIds.contains(it) } == true
+ private fun isCancelled(): Boolean = ::workspace.isInitialized && workspace.isCancelled()
override fun doWork(): Result {
// Invariant: dependencies that are already set (a test worker factory) are not overwritten.
@@ -128,10 +144,20 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
}
+ referenceId = inputData.getString(KEY_REFERENCE_ID)
+ workspace = UploadWorkspace(workspaceDir(context, id))
+ // WorkManager does not interrupt the thread of a stopped worker, so an old run can still be busy.
+ if (!workspace.tryLock()) {
+ Log.w(TAG, "Another run of this upload is still active, trying again later")
+ return Result.retry()
+ }
try {
return doUpload()
} finally {
- referenceId?.let { cancelledReferenceIds.remove(it) }
+ if (!keepWorkspace) {
+ workspace.delete()
+ }
+ workspace.unlock()
}
}
@@ -144,14 +170,18 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
private fun doUpload(): Result {
return try {
// Read first, so a failure below can still mark the placeholder message as failed.
- referenceId = inputData.getString(KEY_REFERENCE_ID)
internalConversationId = inputData.getString(KEY_INTERNAL_CONVERSATION_ID)
+ // The user comes first: a cancelled upload that waited for a retry has to remove its parts from the
+ // server, and that needs the account.
val userId = inputData.getLong(KEY_INTERNAL_USER_ID, 0L)
currentUser = runBlocking { userManager.getUserWithId(userId) } ?: run {
// E.g. the account was removed while the upload waited for a retry.
Log.e(TAG, "No user found for id $userId")
- return failUpload()
+ return if (isCancelled()) cancelledResult() else failUpload()
+ }
+ if (isCancelled()) {
+ return cancelledResult()
}
val sourceFile = inputData.getString(DEVICE_SOURCE_FILE)
roomToken = inputData.getString(ROOM_TOKEN)!!
@@ -162,78 +192,336 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
require(sourceFile.isNotEmpty())
checkNotNull(roomToken)
- var sourceFileUri = sourceFile.toUri()
+ val sourceFileUri = sourceFile.toUri()
fileName = FileUtils.getFileName(sourceFileUri, context)
- file = FileUtils.getFileFromUri(context, sourceFileUri)
+ deleteFinishedWorkspaces()
- if (inputData.getBoolean(COMPRESS_IMAGES, false)) {
- sourceFileUri = compressMediaIfPossible(sourceFileUri)
+ // The stages come before the preparation: after the share nothing is needed any more, after the
+ // upload only the path, and the prepared file may be gone (the system cleaned the cache).
+ if (workspace.isShared()) {
+ return successResult()
+ }
+ if (needsForeground(sourceFileUri)) {
+ startForeground()
}
- val remotePath = getRemotePath(currentUser)
+ val prepared = if (workspace.uploadedPath() != null) {
+ workspace.prepared()
+ } else {
+ prepareOnce(sourceFileUri) ?: return failUpload()
+ }
+ file = prepared?.file
+ fileName = prepared?.fileName ?: workspace.uploadedName() ?: fileName
+ if (isStopped || isCancelled()) {
+ return stoppedResult()
+ }
val useConversationSubfolders = CapabilitiesUtil.hasConversationSubfoldersForAttachments(
currentUser.capabilities!!.spreedCapability!!
)
val allowUpdate = inputData.getBoolean(ALLOW_UPDATE, false)
file?.let { isChunkedUploading = it.length() > CHUNK_UPLOAD_THRESHOLD_SIZE }
- val uploadSuccess: Boolean = uploadFile(
- sourceFileUri = sourceFileUri,
- metaData = metaData,
- remotePath = remotePath,
- useConversationSubfolders = useConversationSubfolders,
- allowUpdate = allowUpdate
- )
+ val uploadUri = prepared?.let { Uri.fromFile(it.file) }
- if (uploadSuccess && (isStopped || isCancelled())) {
- // Cancelled right as the upload finished - don't share a cancelled upload.
- return Result.failure()
+ val shared = if (useConversationSubfolders) {
+ // The conversation subfolder path shares as part of its last step, postConversationAttachment.
+ uploadUsingConversationSubfolders(uploadUri, metaData, allowUpdate)
+ } else {
+ uploadAndShare(uploadUri, metaData)
}
- if (uploadSuccess) {
- // useConversationSubfolders already shares as part of uploadFile() via
- // postConversationAttachment, so only share explicitly for the plain upload path.
- val shareSuccess = useConversationSubfolders || shareFile(remotePath, metaData)
- if (shareSuccess) {
- updatePlaceholderStatus(SendStatus.SENT_PENDING_ACK)
- _uploadCompletedFlow.tryEmit(roomToken)
- return Result.success()
- }
- Log.e(TAG, "Share operation failed after upload")
- return failUpload()
+ if (shared) {
+ workspace.markShared()
+ return successResult()
} else if (isStopped || isCancelled()) {
- // since work is cancelled the result would be ignored anyways
- return Result.failure()
+ return stoppedResult()
}
Log.e(TAG, "Something went wrong when trying to upload file")
failUpload()
- } catch (e: IOException) {
- // Transient network failures (connection reset, timeout, dropped Wi-Fi, ...) shouldn't
- // require the user to manually resend - retry a few times with backoff instead, and only
- // give up once we've exhausted the allowed attempts.
- Log.w(
- TAG,
- "Network error while uploading file (attempt ${runAttemptCount + 1}/$MAX_UPLOAD_ATTEMPTS)",
- e
- )
- if (isStopped || isCancelled()) {
- Result.failure()
- } else if (runAttemptCount < MAX_UPLOAD_ATTEMPTS - 1) {
+ } catch (e: Exception) {
+ handleUploadError(e)
+ }
+ }
+
+ /**
+ * Upload and share are two stages. What is done is kept in the workspace, so a run after a retry or after a
+ * stop does not upload the file or share it a second time.
+ */
+ private fun uploadAndShare(uploadUri: Uri?, metaData: String?): Boolean {
+ val remotePath = workspace.uploadedPath() ?: getRemotePath(currentUser)
+ val uploaded = workspace.uploadedPath() != null || uploadFile(checkNotNull(uploadUri), remotePath)
+ if (uploaded) {
+ workspace.markUploaded(remotePath, fileName)
+ }
+ // Not shared when the user cancelled right as the upload finished.
+ return uploaded && !isCancelled() && shareFile(remotePath, metaData)
+ }
+
+ private fun successResult(): Result {
+ // A stopped work is scheduled again whatever it returns; the stages in the workspace make that run short.
+ keepWorkspace = isStopped
+ updatePlaceholderStatus(SendStatus.SENT_PENDING_ACK)
+ _uploadCompletedFlow.tryEmit(roomToken)
+ return Result.success()
+ }
+
+ private fun handleUploadError(e: Exception): Result {
+ if (isStopped || isCancelled()) {
+ return stoppedResult()
+ }
+ val kind = UploadRetryPolicy.classify(e)
+ val serverErrors = if (kind == UploadRetryPolicy.FailureKind.SERVER) {
+ registerServerError()
+ } else {
+ workspace.serverErrors()
+ }
+ val unavailable = if (kind == UploadRetryPolicy.FailureKind.UNAVAILABLE) {
+ registerUnavailable()
+ } else {
+ Unavailable(System.currentTimeMillis(), 0)
+ }
+ val unavailableFor = System.currentTimeMillis() - unavailable.sinceMillis
+ return when (UploadRetryPolicy.decide(kind, serverErrors, unavailableFor, unavailable.answers)) {
+ UploadRetryPolicy.Decision.RETRY -> {
+ Log.w(
+ TAG,
+ "Upload interrupted ($kind, server errors: $serverErrors, unavailable for $unavailableFor ms, " +
+ "${unavailable.answers} answers), will resume",
+ e
+ )
+ keepWorkspace = true
Result.retry()
- } else {
+ }
+
+ UploadRetryPolicy.Decision.FAIL -> {
+ Log.e(TAG, "Something went wrong when trying to upload file (unavailable for $unavailableFor ms)", e)
failUpload()
}
+ }
+ }
+
+ // A workspace that cannot be written must not end the work as failed (that fails the uploads queued behind it):
+ // decide with what is known, the upload is tried again. A write error that never goes away means a retry
+ // without a limit: this is on purpose, it is better than the FAILED cascade over the queue.
+ private fun registerServerError(): Int =
+ try {
+ workspace.registerServerError()
+ } catch (e: IOException) {
+ Log.w(TAG, "Could not store the server error", e)
+ workspace.serverErrors() + 1
+ }
+
+ private fun registerUnavailable(): Unavailable =
+ try {
+ workspace.registerUnavailable(System.currentTimeMillis())
+ } catch (e: IOException) {
+ Log.w(TAG, "Could not store the unavailable answer", e)
+ Unavailable(System.currentTimeMillis(), 0)
+ }
+
+ /**
+ * A stop by the system keeps the parts on the server and the prepared file, so the next run resumes.
+ * The result of a work that WorkManager stopped is ignored; WorkManager schedules it again by itself.
+ */
+ private fun stoppedResult(): Result =
+ if (isCancelled()) {
+ cancelledResult()
+ } else {
+ keepWorkspace = true
+ Result.retry()
+ }
+
+ /**
+ * The user cancelled the upload. Removes the parts from the server (here, in the worker thread, not in
+ * onStopped, which runs inside a WorkManager cancellation handler) and the placeholder message. Returns
+ * success, not failure or cancelled: a failed or cancelled work in the queue of the conversation makes
+ * WorkManager delete the other uploads of that queue when the next file is sent. For the same reason nothing
+ * is thrown from here: an exception ends the work as failed, and WorkManager fails the uploads queued behind it.
+ *
+ * The result of a work that WorkManager stopped is ignored and the work runs again. That run has to find the
+ * cancel flag, so the workspace stays; it is removed by the run that ends without a stop.
+ */
+ @Suppress("Detekt.TooGenericExceptionCaught")
+ private fun cancelledResult(): Result {
+ keepWorkspace = isStopped
+ abortRemote(deleteUploadedFile = true)
+ try {
+ deletePlaceholder()
} catch (e: Exception) {
- Log.e(TAG, "Something went wrong when trying to upload file", e)
- failUpload()
+ // The chat removes the placeholder of a cancelled upload itself; a failed write must not end the work.
+ Log.w(TAG, "Could not remove the placeholder of the cancelled upload", e)
}
+ return Result.success()
}
- // Result.success(), not failure(): the failure is recorded on the placeholder row, and a failed work
- // request would cascade to every upload already queued behind it in the conversation.
+ private fun newChunkedUploader() =
+ ChunkedFileUploader(
+ okHttpClient,
+ currentUser,
+ this,
+ ncApiCoroutines,
+ isRestarted = workspace::isRestarted,
+ markRestarted = workspace::markRestarted
+ )
+
+ /**
+ * Publishes [uploader] before the last stop check: a stop that comes earlier is seen here, a later one reaches
+ * the uploader through [onStopped].
+ */
+ private fun startChunkedUpload(uploader: ChunkedFileUploader, mimeType: MediaType?, path: String): Boolean {
+ chunkedFileUploader = uploader
+ if (isStopped || isCancelled()) {
+ return false
+ }
+ return uploader.upload(file!!, mimeType, path)
+ }
+
+ /**
+ * Removes what the upload left on the server: the parts of a chunked upload and, when [deleteUploadedFile] is
+ * set and the file was not shared yet, the assembled file. Never throws.
+ */
+ @Suppress("Detekt.TooGenericExceptionCaught")
+ private fun abortRemote(deleteUploadedFile: Boolean = false) {
+ try {
+ if (!::currentUser.isInitialized) {
+ return
+ }
+ val uploader = chunkedFileUploader ?: newChunkedUploader()
+ // The parts are keyed by the prepared file: this finds them also when the upload of this run did not
+ // start yet or ran in an earlier process.
+ val prepared = workspace.prepared()?.file
+ if (prepared != null && prepared.length() > CHUNK_UPLOAD_THRESHOLD_SIZE) {
+ uploader.abortUpload(prepared) {}
+ } else if (chunkedFileUploader != null) {
+ uploader.abortUpload {}
+ }
+ val uploadedPath = workspace.uploadedPath()
+ if (deleteUploadedFile && uploadedPath != null && !workspace.isShared()) {
+ uploader.deleteUploadedFile(uploadedPath)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not remove the upload from the server", e)
+ }
+ }
+
+ private fun deletePlaceholder() {
+ val refId = referenceId?.takeIf { it.isNotEmpty() } ?: return
+ val convId = internalConversationId?.takeIf { it.isNotEmpty() } ?: return
+ chatDao.deleteTempChatMessageIfPending(convId, refId)
+ }
+
+ /**
+ * The notification of a running upload is for files that are uploaded in chunks, which take long. A small file
+ * is one request: it does not need a foreground service. Unknown size counts as big. Nothing is needed when the
+ * file is uploaded already: one request is left. A later run has the prepared copy and asks it for the size,
+ * not the provider of the source (an IPC call, and the copy is what is uploaded).
+ */
+ private fun needsForeground(sourceFileUri: Uri): Boolean {
+ if (workspace.uploadedPath() != null) {
+ return false
+ }
+ val size = workspace.prepared()?.file?.length()
+ ?: if (sourceFileUri.scheme == "file") {
+ sourceFileUri.path?.let { File(it).length() }
+ } else {
+ FileUtils.querySize(context, sourceFileUri)
+ }
+ return size == null || size > CHUNK_UPLOAD_THRESHOLD_SIZE
+ }
+
+ @SuppressLint("InlinedApi")
+ @Suppress("Detekt.TooGenericExceptionCaught")
+ private fun startForeground() {
+ try {
+ setForegroundAsync(
+ ForegroundInfo(
+ notificationId,
+ progressNotification(0),
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+ )
+ ).get()
+ isForeground = true
+ } catch (e: Exception) {
+ // E.g. Android 12+ does not allow starting a foreground service from the background.
+ Log.w(TAG, "Could not start the foreground service, uploading as a regular job", e)
+ }
+ }
+
+ private fun progressNotification(percent: Int) =
+ UploadNotification.build(
+ context,
+ notificationId,
+ fileName,
+ percent,
+ id.toString(),
+ referenceId,
+ internalConversationId
+ )
+
+ private fun prepareFile(sourceFileUri: Uri, dir: File): PreparedUpload? {
+ val original = FileUtils.getFileFromUri(context, sourceFileUri, dir)?.let { FileUtils.ensureInDir(it, dir) }
+ ?: return null
+ throwIfStopped()
+ val compressed = if (inputData.getBoolean(COMPRESS_IMAGES, false)) {
+ compressMediaIfPossible(sourceFileUri, original, dir)
+ } else {
+ null
+ }
+ throwIfStopped()
+ return compressed ?: PreparedUpload(original, fileName)
+ }
+
+ /** Keeps a stopped run from storing its half-finished file as the prepared one (see [PreparationStopped]). */
+ private fun throwIfStopped() {
+ if (isStopped || isCancelled()) {
+ throw PreparationStopped()
+ }
+ }
+
+ private class PreparationStopped : RuntimeException("Stopped while preparing the file")
+
+ // A work that WorkManager does not know, or knows as finished, cannot resume, so its workspace is garbage.
+ @Suppress("Detekt.TooGenericExceptionCaught")
+ private fun deleteFinishedWorkspaces() {
+ UploadWorkspace.deleteFinished(File(context.noBackupFilesDir, WORKSPACE_DIR)) { name ->
+ try {
+ val info = WorkManager.getInstance(context).getWorkInfoById(UUID.fromString(name)).get()
+ info != null && !info.state.isFinished
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not look up work $name, keeping its files", e)
+ true
+ }
+ }
+ }
+
+ /**
+ * Returns null when the file cannot be prepared and waiting does not help (full disk, unreadable source). Any
+ * other IOException, e.g. a lost network while a cloud provider streams the file, is thrown to the retry policy.
+ */
+ private fun prepareOnce(sourceFileUri: Uri): PreparedUpload? =
+ try {
+ workspace.prepareOnce { dir -> prepareFile(sourceFileUri, dir) }
+ } catch (e: IOException) {
+ if (UploadRetryPolicy.classifyPreparation(e) != UploadRetryPolicy.FailureKind.OTHER) {
+ throw e
+ }
+ Log.e(TAG, "Could not prepare the file", e)
+ null
+ }
+
+ /**
+ * Ends the work as success, not failure: the failure is already shown (notification, FAILED placeholder), and
+ * a failed work would make WorkManager fail the uploads queued behind it in the conversation without running
+ * them, leaving their placeholders "sending" for ever.
+ *
+ * A file in the draft folder belongs to this upload alone and nobody posts it any more, so it is removed (the
+ * server moves it out of the draft folder when the message is posted; a file already moved is gone, which is
+ * fine). A file in the attachment folder of Talk is the user's file and stays.
+ */
@Suppress("Detekt.TooGenericExceptionCaught")
private fun failUpload(): Result {
+ abortRemote(deleteUploadedFile = workspace.isDraftUploaded())
updatePlaceholderStatus(SendStatus.FAILED)
try {
showFailedToUploadNotification()
@@ -241,50 +529,35 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
// The failure is recorded already; an exception here must not end the work as failed.
Log.e(TAG, "Could not show the upload failed notification", e)
}
- return Result.success()
+ return Result.success(workDataOf(KEY_UPLOAD_FAILED to true))
}
/**
- * Replaces [file] and [fileName] with a compressed copy if [sourceFileUri] points to a
- * compressible image or video, returning the [Uri] that should be uploaded.
+ * Returns a compressed copy of [originalFile] inside [dir] if [sourceFileUri] points to a compressible image or
+ * video, or null when there is nothing to compress.
*/
- @Suppress("ReturnCount")
- private fun compressMediaIfPossible(sourceFileUri: Uri): Uri {
- val originalFile = file ?: return sourceFileUri
+ private fun compressMediaIfPossible(sourceFileUri: Uri, originalFile: File, dir: File): PreparedUpload? {
val mimeType = FileUtils.resolveMimeType(context, sourceFileUri)
val compressedFile = when {
- ImageCompressor.isCompressible(mimeType) -> ImageCompressor.compress(context, originalFile)
- VideoCompressor.isCompressible(mimeType) -> VideoCompressor.compress(context, originalFile)
+ ImageCompressor.isCompressible(mimeType) -> ImageCompressor.compress(context, originalFile, dir)
+ VideoCompressor.isCompressible(mimeType) -> VideoCompressor.compress(context, originalFile, dir)
else -> null
- } ?: return sourceFileUri
+ } ?: return null
- file = compressedFile
- fileName = compressedFile.name
- return Uri.fromFile(compressedFile)
+ if (originalFile.parentFile == dir) {
+ originalFile.delete()
+ }
+ return PreparedUpload(compressedFile, compressedFile.name)
}
- private fun uploadFile(
- sourceFileUri: Uri,
- metaData: String?,
- remotePath: String,
- useConversationSubfolders: Boolean,
- allowUpdate: Boolean
- ): Boolean =
+ private fun uploadFile(sourceFileUri: Uri, remotePath: String): Boolean =
if (file == null) {
false
- } else if (useConversationSubfolders) {
- uploadUsingConversationSubfolders(sourceFileUri, metaData, allowUpdate)
} else if (isChunkedUploading) {
Log.d(TAG, "starting chunked upload because size is " + file!!.length())
- val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull()
- chunkedFileUploader = ChunkedFileUploader(
- okHttpClient,
- currentUser,
- this,
- ncApiCoroutines
- )
- chunkedFileUploader!!.upload(file!!, mimeType, remotePath)
+ val mimeType = FileUtils.resolveMimeType(context, sourceFileUri)?.toMediaTypeOrNull()
+ startChunkedUpload(newChunkedUploader(), mimeType, remotePath)
} else {
Log.d(TAG, "starting normal upload (not chunked) of $fileName")
val observable = FileUploader(
@@ -329,7 +602,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
}
private fun uploadUsingConversationSubfolders(
- sourceFileUri: Uri,
+ sourceFileUri: Uri?,
metaData: String?,
allowUpdate: Boolean
): Boolean =
@@ -338,7 +611,6 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
currentUser.username,
currentUser.token
) ?: return@runBlocking false
- val uploadId = UUID.randomUUID().toString()
val fileNames = ProbeConversationAttachmentRequestDto().apply {
fileNames = listOf(fileName)
this.allowUpdate = allowUpdate
@@ -356,23 +628,16 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
return@runBlocking false
}
val predictedName = resolveFinalFileName(fileName, probeResponse.ocs?.data!!)
- val tempRemotePath = "/$draftFolderPath/$uploadId-$fileName"
-
- val uploadSuccess = if (isChunkedUploading) {
- val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull()
- chunkedFileUploader = ChunkedFileUploader(
- okHttpClient,
- currentUser,
- this@UploadAndShareFilesWorker,
- ncApiCoroutines
- )
- chunkedFileUploader!!.upload(file!!, mimeType, tempRemotePath)
- } else {
- FileUploader(okHttpClient, context, currentUser, roomToken, ncApi, file!!, ncApiCoroutines)
- .uploadToConversationSubfolder(sourceFileUri, tempRemotePath)
- }
+ // The same id on every run, so a run after the upload does not upload to another path.
+ val tempRemotePath = workspace.uploadedPath() ?: "/$draftFolderPath/${workspace.uploadId()}-$fileName"
- if (!uploadSuccess || isStopped || isCancelled()) {
+ if (workspace.uploadedPath() == null) {
+ if (!uploadToDraftFolder(checkNotNull(sourceFileUri), tempRemotePath)) {
+ return@runBlocking false
+ }
+ workspace.markUploaded(tempRemotePath, fileName, draft = true)
+ }
+ if (isCancelled()) {
return@runBlocking false
}
@@ -384,15 +649,22 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
this.allowUpdate = allowUpdate
}
- runCatching {
- ncApiCoroutines.postConversationAttachment(
- credentials,
- ApiUtils.getUrlForChatAttachment(ApiUtils.API_V1, currentUser.baseUrl, roomToken),
- params
- )
- }
- .onFailure { Log.e(TAG, "Failed to finalize uploaded attachment", it) }
- .isSuccess
+ // Errors are not caught: an IOException of a lost network must reach the retry policy.
+ ncApiCoroutines.postConversationAttachment(
+ credentials,
+ ApiUtils.getUrlForChatAttachment(ApiUtils.API_V1, currentUser.baseUrl, roomToken),
+ params
+ )
+ true
+ }
+
+ private suspend fun uploadToDraftFolder(sourceFileUri: Uri, tempRemotePath: String): Boolean =
+ if (isChunkedUploading) {
+ val mimeType = FileUtils.resolveMimeType(context, sourceFileUri)?.toMediaTypeOrNull()
+ startChunkedUpload(newChunkedUploader(), mimeType, tempRemotePath)
+ } else {
+ FileUploader(okHttpClient, context, currentUser, roomToken, ncApi, file!!, ncApiCoroutines)
+ .uploadToConversationSubfolder(sourceFileUri, tempRemotePath)
}
@SuppressLint("CheckResult")
@@ -425,6 +697,24 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
override fun onTransferProgress(percentage: Int) {
setProgressAsync(Data.Builder().putInt(PROGRESS_KEY, percentage).build())
+ if (progressThrottle.shouldUpdate(percentage)) {
+ recordProgress(percentage)
+ if (isCancelled()) {
+ // Interrupts the upload; doWork then sees the flag and aborts.
+ chunkedFileUploader?.stop()
+ } else if (isForeground && !isStopped) {
+ notificationManager.notify(notificationId, progressNotification(percentage))
+ }
+ }
+ }
+
+ // Called from the upload thread: a workspace that cannot be written must not break the upload.
+ private fun recordProgress(percentage: Int) {
+ try {
+ workspace.recordProgress(percentage)
+ } catch (e: IOException) {
+ Log.w(TAG, "Could not store the progress", e)
+ }
}
private fun updatePlaceholderStatus(status: SendStatus) {
@@ -433,12 +723,20 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
chatDao.updateTempMessageSendStatus(convId, refId, status)
}
+ /**
+ * WorkManager runs this inside a coroutine cancellation handler: an exception thrown here crashes the process,
+ * and a network call blocks WorkManager's own thread. So it only interrupts the upload and keeps the parts on
+ * the server, so the next run resumes. The user's cancel does not come here, see [isCancelled].
+ */
+ @Suppress("Detekt.TooGenericExceptionCaught")
override fun onStopped() {
- if (file != null && isChunkedUploading) {
- chunkedFileUploader?.abortUpload {}
+ try {
+ chunkedFileUploader?.stop()
+ uploadDisposable?.dispose()
+ uploadLatch?.countDown()
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to interrupt the upload", e)
}
- uploadDisposable?.dispose()
- uploadLatch?.countDown()
super.onStopped()
}
@@ -482,20 +780,22 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
const val KEY_REFERENCE_ID = "REFERENCE_ID"
const val KEY_INTERNAL_CONVERSATION_ID = "INTERNAL_CONVERSATION_ID"
const val PROGRESS_KEY = "UPLOAD_PROGRESS"
+
+ /**
+ * Output data: true when the upload failed for good. A failed upload ends as SUCCEEDED, not FAILED, because
+ * WorkManager fails the works chained behind a failed one without running them. An observer of the work
+ * reads this flag instead. A cancelled upload does not set it. Read it from the output data only: the queue
+ * (APPEND_OR_REPLACE, OverwritingInputMerger) copies the output into the input data of the next work, and
+ * this worker does not read the flag in its input.
+ */
+ const val KEY_UPLOAD_FAILED = "UPLOAD_FAILED"
private const val COMPRESS_IMAGES = "COMPRESS_IMAGES"
private const val ALLOW_UPDATE = "ALLOW_UPDATE"
private const val CHUNK_UPLOAD_THRESHOLD_SIZE: Long = 1024 * 1024
- // Total attempts allowed for a single upload (1 initial run + retries) before giving up on a
- // transient network failure and marking the placeholder FAILED.
- private const val MAX_UPLOAD_ATTEMPTS = 4
+ private const val WORKSPACE_DIR = "uploads"
const val REQUEST_PERMISSION = 3123
- // referenceIds the user cancelled - set synchronously here, before cancelUniqueWork() is
- // even called, so doWork() can see it immediately instead of waiting for isStopped, which
- // only becomes true once WorkManager's own async cancellation dispatch completes.
- private val cancelledReferenceIds: MutableSet = Collections.newSetFromMap(ConcurrentHashMap())
-
private val _uploadCompletedFlow: MutableSharedFlow = MutableSharedFlow(
replay = 1,
extraBufferCapacity = 1
@@ -566,13 +866,18 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
.build()
val uploadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(UploadAndShareFilesWorker::class.java)
.setInputData(data)
+ .apply {
+ if (referenceId.isNotEmpty()) {
+ addTag(referenceTag(referenceId))
+ }
+ }
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(
- BackoffPolicy.EXPONENTIAL,
+ BackoffPolicy.LINEAR,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
@@ -596,12 +901,37 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
private fun uploadQueueName(internalConversationId: String) = "upload_queue_$internalConversationId"
- // Cancellation is by the WorkRequest's own id (not enqueueUniqueWork's name) since that name
- // is now shared by every file queued in the same conversation - cancelling by name would
- // cancel the whole queue instead of just this one upload.
- fun cancelUpload(referenceId: String, workerId: UUID) {
- cancelledReferenceIds.add(referenceId)
- WorkManager.getInstance().cancelWorkById(workerId)
+ private fun referenceTag(referenceId: String) = "upload_ref:$referenceId"
+
+ private fun workspaceDir(context: Context, workId: UUID) =
+ File(File(context.noBackupFilesDir, WORKSPACE_DIR), workId.toString())
+
+ /**
+ * Asks an upload to cancel itself. Only a flag in the workspace of the work is set: whether the work waits
+ * for the network or runs right now, it sees the flag and aborts (see [isCancelled]). Nothing is cancelled
+ * in WorkManager. The id is only known in memory of the chat, so after a restart the work is found by its
+ * reference id tag. Blocks on WorkManager: call it off the main thread.
+ */
+ @Suppress("Detekt.TooGenericExceptionCaught")
+ fun cancelUpload(referenceId: String, workId: UUID?) {
+ // Called from the chat and from the notification right when an upload may be ending, so a failed
+ // lookup or a workspace deleted meanwhile must not crash the app.
+ try {
+ val context = NextcloudTalkApplication.sharedApplication!!.applicationContext
+ val workManager = WorkManager.getInstance(context)
+ val infos = if (workId != null) {
+ listOfNotNull(workManager.getWorkInfoById(workId).get())
+ } else if (referenceId.isNotEmpty()) {
+ workManager.getWorkInfosByTag(referenceTag(referenceId)).get()
+ } else {
+ emptyList()
+ }
+ infos.filter { !it.state.isFinished }.forEach { info ->
+ UploadWorkspace(workspaceDir(context, info.id)).markCancelled()
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to cancel upload", e)
+ }
}
}
}
diff --git a/app/src/main/java/com/nextcloud/talk/receivers/UploadCancelReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/UploadCancelReceiver.kt
new file mode 100644
index 0000000000..f329494225
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/receivers/UploadCancelReceiver.kt
@@ -0,0 +1,63 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.receivers
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import autodagger.AutoInjector
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.data.database.dao.ChatMessagesDao
+import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
+import java.util.UUID
+import javax.inject.Inject
+import kotlin.concurrent.thread
+
+/**
+ * Cancels an upload from its notification the same way the chat does: the worker aborts and the placeholder
+ * message of the upload is removed.
+ */
+@AutoInjector(NextcloudTalkApplication::class)
+class UploadCancelReceiver : BroadcastReceiver() {
+
+ @Inject
+ lateinit var chatDao: ChatMessagesDao
+
+ @Suppress("TooGenericExceptionCaught")
+ override fun onReceive(context: Context, intent: Intent) {
+ val workId = intent.getStringExtra(KEY_WORK_ID)?.let { runCatching { UUID.fromString(it) }.getOrNull() }
+ val referenceId = intent.getStringExtra(KEY_REFERENCE_ID).orEmpty()
+ val internalConversationId = intent.getStringExtra(KEY_INTERNAL_CONVERSATION_ID).orEmpty()
+ if (workId == null) {
+ Log.w(TAG, "Cancel request without a valid work id")
+ return
+ }
+
+ NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
+ val pendingResult = goAsync()
+ thread {
+ try {
+ UploadAndShareFilesWorker.cancelUpload(referenceId, workId)
+ if (referenceId.isNotEmpty() && internalConversationId.isNotEmpty()) {
+ chatDao.deleteTempChatMessageIfPending(internalConversationId, referenceId)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to cancel upload", e)
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+
+ companion object {
+ private val TAG = UploadCancelReceiver::class.simpleName
+ const val KEY_WORK_ID = "KEY_UPLOAD_WORK_ID"
+ const val KEY_REFERENCE_ID = "KEY_UPLOAD_REFERENCE_ID"
+ const val KEY_INTERNAL_CONVERSATION_ID = "KEY_UPLOAD_INTERNAL_CONVERSATION_ID"
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/upload/UploadNotification.kt b/app/src/main/java/com/nextcloud/talk/upload/UploadNotification.kt
new file mode 100644
index 0000000000..81fcf86e5f
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/upload/UploadNotification.kt
@@ -0,0 +1,60 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.upload
+
+import android.app.Notification
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import com.nextcloud.talk.R
+import com.nextcloud.talk.receivers.UploadCancelReceiver
+import com.nextcloud.talk.utils.NotificationUtils
+
+/** The ongoing notification of the foreground service that keeps an upload running. */
+object UploadNotification {
+
+ private const val MAX_PERCENT = 100
+
+ @Suppress("LongParameterList")
+ fun build(
+ context: Context,
+ notificationId: Int,
+ fileName: String,
+ percent: Int,
+ workId: String,
+ referenceId: String?,
+ internalConversationId: String?
+ ): Notification {
+ val cancelIntent = Intent(context, UploadCancelReceiver::class.java)
+ .putExtra(UploadCancelReceiver.KEY_WORK_ID, workId)
+ .putExtra(UploadCancelReceiver.KEY_REFERENCE_ID, referenceId)
+ .putExtra(UploadCancelReceiver.KEY_INTERNAL_CONVERSATION_ID, internalConversationId)
+ val cancelPendingIntent = PendingIntent.getBroadcast(
+ context,
+ notificationId,
+ cancelIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ return NotificationCompat.Builder(
+ context,
+ NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_UPLOADS.name
+ )
+ .setContentTitle(context.getString(R.string.nc_upload_in_progess))
+ .setContentText(fileName)
+ .setSmallIcon(R.drawable.upload_white)
+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
+ .setProgress(MAX_PERCENT, percent, percent <= 0)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setSilent(true)
+ .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
+ .addAction(R.drawable.ic_baseline_close_24, context.getString(R.string.nc_cancel), cancelPendingIntent)
+ .build()
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/upload/UploadProgressThrottle.kt b/app/src/main/java/com/nextcloud/talk/upload/UploadProgressThrottle.kt
new file mode 100644
index 0000000000..29226b2833
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/upload/UploadProgressThrottle.kt
@@ -0,0 +1,32 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.upload
+
+/** Lets a progress update through only when the value changed and [minIntervalMs] passed since the last one. */
+class UploadProgressThrottle(
+ private val minIntervalMs: Long = DEFAULT_INTERVAL_MS,
+ private val clock: () -> Long = System::currentTimeMillis
+) {
+ private var lastPercent = -1
+ private var lastUpdateMs = 0L
+
+ @Synchronized
+ fun shouldUpdate(percent: Int): Boolean {
+ val now = clock()
+ val due = lastPercent < 0 || now - lastUpdateMs >= minIntervalMs
+ if (percent == lastPercent || !due) {
+ return false
+ }
+ lastPercent = percent
+ lastUpdateMs = now
+ return true
+ }
+
+ companion object {
+ private const val DEFAULT_INTERVAL_MS = 1000L
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt b/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt
new file mode 100644
index 0000000000..ece4d2fec9
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt
@@ -0,0 +1,143 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.upload
+
+import android.system.ErrnoException
+import android.system.OsConstants
+import at.bitfire.dav4jvm.exception.HttpException as DavHttpException
+import retrofit2.HttpException as RetrofitHttpException
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.net.UnknownServiceException
+import java.security.cert.CertPathValidatorException
+import java.security.cert.CertificateException
+import javax.net.ssl.SSLException
+import javax.net.ssl.SSLHandshakeException
+import javax.net.ssl.SSLPeerUnverifiedException
+
+/**
+ * Decides what an upload does after an error or after WorkManager stopped it.
+ *
+ * Network problems and stops by the system never use up attempts: the upload resumes once the network is back.
+ * A server that answers "unavailable" is waited for up to [MAX_UNAVAILABLE_MILLIS]. Other errors answered by the
+ * server count against [MAX_SERVER_ERRORS].
+ */
+object UploadRetryPolicy {
+
+ /** Server errors one upload may hit before it is marked as failed. */
+ const val MAX_SERVER_ERRORS = 4
+
+ /** How long a server may keep answering "unavailable" without taking data before the upload fails: 24 hours. */
+ const val MAX_UNAVAILABLE_MILLIS = 24L * 60 * 60 * 1000
+
+ /**
+ * "Unavailable" answers the server must have given, besides the 24 hours, before the upload fails. Time without
+ * a network passes too; a few answers after a long wait do not show a server that keeps refusing.
+ */
+ const val MIN_UNAVAILABLE_ANSWERS = 10
+
+ private const val MAX_CAUSE_DEPTH = 8
+ private const val HTTP_REQUEST_TIMEOUT = 408
+ private const val HTTP_TOO_MANY_REQUESTS = 429
+ private const val HTTP_SERVER_ERROR = 500
+ private const val HTTP_NOT_IMPLEMENTED = 501
+ private const val HTTP_VERSION_NOT_SUPPORTED = 505
+ private const val HTTP_INSUFFICIENT_STORAGE = 507
+
+ // A server that cannot do the request or has a full quota does not change within the few attempts.
+ private val PERMANENT_SERVER_ERRORS =
+ setOf(HTTP_NOT_IMPLEMENTED, HTTP_VERSION_NOT_SUPPORTED, HTTP_INSUFFICIENT_STORAGE)
+
+ /**
+ * NETWORK: no connection, retried without a limit.
+ * UNAVAILABLE: the server answered that it is overloaded or restarting (5xx except 501, 505 and 507, 408,
+ * 429), retried until [MAX_UNAVAILABLE_MILLIS] pass and [MIN_UNAVAILABLE_ANSWERS] answers came without
+ * progress of the upload.
+ * SERVER: an answer or a TLS problem that may not go away, retried up to [MAX_SERVER_ERRORS] times.
+ * OTHER: a local problem, fails at once.
+ */
+ enum class FailureKind { NETWORK, UNAVAILABLE, SERVER, OTHER }
+
+ enum class Decision { RETRY, FAIL }
+
+ /**
+ * Looks through the whole chain of causes, because RxJava wraps a checked [IOException] in a RuntimeException.
+ * An HTTP error wins over everything else. Errors of an overloaded or restarting server (5xx except 501, 505
+ * and 507, 408, 429) are UNAVAILABLE: the upload waits and goes on, but not for ever. Other HTTP errors and
+ * certificate problems count against [MAX_SERVER_ERRORS]. Any other [SSLException] is a network error: on
+ * Android a connection that drops in the middle of an HTTPS transfer arrives as an SSLException. A
+ * [FileNotFoundException] anywhere in the chain is OTHER: the prepared file cannot be read.
+ */
+ fun classify(error: Throwable): FailureKind {
+ val causes = causeChain(error)
+ val httpCode = causes.firstNotNullOfOrNull(::httpCodeOf)
+ return when {
+ httpCode != null -> if (isTransientHttpCode(httpCode)) FailureKind.UNAVAILABLE else FailureKind.SERVER
+ isCertificateProblem(causes) -> FailureKind.SERVER
+ // ChunkFromFileRequestBody wraps every error of reading a chunk in a FileNotFoundException, also an
+ // IOException of the disk. The file is in noBackupFilesDir: waiting does not repair it.
+ causes.any { it is FileNotFoundException } -> FailureKind.OTHER
+ causes.any { it is IOException } -> FailureKind.NETWORK
+ else -> FailureKind.OTHER
+ }
+ }
+
+ private fun causeChain(error: Throwable): List =
+ generateSequence(error) { it.cause }.take(MAX_CAUSE_DEPTH).toList()
+
+ private fun isCertificateProblem(causes: List): Boolean =
+ causes.any { it is SSLPeerUnverifiedException || it is UnknownServiceException } ||
+ (
+ causes.any { it is SSLHandshakeException } &&
+ causes.any { it is CertificateException || it is CertPathValidatorException }
+ )
+
+ /**
+ * Like [classify], for an error while the file is prepared (copy, compression). A full or read-only disk and
+ * a file that cannot be read do not go away by waiting, so they are OTHER; any other IOException, e.g. a lost
+ * network while a cloud provider streams the file, is waited for.
+ */
+ fun classifyPreparation(error: Throwable): FailureKind =
+ if (causeChain(error).any(::isUnrecoverableLocal)) FailureKind.OTHER else classify(error)
+
+ private fun isUnrecoverableLocal(error: Throwable): Boolean {
+ val errno = (error as? ErrnoException)?.errno
+ return error is FileNotFoundException || errno == OsConstants.ENOSPC || errno == OsConstants.EROFS
+ }
+
+ private fun httpCodeOf(error: Throwable): Int? =
+ when (error) {
+ is DavHttpException -> error.code
+ is RetrofitHttpException -> error.code()
+ else -> null
+ }
+
+ private fun isTransientHttpCode(code: Int): Boolean =
+ (code >= HTTP_SERVER_ERROR && code !in PERMANENT_SERVER_ERRORS) ||
+ code == HTTP_REQUEST_TIMEOUT ||
+ code == HTTP_TOO_MANY_REQUESTS
+
+ /**
+ * @param serverErrorCount server errors of this upload so far, including the one being decided on
+ * @param unavailableForMillis how long the server has been answering "unavailable" without the upload moving on
+ * @param unavailableAnswers how many such answers came in that time, including the one being decided on
+ */
+ fun decide(
+ kind: FailureKind,
+ serverErrorCount: Int,
+ unavailableForMillis: Long,
+ unavailableAnswers: Int
+ ): Decision =
+ when {
+ kind == FailureKind.NETWORK -> Decision.RETRY
+ kind == FailureKind.UNAVAILABLE &&
+ (unavailableForMillis < MAX_UNAVAILABLE_MILLIS || unavailableAnswers < MIN_UNAVAILABLE_ANSWERS) ->
+ Decision.RETRY
+ kind == FailureKind.SERVER && serverErrorCount < MAX_SERVER_ERRORS -> Decision.RETRY
+ else -> Decision.FAIL
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt b/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt
new file mode 100644
index 0000000000..f234ff201d
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt
@@ -0,0 +1,299 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.upload
+
+import java.io.File
+import java.io.IOException
+import java.io.RandomAccessFile
+import java.nio.channels.FileLock
+import java.nio.channels.OverlappingFileLockException
+import java.util.UUID
+
+/** Since when the server answers "unavailable" without the upload moving on, and how many answers came. */
+data class Unavailable(val sinceMillis: Long, val answers: Int)
+
+/** The file an upload sends and the name it gets on the server. */
+data class PreparedUpload(val file: File, val fileName: String)
+
+/**
+ * Holds what one upload needs across runs of its worker: the prepared file (copy of the content uri, compressed
+ * media), the number of server errors, the stages already done (uploaded, shared), the cancel flag and a lock
+ * that keeps two runs of the same work and the cleanup of dead workspaces apart.
+ *
+ * The prepared file is created once. Its name, size and modification time stay the same on every run, so the
+ * chunk folder on the server, which is keyed by them, stays the same too.
+ */
+@Suppress("TooManyFunctions")
+class UploadWorkspace(private val dir: File) {
+
+ private val preparedFile = File(dir, PREPARED_FILE)
+ private val serverErrorsFile = File(dir, SERVER_ERRORS_FILE)
+ private val uploadedFile = File(dir, UPLOADED_FILE)
+ private val sharedFile = File(dir, SHARED_FILE)
+ private val cancelledFile = File(dir, CANCELLED_FILE)
+ private val uploadIdFile = File(dir, UPLOAD_ID_FILE)
+ private val lockFile = File(dir, LOCK_FILE)
+ private val restartedFile = File(dir, RESTARTED_FILE)
+ private val unavailableFile = File(dir, UNAVAILABLE_FILE)
+ private val progressFile = File(dir, PROGRESS_FILE)
+
+ private var lockChannel: RandomAccessFile? = null
+ private var lock: FileLock? = null
+
+ /**
+ * Takes the lock of this workspace. WorkManager does not interrupt a worker thread it stopped, so the old run
+ * can still be busy when a new run of the same work starts. Returns false when somebody else holds the lock.
+ *
+ * The lock protects only inside this process: within one process a [FileLock] is refused with
+ * [OverlappingFileLockException], and that is what this relies on. Do not rely on it between processes.
+ */
+ @Synchronized
+ fun tryLock(): Boolean {
+ if (lock == null) {
+ dir.mkdirs()
+ val channel = runCatching { RandomAccessFile(lockFile, "rw") }.getOrNull()
+ val acquired = channel?.let { acquire(it) }
+ if (acquired == null) {
+ channel?.close()
+ } else {
+ lockChannel = channel
+ lock = acquired
+ }
+ }
+ return lock != null
+ }
+
+ private fun acquire(channel: RandomAccessFile): FileLock? =
+ try {
+ channel.channel.tryLock()
+ } catch (e: OverlappingFileLockException) {
+ // The lock is held by another workspace object of this process.
+ null
+ } catch (e: IOException) {
+ null
+ }
+
+ @Synchronized
+ fun unlock() {
+ runCatching { lock?.release() }
+ runCatching { lockChannel?.close() }
+ lock = null
+ lockChannel = null
+ }
+
+ /**
+ * Returns the prepared file of an earlier run, or runs [prepare] once and keeps its result.
+ * [prepare] gets an empty directory it may create files in, and returns null when the file cannot be prepared.
+ * The directory is a subdirectory of the workspace, so a file named like a file of the workspace itself
+ * ("prepared", "lock", "restarted", anything ending in ".tmp") cannot replace it.
+ */
+ fun prepareOnce(prepare: (File) -> PreparedUpload?): PreparedUpload? {
+ stored()?.let { return it }
+ resetFiles()
+ // A new preparation is a new file: the chunks start from the beginning, the old progress means nothing.
+ forgetProgress()
+ val filesDir = File(dir, FILES_DIR).apply { mkdirs() }
+ return prepare(filesDir)?.also { write(preparedFile, "${it.file.absolutePath}\n${it.fileName}") }
+ }
+
+ fun serverErrors(): Int = readLong(serverErrorsFile)?.toInt() ?: 0
+
+ fun registerServerError(): Int {
+ val count = serverErrors() + 1
+ dir.mkdirs()
+ write(serverErrorsFile, count.toString())
+ return count
+ }
+
+ /** The prepared file of an earlier run, if there is one. */
+ fun prepared(): PreparedUpload? = stored()
+
+ /** The path on the server the file was uploaded to, or null while the upload is not complete. */
+ fun uploadedPath(): String? = uploadedLines()?.getOrNull(0)?.takeIf { it.isNotEmpty() }
+
+ /** The name the uploaded file was shared under, kept because the prepared file may be gone by then. */
+ fun uploadedName(): String? = uploadedLines()?.getOrNull(1)?.takeIf { it.isNotEmpty() }
+
+ /**
+ * @param draft the file went to the draft folder of the conversation, from where the server moves it when the
+ * message is posted; see [isDraftUploaded]
+ */
+ fun markUploaded(path: String, fileName: String, draft: Boolean = false) {
+ dir.mkdirs()
+ write(uploadedFile, "$path\n$fileName" + if (draft) "\n$DRAFT_MARK" else "")
+ }
+
+ /**
+ * Whether the uploaded file is in the draft folder, a file of this upload alone. The file in the attachment
+ * folder of Talk (the path without conversation subfolders) is a file of the user and is never removed.
+ */
+ fun isDraftUploaded(): Boolean = uploadedLines()?.getOrNull(2) == DRAFT_MARK
+
+ private fun uploadedLines(): List? = runCatching { uploadedFile.readLines() }.getOrNull()
+
+ private class StoredUnavailable(val since: Long, val point: Int, val answers: Int)
+
+ private fun readUnavailable(): StoredUnavailable? {
+ val lines = runCatching { unavailableFile.readLines() }.getOrNull() ?: return null
+ val since = lines.getOrNull(0)?.toLongOrNull()
+ val point = lines.getOrNull(1)?.toIntOrNull()
+ val answers = lines.getOrNull(2)?.toIntOrNull()
+ return if (since != null && point != null && answers != null) StoredUnavailable(since, point, answers) else null
+ }
+
+ /** Remembers the furthest percent of the upload that was sent, in any run. Writes only when it grew. */
+ fun recordProgress(percent: Int) {
+ if (percent > storedProgress()) {
+ dir.mkdirs()
+ write(progressFile, percent.toString())
+ }
+ }
+
+ private fun storedProgress(): Int = readLong(progressFile)?.toInt() ?: NO_PROGRESS
+
+ /** How far the upload is: a finished upload counts as further than any percent. */
+ private fun progressPoint(): Int = if (uploadedPath() != null) UPLOADED_PROGRESS else storedProgress()
+
+ /**
+ * Counts an answer "the server is unavailable". The first such answer after which the upload did not move on
+ * starts the time, together with the progress it had then. A later answer with more progress (more sent, or the
+ * file uploaded) starts it again, because the server took data in between; so does a clock that went back.
+ */
+ fun registerUnavailable(now: Long): Unavailable {
+ val stored = readUnavailable()
+ val current = progressPoint()
+ val result = if (stored == null || current > stored.point || stored.since > now) {
+ Unavailable(now, 1) to current
+ } else {
+ Unavailable(stored.since, stored.answers + 1) to stored.point
+ }
+ dir.mkdirs()
+ write(unavailableFile, "${result.first.sinceMillis}\n${result.second}\n${result.first.answers}")
+ return result.first
+ }
+
+ /** Whether the parts were already removed once after the server rejected the assembly. */
+ fun isRestarted(): Boolean = restartedFile.exists()
+
+ fun markRestarted() {
+ dir.mkdirs()
+ write(restartedFile, "1")
+ // The upload starts from the beginning: the progress and the time of the old answers mean nothing.
+ forgetProgress()
+ }
+
+ private fun forgetProgress() {
+ progressFile.delete()
+ unavailableFile.delete()
+ }
+
+ fun isShared(): Boolean = sharedFile.exists()
+
+ fun markShared() {
+ dir.mkdirs()
+ write(sharedFile, "1")
+ }
+
+ /** Stays the same on every run, so the target of the assembled file in the draft folder does not change. */
+ fun uploadId(): String {
+ runCatching { uploadIdFile.readText() }.getOrNull()?.takeIf { it.isNotEmpty() }?.let { return it }
+ val id = UUID.randomUUID().toString()
+ dir.mkdirs()
+ write(uploadIdFile, id)
+ return id
+ }
+
+ /** Asks the work to abort when it runs. Creates the directory, because the work may not have run yet. */
+ fun markCancelled() {
+ dir.mkdirs()
+ write(cancelledFile, "1")
+ }
+
+ fun isCancelled(): Boolean = cancelledFile.exists()
+
+ /** Call only while holding the lock. */
+ fun delete() {
+ dir.deleteRecursively()
+ }
+
+ private fun stored(): PreparedUpload? {
+ val lines = runCatching { preparedFile.readLines() }.getOrNull()
+ val file = lines?.getOrNull(0)?.let(::File)
+ val name = lines?.getOrNull(1)
+ return if (file != null && name != null && file.isFile) PreparedUpload(file, name) else null
+ }
+
+ private fun resetFiles() {
+ dir.listFiles()?.filter { it.name !in KEPT_FILES }?.forEach {
+ it.deleteRecursively()
+ }
+ }
+
+ private fun readLong(file: File): Long? = runCatching { file.readText().trim().toLong() }.getOrNull()
+
+ private fun write(file: File, text: String) {
+ val tmp = File(dir, file.name + TMP_SUFFIX)
+ tmp.writeText(text)
+ tmp.renameTo(file)
+ }
+
+ companion object {
+ private const val PREPARED_FILE = "prepared"
+ private const val SERVER_ERRORS_FILE = "server_errors"
+ private const val UPLOADED_FILE = "uploaded"
+ private const val SHARED_FILE = "shared"
+ private const val CANCELLED_FILE = "cancelled"
+ private const val UPLOAD_ID_FILE = "upload_id"
+ private const val LOCK_FILE = "lock"
+ private const val RESTARTED_FILE = "restarted"
+ private const val UNAVAILABLE_FILE = "unavailable"
+ private const val PROGRESS_FILE = "progress"
+ private const val NO_PROGRESS = -1
+
+ /** The progress of an upload that is complete on the server, further than any percent. */
+ private const val UPLOADED_PROGRESS = 101
+ private const val DRAFT_MARK = "draft"
+ private const val TMP_SUFFIX = ".tmp"
+ private const val FILES_DIR = "file"
+
+ /** Not part of the prepared file: survive a new preparation. */
+ private val KEPT_FILES = setOf(
+ SERVER_ERRORS_FILE,
+ CANCELLED_FILE,
+ LOCK_FILE,
+ UPLOAD_ID_FILE,
+ UPLOADED_FILE,
+ SHARED_FILE,
+ RESTARTED_FILE,
+ UNAVAILABLE_FILE,
+ PROGRESS_FILE
+ )
+
+ /**
+ * Removes the workspaces of uploads that are no longer alive, e.g. left behind by a killed process.
+ * [isAlive] gets the name of a workspace directory, which is the id of its work. A workspace whose lock is
+ * held belongs to a running work and is left alone.
+ */
+ fun deleteFinished(root: File, isAlive: (String) -> Boolean) {
+ root.listFiles()?.filter { it.isDirectory }?.forEach { deleteIfDead(it, isAlive) }
+ }
+
+ private fun deleteIfDead(dir: File, isAlive: (String) -> Boolean) {
+ val workspace = UploadWorkspace(dir)
+ if (!workspace.tryLock()) {
+ return
+ }
+ try {
+ if (!isAlive(dir.name)) {
+ workspace.delete()
+ }
+ } finally {
+ workspace.unlock()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploader.kt b/app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploader.kt
index c6b2db38be..70e6d5b9d5 100644
--- a/app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploader.kt
+++ b/app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploader.kt
@@ -16,6 +16,7 @@ import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.property.DisplayName
+import at.bitfire.dav4jvm.property.GetContentLength
import at.bitfire.dav4jvm.property.GetContentType
import at.bitfire.dav4jvm.property.GetLastModified
import at.bitfire.dav4jvm.property.ResourceType
@@ -35,6 +36,8 @@ import com.nextcloud.talk.remotefilebrowser.model.RemoteFileBrowserItem
import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.FileUtils
import com.nextcloud.talk.utils.Mimetype
+import okhttp3.Dispatcher
+import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType
import okhttp3.OkHttpClient
@@ -46,17 +49,24 @@ import java.io.RandomAccessFile
import java.nio.channels.FileChannel
import java.util.Locale
+@Suppress("TooManyFunctions")
@AutoInjector(NextcloudTalkApplication::class)
class ChunkedFileUploader(
okHttpClient: OkHttpClient,
val currentUser: User,
val listener: OnDataTransferProgressListener,
- val ncApiCoroutines: NcApiCoroutines
+ val ncApiCoroutines: NcApiCoroutines,
+ private val isRestarted: () -> Boolean = { false },
+ private val markRestarted: () -> Unit = {}
) {
private var okHttpClientNoRedirects: OkHttpClient? = null
private var remoteChunkUrl: String
+
+ @Volatile
private var uploadFolderUri: String = ""
+
+ @Volatile
private var isUploadAborted = false
init {
@@ -65,50 +75,76 @@ class ChunkedFileUploader(
}
/**
- * Uploads [localFile] in chunks, skipping chunks that are already on the server, and assembles them at
- * [targetPath].
+ * Uploads the parts of [localFile] that the server does not have yet, then assembles them into [targetPath].
+ * The upload folder on the server is keyed by the file, so a repeated call resumes a previous one.
*
- * @return false if the upload was aborted or the server answered with an HTTP error
- * @throws IOException on I/O failures (e.g. connection loss or timeout) and chunk folder creation failures,
- * so the caller can retry; chunks already on the server are skipped as long as [localFile] is unchanged
+ * @return true when the file was assembled, false when the upload was stopped or aborted
+ * @throws IOException on I/O failures (e.g. connection loss or timeout), so the caller can retry; the parts
+ * already on the server are kept and skipped by the retry as long as [localFile] is unchanged
+ * @throws Exception when another request fails; the parts already on the server are kept
*/
- @Suppress("Detekt.TooGenericExceptionCaught")
@Throws(IOException::class)
fun upload(localFile: File, mimeType: MediaType?, targetPath: String): Boolean {
+ prepare(localFile)
+ return uploadMissingParts(localFile, mimeType) && assembleOrRestart(localFile, mimeType, targetPath)
+ }
+
+ /** @return false when the upload was stopped while it was sent again after a rejected assembly */
+ private fun assembleOrRestart(localFile: File, mimeType: MediaType?, targetPath: String): Boolean {
try {
- var isUploadSuccessful = true
- uploadFolderUri = remoteChunkUrl + "/" + FileUtils.md5Sum(localFile)
- val davResource = DavResource(
- okHttpClientNoRedirects!!,
- uploadFolderUri.toHttpUrlOrNull()!!
- )
+ assembleChunks(uploadFolderUri, targetPath, localFile.length())
+ } catch (e: HttpException) {
+ // The server refused the assembly (e.g. the length does not match its parts): the parts it holds are
+ // not usable, so remove them once per upload (the worker remembers it) and send the file again.
+ // A rejected part is not this case and is thrown by uploadMissingParts.
+ if (e.code != HTTP_BAD_REQUEST || isRestarted()) {
+ throw e
+ }
+ Log.w(TAG, "Server rejected the assembly, uploading the file again", e)
+ deleteUploadFolder()
+ markRestarted()
+ if (!uploadMissingParts(localFile, mimeType)) {
+ return false
+ }
+ assembleChunks(uploadFolderUri, targetPath, localFile.length())
+ }
+ return true
+ }
+
+ /**
+ * Fixes the upload folder of [localFile], so that [abortUpload] can remove it. Done by [upload] when not called
+ * before; hashes the whole file once.
+ */
+ fun prepare(localFile: File) {
+ if (uploadFolderUri.isEmpty()) {
+ uploadFolderUri = folderUriOf(localFile)
+ }
+ }
- createFolder(davResource)
+ private fun folderUriOf(localFile: File) = remoteChunkUrl + "/" + FileUtils.md5Sum(localFile)
+
+ /** @return false when the upload was stopped before every missing part was sent */
+ private fun uploadMissingParts(localFile: File, mimeType: MediaType?): Boolean {
+ val davResource = DavResource(
+ okHttpClientNoRedirects!!,
+ uploadFolderUri.toHttpUrlOrNull()!!
+ )
- val chunksOnServer: MutableList = getUploadedChunks(davResource, uploadFolderUri)
- Log.d(TAG, "chunksOnServer: " + chunksOnServer.size)
+ createFolder(davResource)
- val missingChunks: List = checkMissingChunks(chunksOnServer, localFile.length())
- Log.d(TAG, "missingChunks: " + missingChunks.size)
+ val chunksOnServer: MutableList = getUploadedChunks(davResource, uploadFolderUri, localFile.length())
+ Log.d(TAG, "chunksOnServer: " + chunksOnServer.size)
- for (missingChunk in missingChunks) {
- if (isUploadAborted) {
- isUploadSuccessful = false
- break
- }
- uploadChunk(localFile, uploadFolderUri, mimeType, missingChunk, missingChunk.length())
- }
+ val missingChunks: List = checkMissingChunks(chunksOnServer, localFile.length())
+ Log.d(TAG, "missingChunks: " + missingChunks.size)
- if (isUploadSuccessful) {
- assembleChunks(uploadFolderUri, targetPath)
+ for (missingChunk in missingChunks) {
+ if (isUploadAborted) {
+ break
}
- return isUploadSuccessful
- } catch (e: IOException) {
- throw e
- } catch (e: Exception) {
- Log.e(TAG, "Something went wrong in ChunkedFileUploader", e)
- return false
+ uploadChunk(localFile, uploadFolderUri, mimeType, missingChunk, missingChunk.length())
}
+ return !isUploadAborted
}
@Suppress("Detekt.ThrowsCount")
@@ -134,14 +170,21 @@ class ChunkedFileUploader(
}
@Suppress("Detekt.ComplexMethod", "Detekt.ReturnCount")
- private fun getUploadedChunks(davResource: DavResource, uploadFolderUri: String): MutableList {
+ private fun getUploadedChunks(
+ davResource: DavResource,
+ uploadFolderUri: String,
+ fileLength: Long
+ ): MutableList {
val davResponse = DavResponse()
val memberElements: MutableList = ArrayList()
val rootElement = arrayOfNulls(1)
val remoteFiles: MutableList = ArrayList()
try {
davResource.propfind(
- 1
+ 1,
+ ResourceType.NAME,
+ GetContentLength.NAME,
+ DisplayName.NAME
) { response: at.bitfire.dav4jvm.Response, hrefRelation: at.bitfire.dav4jvm.Response.HrefRelation? ->
davResponse.setResponse(response)
when (hrefRelation) {
@@ -152,13 +195,15 @@ class ChunkedFileUploader(
}
Unit
}
- } catch (e: IOException) {
- // PROPFIND on Nextcloud chunked-upload folders can return unexpected responses
- // (e.g. 200 instead of 207). Treat any failure as "no chunks uploaded yet" so
- // we fall back to a full upload rather than aborting entirely.
- Log.w(TAG, "PROPFIND failed — assuming no chunks on server, will upload from scratch: ${e.message}")
- return ArrayList()
} catch (e: DavException) {
+ if (e is HttpException && (e.code >= HTTP_SERVER_ERROR || e.code in LISTING_ERROR_CODES)) {
+ throw e
+ }
+ // Failures of the request itself (login, rate limit, server) are no answer about the parts either.
+ // An IOException is not caught on purpose: a lost network must repeat the request,
+ // not send the whole file again.
+ // PROPFIND on Nextcloud chunked-upload folders can return unexpected responses
+ // (e.g. 200 instead of 207). Treat any such answer as "no chunks uploaded yet".
Log.w(TAG, "PROPFIND failed — assuming no chunks on server, will upload from scratch: ${e.message}")
return ArrayList()
}
@@ -177,24 +222,34 @@ class ChunkedFileUploader(
val chunksOnServer: MutableList = ArrayList()
for (remoteFile in remoteFiles) {
- if (!".file".equals(remoteFile.displayName, ignoreCase = true) && remoteFile.isFile) {
- val part: List = remoteFile.displayName!!.split("-")
- chunksOnServer.add(
- Chunk(
- part[0].toLong(),
- part[1].toLong()
- )
- )
+ if (remoteFile.isFile) {
+ parseUploadedChunk(remoteFile.displayName, remoteFile.size, fileLength)?.let { chunksOnServer.add(it) }
}
}
return chunksOnServer
}
- private fun checkMissingChunks(chunks: List, length: Long): List {
+ /**
+ * Returns the part a file on the server stands for, or null when it is not a part (e.g. `.file`), or its size
+ * differs from what its name says, so it has to be uploaded again. The last part is named one byte longer than
+ * its content.
+ */
+ internal fun parseUploadedChunk(name: String?, size: Long?, fileLength: Long): Chunk? {
+ if (name == null || !CHUNK_NAME_REGEX.matches(name)) {
+ return null
+ }
+ val chunk =
+ Chunk(name.substring(0, CHUNK_NUMBER_LENGTH).toLong(), name.substring(CHUNK_NUMBER_LENGTH + 1).toLong())
+ val expectedSize = minOf(chunk.length(), fileLength - chunk.start)
+ return if (size == expectedSize) chunk else null
+ }
+
+ internal fun checkMissingChunks(chunks: List, length: Long): List {
val missingChunks: MutableList = java.util.ArrayList()
+ val sortedChunks = chunks.sortedBy { it.start }
var start: Long = 0
while (start <= length) {
- val nextChunk: Chunk? = findNextFittingChunk(chunks, start)
+ val nextChunk: Chunk? = findNextFittingChunk(sortedChunks, start)
if (nextChunk == null) {
// create new chunk
val end: Long = if (start + CHUNK_SIZE <= length) {
@@ -287,7 +342,10 @@ class ChunkedFileUploader(
}
private fun initHttpClient(okHttpClient: OkHttpClient, currentUser: User) {
+ // Own dispatcher: stop() cancels all calls of the dispatcher, and the one of okHttpClient is shared with
+ // the whole app (chat requests, signaling websocket, other uploads).
val builder = okHttpClient.newBuilder()
+ .dispatcher(Dispatcher())
.followRedirects(false)
.followSslRedirects(false)
.protocols(listOf(Protocol.HTTP_1_1))
@@ -306,7 +364,7 @@ class ChunkedFileUploader(
this.okHttpClientNoRedirects = builder.build()
}
- private fun assembleChunks(uploadFolderUri: String, targetPath: String) {
+ private fun assembleChunks(uploadFolderUri: String, targetPath: String, totalLength: Long) {
val destinationUri = ApiUtils.getUrlForFileUpload(
currentUser.baseUrl!!,
currentUser.userId!!,
@@ -316,8 +374,13 @@ class ChunkedFileUploader(
createRemoteFolder(targetPath)
val originUri = "$uploadFolderUri/.file"
+ val client = okHttpClientNoRedirects!!.newBuilder()
+ .addInterceptor { chain ->
+ chain.proceed(chain.request().newBuilder().header(TOTAL_LENGTH_HEADER, totalLength.toString()).build())
+ }
+ .build()
DavResource(
- okHttpClientNoRedirects!!,
+ client,
originUri.toHttpUrlOrNull()!!
).move(
destinationUri.toHttpUrlOrNull()!!,
@@ -347,12 +410,21 @@ class ChunkedFileUploader(
}
/**
+ * Interrupts a running [upload] without touching the parts on the server, so a later [upload] can resume.
* Called from the worker's onStopped, which WorkManager runs inside a coroutine cancellation handler:
* anything thrown here crashes the process, so this function must never throw.
*/
+ fun stop() {
+ isUploadAborted = true
+ okHttpClientNoRedirects?.dispatcher?.cancelAll()
+ }
+
+ /**
+ * Interrupts a running [upload] and removes its parts from the server. Same contract as [stop]: never throws.
+ */
@Suppress("Detekt.TooGenericExceptionCaught")
fun abortUpload(onSuccess: () -> Unit) {
- isUploadAborted = true
+ stop()
val client = okHttpClientNoRedirects
val folderUrl = uploadFolderUri.toHttpUrlOrNull()
if (client == null || folderUrl == null) {
@@ -378,6 +450,36 @@ class ChunkedFileUploader(
}
}
+ /** Interrupts this uploader and removes the parts of [localFile], also those an earlier run left. */
+ fun abortUpload(localFile: File, onSuccess: () -> Unit) {
+ uploadFolderUri = folderUriOf(localFile)
+ abortUpload(onSuccess)
+ }
+
+ /**
+ * Removes a file this uploader or an earlier run assembled on the server, e.g. when the user cancelled after
+ * the upload and before the share. Never throws, like [abortUpload].
+ */
+ @Suppress("Detekt.TooGenericExceptionCaught")
+ fun deleteUploadedFile(targetPath: String) {
+ try {
+ val url = ApiUtils.getUrlForFileUpload(currentUser.baseUrl!!, currentUser.userId!!, targetPath)
+ DavResource(okHttpClientNoRedirects!!, url.toHttpUrl()).delete { _ -> }
+ } catch (e: NotFoundException) {
+ Log.i(TAG, "Uploaded file is already gone", e)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to remove the uploaded file", e)
+ }
+ }
+
+ private fun deleteUploadFolder() {
+ try {
+ DavResource(okHttpClientNoRedirects!!, uploadFolderUri.toHttpUrlOrNull()!!).delete { _ -> }
+ } catch (e: NotFoundException) {
+ Log.i(TAG, "Chunk upload folder is already gone", e)
+ }
+ }
+
private fun getModelFromResponse(response: at.bitfire.dav4jvm.Response, remotePath: String): RemoteFileBrowserItem {
val remoteFileBrowserItem = RemoteFileBrowserItem()
remoteFileBrowserItem.path = Uri.decode(remotePath)
@@ -413,6 +515,10 @@ class ChunkedFileUploader(
remoteFileBrowserItem.modifiedTimestamp = property.lastModified
}
+ is GetContentLength -> {
+ remoteFileBrowserItem.size = property.contentLength
+ }
+
is GetContentType -> {
remoteFileBrowserItem.mimeType = property.type
}
@@ -448,5 +554,11 @@ class ChunkedFileUploader(
private const val READ_PERMISSION = "R"
private const val CHUNK_SIZE: Long = 1024000
private const val METHOD_NOT_ALLOWED_CODE: Int = 405
+ private const val HTTP_BAD_REQUEST: Int = 400
+ private const val HTTP_SERVER_ERROR: Int = 500
+ private val LISTING_ERROR_CODES = setOf(401, 403, 408, 429)
+ private const val CHUNK_NUMBER_LENGTH = 16
+ private val CHUNK_NAME_REGEX = Regex("^\\d{16}-\\d{16}$")
+ private const val TOTAL_LENGTH_HEADER = "OC-Total-Length"
}
}
diff --git a/app/src/main/java/com/nextcloud/talk/upload/normal/FileUploader.kt b/app/src/main/java/com/nextcloud/talk/upload/normal/FileUploader.kt
index cbcb919101..b6ca1d622e 100644
--- a/app/src/main/java/com/nextcloud/talk/upload/normal/FileUploader.kt
+++ b/app/src/main/java/com/nextcloud/talk/upload/normal/FileUploader.kt
@@ -28,6 +28,7 @@ import okhttp3.Protocol
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
+import retrofit2.HttpException as RetrofitHttpException
import java.io.File
import java.io.IOException
@@ -58,7 +59,11 @@ class FileUploader(
),
createRequestBody(sourceFileUri) ?: return false
)
- return response.isSuccessful
+ // An error answer is thrown, so the retry policy can tell an unavailable server from a refusal.
+ if (!response.isSuccessful) {
+ throw RetrofitHttpException(response)
+ }
+ return true
}
fun upload(sourceFileUri: Uri, fileName: String, remotePath: String, metaData: String?): Observable =
@@ -85,7 +90,7 @@ class FileUploader(
) {
createDavResource(sourceFileUri, fileName, remotePath, metaData)
} else {
- Observable.just(false)
+ Observable.error(RetrofitHttpException(response))
}
}
}
diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt
index 9e44d9f140..27460e7d55 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt
+++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt
@@ -19,15 +19,16 @@ import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
-import java.io.InputStream
import java.math.BigInteger
import java.security.MessageDigest
+@Suppress("TooManyFunctions")
object FileUtils {
private val TAG = FileUtils::class.java.simpleName
private const val RADIX: Int = 16
private const val MD5_LENGTH: Int = 32
private const val SHARED_ATTACHMENTS_DIRECTORY = "shared_attachments"
+ private const val PARTIAL_SUFFIX = ".part"
/**
* Returns the dedicated cache directory used for externally shared attachments.
@@ -155,7 +156,7 @@ object FileUtils {
}
@Suppress("ThrowsCount")
- fun getFileFromUri(context: Context, sourceFileUri: Uri): File? {
+ fun getFileFromUri(context: Context, sourceFileUri: Uri, targetDir: File = context.cacheDir): File? {
val fileName = getFileName(sourceFileUri, context)
val scheme = sourceFileUri.scheme
@@ -163,7 +164,7 @@ object FileUtils {
Log.d(TAG, "relative uri: " + sourceFileUri.path)
throw IllegalArgumentException("relative paths are not supported")
} else if (ContentResolver.SCHEME_CONTENT == scheme) {
- copyFileToCache(context, sourceFileUri, fileName)
+ copyFileToCache(context, sourceFileUri, fileName, targetDir)
} else if (ContentResolver.SCHEME_FILE == scheme) {
val path = sourceFileUri.path
?: throw IllegalArgumentException("uri does not contain path")
@@ -181,28 +182,71 @@ object FileUtils {
return file
}
+ /**
+ * Returns [file] when it already lies in [dir], otherwise a copy of it there. A file:// source is the caller's
+ * own file in the cache; a copy in [dir] keeps the prepared upload independent of it.
+ */
+ fun ensureInDir(file: File, dir: File): File {
+ if (file.canonicalFile.parentFile == dir.canonicalFile) {
+ return file
+ }
+ val copy = File(dir, file.name)
+ try {
+ return file.copyTo(copy, overwrite = true)
+ } catch (e: IOException) {
+ copy.delete()
+ throw e
+ }
+ }
+
@Suppress("NestedBlockDepth")
- fun copyFileToCache(context: Context, sourceFileUri: Uri, filename: String): File? {
- val cachedFile = File(context.cacheDir, filename)
+ fun copyFileToCache(
+ context: Context,
+ sourceFileUri: Uri,
+ filename: String,
+ targetDir: File = context.cacheDir
+ ): File? {
+ val cachedFile = File(targetDir, filename)
- if (!cachedFile.toPath().normalize().startsWith(context.cacheDir.toPath())) {
+ if (!cachedFile.toPath().normalize().startsWith(targetDir.toPath())) {
Log.w(TAG, "cachedFile was not created in cacheDir. Aborting for security reasons.")
cachedFile.delete()
return null
}
- if (cachedFile.exists()) {
+ val ready = if (cachedFile.exists() && cachedFile.length() > 0) {
Log.d(TAG, "file is already in cache")
+ true
} else {
- val outputStream = FileOutputStream(cachedFile)
+ copyAside(context, sourceFileUri, cachedFile)
+ }
+ if (!ready) {
+ cachedFile.delete()
+ }
+ return cachedFile.takeIf { ready }
+ }
+
+ /** Copies aside and renames after success: a copy that dies half way never becomes the cached file. */
+ private fun copyAside(context: Context, sourceFileUri: Uri, target: File): Boolean {
+ val partial = File(target.parentFile, "${target.name}.${System.nanoTime()}$PARTIAL_SUFFIX")
+ return try {
+ copyToFile(context, sourceFileUri, partial) && partial.renameTo(target)
+ } finally {
+ partial.delete()
+ }
+ }
+
+ /** Returns false when the source could not be read completely, so the copy is not a usable file. */
+ @Suppress("NestedBlockDepth")
+ private fun copyToFile(context: Context, sourceFileUri: Uri, target: File): Boolean {
+ var copied = false
+ FileOutputStream(target).use { output ->
try {
- val inputStream: InputStream? = context.contentResolver.openInputStream(sourceFileUri)
- inputStream?.use { input ->
- outputStream.use { output ->
- input.copyTo(output)
- }
+ context.contentResolver.openInputStream(sourceFileUri)?.use { input ->
+ input.copyTo(output)
+ copied = true
}
- outputStream.flush()
+ output.flush()
} catch (e: FileNotFoundException) {
Log.w(TAG, "failed to copy file to cache", e)
} catch (e: SecurityException) {
@@ -212,9 +256,33 @@ object FileUtils {
Log.w(TAG, "no longer permitted to read $sourceFileUri", e)
}
}
- return cachedFile
+ if (!copied) {
+ return false
+ }
+ // The size a provider reports is not reliable (transcoding, cloud providers), so it is only logged.
+ val reported = querySize(context, sourceFileUri)
+ if (reported != null && reported > 0 && reported != target.length()) {
+ Log.w(TAG, "copy of $sourceFileUri has ${target.length()} bytes, the provider reports $reported")
+ }
+ // A stream that broke off throws and never gets here, so an empty copy of an empty source is complete.
+ // An empty copy of a source that reports content is a placeholder that was not loaded: not a usable file.
+ return target.length() > 0 || reported == null || reported <= 0
}
+ internal fun querySize(context: Context, uri: Uri): Long? =
+ try {
+ context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor ->
+ val index = cursor.getColumnIndex(OpenableColumns.SIZE)
+ if (index >= 0 && cursor.moveToFirst() && !cursor.isNull(index)) cursor.getLong(index) else null
+ }
+ } catch (e: SecurityException) {
+ Log.w(TAG, "could not query the size of $uri", e)
+ null
+ } catch (e: IllegalArgumentException) {
+ Log.w(TAG, "could not query the size of $uri", e)
+ null
+ }
+
fun getFileName(uri: Uri, context: Context?): String {
var filename: String? = null
if (uri.scheme == "content" && context != null) {
diff --git a/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt b/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt
index 7f7807ffb1..263b205b67 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt
+++ b/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt
@@ -52,13 +52,13 @@ object ImageCompressor {
}
/**
- * Returns a downscaled, JPEG re-encoded copy of [sourceFile] in the app cache directory,
- * or null if the image could not be decoded or written.
+ * Returns a downscaled, JPEG re-encoded copy of [sourceFile] in [outputDir] (the app cache directory by
+ * default), or null if the image could not be decoded or written.
*/
- fun compress(context: Context, sourceFile: File): File? {
+ fun compress(context: Context, sourceFile: File, outputDir: File = context.cacheDir): File? {
val (bitmap, bytes) = encode(sourceFile) ?: return null
return try {
- val compressedFile = File(context.cacheDir, sourceFile.nameWithoutExtension + COMPRESSED_FILE_SUFFIX)
+ val compressedFile = File(outputDir, sourceFile.nameWithoutExtension + COMPRESSED_FILE_SUFFIX)
compressedFile.writeBytes(bytes)
compressedFile
} catch (e: IOException) {
diff --git a/app/src/main/java/com/nextcloud/talk/utils/VideoCompressor.kt b/app/src/main/java/com/nextcloud/talk/utils/VideoCompressor.kt
index 428bc466ae..7d9d43ad81 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/VideoCompressor.kt
+++ b/app/src/main/java/com/nextcloud/talk/utils/VideoCompressor.kt
@@ -93,18 +93,23 @@ object VideoCompressor {
}
/**
- * Returns a downscaled, re-encoded copy of [sourceFile] in the app cache directory, or null if
+ * Returns a downscaled, re-encoded copy of [sourceFile] in [outputDir] (default: the app cache dir), or null if
* the video was already small enough, could not be read, or transcoding failed/timed out.
*
* [onProgress] is invoked with a 0-100 value from a background thread while transcoding runs.
*/
@Suppress("ReturnCount")
- fun compress(context: Context, sourceFile: File, onProgress: (Int) -> Unit = {}): File? {
+ fun compress(
+ context: Context,
+ sourceFile: File,
+ outputDir: File = context.cacheDir,
+ onProgress: (Int) -> Unit = {}
+ ): File? {
Log.d(TAG, "compressing ${sourceFile.name}")
val original = readVideoInfo(sourceFile) ?: return null
if (minOf(original.width, original.height) <= TARGET_SHORT_SIDE) return null
- val outputFile = File(context.cacheDir, sourceFile.nameWithoutExtension + COMPRESSED_FILE_SUFFIX)
+ val outputFile = File(outputDir, sourceFile.nameWithoutExtension + COMPRESSED_FILE_SUFFIX)
outputFile.delete()
val success = runTransform(context, sourceFile, outputFile, original.durationMs, onProgress)
diff --git a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFailureTest.kt b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFailureTest.kt
new file mode 100644
index 0000000000..6832196bbc
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFailureTest.kt
@@ -0,0 +1,100 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.chat.viewmodels
+
+import android.content.Context
+import android.os.Looper
+import androidx.work.Configuration
+import androidx.work.Data
+import androidx.work.ListenableWorker
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.Worker
+import androidx.work.WorkerFactory
+import androidx.work.WorkerParameters
+import androidx.work.testing.SynchronousExecutor
+import androidx.work.testing.WorkManagerTestInitHelper
+import androidx.work.workDataOf
+import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+import java.util.UUID
+import java.util.concurrent.TimeUnit
+
+/**
+ * A failed upload must not leave its placeholder pending, whether the worker fails or only reports the failure.
+ */
+class ChatViewModelUploadFailureTest : ChatViewModelUploadTestBase() {
+ @Test
+ fun `an upload that reports its failure before the placeholder exists does not leave the placeholder pending`() {
+ // The worker ends a failed upload as SUCCEEDED, so the uploads queued behind it still run, and reports the
+ // failure in its output data.
+ useUploadWorkerResult(workDataOf(UploadAndShareFilesWorker.KEY_UPLOAD_FAILED to true))
+
+ val workId = sendSlowFileAndRunItsWorker()
+ awaitPlaceholderMarkedFailed()
+
+ val workInfo = WorkManager.getInstance(RuntimeEnvironment.getApplication()).getWorkInfoById(workId).get()
+ assertEquals(WorkInfo.State.SUCCEEDED, workInfo?.state)
+ assertEquals("the placeholder is marked as failed", 0L, placeholderMarkedFailed.count)
+ }
+
+ @Test
+ fun `an upload that succeeds does not mark its placeholder as failed`() {
+ useUploadWorkerResult(Data.EMPTY)
+
+ val workId = sendSlowFileAndRunItsWorker()
+ awaitPlaceholderMarkedFailed()
+
+ val workInfo = WorkManager.getInstance(RuntimeEnvironment.getApplication()).getWorkInfoById(workId).get()
+ assertEquals(WorkInfo.State.SUCCEEDED, workInfo?.state)
+ assertEquals("the placeholder is left alone", 1L, placeholderMarkedFailed.count)
+ }
+
+ // Every upload work ends at once with the given output, before the slow provider let the placeholder in.
+ private fun useUploadWorkerResult(output: Data) {
+ val factory = object : WorkerFactory() {
+ override fun createWorker(
+ appContext: Context,
+ workerClassName: String,
+ workerParameters: WorkerParameters
+ ): ListenableWorker =
+ object : Worker(appContext, workerParameters) {
+ override fun doWork(): Result = Result.success(output)
+ }
+ }
+ val configuration = Configuration.Builder()
+ .setExecutor(SynchronousExecutor())
+ .setWorkerFactory(factory)
+ .build()
+ WorkManagerTestInitHelper.initializeTestWorkManager(RuntimeEnvironment.getApplication(), configuration)
+ }
+
+ private fun sendSlowFileAndRunItsWorker(): UUID {
+ val viewModel = newViewModel()
+ viewModel.send("slow-a.pdf", order = 1)
+ val workId = viewModel.uploadWorkIds().getValue(referenceId(1))
+ WorkManagerTestInitHelper.getTestDriver(RuntimeEnvironment.getApplication())!!.setAllConstraintsMet(workId)
+ return workId
+ }
+
+ // Waits for the placeholder, then gives the observer the time to mark it.
+ private fun awaitPlaceholderMarkedFailed() {
+ val deadline = System.currentTimeMillis() + TIMEOUT_MS
+ while (placeholderAdded.count > 0 && System.currentTimeMillis() < deadline) {
+ shadowOf(Looper.getMainLooper()).idle()
+ placeholderAdded.await(POLL_MS, TimeUnit.MILLISECONDS)
+ }
+ val markDeadline = System.currentTimeMillis() + MARK_WAIT_MS
+ while (placeholderMarkedFailed.count > 0 && System.currentTimeMillis() < markDeadline) {
+ shadowOf(Looper.getMainLooper()).idle()
+ placeholderMarkedFailed.await(POLL_MS, TimeUnit.MILLISECONDS)
+ }
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFileTest.kt b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFileTest.kt
index ff0aea2531..e5ec3d9ccf 100644
--- a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFileTest.kt
+++ b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFileTest.kt
@@ -6,97 +6,24 @@
*/
package com.nextcloud.talk.chat.viewmodels
-import android.content.ContentProvider
-import android.content.ContentValues
-import android.database.Cursor
-import android.database.MatrixCursor
-import android.net.Uri
import android.os.Looper
-import android.provider.OpenableColumns
import androidx.lifecycle.viewModelScope
import androidx.work.WorkManager
import androidx.work.impl.WorkManagerImpl
import androidx.work.testing.WorkManagerTestInitHelper
-import com.nextcloud.talk.application.NextcloudTalkApplication
-import com.nextcloud.talk.chat.data.ChatMessageRepository
-import com.nextcloud.talk.chat.data.model.ChatMessage
-import com.nextcloud.talk.data.user.model.User
-import com.nextcloud.talk.utils.ParticipantPermissions
-import com.nextcloud.talk.utils.message.SendMessageUtils
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.runBlocking
-import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
-import org.junit.Before
import org.junit.Test
-import org.junit.runner.RunWith
-import org.mockito.Mockito
-import org.mockito.Mockito.RETURNS_DEEP_STUBS
-import org.mockito.kotlin.any
-import org.mockito.kotlin.anyOrNull
-import org.mockito.kotlin.mock
-import org.mockito.kotlin.whenever
-import org.robolectric.Robolectric
-import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
-import org.robolectric.annotation.Config
-import java.util.UUID
-import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
-import java.util.concurrent.atomic.AtomicInteger
/**
* The name, type and size of a file to send come from the provider of the app that owns it (Nextcloud Files):
* a blocking binder call into another process. Sending a file must not make it on the main thread.
*/
-@RunWith(RobolectricTestRunner::class)
-@Config(sdk = [33])
-class ChatViewModelUploadFileTest {
-
- private val chatRepository: ChatMessageRepository = mock()
- private val placeholderAdded = CountDownLatch(1)
- private val placeholderMarkedFailed = CountDownLatch(1)
- private var previousApplication: NextcloudTalkApplication? = null
-
- // the setter of the singleton is protected: only the real application sets it
- private val sharedApplicationField = NextcloudTalkApplication::class.java
- .getDeclaredField("sharedApplication")
- .apply { isAccessible = true }
-
- @Before
- fun setUp() {
- val context = RuntimeEnvironment.getApplication()
- WorkManagerTestInitHelper.initializeTestWorkManager(context)
- previousApplication = sharedApplicationField.get(null) as NextcloudTalkApplication?
- val application = mock()
- whenever(application.contentResolver).thenReturn(context.contentResolver)
- whenever(application.applicationContext).thenReturn(context)
- sharedApplicationField.set(null, application)
-
- SlowProvider.reset()
- Robolectric.buildContentProvider(SlowProvider::class.java).create(AUTHORITY)
- runBlocking {
- whenever(chatRepository.addUploadPlaceholderMessage(any(), any(), any(), anyOrNull(), any(), any()))
- .thenAnswer {
- placeholderAdded.countDown()
- flowOf>()
- }
- whenever(chatRepository.markUploadPlaceholderFailed(any())).thenAnswer {
- placeholderMarkedFailed.countDown()
- }
- }
- }
-
- @After
- fun tearDown() {
- sharedApplicationField.set(null, previousApplication)
- }
-
+class ChatViewModelUploadFileTest : ChatViewModelUploadTestBase() {
@Test
fun `sending a file does not ask its provider on the main thread`() {
sendAndAwaitPlaceholder("document.pdf")
@@ -171,118 +98,4 @@ class ChatViewModelUploadFileTest {
assertEquals("the placeholder is marked as failed", 0L, placeholderMarkedFailed.count)
}
-
- private fun queuedUploads(): Int =
- WorkManager.getInstance(RuntimeEnvironment.getApplication())
- .getWorkInfosForUniqueWork("upload_queue_1@room")
- .get()
- .size
-
- private fun referenceId(order: Int) = SendMessageUtils().generateGroupedReferenceId(UPLOAD_ID, order)
-
- @Suppress("UNCHECKED_CAST")
- private fun ChatViewModel.uploadWorkIds(): Map =
- ChatViewModel::class.java.getDeclaredField("uploadReferenceToWorkId")
- .apply { isAccessible = true }
- .get(this) as Map
-
- private fun ChatViewModel.send(name: String, order: Int) {
- uploadFile(
- fileUri = "content://" + AUTHORITY + "/" + name,
- isVoiceMessage = false,
- displayName = "Team",
- uploadId = UPLOAD_ID,
- order = order
- )
- }
-
- private fun newViewModel(): ChatViewModel {
- val user = User().apply { id = 1L }
- val viewModel = ChatViewModel(
- logger = mock(),
- appPreferences = mock(defaultAnswer = RETURNS_DEEP_STUBS),
- chatNetworkDataSource = mock(defaultAnswer = RETURNS_DEEP_STUBS),
- chatRepository = chatRepository,
- threadsRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS),
- conversationRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS),
- reactionsRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS),
- unifiedSearchRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS),
- mediaRecorderManager = mock(defaultAnswer = RETURNS_DEEP_STUBS),
- audioFocusRequestManager = mock(defaultAnswer = RETURNS_DEEP_STUBS),
- userManager = mock(defaultAnswer = RETURNS_DEEP_STUBS),
- appScope = CoroutineScope(Dispatchers.Unconfined),
- initialUser = user,
- chatRoomToken = "room",
- conversationThreadId = null
- )
- viewModel.participantPermissions = Mockito.mock(ParticipantPermissions::class.java).also {
- whenever(it.hasChatPermission()).thenReturn(true)
- }
- return viewModel
- }
-
- private fun sendAndAwaitPlaceholder(name: String) {
- newViewModel().send(name, order = 1)
-
- val deadline = System.currentTimeMillis() + TIMEOUT_MS
- while (placeholderAdded.count > 0 && System.currentTimeMillis() < deadline) {
- shadowOf(Looper.getMainLooper()).idle()
- placeholderAdded.await(POLL_MS, TimeUnit.MILLISECONDS)
- }
- }
-
- class SlowProvider : ContentProvider() {
- override fun onCreate() = true
-
- override fun getType(uri: Uri): String {
- count(uri)
- require(uri.lastPathSegment != "rejected.pdf") { "Unknown URL " + uri }
- return "application/pdf"
- }
-
- override fun query(
- uri: Uri,
- projection: Array?,
- selection: String?,
- selectionArgs: Array?,
- sortOrder: String?
- ): Cursor {
- count(uri)
- return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)).apply {
- addRow(arrayOf(uri.lastPathSegment, FILE_BYTES))
- }
- }
-
- override fun insert(uri: Uri, values: ContentValues?): Uri? = null
-
- override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0
-
- override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0
-
- private fun count(uri: Uri) {
- val onMain = Looper.myLooper() == Looper.getMainLooper()
- (if (onMain) mainThreadCalls else otherCalls).incrementAndGet()
- Thread.sleep(if (uri.lastPathSegment.orEmpty().startsWith("slow")) SLOW_DELAY_MS else PROVIDER_DELAY_MS)
- }
-
- companion object {
- val mainThreadCalls = AtomicInteger()
- val otherCalls = AtomicInteger()
-
- fun reset() {
- mainThreadCalls.set(0)
- otherCalls.set(0)
- }
- }
- }
-
- companion object {
- private const val AUTHORITY = "com.nextcloud.client.documentsfileprovider"
- private const val PROVIDER_DELAY_MS = 300L
- private const val SLOW_DELAY_MS = 1500L
- private const val FILE_BYTES = 2048L
- private const val TIMEOUT_MS = 20_000L
- private const val POLL_MS = 50L
- private const val UPLOAD_ID = "upload"
- }
}
diff --git a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadTestBase.kt b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadTestBase.kt
new file mode 100644
index 0000000000..75289c7700
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadTestBase.kt
@@ -0,0 +1,206 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.chat.viewmodels
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.os.Looper
+import android.provider.OpenableColumns
+import androidx.work.WorkManager
+import androidx.work.testing.WorkManagerTestInitHelper
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.chat.data.ChatMessageRepository
+import com.nextcloud.talk.chat.data.model.ChatMessage
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.utils.ParticipantPermissions
+import com.nextcloud.talk.utils.message.SendMessageUtils
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Before
+import org.junit.runner.RunWith
+import org.mockito.Mockito
+import org.mockito.Mockito.RETURNS_DEEP_STUBS
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import java.util.UUID
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+
+internal const val AUTHORITY = "com.nextcloud.client.documentsfileprovider"
+internal const val PROVIDER_DELAY_MS = 300L
+internal const val SLOW_DELAY_MS = 1500L
+internal const val FILE_BYTES = 2048L
+internal const val TIMEOUT_MS = 20_000L
+internal const val POLL_MS = 50L
+internal const val MARK_WAIT_MS = 3_000L
+internal const val UPLOAD_ID = "upload"
+
+class SlowProvider : ContentProvider() {
+ override fun onCreate() = true
+
+ override fun getType(uri: Uri): String {
+ count(uri)
+ require(uri.lastPathSegment != "rejected.pdf") { "Unknown URL " + uri }
+ return "application/pdf"
+ }
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?
+ ): Cursor {
+ count(uri)
+ return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)).apply {
+ addRow(arrayOf(uri.lastPathSegment, FILE_BYTES))
+ }
+ }
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0
+
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0
+
+ private fun count(uri: Uri) {
+ val onMain = Looper.myLooper() == Looper.getMainLooper()
+ (if (onMain) mainThreadCalls else otherCalls).incrementAndGet()
+ Thread.sleep(if (uri.lastPathSegment.orEmpty().startsWith("slow")) SLOW_DELAY_MS else PROVIDER_DELAY_MS)
+ }
+
+ companion object {
+ val mainThreadCalls = AtomicInteger()
+ val otherCalls = AtomicInteger()
+
+ fun reset() {
+ mainThreadCalls.set(0)
+ otherCalls.set(0)
+ }
+ }
+}
+
+/**
+ * What the upload tests of the view model share: a work manager, a provider that answers slowly, a view model
+ * and the wait for its placeholder.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33])
+abstract class ChatViewModelUploadTestBase {
+
+ protected val chatRepository: ChatMessageRepository = mock()
+ protected val placeholderAdded = CountDownLatch(1)
+ protected val placeholderMarkedFailed = CountDownLatch(1)
+ private var previousApplication: NextcloudTalkApplication? = null
+
+ // the setter of the singleton is protected: only the real application sets it
+ private val sharedApplicationField = NextcloudTalkApplication::class.java
+ .getDeclaredField("sharedApplication")
+ .apply { isAccessible = true }
+
+ @Before
+ fun setUp() {
+ val context = RuntimeEnvironment.getApplication()
+ WorkManagerTestInitHelper.initializeTestWorkManager(context)
+ previousApplication = sharedApplicationField.get(null) as NextcloudTalkApplication?
+ val application = mock()
+ whenever(application.contentResolver).thenReturn(context.contentResolver)
+ whenever(application.applicationContext).thenReturn(context)
+ sharedApplicationField.set(null, application)
+
+ SlowProvider.reset()
+ Robolectric.buildContentProvider(SlowProvider::class.java).create(AUTHORITY)
+ runBlocking {
+ whenever(chatRepository.addUploadPlaceholderMessage(any(), any(), any(), anyOrNull(), any(), any()))
+ .thenAnswer {
+ placeholderAdded.countDown()
+ flowOf>()
+ }
+ whenever(chatRepository.markUploadPlaceholderFailed(any())).thenAnswer {
+ placeholderMarkedFailed.countDown()
+ }
+ }
+ }
+
+ @After
+ fun tearDown() {
+ sharedApplicationField.set(null, previousApplication)
+ }
+
+ protected fun queuedUploads(): Int =
+ WorkManager.getInstance(RuntimeEnvironment.getApplication())
+ .getWorkInfosForUniqueWork("upload_queue_1@room")
+ .get()
+ .size
+
+ protected fun referenceId(order: Int) = SendMessageUtils().generateGroupedReferenceId(UPLOAD_ID, order)
+
+ @Suppress("UNCHECKED_CAST")
+ protected fun ChatViewModel.uploadWorkIds(): Map =
+ ChatViewModel::class.java.getDeclaredField("uploadReferenceToWorkId")
+ .apply { isAccessible = true }
+ .get(this) as Map
+
+ protected fun ChatViewModel.send(name: String, order: Int) {
+ uploadFile(
+ fileUri = "content://" + AUTHORITY + "/" + name,
+ isVoiceMessage = false,
+ displayName = "Team",
+ uploadId = UPLOAD_ID,
+ order = order
+ )
+ }
+
+ protected fun newViewModel(): ChatViewModel {
+ val user = User().apply { id = 1L }
+ val viewModel = ChatViewModel(
+ logger = mock(),
+ appPreferences = mock(defaultAnswer = RETURNS_DEEP_STUBS),
+ chatNetworkDataSource = mock(defaultAnswer = RETURNS_DEEP_STUBS),
+ chatRepository = chatRepository,
+ threadsRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS),
+ conversationRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS),
+ reactionsRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS),
+ unifiedSearchRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS),
+ mediaRecorderManager = mock(defaultAnswer = RETURNS_DEEP_STUBS),
+ audioFocusRequestManager = mock(defaultAnswer = RETURNS_DEEP_STUBS),
+ userManager = mock(defaultAnswer = RETURNS_DEEP_STUBS),
+ appScope = CoroutineScope(Dispatchers.Unconfined),
+ initialUser = user,
+ chatRoomToken = "room",
+ conversationThreadId = null
+ )
+ viewModel.participantPermissions = Mockito.mock(ParticipantPermissions::class.java).also {
+ whenever(it.hasChatPermission()).thenReturn(true)
+ }
+ return viewModel
+ }
+
+ protected fun sendAndAwaitPlaceholder(name: String) {
+ newViewModel().send(name, order = 1)
+
+ val deadline = System.currentTimeMillis() + TIMEOUT_MS
+ while (placeholderAdded.count > 0 && System.currentTimeMillis() < deadline) {
+ shadowOf(Looper.getMainLooper()).idle()
+ placeholderAdded.await(POLL_MS, TimeUnit.MILLISECONDS)
+ }
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt
new file mode 100644
index 0000000000..2244536533
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt
@@ -0,0 +1,557 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.jobs
+
+import android.app.Application
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.res.Resources
+import android.net.Uri
+import androidx.concurrent.futures.ResolvableFuture
+import androidx.test.core.app.ApplicationProvider
+import androidx.work.Data
+import androidx.work.ListenableWorker
+import androidx.work.WorkerFactory
+import androidx.work.WorkerParameters
+import androidx.work.testing.TestListenableWorkerBuilder
+import com.nextcloud.talk.api.NcApi
+import com.nextcloud.talk.api.NcApiCoroutines
+import com.nextcloud.talk.chat.data.model.ChatMessage
+import com.nextcloud.talk.data.database.dao.ChatMessagesDao
+import com.nextcloud.talk.data.database.model.ChatMessageEntity
+import com.nextcloud.talk.data.database.model.SendStatus
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto
+import com.nextcloud.talk.models.json.capabilities.SpreedCapabilityDto
+import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachmentDataDto
+import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachmentFolderOCS
+import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachmentFolderOverall
+import com.nextcloud.talk.models.json.generic.GenericOverall
+import com.nextcloud.talk.upload.PreparedUpload
+import com.nextcloud.talk.upload.UploadRetryPolicy
+import com.nextcloud.talk.upload.UploadWorkspace
+import com.nextcloud.talk.users.UserManager
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID
+import io.reactivex.Observable
+import java.io.File
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import mockwebserver3.Dispatcher
+import mockwebserver3.MockResponse
+import mockwebserver3.MockWebServer
+import mockwebserver3.RecordedRequest
+import okhttp3.OkHttpClient
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import retrofit2.HttpException
+import retrofit2.Response
+
+/**
+ * What an upload that fails leaves behind: the state of the work, the placeholder message of the chat and the
+ * workspace. The worker runs directly (doWork), without a WorkManager, so one run is one call.
+ */
+@Suppress("TooManyFunctions")
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [33])
+class UploadAndShareFilesWorkerFailureTest {
+
+ private val rows = ConcurrentHashMap()
+ private val foregroundRequests = AtomicInteger()
+ private val spreed: SpreedCapabilityDto = mock()
+ private val capabilities: CapabilitiesDto = mock { on { spreedCapability } doReturn spreed }
+ private val user: User = mock {
+ on { username } doReturn "alice"
+ on { userId } doReturn "alice"
+ on { token } doReturn "secret"
+ on { baseUrl } doReturn "https://cloud.example"
+ on { capabilities } doReturn capabilities
+ }
+ private val userManager: UserManager = mock {
+ on { runBlocking { getUserWithId(ACCOUNT_ID) } } doReturn user
+ }
+ private val ncApi: NcApi = mock()
+ private val ncApiCoroutines: NcApiCoroutines = mock()
+ private val server = MockWebServer()
+ private val chatDao: ChatMessagesDao = mock {
+ on { getTempMessageForConversation(any(), any(), anyOrNull()) } doAnswer {
+ flowOf(rows[it.getArgument(1)])
+ }
+ }
+
+ init {
+ // The worker writes only the send status of the temporary row.
+ doAnswer {
+ val referenceId = it.getArgument(1)
+ val row = rows[referenceId]
+ row?.let { found -> rows[referenceId] = found.copy(sendStatus = it.getArgument(2)) }
+ if (row == null) 0 else 1
+ }.whenever(chatDao).updateTempMessageSendStatus(any(), any(), any())
+ }
+
+ @After
+ fun tearDown() {
+ runCatching { server.close() }
+ }
+
+ @Test
+ fun `an unavailable server is waited for and the placeholder stays pending`() {
+ val worker = worker()
+ uploadedAndRefusedByServer(500)
+
+ val result = worker.doWork()
+
+ assertEquals(ListenableWorker.Result.retry(), result)
+ assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus)
+ assertEquals(true, File(workspaceDir(worker.id), "unavailable").exists())
+ }
+
+ @Test
+ fun `a server that stays unavailable for 24 hours fails the upload`() {
+ val worker = worker()
+ uploadedAndRefusedByServer(503)
+ // The first "unavailable" answer came 25 hours ago, the server kept answering and the upload did not move on.
+ repeat(UploadRetryPolicy.MIN_UNAVAILABLE_ANSWERS) {
+ UploadWorkspace(workspaceDir(worker.id)).registerUnavailable(now() - HOURS_25)
+ }
+
+ val result = worker.doWork()
+
+ assertTrue(result is ListenableWorker.Result.Success)
+ assertEquals(SendStatus.FAILED, rows[REFERENCE_ID]?.sendStatus)
+ assertFalse("the workspace is removed", workspaceDir(worker.id).exists())
+ }
+
+ @Test
+ fun `time without a network does not fail the upload at the first answer of the server`() {
+ val worker = worker()
+ uploadedAndRefusedByServer(503)
+ // One answer 25 hours ago, then no network until now: this is the second answer.
+ UploadWorkspace(workspaceDir(worker.id)).registerUnavailable(now() - HOURS_25)
+
+ val result = worker.doWork()
+
+ assertEquals(ListenableWorker.Result.retry(), result)
+ assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus)
+ }
+
+ @Test
+ fun `a clock that went back starts the 24 hours again`() {
+ val worker = worker()
+ uploadedAndRefusedByServer(503)
+ repeat(ANSWERS) { UploadWorkspace(workspaceDir(worker.id)).registerUnavailable(now() + HOURS_25) }
+ val before = now()
+
+ val result = worker.doWork()
+
+ assertEquals(ListenableWorker.Result.retry(), result)
+ val since = File(workspaceDir(worker.id), "unavailable").readLines().first().toLong()
+ assertTrue("the clock starts at the time of the answer", since in before..now())
+ }
+
+ @Test
+ fun `the file that got uploaded since the last answer starts the 24 hours again`() {
+ val worker = worker()
+ uploadedAndRefusedByServer(503)
+ // The last answers came before the file was uploaded (the point -1 is "nothing sent yet").
+ seedUnavailable(worker, since = now() - HOURS_25, point = -1, answers = ANSWERS)
+
+ val result = worker.doWork()
+
+ assertEquals(ListenableWorker.Result.retry(), result)
+ assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus)
+ }
+
+ @Test
+ fun `progress made after the last answer keeps the upload alive in a later run`() {
+ // Run 1: unavailable at 10 %. The upload went on to 80 %, then the network was lost and the process killed.
+ // Run 2, 26 hours later: the first request of the run, the probe, is answered "unavailable".
+ val worker = worker(source = fileUri(SMALL_FILE_SIZE), uploaded = false, draft = true)
+ probeRefusedByServer(503)
+ seedUnavailable(worker, since = now() - HOURS_26, point = 10, answers = ANSWERS)
+ File(workspaceDir(worker.id), "progress").writeText("80")
+
+ val result = worker.doWork()
+
+ assertEquals(ListenableWorker.Result.retry(), result)
+ assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus)
+ }
+
+ @Test
+ fun `an upload restarted from the beginning is not failed for the progress it had before`() {
+ val worker = worker(source = fileUri(SMALL_FILE_SIZE), uploaded = false, draft = true)
+ probeRefusedByServer(503)
+ seedUnavailable(worker, since = now() - HOURS_25, point = 100, answers = ANSWERS)
+ val workspace = UploadWorkspace(workspaceDir(worker.id))
+ workspace.recordProgress(100)
+ // The server rejected the assembly: the chunks are sent again from 0 %, now at 30 %.
+ workspace.markRestarted()
+ workspace.recordProgress(30)
+
+ val result = worker.doWork()
+
+ assertEquals(ListenableWorker.Result.retry(), result)
+ assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus)
+ }
+
+ @Test
+ fun `progress reported to the worker starts the 24 hours again`() {
+ val id = UUID.randomUUID()
+ val first = worker(id = id, source = fileUri(SMALL_FILE_SIZE), uploaded = false, draft = true)
+ probeRefusedByServer(503)
+ assertEquals(ListenableWorker.Result.retry(), first.doWork())
+ // The upload sends data, the network is lost, 25 hours pass (the workspace of the retry is kept).
+ first.onTransferProgress(PROGRESS_PERCENT)
+ seedUnavailable(first, since = now() - HOURS_25, point = -1, answers = ANSWERS)
+
+ val second = worker(id = id, source = fileUri(SMALL_FILE_SIZE), uploaded = false, draft = true)
+ probeRefusedByServer(503)
+ val result = second.doWork()
+
+ assertEquals(ListenableWorker.Result.retry(), result)
+ assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus)
+ }
+
+ @Test
+ fun `a workspace that cannot be written does not end the work as failed`() {
+ val worker = worker()
+ uploadedAndRefusedByServer(503)
+ // A directory in the place of the temporary file makes every write of this file fail.
+ File(workspaceDir(worker.id), "unavailable.tmp").mkdirs()
+
+ val result = worker.doWork()
+
+ assertEquals(ListenableWorker.Result.retry(), result)
+ assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus)
+ }
+
+ @Test
+ fun `a server error that cannot be stored does not end the work as failed`() {
+ val worker = worker()
+ uploadedAndRefusedByServer(403)
+ File(workspaceDir(worker.id), "server_errors.tmp").mkdirs()
+
+ val result = worker.doWork()
+
+ assertEquals(ListenableWorker.Result.retry(), result)
+ assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus)
+ }
+
+ // The file "unavailable" as the workspace writes it: the time, the progress point and the number of answers.
+ private fun seedUnavailable(worker: UploadAndShareFilesWorker, since: Long, point: Int, answers: Int) {
+ workspaceDir(worker.id).mkdirs()
+ File(workspaceDir(worker.id), "unavailable").writeText("$since\n$point\n$answers")
+ }
+
+ private fun probeRefusedByServer(code: Int) {
+ val error = HttpException(Response.error(code, "".toResponseBody()))
+ runBlocking {
+ whenever(ncApiCoroutines.probeConversationAttachmentFolder(any(), any(), any())).thenThrow(error)
+ }
+ }
+
+ @Test
+ fun `a failed upload reports the failure in the output data`() {
+ val worker = worker()
+ whenever(runBlocking { userManager.getUserWithId(ACCOUNT_ID) }).thenReturn(null)
+
+ val result = worker.doWork()
+
+ assertTrue(result is ListenableWorker.Result.Success)
+ assertEquals(true, result.outputData.getBoolean(UploadAndShareFilesWorker.KEY_UPLOAD_FAILED, false))
+ assertEquals(SendStatus.FAILED, rows[REFERENCE_ID]?.sendStatus)
+ }
+
+ @Test
+ fun `a cancelled upload does not report a failure`() {
+ val worker = worker()
+ UploadWorkspace(workspaceDir(worker.id)).markCancelled()
+
+ val result = worker.doWork()
+
+ assertTrue(result is ListenableWorker.Result.Success)
+ assertFalse(result.outputData.getBoolean(UploadAndShareFilesWorker.KEY_UPLOAD_FAILED, false))
+ }
+
+ @Test
+ fun `a delivered upload does not report a failure`() {
+ val worker = worker()
+ UploadWorkspace(workspaceDir(worker.id)).markShared()
+
+ val result = worker.doWork()
+
+ assertTrue(result is ListenableWorker.Result.Success)
+ assertFalse(result.outputData.getBoolean(UploadAndShareFilesWorker.KEY_UPLOAD_FAILED, false))
+ assertEquals(SendStatus.SENT_PENDING_ACK, rows[REFERENCE_ID]?.sendStatus)
+ }
+
+ @Test
+ fun `a failed upload removes its file from the draft folder`() {
+ val server = startServer()
+ val worker = worker(draft = true)
+ refusePublishing(worker)
+
+ worker.doWork()
+
+ assertEquals(SendStatus.FAILED, rows[REFERENCE_ID]?.sendStatus)
+ val request = server.takeRequest(REQUEST_WAIT_SECONDS, TimeUnit.SECONDS)
+ assertEquals("DELETE", request?.method)
+ assertEquals("/remote.php/dav/files/alice/Talk/draft/id-photo.jpg", request?.target)
+ }
+
+ @Test
+ fun `a failed upload leaves the file in the attachment folder of Talk`() {
+ val server = startServer()
+ val worker = worker(draft = false)
+ refusePublishing(worker)
+
+ worker.doWork()
+
+ assertEquals(SendStatus.FAILED, rows[REFERENCE_ID]?.sendStatus)
+ assertEquals(0, server.requestCount)
+ }
+
+ @Test
+ fun `a cancelled upload still removes its draft file`() {
+ val server = startServer()
+ val worker = worker(draft = true)
+ UploadWorkspace(workspaceDir(worker.id)).markCancelled()
+
+ worker.doWork()
+
+ assertEquals("DELETE", server.takeRequest(REQUEST_WAIT_SECONDS, TimeUnit.SECONDS)?.method)
+ }
+
+ // Three server errors are counted already, so the next refusal of the server is the last one.
+ private fun refusePublishing(worker: UploadAndShareFilesWorker) {
+ val workspace = UploadWorkspace(workspaceDir(worker.id))
+ repeat(UploadRetryPolicy.MAX_SERVER_ERRORS - 1) { workspace.registerServerError() }
+ val forbidden = HttpException(Response.error(FORBIDDEN, "".toResponseBody()))
+ // The draft path publishes with postConversationAttachment, the attachment folder path shares.
+ whenever(runBlocking { ncApiCoroutines.postConversationAttachment(any(), any(), any()) }).thenThrow(forbidden)
+ whenever(ncApi.createRemoteShare(anyOrNull(), any(), any(), any(), any(), anyOrNull(), any()))
+ .thenReturn(Observable.error(forbidden))
+ }
+
+ private fun startServer(): MockWebServer {
+ server.dispatcher = object : Dispatcher() {
+ override fun dispatch(request: RecordedRequest) = MockResponse(code = NO_CONTENT)
+ }
+ server.start()
+ whenever(user.baseUrl).thenReturn(server.url("/").toString().trimEnd('/'))
+ return server
+ }
+
+ @Test
+ fun `a small file is uploaded without a foreground service`() {
+ val worker = worker(source = fileUri(SMALL_FILE_SIZE), uploaded = false)
+
+ worker.doWork()
+
+ assertEquals(0, foregroundRequests.get())
+ }
+
+ @Test
+ fun `a file that is uploaded in chunks runs as a foreground service`() {
+ val worker = worker(source = fileUri(BIG_FILE_SIZE), uploaded = false)
+
+ worker.doWork()
+
+ assertEquals(1, foregroundRequests.get())
+ }
+
+ @Test
+ fun `an upload that only needs the last request runs without a foreground service`() {
+ val worker = worker(source = fileUri(BIG_FILE_SIZE), uploaded = true)
+ uploadedAndRefusedByServer(500)
+
+ worker.doWork()
+
+ assertEquals(0, foregroundRequests.get())
+ }
+
+ @Test
+ fun `a later run takes the size from the prepared copy, not from the source`() {
+ // The source of unknown size would count as big; its copy is small.
+ val worker = worker(source = "content://media/photo.jpg", uploaded = false, preparedSize = SMALL_FILE_SIZE)
+
+ worker.doWork()
+
+ assertEquals(0, foregroundRequests.get())
+ }
+
+ @Test
+ fun `a later run with a big prepared copy runs as a foreground service`() {
+ val worker = worker(source = fileUri(SMALL_FILE_SIZE), uploaded = false, preparedSize = BIG_FILE_SIZE)
+
+ worker.doWork()
+
+ assertEquals(1, foregroundRequests.get())
+ }
+
+ @Test
+ fun `an empty file is uploaded without a foreground service`() {
+ val worker = worker(source = fileUri(0), uploaded = false)
+
+ worker.doWork()
+
+ assertEquals(0, foregroundRequests.get())
+ }
+
+ @Test
+ fun `an empty prepared copy is taken as it is and needs no foreground service`() {
+ // The source of unknown size would count as big; the copy is empty and small.
+ val worker = worker(source = "content://media/empty.txt", uploaded = false, preparedSize = 0)
+
+ worker.doWork()
+
+ assertEquals(0, foregroundRequests.get())
+ }
+
+ private fun fileUri(size: Int): String =
+ Uri.fromFile(File(context().cacheDir, "source-$size.bin").apply { writeBytes(ByteArray(size)) }).toString()
+
+ /** The file is uploaded already and the server refuses the share with [code]. */
+ private fun uploadedAndRefusedByServer(code: Int) {
+ whenever(ncApi.createRemoteShare(anyOrNull(), any(), any(), any(), any(), anyOrNull(), any()))
+ .thenReturn(Observable.error(HttpException(Response.error(code, "".toResponseBody()))))
+ }
+
+ private fun worker(
+ source: String = "content://media/photo.jpg",
+ uploaded: Boolean = true,
+ draft: Boolean = false,
+ id: UUID? = null,
+ preparedSize: Int? = null
+ ): UploadAndShareFilesWorker {
+ rows[REFERENCE_ID] = placeholder()
+ if (draft) {
+ // The draft folder path is used when the server has conversation subfolders.
+ val attachments = hashMapOf("conversation-subfolders" to true)
+ whenever(spreed.config).thenReturn(hashMapOf("attachments" to attachments))
+ runBlocking {
+ whenever(ncApiCoroutines.probeConversationAttachmentFolder(any(), any(), any())).thenReturn(
+ ChatProbeAttachmentFolderOverall(
+ ChatProbeAttachmentFolderOCS(null, ChatProbeAttachmentDataDto(folder = "Talk/draft"))
+ )
+ )
+ }
+ }
+ // Without capabilities a run that has to upload ends at once, after the foreground service was asked for.
+ if (!uploaded && !draft) {
+ whenever(user.capabilities).thenReturn(null)
+ }
+ val factory = object : WorkerFactory() {
+ override fun createWorker(
+ appContext: Context,
+ workerClassName: String,
+ workerParameters: WorkerParameters
+ ): ListenableWorker =
+ UploadAndShareFilesWorker(appContext, workerParameters).also {
+ it.userManager = userManager
+ it.chatDao = chatDao
+ it.ncApi = ncApi
+ it.ncApiCoroutines = ncApiCoroutines
+ it.okHttpClient = OkHttpClient()
+ }
+ }
+ val input = Data.Builder()
+ .putLong(KEY_INTERNAL_USER_ID, ACCOUNT_ID)
+ .putString("DEVICE_SOURCE_FILE", source)
+ .putString("ROOM_TOKEN", "room")
+ .putString("CONVERSATION_NAME", "Room")
+ .putString(UploadAndShareFilesWorker.KEY_REFERENCE_ID, REFERENCE_ID)
+ .putString(UploadAndShareFilesWorker.KEY_INTERNAL_CONVERSATION_ID, CONVERSATION_ID)
+ .build()
+ val worker = TestListenableWorkerBuilder(contextWithStrings())
+ .setInputData(input)
+ .setWorkerFactory(factory)
+ .apply { id?.let { setId(it) } }
+ .setForegroundUpdater { _, _, _ ->
+ foregroundRequests.incrementAndGet()
+ ResolvableFuture.create().also { it.set(null) }
+ }
+ .build()
+ if (uploaded) {
+ val path = if (draft) "/Talk/draft/id-photo.jpg" else "/Talk/photo.jpg"
+ UploadWorkspace(workspaceDir(worker.id)).markUploaded(path, "photo.jpg", draft)
+ }
+ preparedSize?.let { size ->
+ UploadWorkspace(workspaceDir(worker.id)).prepareOnce { dir ->
+ PreparedUpload(File(dir, "photo.jpg").apply { writeBytes(ByteArray(size)) }, "photo.jpg")
+ }
+ }
+ return worker
+ }
+
+ private fun placeholder() =
+ ChatMessageEntity(
+ internalId = "$CONVERSATION_ID@_temp_$REFERENCE_ID",
+ accountId = ACCOUNT_ID,
+ token = "room",
+ internalConversationId = CONVERSATION_ID,
+ actorDisplayName = "Me",
+ message = "file",
+ actorId = "me",
+ actorType = "users",
+ messageType = "comment",
+ systemMessageType = ChatMessage.SystemMessageType.DUMMY,
+ referenceId = REFERENCE_ID,
+ isTemporary = true,
+ sendStatus = SendStatus.PENDING
+ )
+
+ private fun workspaceDir(id: UUID) = File(File(context().noBackupFilesDir, "uploads"), id.toString())
+
+ private fun now() = System.currentTimeMillis()
+
+ // The unit tests have no resources of the app; the failure notification only needs some text.
+ private fun contextWithStrings(): Context {
+ val base = context().resources
+
+ @Suppress("DEPRECATION")
+ val resources = object : Resources(base.assets, base.displayMetrics, base.configuration) {
+ override fun getString(id: Int): String = "text %s"
+ }
+ return object : ContextWrapper(context()) {
+ override fun getResources(): Resources = resources
+ }
+ }
+
+ private fun context(): Context = ApplicationProvider.getApplicationContext()
+
+ companion object {
+ private const val ACCOUNT_ID = 1L
+ private const val CONVERSATION_ID = "1@room"
+ private const val REFERENCE_ID = "ref-a"
+ private const val HOURS_25 = 25L * 60 * 60 * 1000
+ private const val HOURS_26 = 26L * 60 * 60 * 1000
+ private const val ANSWERS = 10
+ private const val PROGRESS_PERCENT = 50
+ private const val FORBIDDEN = 403
+ private const val NO_CONTENT = 204
+ private const val REQUEST_WAIT_SECONDS = 5L
+ private const val SMALL_FILE_SIZE = 10 * 1024
+ private const val BIG_FILE_SIZE = 1024 * 1024 + 1
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt
new file mode 100644
index 0000000000..58b3a5687e
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt
@@ -0,0 +1,361 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.jobs
+
+import android.app.Application
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.work.Configuration
+import androidx.work.ListenableWorker
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.WorkerFactory
+import androidx.work.WorkerParameters
+import androidx.work.impl.Processor
+import androidx.work.impl.StartStopToken
+import androidx.work.impl.WorkManagerImpl
+import androidx.work.testing.WorkManagerTestInitHelper
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.chat.data.model.ChatMessage
+import com.nextcloud.talk.data.database.dao.ChatMessagesDao
+import com.nextcloud.talk.data.database.model.ChatMessageEntity
+import com.nextcloud.talk.data.database.model.SendStatus
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.upload.UploadWorkspace
+import com.nextcloud.talk.users.UserManager
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.File
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * The uploads of a conversation are chained behind each other (APPEND_OR_REPLACE). Cancelling one upload must
+ * not cancel or fail the uploads behind it: WorkManager gives every request chained behind a cancelled or failed
+ * one the same state without running it, and their placeholder rows would stay PENDING for ever.
+ *
+ * The works run on a real WorkManager. A request chained behind another one is BLOCKED until the one ahead is
+ * done, so the network constraint is granted one request after the other, as in [UploadAndShareFilesWorkerTest].
+ * The uploads that are not cancelled are marked as shared, so they finish at once without a server.
+ */
+@Suppress("TooManyFunctions")
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [33])
+class UploadCancelCascadeTest {
+
+ private val rows = ConcurrentHashMap()
+
+ // Not a SynchronousExecutor: the worker looks up other works (deleteFinishedWorkspaces) and waits for the
+ // answer, which a worker running on the thread of WorkManager's own task executor would never get.
+ private val workerExecutor = Executors.newFixedThreadPool(2)
+ private val previousApplication = NextcloudTalkApplication.sharedApplication
+ private val user: User = mock()
+ private val userManager: UserManager = mock {
+ on { runBlocking { getUserWithId(ACCOUNT_ID) } } doReturn user
+ }
+ private val chatDao: ChatMessagesDao = mock {
+ on { getTempMessageForConversation(any(), any(), anyOrNull()) } doAnswer {
+ flowOf(rows[it.getArgument(1)])
+ }
+ on { deleteTempChatMessageIfPending(any(), any()) } doAnswer {
+ val removed = rows[it.getArgument(1)]?.takeIf { row -> row.sendStatus == SendStatus.PENDING }
+ removed?.let { row -> rows.remove(row.referenceId) }
+ if (removed == null) 0 else 1
+ }
+ }
+
+ init {
+ // The worker writes only the send status of the temporary row.
+ doAnswer {
+ val referenceId = it.getArgument(1)
+ rows[referenceId]?.let { row -> rows[referenceId] = row.copy(sendStatus = it.getArgument(2)) }
+ 1
+ }.whenever(chatDao).updateTempMessageSendStatus(any(), any(), any())
+ }
+
+ @After
+ fun tearDown() {
+ workerExecutor.shutdownNow()
+ setSharedApplication(previousApplication)
+ }
+
+ @Test
+ fun `cancelling the first upload does not cancel the uploads behind it`() {
+ val workManager = initWorkManager()
+ val (a, b, c) = enqueue("ref-a", "ref-b", "ref-c")
+
+ UploadAndShareFilesWorker.cancelUpload("ref-a", a)
+ awaitAllFinished(workManager, a, b, c)
+
+ assertAllSucceeded(workManager, a, b, c)
+ assertCancelled("ref-a")
+ assertDelivered("ref-b", "ref-c")
+ }
+
+ @Test
+ fun `cancelling a middle upload leaves the ones before and behind it`() {
+ val workManager = initWorkManager()
+ val (a, b, c) = enqueue("ref-a", "ref-b", "ref-c")
+
+ UploadAndShareFilesWorker.cancelUpload("ref-b", b)
+ awaitAllFinished(workManager, a, b, c)
+
+ assertAllSucceeded(workManager, a, b, c)
+ assertCancelled("ref-b")
+ assertDelivered("ref-a", "ref-c")
+ }
+
+ @Test
+ fun `cancelling the last upload leaves the ones ahead of it`() {
+ val workManager = initWorkManager()
+ val (a, b, c) = enqueue("ref-a", "ref-b", "ref-c")
+
+ UploadAndShareFilesWorker.cancelUpload("ref-c", c)
+ awaitAllFinished(workManager, a, b, c)
+
+ assertAllSucceeded(workManager, a, b, c)
+ assertCancelled("ref-c")
+ assertDelivered("ref-a", "ref-b")
+ }
+
+ @Test
+ fun `cancelling every upload one after the other finishes them all`() {
+ val workManager = initWorkManager()
+ val (a, b, c) = enqueue("ref-a", "ref-b", "ref-c")
+
+ UploadAndShareFilesWorker.cancelUpload("ref-a", a)
+ UploadAndShareFilesWorker.cancelUpload("ref-b", b)
+ UploadAndShareFilesWorker.cancelUpload("ref-c", c)
+ awaitAllFinished(workManager, a, b, c)
+
+ assertAllSucceeded(workManager, a, b, c)
+ assertCancelled("ref-a", "ref-b", "ref-c")
+ }
+
+ @Test
+ fun `an upload cancelled by its tag after a restart does not cancel the uploads behind it`() {
+ val workManager = initWorkManager()
+ val (a, b, c) = enqueue("ref-a", "ref-b", "ref-c")
+
+ // After a restart of the app the id of the work is not known, only the reference id.
+ UploadAndShareFilesWorker.cancelUpload("ref-a", null)
+ awaitAllFinished(workManager, a, b, c)
+
+ assertAllSucceeded(workManager, a, b, c)
+ assertCancelled("ref-a")
+ assertDelivered("ref-b", "ref-c")
+ }
+
+ @Test
+ fun `a cancelled upload whose placeholder cannot be removed does not fail the uploads behind it`() {
+ val workManager = initWorkManager()
+ whenever(chatDao.deleteTempChatMessageIfPending(any(), any())).thenThrow(IllegalStateException("db closed"))
+ val (a, b, c) = enqueue("ref-a", "ref-b", "ref-c")
+
+ UploadAndShareFilesWorker.cancelUpload("ref-a", a)
+ awaitAllFinished(workManager, a, b, c)
+
+ assertAllSucceeded(workManager, a, b, c)
+ assertDelivered("ref-b", "ref-c")
+ }
+
+ @Test
+ fun `an upload cancelled while WorkManager stops it is still cancelled in its next run`() {
+ val workManager = initWorkManager()
+ val firstRunInside = CountDownLatch(1)
+ val firstRunMayGo = CountDownLatch(1)
+ val lookups = AtomicInteger()
+ // The first run waits at the lookup of the account, as an upload that is busy when its network is lost.
+ whenever(runBlocking { userManager.getUserWithId(ACCOUNT_ID) }).thenAnswer {
+ if (lookups.getAndIncrement() == 0) {
+ firstRunInside.countDown()
+ firstRunMayGo.await(WAIT_MILLIS, TimeUnit.MILLISECONDS)
+ }
+ user
+ }
+ val (a) = enqueue("ref-a")
+ val driver = WorkManagerTestInitHelper.getTestDriver(context())!!
+
+ driver.setAllConstraintsMet(a)
+ assertTrue(firstRunInside.await(WAIT_MILLIS, TimeUnit.MILLISECONDS))
+ // The network is lost: WorkManager stops the run, which goes on in its thread, and the user cancels.
+ stopRun(a)
+ UploadAndShareFilesWorker.cancelUpload("ref-a", a)
+ firstRunMayGo.countDown()
+ awaitStoppedRunFinished(a)
+ assertTrue("the cancel flag survives the stopped run", File(workspaceDir(a), "cancelled").exists())
+
+ awaitAllFinished(workManager, a)
+
+ assertAllSucceeded(workManager, a)
+ assertCancelled("ref-a")
+ assertEquals("the second run looked the account up", 2, lookups.get())
+ }
+
+ // What WorkManager does when the network is lost: stops the run, whose thread goes on, and schedules the
+ // work again.
+ private fun stopRun(id: UUID) {
+ val processor = WorkManagerImpl.getInstance(context()).processor
+ // The processor knows a run by the token it started it with, so the token is taken from its map of runs.
+ val runs = Processor::class.java.getDeclaredField("mWorkRuns").apply { isAccessible = true }
+ .get(processor) as Map<*, *>
+ val token = (runs[id.toString()] as Set<*>).single() as StartStopToken
+ assertTrue(
+ "the run was stopped",
+ processor.stopWork(token, WorkInfo.STOP_REASON_CONSTRAINT_CONNECTIVITY)
+ )
+ }
+
+ // The run that WorkManager stopped is over when it released the lock of its workspace.
+ private fun awaitStoppedRunFinished(id: UUID) {
+ val deadline = System.currentTimeMillis() + WAIT_MILLIS
+ while (true) {
+ val workspace = UploadWorkspace(workspaceDir(id))
+ if (workspace.tryLock()) {
+ workspace.unlock()
+ return
+ }
+ check(System.currentTimeMillis() < deadline) { "the stopped run did not finish" }
+ Thread.sleep(POLL_MILLIS)
+ }
+ }
+
+ private fun workspaceDir(id: UUID) = File(File(context().noBackupFilesDir, "uploads"), id.toString())
+
+ private fun assertAllSucceeded(workManager: WorkManager, vararg ids: UUID) {
+ assertEquals(
+ ids.map { WorkInfo.State.SUCCEEDED },
+ ids.map { workManager.getWorkInfoById(it).get()!!.state }
+ )
+ }
+
+ private fun assertCancelled(vararg referenceIds: String) {
+ referenceIds.forEach {
+ assertNull("the placeholder of $it is removed", rows[it])
+ }
+ }
+
+ private fun assertDelivered(vararg referenceIds: String) {
+ referenceIds.forEach {
+ assertEquals("the placeholder of $it", SendStatus.SENT_PENDING_ACK, rows[it]?.sendStatus)
+ }
+ }
+
+ /**
+ * Enqueues one upload per reference id into the queue of the conversation, in this order. The placeholder rows
+ * are PENDING. The uploads are marked as shared; only a cancel changes what a work does.
+ */
+ private fun enqueue(vararg referenceIds: String): List =
+ referenceIds.map { referenceId ->
+ rows[referenceId] = placeholder(referenceId)
+ val id = UploadAndShareFilesWorker.upload(
+ userId = ACCOUNT_ID,
+ fileUri = "content://media/$referenceId",
+ roomToken = "room",
+ conversationName = "Room",
+ metaData = null,
+ referenceId = referenceId,
+ internalConversationId = CONVERSATION_ID
+ )
+ UploadWorkspace(workspaceDir(id)).markShared()
+ id
+ }
+
+ private fun placeholder(referenceId: String) =
+ ChatMessageEntity(
+ internalId = "$CONVERSATION_ID@_temp_$referenceId",
+ accountId = ACCOUNT_ID,
+ token = "room",
+ internalConversationId = CONVERSATION_ID,
+ actorDisplayName = "Me",
+ message = "file",
+ actorId = "me",
+ actorType = "users",
+ messageType = "comment",
+ systemMessageType = ChatMessage.SystemMessageType.DUMMY,
+ referenceId = referenceId,
+ isTemporary = true,
+ sendStatus = SendStatus.PENDING
+ )
+
+ private fun initWorkManager(): WorkManager {
+ val factory = object : WorkerFactory() {
+ override fun createWorker(
+ appContext: Context,
+ workerClassName: String,
+ workerParameters: WorkerParameters
+ ): ListenableWorker =
+ UploadAndShareFilesWorker(appContext, workerParameters).also {
+ it.userManager = userManager
+ it.chatDao = chatDao
+ }
+ }
+ val configuration = Configuration.Builder()
+ .setExecutor(workerExecutor)
+ .setWorkerFactory(factory)
+ .build()
+ WorkManagerTestInitHelper.initializeTestWorkManager(context(), configuration)
+ // cancelUpload finds the context of the application here.
+ setSharedApplication(
+ mock {
+ on { applicationContext } doReturn context()
+ }
+ )
+ return WorkManager.getInstance(context())
+ }
+
+ private fun awaitAllFinished(workManager: WorkManager, vararg ids: UUID) {
+ val deadline = System.currentTimeMillis() + WAIT_MILLIS
+ val driver = WorkManagerTestInitHelper.getTestDriver(context())!!
+ while (ids.any { !workManager.getWorkInfoById(it).get()!!.state.isFinished }) {
+ // A request only becomes ENQUEUED once the one ahead of it is done. The network constraint is granted
+ // to every request of the queue that is waiting for it.
+ workManager.getWorkInfosForUniqueWork("upload_queue_$CONVERSATION_ID").get()
+ .filter { it.state == WorkInfo.State.ENQUEUED }
+ .forEach { driver.setAllConstraintsMet(it.id) }
+ check(System.currentTimeMillis() < deadline) {
+ "works did not finish: " + ids.map { workManager.getWorkInfoById(it).get()!!.state }
+ }
+ Thread.sleep(POLL_MILLIS)
+ }
+ }
+
+ // The setter of sharedApplication is protected, and the real application is not started in this test.
+ private fun setSharedApplication(application: NextcloudTalkApplication?) {
+ NextcloudTalkApplication::class.java.getDeclaredField("sharedApplication").apply {
+ isAccessible = true
+ set(null, application)
+ }
+ }
+
+ private fun context(): Context = ApplicationProvider.getApplicationContext()
+
+ companion object {
+ private const val ACCOUNT_ID = 1L
+ private const val CONVERSATION_ID = "1@room"
+ private const val WAIT_MILLIS = 30_000L
+ private const val POLL_MILLIS = 20L
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/upload/UploadProgressThrottleTest.kt b/app/src/test/java/com/nextcloud/talk/upload/UploadProgressThrottleTest.kt
new file mode 100644
index 0000000000..ccaabc16de
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/upload/UploadProgressThrottleTest.kt
@@ -0,0 +1,52 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.upload
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class UploadProgressThrottleTest {
+
+ private var now = 0L
+ private val throttle = UploadProgressThrottle(1000) { now }
+
+ @Test
+ fun `first update passes`() {
+ assertTrue(throttle.shouldUpdate(1))
+ }
+
+ @Test
+ fun `updates within a second are dropped`() {
+ throttle.shouldUpdate(1)
+ now = 999
+ assertFalse(throttle.shouldUpdate(2))
+ }
+
+ @Test
+ fun `update passes after a second`() {
+ throttle.shouldUpdate(1)
+ now = 1000
+ assertTrue(throttle.shouldUpdate(2))
+ }
+
+ @Test
+ fun `unchanged value is dropped`() {
+ throttle.shouldUpdate(5)
+ now = 5000
+ assertFalse(throttle.shouldUpdate(5))
+ }
+
+ @Test
+ fun `a dropped update does not restart the interval`() {
+ throttle.shouldUpdate(1)
+ now = 600
+ throttle.shouldUpdate(2)
+ now = 1000
+ assertTrue(throttle.shouldUpdate(3))
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt b/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt
new file mode 100644
index 0000000000..4edcb6c2f1
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt
@@ -0,0 +1,205 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.upload
+
+import at.bitfire.dav4jvm.exception.HttpException as DavHttpException
+import com.nextcloud.talk.upload.UploadRetryPolicy.Decision
+import com.nextcloud.talk.upload.UploadRetryPolicy.FailureKind
+import android.app.Application
+import android.system.ErrnoException
+import android.system.OsConstants
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import retrofit2.HttpException as RetrofitHttpException
+import retrofit2.Response
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.net.SocketTimeoutException
+import java.net.UnknownHostException
+import java.net.UnknownServiceException
+import java.security.cert.CertPathValidatorException
+import java.security.cert.CertificateExpiredException
+import javax.net.ssl.SSLException
+import javax.net.ssl.SSLHandshakeException
+import javax.net.ssl.SSLPeerUnverifiedException
+
+@Suppress("TooManyFunctions")
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [33])
+class UploadRetryPolicyTest {
+
+ @Test
+ fun `connection problems are network errors`() {
+ assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classify(SocketTimeoutException()))
+ assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classify(UnknownHostException()))
+ assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classify(IOException("Canceled")))
+ }
+
+ @Test
+ fun `http errors that may stay are server errors`() {
+ assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(DavHttpException(403, "no")))
+ assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(DavHttpException(400, "bad")))
+ listOf(400, 401, 403, 404, 409, 412, 501, 505, 507).forEach {
+ assertEquals("code $it", FailureKind.SERVER, UploadRetryPolicy.classify(DavHttpException(it, "x")))
+ assertEquals("retrofit $it", FailureKind.SERVER, UploadRetryPolicy.classify(retrofit(it)))
+ }
+ assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(retrofit(404)))
+ }
+
+ @Test
+ fun `a server that is unavailable is waited for, but not for ever`() {
+ listOf(500, 502, 503, 504, 408, 429).forEach {
+ assertEquals("code $it", FailureKind.UNAVAILABLE, UploadRetryPolicy.classify(DavHttpException(it, "x")))
+ }
+ listOf(500, 502, 503, 504, 408, 429).forEach {
+ assertEquals("retrofit $it", FailureKind.UNAVAILABLE, UploadRetryPolicy.classify(retrofit(it)))
+ }
+ }
+
+ @Test
+ fun `http error wrapped in an IOException is still a server error`() {
+ val wrapped = IOException("failed to create folder", DavHttpException(403, "no"))
+ assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(wrapped))
+ }
+
+ @Test
+ fun `an IOException wrapped by RxJava is a network error`() {
+ assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classify(RuntimeException(IOException("reset"))))
+ assertEquals(
+ FailureKind.NETWORK,
+ UploadRetryPolicy.classify(RuntimeException(RuntimeException(UnknownHostException())))
+ )
+ }
+
+ @Test
+ fun `an http error wrapped by RxJava is classified by its code`() {
+ assertEquals(FailureKind.UNAVAILABLE, UploadRetryPolicy.classify(RuntimeException(retrofit(503))))
+ assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(RuntimeException(retrofit(403))))
+ }
+
+ @Test
+ fun `an http error wins over an IOException in the chain`() {
+ val error = IOException("outer", RuntimeException(DavHttpException(403, "no")))
+ assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(error))
+ }
+
+ @Test
+ fun `certificate problems count against the limit`() {
+ assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(SSLPeerUnverifiedException("who")))
+ assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(UnknownServiceException("cleartext")))
+ val untrusted = SSLHandshakeException("bad certificate").apply {
+ initCause(CertPathValidatorException("trust anchor not found"))
+ }
+ assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(untrusted))
+ assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(RuntimeException(untrusted)))
+ val expired = SSLHandshakeException("expired").apply { initCause(CertificateExpiredException()) }
+ assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(expired))
+ }
+
+ @Test
+ fun `a connection that drops inside TLS is a network error`() {
+ val reset = SSLException("Read error: ssl=0x1: I/O error during system call, Connection reset by peer")
+ assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classify(reset))
+ assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classify(SSLException("Write error: Broken pipe")))
+ assertEquals(
+ FailureKind.NETWORK,
+ UploadRetryPolicy.classify(SSLHandshakeException("Connection closed by peer"))
+ )
+ assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classify(RuntimeException(reset)))
+ }
+
+ @Test
+ fun `a full or read only disk fails the preparation at once`() {
+ val full = IOException("write failed: ENOSPC", ErrnoException("write", OsConstants.ENOSPC))
+ val readOnly = IOException("open failed: EROFS", ErrnoException("open", OsConstants.EROFS))
+ assertEquals(FailureKind.OTHER, UploadRetryPolicy.classifyPreparation(full))
+ assertEquals(FailureKind.OTHER, UploadRetryPolicy.classifyPreparation(readOnly))
+ assertEquals(FailureKind.OTHER, UploadRetryPolicy.classifyPreparation(FileNotFoundException()))
+ }
+
+ @Test
+ fun `other IO errors while preparing are waited for`() {
+ assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classifyPreparation(IOException("stream closed")))
+ assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classifyPreparation(SocketTimeoutException()))
+ val brokenPipe = IOException("write failed", ErrnoException("write", OsConstants.EPIPE))
+ assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classifyPreparation(brokenPipe))
+ }
+
+ @Test
+ fun `local problems are neither network nor server errors`() {
+ assertEquals(FailureKind.OTHER, UploadRetryPolicy.classify(FileNotFoundException()))
+ assertEquals(FailureKind.OTHER, UploadRetryPolicy.classify(RuntimeException(FileNotFoundException())))
+ assertEquals(FailureKind.OTHER, UploadRetryPolicy.classify(IllegalArgumentException()))
+ }
+
+ @Test
+ fun `a chunk that cannot be read fails at once whatever its cause is`() {
+ // ChunkFromFileRequestBody wraps every IOException of reading the chunk in a FileNotFoundException.
+ val unreadable = FileNotFoundException("Exception reading source file").apply { initCause(IOException("EIO")) }
+ assertEquals(FailureKind.OTHER, UploadRetryPolicy.classify(unreadable))
+ assertEquals(FailureKind.OTHER, UploadRetryPolicy.classify(RuntimeException(unreadable)))
+ assertEquals(FailureKind.OTHER, UploadRetryPolicy.classify(IOException("outer", unreadable)))
+ }
+
+ @Test
+ fun `an http error wins over an unreadable file`() {
+ val error = FileNotFoundException("x").apply { initCause(DavHttpException(403, "no")) }
+ assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(error))
+ }
+
+ @Test
+ fun `network errors never use up attempts`() {
+ assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.NETWORK, 0, 0L, 0))
+ assertEquals(
+ Decision.RETRY,
+ UploadRetryPolicy.decide(FailureKind.NETWORK, 0, UploadRetryPolicy.MAX_UNAVAILABLE_MILLIS * 2, 0)
+ )
+ }
+
+ @Test
+ fun `an unavailable server is waited for up to 24 hours`() {
+ val limit = UploadRetryPolicy.MAX_UNAVAILABLE_MILLIS
+ val answers = UploadRetryPolicy.MIN_UNAVAILABLE_ANSWERS
+ assertEquals(24L * 60 * 60 * 1000, limit)
+ assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, 0L, 1))
+ assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit - 1, answers))
+ assertEquals(Decision.FAIL, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit, answers))
+ assertEquals(Decision.FAIL, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit + 1, answers))
+ }
+
+ @Test
+ fun `a few answers after a long wait do not fail the upload`() {
+ val limit = UploadRetryPolicy.MAX_UNAVAILABLE_MILLIS
+ val answers = UploadRetryPolicy.MIN_UNAVAILABLE_ANSWERS
+ assertEquals(10, answers)
+ assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit * 2, 1))
+ assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit * 2, answers - 1))
+ assertEquals(Decision.FAIL, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit * 2, answers))
+ }
+
+ @Test
+ fun `server errors are retried until the limit`() {
+ for (count in 1 until UploadRetryPolicy.MAX_SERVER_ERRORS) {
+ assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.SERVER, count, 0L, 0))
+ }
+ assertEquals(
+ Decision.FAIL,
+ UploadRetryPolicy.decide(FailureKind.SERVER, UploadRetryPolicy.MAX_SERVER_ERRORS, 0L, 0)
+ )
+ }
+
+ @Test
+ fun `other errors fail at once`() {
+ assertEquals(Decision.FAIL, UploadRetryPolicy.decide(FailureKind.OTHER, 0, 0L, 0))
+ }
+
+ private fun retrofit(code: Int) = RetrofitHttpException(Response.error(code, "".toResponseBody()))
+}
diff --git a/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt b/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt
new file mode 100644
index 0000000000..cb76ad61ea
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt
@@ -0,0 +1,376 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.upload
+
+import com.nextcloud.talk.utils.FileUtils
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+
+@Suppress("TooManyFunctions")
+class UploadWorkspaceTest {
+
+ @get:Rule
+ val tempFolder = TemporaryFolder()
+
+ private fun workspace(name: String = "work") = UploadWorkspace(File(tempFolder.root, name))
+
+ private fun create(dir: File, name: String, lastModified: Long): PreparedUpload {
+ val file = File(dir, name).apply {
+ writeBytes(ByteArray(SIZE))
+ setLastModified(lastModified)
+ }
+ return PreparedUpload(file, name)
+ }
+
+ @Test
+ fun `file is prepared once and reused by later runs`() {
+ var calls = 0
+ val first = workspace().prepareOnce { dir -> calls++.let { create(dir, "photo.jpg", 1_000) } }
+ val second = workspace().prepareOnce { dir -> calls++.let { create(dir, "photo.jpg", 2_000) } }
+
+ assertEquals(1, calls)
+ assertEquals(first, second)
+ }
+
+ @Test
+ fun `chunk folder key stays the same between runs`() {
+ val first = workspace().prepareOnce { create(it, "video.mp4", 1_000) }!!
+ val keyBefore = FileUtils.md5Sum(first.file)
+ val second = workspace().prepareOnce { create(it, "video.mp4", 9_000) }!!
+
+ assertEquals(keyBefore, FileUtils.md5Sum(second.file))
+ }
+
+ @Test
+ fun `a new preparation of the same file gets another chunk folder key`() {
+ val first = workspace("a").prepareOnce { create(it, "video.mp4", 1_000) }!!
+ val second = workspace("b").prepareOnce { create(it, "video.mp4", 2_000) }!!
+
+ assertTrue(FileUtils.md5Sum(first.file) != FileUtils.md5Sum(second.file))
+ }
+
+ @Test
+ fun `file is prepared again when it disappeared`() {
+ val first = workspace().prepareOnce { create(it, "photo.jpg", 1_000) }!!
+ first.file.delete()
+ var calls = 0
+ val second = workspace().prepareOnce { dir -> calls++.let { create(dir, "photo.jpg", 3_000) } }
+
+ assertEquals(1, calls)
+ assertNotNull(second)
+ assertTrue(second!!.file.isFile)
+ }
+
+ @Test
+ fun `leftovers of an interrupted preparation are removed before the next one`() {
+ val ws = workspace()
+ ws.prepareOnce { dir ->
+ File(dir, "half.jpg").writeText("partial")
+ null
+ }
+ ws.prepareOnce { dir ->
+ assertEquals(emptyList(), dir.list()!!.toList())
+ create(dir, "photo.jpg", 1_000)
+ }
+ }
+
+ @Test
+ fun `a file named like a file of the workspace stays intact and is found again`() {
+ val names = WORKSPACE_FILE_NAMES + WORKSPACE_FILE_NAMES.map { "$it.tmp" } + "file"
+ for (name in names) {
+ val ws = workspace(name)
+ // The worker takes the lock before it prepares the file.
+ assertTrue(ws.tryLock())
+ val prepared = ws.prepareOnce { create(it, name, 1_000) }!!
+ ws.markRestarted()
+ ws.markCancelled()
+ ws.markUploaded("/Talk/$name", name)
+ ws.markShared()
+ ws.registerServerError()
+ ws.recordProgress(40)
+ ws.registerUnavailable(5_000L)
+ val uploadId = ws.uploadId()
+ assertFalse(workspace(name).tryLock())
+
+ assertEquals(name, SIZE.toLong(), prepared.file.length())
+ assertEquals(name, prepared, workspace(name).prepareOnce { null })
+ assertEquals(name, prepared, workspace(name).prepared())
+ assertEquals(name, SIZE.toLong(), prepared.file.length())
+ assertTrue(name, workspace(name).isRestarted())
+ assertTrue(name, workspace(name).isCancelled())
+ assertTrue(name, workspace(name).isShared())
+ assertEquals(name, "/Talk/$name", workspace(name).uploadedPath())
+ assertEquals(name, name, workspace(name).uploadedName())
+ assertEquals(name, 1, workspace(name).serverErrors())
+ assertEquals(name, uploadId, workspace(name).uploadId())
+ assertEquals(name, Unavailable(5_000L, 2), workspace(name).registerUnavailable(6_000L))
+ assertFalse(workspace(name).tryLock())
+ ws.unlock()
+ assertTrue(workspace(name).tryLock())
+ }
+ }
+
+ @Test
+ fun `an empty prepared file is kept and found again`() {
+ val prepared = workspace().prepareOnce { dir ->
+ PreparedUpload(File(dir, "empty.txt").apply { writeBytes(ByteArray(0)) }, "empty.txt")
+ }!!
+
+ assertEquals(0L, prepared.file.length())
+ assertEquals(prepared, workspace().prepareOnce { null })
+ assertEquals(prepared, workspace().prepared())
+ }
+
+ @Test
+ fun `server errors are counted across runs`() {
+ assertEquals(0, workspace().serverErrors())
+ assertEquals(1, workspace().registerServerError())
+ assertEquals(2, workspace().registerServerError())
+ assertEquals(2, workspace().serverErrors())
+ }
+
+ @Test
+ fun `server error count survives a new preparation`() {
+ workspace().registerServerError()
+ workspace().prepareOnce { create(it, "photo.jpg", 1_000) }
+
+ assertEquals(1, workspace().serverErrors())
+ }
+
+ @Test
+ fun `delete removes the file and the counters`() {
+ val ws = workspace()
+ val prepared = ws.prepareOnce { create(it, "photo.jpg", 1_000) }!!
+ ws.registerServerError()
+ ws.delete()
+
+ assertFalse(prepared.file.exists())
+ assertEquals(0, workspace().serverErrors())
+ assertNull(workspace().prepareOnce { null })
+ }
+
+ @Test
+ fun `workspaces of finished uploads are removed and living ones kept`() {
+ workspace("finished").prepareOnce { create(it, "a.jpg", 1_000) }
+ workspace("waiting-for-network").prepareOnce { create(it, "b.jpg", 1_000) }
+
+ UploadWorkspace.deleteFinished(tempFolder.root) { it == "waiting-for-network" }
+
+ assertFalse(File(tempFolder.root, "finished").exists())
+ assertTrue(File(tempFolder.root, "waiting-for-network").exists())
+ }
+
+ @Test
+ fun `a locked workspace is not removed even when its work is unknown`() {
+ val running = workspace("running")
+ running.prepareOnce { create(it, "a.jpg", 1_000) }
+ assertTrue(running.tryLock())
+
+ UploadWorkspace.deleteFinished(tempFolder.root) { false }
+
+ assertTrue(File(tempFolder.root, "running").exists())
+ running.unlock()
+ UploadWorkspace.deleteFinished(tempFolder.root) { false }
+ assertFalse(File(tempFolder.root, "running").exists())
+ }
+
+ @Test
+ fun `only one run can hold the lock of a workspace`() {
+ val first = workspace()
+ val second = workspace()
+
+ assertTrue(first.tryLock())
+ assertFalse(second.tryLock())
+ first.unlock()
+ assertTrue(second.tryLock())
+ second.unlock()
+ }
+
+ @Test
+ fun `the uploaded and shared stages are kept between runs`() {
+ val ws = workspace()
+ assertNull(ws.uploadedPath())
+ assertFalse(ws.isShared())
+
+ ws.markUploaded("/Talk/video.mp4", "video_compressed.mp4")
+ assertEquals("/Talk/video.mp4", workspace().uploadedPath())
+ assertEquals("video_compressed.mp4", workspace().uploadedName())
+ assertFalse(workspace().isShared())
+
+ ws.markShared()
+ assertTrue(workspace().isShared())
+ }
+
+ @Test
+ fun `an uploaded file is marked as draft only when it went to the draft folder`() {
+ val ws = workspace()
+ assertFalse(ws.isDraftUploaded())
+
+ ws.markUploaded("/Talk/a.jpg", "a.jpg")
+ assertFalse(workspace().isDraftUploaded())
+ assertEquals("a.jpg", workspace().uploadedName())
+
+ ws.markUploaded("/draft/id-a.jpg", "a.jpg", draft = true)
+ assertTrue(workspace().isDraftUploaded())
+ assertEquals("/draft/id-a.jpg", workspace().uploadedPath())
+ assertEquals("a.jpg", workspace().uploadedName())
+ }
+
+ @Test
+ fun `the stages survive a new preparation after the prepared file is gone`() {
+ val ws = workspace()
+ val prepared = ws.prepareOnce { create(it, "a.jpg", 1_000) }!!
+ ws.markUploaded("/Talk/a.jpg", "a.jpg")
+ ws.markShared()
+ ws.markRestarted()
+ prepared.file.delete()
+
+ workspace().prepareOnce { create(it, "a.jpg", 2_000) }
+
+ assertEquals("/Talk/a.jpg", workspace().uploadedPath())
+ assertTrue(workspace().isShared())
+ assertTrue(workspace().isRestarted())
+ }
+
+ @Test
+ fun `the upload id stays the same on every run`() {
+ val id = workspace().uploadId()
+
+ assertEquals(id, workspace().uploadId())
+ }
+
+ @Test
+ fun `a cancel flag can be set before the work ran and survives a new preparation`() {
+ val ws = workspace()
+ assertFalse(ws.isCancelled())
+
+ ws.markCancelled()
+ ws.prepareOnce { create(it, "a.jpg", 1_000) }
+
+ assertTrue(workspace().isCancelled())
+ }
+
+ @Test
+ fun `the stored prepared file is available without preparing`() {
+ assertNull(workspace().prepared())
+
+ val prepared = workspace().prepareOnce { create(it, "a.jpg", 1_000) }
+
+ assertEquals(prepared, workspace().prepared())
+ }
+
+ @Test
+ fun `the first unavailable answer starts the clock and later answers keep it`() {
+ assertEquals(Unavailable(1_000L, 1), workspace().registerUnavailable(1_000L))
+ assertEquals(Unavailable(1_000L, 2), workspace().registerUnavailable(5_000L))
+ assertEquals(Unavailable(1_000L, 3), workspace().registerUnavailable(9_000L))
+ }
+
+ @Test
+ fun `more progress of the upload starts the clock again`() {
+ workspace().recordProgress(10)
+ assertEquals(Unavailable(1_000L, 1), workspace().registerUnavailable(1_000L))
+ workspace().recordProgress(80)
+ assertEquals(Unavailable(5_000L, 1), workspace().registerUnavailable(5_000L))
+ assertEquals(Unavailable(5_000L, 2), workspace().registerUnavailable(9_000L))
+ }
+
+ @Test
+ fun `the uploaded file starts the clock again`() {
+ workspace().recordProgress(99)
+ workspace().registerUnavailable(1_000L)
+ workspace().markUploaded("/Talk/a.jpg", "a.jpg")
+
+ assertEquals(Unavailable(5_000L, 1), workspace().registerUnavailable(5_000L))
+ }
+
+ @Test
+ fun `a clock that went back starts the clock again`() {
+ workspace().registerUnavailable(10_000L)
+
+ assertEquals(Unavailable(2_000L, 1), workspace().registerUnavailable(2_000L))
+ }
+
+ @Test
+ fun `progress is kept as the furthest one`() {
+ workspace().recordProgress(40)
+ workspace().recordProgress(20)
+ workspace().registerUnavailable(1_000L)
+ workspace().recordProgress(40)
+
+ // 40 is not further than the 40 of the first answer.
+ assertEquals(Unavailable(1_000L, 2), workspace().registerUnavailable(2_000L))
+ }
+
+ @Test
+ fun `the clock and the progress survive a run that reuses the prepared file`() {
+ workspace().prepareOnce { create(it, "a.jpg", 1_000) }
+ workspace().recordProgress(40)
+ workspace().registerUnavailable(1_000L)
+
+ workspace().prepareOnce { create(it, "a.jpg", 2_000) }
+
+ assertEquals(Unavailable(1_000L, 2), workspace().registerUnavailable(9_000L))
+ workspace().recordProgress(41)
+ assertEquals(Unavailable(9_500L, 1), workspace().registerUnavailable(9_500L))
+ }
+
+ @Test
+ fun `a restarted upload forgets the old progress and clock`() {
+ val ws = workspace()
+ ws.recordProgress(100)
+ repeat(ANSWERS) { ws.registerUnavailable(1_000L) }
+
+ ws.markRestarted()
+ ws.recordProgress(30)
+
+ // The upload sends from the beginning: 30 % is progress, the old 25 hours do not count.
+ assertEquals(Unavailable(90_000_000L, 1), workspace().registerUnavailable(90_000_000L))
+ assertTrue(workspace().isRestarted())
+ }
+
+ @Test
+ fun `a new preparation forgets the old progress and clock`() {
+ val ws = workspace()
+ val first = ws.prepareOnce { create(it, "a.jpg", 1_000) }!!
+ ws.recordProgress(100)
+ repeat(ANSWERS) { ws.registerUnavailable(1_000L) }
+ first.file.delete()
+
+ workspace().prepareOnce { create(it, "a.jpg", 2_000) }
+ workspace().recordProgress(30)
+
+ assertEquals(Unavailable(90_000_000L, 1), workspace().registerUnavailable(90_000_000L))
+ }
+
+ companion object {
+ private const val SIZE = 16
+
+ // The files of the workspace itself, plus names a later version may use.
+ private val WORKSPACE_FILE_NAMES = listOf(
+ "prepared",
+ "lock",
+ "restarted",
+ "server_errors",
+ "uploaded",
+ "shared",
+ "cancelled",
+ "upload_id",
+ "unavailable",
+ "progress"
+ )
+
+ private const val ANSWERS = 10
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderAbortTest.kt b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderAbortTest.kt
index b5f233a4de..0b39e1c1c0 100644
--- a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderAbortTest.kt
+++ b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderAbortTest.kt
@@ -104,6 +104,19 @@ class ChunkedFileUploaderAbortTest {
assertEquals(1, successCalls)
}
+ @Test
+ fun `removes the folder of an uploader that is prepared but not yet uploading`() {
+ val file = tempFolder.newFile("prepared.txt").apply { writeBytes(ByteArray(FILE_SIZE)) }
+ val uploader = uploader()
+ uploader.prepare(file)
+ server.enqueue(MockResponse(code = NO_CONTENT))
+
+ uploader.abortUpload { successCalls++ }
+
+ assertEquals(1, successCalls)
+ assertEquals("DELETE", server.takeRequest(1, TimeUnit.SECONDS)?.method)
+ }
+
@Test
fun `reports success when the upload folder is deleted`() {
val uploader = startedUploader()
@@ -139,7 +152,7 @@ class ChunkedFileUploaderAbortTest {
server.enqueue(MockResponse(code = NOT_FOUND))
server.enqueue(MockResponse(code = FORBIDDEN))
val uploader = uploader()
- uploader.upload(file, "text/plain".toMediaType(), "/Talk/test.txt")
+ runCatching { uploader.upload(file, "text/plain".toMediaType(), "/Talk/test.txt") }
repeat(server.requestCount) { server.takeRequest(1, TimeUnit.SECONDS) }
return uploader
}
diff --git a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt
new file mode 100644
index 0000000000..10c8425053
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt
@@ -0,0 +1,535 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.upload.chunked
+
+import android.app.Application
+import at.bitfire.dav4jvm.exception.HttpException
+import com.nextcloud.talk.api.NcApiCoroutines
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.upload.UploadRetryPolicy
+import com.nextcloud.talk.utils.FileUtils
+import java.io.File
+import java.io.IOException
+import java.util.Collections
+import java.util.concurrent.TimeUnit
+import mockwebserver3.Dispatcher
+import mockwebserver3.MockResponse
+import mockwebserver3.MockWebServer
+import mockwebserver3.RecordedRequest
+import mockwebserver3.SocketEffect
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.Headers
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@Suppress("TooManyFunctions")
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [33])
+class ChunkedFileUploaderResumeTest {
+
+ @get:Rule
+ val tempFolder = TemporaryFolder()
+
+ private val server = MockWebServer()
+ private val requests: MutableList = Collections.synchronizedList(mutableListOf())
+ private lateinit var client: OkHttpClient
+ private lateinit var user: User
+ private lateinit var uploader: ChunkedFileUploader
+ private lateinit var file: File
+
+ /** Parts the fake server already holds: name -> size. */
+ private var partsOnServer: Map = emptyMap()
+ private var putResponseCode = CREATED
+ private var propfindResponseCode: Int? = null
+ private var propfindDisconnects = false
+ private var putDisconnects = false
+ private var deleteDisconnects = false
+
+ /** Answers of the next MOVE requests; 201 when empty. */
+ private val moveResponseCodes: MutableList = Collections.synchronizedList(mutableListOf())
+
+ @Before
+ fun setUp() {
+ server.dispatcher = object : Dispatcher() {
+ override fun dispatch(request: RecordedRequest): MockResponse {
+ requests.add(request)
+ return when (request.method) {
+ "PROPFIND" -> when {
+ propfindDisconnects -> disconnect()
+ propfindResponseCode != null -> MockResponse(code = propfindResponseCode!!)
+ else -> propfindResponse(request.target)
+ }
+ "MOVE" -> MockResponse(
+ code = if (moveResponseCodes.isEmpty()) CREATED else moveResponseCodes.removeAt(0)
+ )
+ "PUT" -> if (putDisconnects) {
+ disconnect()
+ } else {
+ MockResponse(code = putResponseCode)
+ }
+ "MKCOL" -> MockResponse(code = METHOD_NOT_ALLOWED)
+ "DELETE" -> if (deleteDisconnects) {
+ disconnect()
+ } else {
+ MockResponse(code = CREATED)
+ }
+ else -> MockResponse(code = CREATED)
+ }
+ }
+ }
+ server.start()
+ client = OkHttpClient.Builder()
+ .retryOnConnectionFailure(false)
+ .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .build()
+ user = User(
+ id = 1,
+ userId = "alice",
+ username = "alice",
+ token = "token",
+ baseUrl = server.url("/").toString().trimEnd('/')
+ )
+ uploader = ChunkedFileUploader(client, user, mock(), mock())
+ file = tempFolder.newFile("video.mp4").apply { writeBytes(ByteArray(FILE_SIZE)) }
+ }
+
+ @After
+ fun tearDown() {
+ server.close()
+ }
+
+ @Test
+ fun `uploads only the parts missing on the server`() {
+ partsOnServer = mapOf(PART_0 to CHUNK, PART_1 to CHUNK)
+
+ assertTrue(uploader.upload(file, null, "/Talk/video.mp4"))
+
+ assertEquals(listOf("/$PART_2"), putPaths())
+ }
+
+ @Test
+ fun `uploads everything when the server has no parts`() {
+ assertTrue(uploader.upload(file, null, "/Talk/video.mp4"))
+
+ assertEquals(listOf("/$PART_0", "/$PART_1", "/$PART_2"), putPaths())
+ }
+
+ @Test
+ fun `uploads from scratch when the server removed the upload folder`() {
+ propfindResponseCode = NOT_FOUND
+
+ assertTrue(uploader.upload(file, null, "/Talk/video.mp4"))
+
+ assertEquals(listOf("/$PART_0", "/$PART_1", "/$PART_2"), putPaths())
+ }
+
+ @Test
+ fun `uploads nothing but the assembly when the server has all parts`() {
+ partsOnServer = mapOf(PART_0 to CHUNK, PART_1 to CHUNK, PART_2 to LAST_CHUNK)
+
+ assertTrue(uploader.upload(file, null, "/Talk/video.mp4"))
+
+ assertEquals(emptyList(), putPaths())
+ assertEquals(1, requests.count { it.method == "MOVE" })
+ }
+
+ @Test
+ fun `a part with a wrong size on the server is uploaded again`() {
+ partsOnServer = mapOf(PART_0 to CHUNK, PART_1 to CHUNK - 1, PART_2 to LAST_CHUNK)
+
+ uploader.upload(file, null, "/Talk/video.mp4")
+
+ assertEquals(listOf("/$PART_1"), putPaths())
+ }
+
+ @Test
+ fun `foreign names in the upload folder do not break the upload`() {
+ partsOnServer = mapOf(PART_0 to CHUNK, "readme.txt" to 3, "1-2" to 2, ".file" to FILE_SIZE)
+
+ assertTrue(uploader.upload(file, null, "/Talk/video.mp4"))
+
+ assertEquals(listOf("/$PART_1", "/$PART_2"), putPaths())
+ }
+
+ @Test
+ fun `assembly tells the server the total length`() {
+ uploader.upload(file, null, "/Talk/video.mp4")
+
+ val move = requests.single { it.method == "MOVE" }
+ assertEquals(FILE_SIZE.toString(), move.headers["OC-Total-Length"])
+ assertTrue(move.target.endsWith("/.file"))
+ }
+
+ @Test
+ fun `a failing request throws and keeps the parts on the server`() {
+ putResponseCode = SERVER_ERROR
+
+ try {
+ uploader.upload(file, null, "/Talk/video.mp4")
+ fail("expected the upload to throw")
+ } catch (e: HttpException) {
+ assertEquals(SERVER_ERROR, e.code)
+ }
+ assertEquals(0, requests.count { it.method == "DELETE" })
+ }
+
+ @Test
+ fun `the worker tries again after a service unavailable answer to a part but not after a rejected part`() {
+ putResponseCode = SERVICE_UNAVAILABLE
+ val unavailable = assertThrows(HttpException::class.java) { uploader.upload(file, null, "/Talk/video.mp4") }
+ assertEquals(UploadRetryPolicy.FailureKind.UNAVAILABLE, UploadRetryPolicy.classify(unavailable))
+ assertEquals(
+ UploadRetryPolicy.Decision.RETRY,
+ decideAfterManyServerErrors(unavailable)
+ )
+
+ putResponseCode = BAD_REQUEST
+ val rejected = assertThrows(HttpException::class.java) { uploader.upload(file, null, "/Talk/video.mp4") }
+ assertEquals(UploadRetryPolicy.FailureKind.SERVER, UploadRetryPolicy.classify(rejected))
+ assertEquals(
+ UploadRetryPolicy.Decision.FAIL,
+ decideAfterManyServerErrors(rejected)
+ )
+ }
+
+ // The server errors are used up, the server answered "unavailable" for the first time.
+ private fun decideAfterManyServerErrors(error: Throwable) =
+ UploadRetryPolicy.decide(UploadRetryPolicy.classify(error), UploadRetryPolicy.MAX_SERVER_ERRORS, 0L, 1)
+
+ @Test
+ fun `a lost network while sending a part is an error and keeps the parts on the server`() {
+ putDisconnects = true
+
+ try {
+ uploader.upload(file, null, "/Talk/video.mp4")
+ fail("expected the upload to throw")
+ } catch (e: IOException) {
+ // retried later by the worker
+ }
+ assertEquals(0, requests.count { it.method == "DELETE" || it.method == "MOVE" })
+ }
+
+ @Test
+ fun `stop interrupts the upload without removing the parts`() {
+ uploader.stop()
+
+ assertFalse(uploader.upload(file, null, "/Talk/video.mp4"))
+
+ assertEquals(0, requests.count { it.method == "DELETE" || it.method == "MOVE" })
+ assertEquals(emptyList(), putPaths())
+ }
+
+ @Test
+ fun `abort removes the parts from the server`() {
+ uploader.upload(file, null, "/Talk/video.mp4")
+ requests.clear()
+
+ uploader.abortUpload {}
+
+ assertEquals(1, requests.count { it.method == "DELETE" })
+ }
+
+ @Test
+ fun `stop does not cancel calls of other clients built from the same base client`() {
+ val hanging = MockWebServer()
+ hanging.dispatcher = object : Dispatcher() {
+ override fun dispatch(request: RecordedRequest) =
+ MockResponse.Builder().onResponseStart(SocketEffect.Stall).build()
+ }
+ hanging.start()
+ try {
+ val other = client.newBuilder().build()
+ val call = other.newCall(Request.Builder().url(hanging.url("/")).build())
+ call.enqueue(object : Callback {
+ override fun onFailure(call: Call, e: IOException) = Unit
+ override fun onResponse(call: Call, response: Response) = Unit
+ })
+ hanging.takeRequest(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+
+ uploader.stop()
+
+ assertFalse(call.isCanceled())
+ call.cancel()
+ } finally {
+ hanging.close()
+ }
+ }
+
+ @Test
+ fun `a rejected assembly removes the parts once and uploads the file again`() {
+ moveResponseCodes.add(BAD_REQUEST)
+
+ assertTrue(uploader.upload(file, null, "/Talk/video.mp4"))
+
+ assertEquals(1, requests.count { it.method == "DELETE" })
+ assertEquals(2, requests.count { it.method == "MOVE" })
+ assertEquals(2, requests.count { it.method == "PROPFIND" })
+ }
+
+ @Test
+ fun `an assembly rejected after an earlier restart is an error at once`() {
+ moveResponseCodes.add(BAD_REQUEST)
+ var marked = 0
+ val restarted = ChunkedFileUploader(
+ client,
+ user,
+ mock(),
+ mock(),
+ isRestarted = { true },
+ markRestarted = { marked++ }
+ )
+
+ try {
+ restarted.upload(file, null, "/Talk/video.mp4")
+ fail("expected the upload to throw")
+ } catch (e: HttpException) {
+ assertEquals(BAD_REQUEST, e.code)
+ }
+ assertEquals(0, requests.count { it.method == "DELETE" })
+ assertEquals(0, marked)
+ }
+
+ @Test
+ fun `a restart after a rejected assembly is reported once`() {
+ moveResponseCodes.add(BAD_REQUEST)
+ var marked = 0
+ val first = ChunkedFileUploader(
+ client,
+ user,
+ mock(),
+ mock(),
+ markRestarted = { marked++ }
+ )
+
+ assertTrue(first.upload(file, null, "/Talk/video.mp4"))
+
+ assertEquals(1, marked)
+ }
+
+ @Test
+ fun `a restart is not reported when the parts could not be removed`() {
+ moveResponseCodes.add(BAD_REQUEST)
+ deleteDisconnects = true
+ var marked = 0
+ val first = ChunkedFileUploader(
+ client,
+ user,
+ mock(),
+ mock(),
+ markRestarted = { marked++ }
+ )
+
+ try {
+ first.upload(file, null, "/Talk/video.mp4")
+ fail("expected the upload to throw")
+ } catch (e: IOException) {
+ assertEquals(0, marked)
+ }
+ }
+
+ @Test
+ fun `a server error while listing the parts is an error`() {
+ propfindResponseCode = SERVER_ERROR
+
+ try {
+ uploader.upload(file, null, "/Talk/video.mp4")
+ fail("expected the upload to throw")
+ } catch (e: HttpException) {
+ assertEquals(SERVER_ERROR, e.code)
+ }
+ assertEquals(emptyList(), putPaths())
+ }
+
+ @Test
+ fun `a rejected part is an error and keeps the parts on the server`() {
+ putResponseCode = BAD_REQUEST
+
+ try {
+ uploader.upload(file, null, "/Talk/video.mp4")
+ fail("expected the upload to throw")
+ } catch (e: HttpException) {
+ assertEquals(BAD_REQUEST, e.code)
+ }
+ assertEquals(0, requests.count { it.method == "DELETE" })
+ assertEquals(0, requests.count { it.method == "MOVE" })
+ }
+
+ @Test
+ fun `login, rate limit and timeout answers while listing the parts are errors`() {
+ for (code in listOf(401, 403, 408, 429)) {
+ requests.clear()
+ propfindResponseCode = code
+ val fresh = ChunkedFileUploader(client, user, mock(), mock())
+
+ try {
+ fresh.upload(file, null, "/Talk/video.mp4")
+ fail("expected the upload to throw for $code")
+ } catch (e: HttpException) {
+ assertEquals(code, e.code)
+ }
+ assertEquals(emptyList(), putPaths())
+ }
+ }
+
+ @Test
+ fun `an uploaded file is removed from the server`() {
+ uploader.deleteUploadedFile("/Talk/video.mp4")
+
+ val delete = requests.single { it.method == "DELETE" }
+ assertTrue(delete.target.endsWith("/files/alice/Talk/video.mp4"))
+ }
+
+ @Test
+ fun `removing an uploaded file never throws`() {
+ server.close()
+
+ uploader.deleteUploadedFile("/Talk/video.mp4")
+ }
+
+ @Test
+ fun `an assembly rejected twice is an error`() {
+ moveResponseCodes.addAll(listOf(BAD_REQUEST, BAD_REQUEST))
+
+ try {
+ uploader.upload(file, null, "/Talk/video.mp4")
+ fail("expected the upload to throw")
+ } catch (e: HttpException) {
+ assertEquals(BAD_REQUEST, e.code)
+ }
+ assertEquals(1, requests.count { it.method == "DELETE" })
+ }
+
+ @Test
+ fun `a lost network while listing the parts is an error and does not restart the upload`() {
+ propfindDisconnects = true
+
+ try {
+ uploader.upload(file, null, "/Talk/video.mp4")
+ fail("expected the upload to throw")
+ } catch (e: IOException) {
+ // retried later by the worker
+ }
+ assertEquals(emptyList(), putPaths())
+ }
+
+ @Test
+ fun `abort of an uploader that did not start removes the folder of the file`() {
+ val fresh = ChunkedFileUploader(client, user, mock(), mock())
+
+ fresh.abortUpload(file) {}
+
+ val delete = requests.single { it.method == "DELETE" }
+ assertTrue(delete.target.endsWith("/" + FileUtils.md5Sum(file)))
+ }
+
+ @Test
+ fun `missing parts are planned around existing ones`() {
+ val onServer = listOf(Chunk(0, CHUNK - 1L), Chunk(2L * CHUNK, FILE_SIZE.toLong()))
+
+ val missing = uploader.checkMissingChunks(onServer, FILE_SIZE.toLong())
+
+ assertEquals(listOf(Chunk(CHUNK.toLong(), 2L * CHUNK - 1)), missing)
+ }
+
+ @Test
+ fun `parts listed out of order are not uploaded again`() {
+ val onServer = listOf(Chunk(2L * CHUNK, FILE_SIZE.toLong()), Chunk(0, CHUNK - 1L))
+
+ val missing = uploader.checkMissingChunks(onServer, FILE_SIZE.toLong())
+
+ assertEquals(listOf(Chunk(CHUNK.toLong(), 2L * CHUNK - 1)), missing)
+ }
+
+ @Test
+ fun `all parts are planned when nothing is on the server`() {
+ val missing = uploader.checkMissingChunks(emptyList(), FILE_SIZE.toLong())
+
+ assertEquals(
+ listOf(Chunk(0, CHUNK - 1L), Chunk(CHUNK.toLong(), 2L * CHUNK - 1), Chunk(2L * CHUNK, FILE_SIZE.toLong())),
+ missing
+ )
+ }
+
+ @Test
+ fun `only well formed parts of the right size are accepted`() {
+ val length = FILE_SIZE.toLong()
+ assertEquals(Chunk(0, CHUNK - 1L), uploader.parseUploadedChunk(PART_0, CHUNK.toLong(), length))
+ assertEquals(Chunk(2L * CHUNK, length), uploader.parseUploadedChunk(PART_2, LAST_CHUNK.toLong(), length))
+ assertNull(uploader.parseUploadedChunk(PART_0, CHUNK - 1L, length))
+ assertNull(uploader.parseUploadedChunk(".file", length, length))
+ assertNull(uploader.parseUploadedChunk("readme.txt", 3, length))
+ assertNull(uploader.parseUploadedChunk("0-1", 2, length))
+ assertNull(uploader.parseUploadedChunk(null, 1, length))
+ }
+
+ private fun disconnect() = MockResponse.Builder().onResponseStart(SocketEffect.CloseSocket()).build()
+
+ private fun putPaths(): List =
+ requests.filter { it.method == "PUT" }.map { "/" + it.target.substringAfterLast('/') }
+
+ private fun propfindResponse(path: String): MockResponse {
+ val folder = path.trimEnd('/')
+ val members = partsOnServer.entries.joinToString("") { (name, size) ->
+ """
+
+ $folder/$name
+ $size
+ HTTP/1.1 200 OK
+
+ """
+ }
+ val body = """
+
+
+ $folder/
+
+ HTTP/1.1 200 OK
+
+ $members
+ """
+ return MockResponse(
+ code = MULTI_STATUS,
+ headers = Headers.headersOf("Content-Type", "application/xml; charset=utf-8"),
+ body = body
+ )
+ }
+
+ companion object {
+ private const val CHUNK = 1_024_000
+ private const val FILE_SIZE = 2 * CHUNK + 952_000
+ private const val LAST_CHUNK = 952_000
+ private const val PART_0 = "0000000000000000-0000000001023999"
+ private const val PART_1 = "0000000001024000-0000000002047999"
+ private const val PART_2 = "0000000002048000-0000000003000000"
+ private const val TIMEOUT_SECONDS = 10L
+ private const val CREATED = 201
+ private const val MULTI_STATUS = 207
+ private const val METHOD_NOT_ALLOWED = 405
+ private const val NOT_FOUND = 404
+ private const val BAD_REQUEST = 400
+ private const val SERVER_ERROR = 500
+ private const val SERVICE_UNAVAILABLE = 503
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderTest.kt b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderTest.kt
index c10b7e597d..e0c58917c9 100644
--- a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderTest.kt
+++ b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderTest.kt
@@ -7,8 +7,10 @@
package com.nextcloud.talk.upload.chunked
import android.app.Application
+import at.bitfire.dav4jvm.exception.HttpException
import com.nextcloud.talk.api.NcApiCoroutines
import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.upload.UploadRetryPolicy
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import mockwebserver3.MockResponse
@@ -16,7 +18,6 @@ import mockwebserver3.MockWebServer
import mockwebserver3.SocketEffect
import org.junit.After
import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
@@ -101,20 +102,21 @@ class ChunkedFileUploaderTest {
}
@Test
- fun `returns false when the server rejects the chunk with an HTTP error`() {
+ fun `throws the HTTP error when the server rejects the chunk`() {
val file = localFile(SMALL_FILE_SIZE)
enqueueFolderCreated()
enqueueNoChunksOnServer()
server.enqueue(MockResponse(code = FORBIDDEN))
- val result = uploader().upload(file, MIME_TYPE, TARGET_PATH)
+ assertThrows(HttpException::class.java) {
+ uploader().upload(file, MIME_TYPE, TARGET_PATH)
+ }
- assertFalse(result)
assertEquals(listOf("MKCOL", "PROPFIND", "PUT"), takeRequests().map { it.method })
}
@Test
- fun `returns false when the server rejects assembling the chunks with an HTTP error`() {
+ fun `throws the HTTP error when the server rejects assembling the chunks`() {
val file = localFile(SMALL_FILE_SIZE)
enqueueFolderCreated()
enqueueNoChunksOnServer()
@@ -122,12 +124,31 @@ class ChunkedFileUploaderTest {
server.enqueue(MockResponse(code = CREATED))
server.enqueue(MockResponse(code = FORBIDDEN))
- val result = uploader().upload(file, MIME_TYPE, TARGET_PATH)
+ assertThrows(HttpException::class.java) {
+ uploader().upload(file, MIME_TYPE, TARGET_PATH)
+ }
- assertFalse(result)
assertEquals(listOf("MKCOL", "PROPFIND", "PUT", "MKCOL", "MOVE"), takeRequests().map { it.method })
}
+ @Test
+ fun `a chunk that cannot be read fails the upload at once`() {
+ val file = localFile(SMALL_FILE_SIZE)
+ enqueueFolderCreated()
+ enqueueNoChunksOnServer()
+ server.enqueue(MockResponse(code = CREATED))
+ // An IOException while the chunk is written reaches the uploader as a FileNotFoundException.
+ val failingListener = object : OnDataTransferProgressListener {
+ override fun onTransferProgress(percentage: Int): Unit = throw IOException("EIO")
+ }
+
+ val error = assertThrows(IOException::class.java) {
+ ChunkedFileUploader(client, user, failingListener, ncApiCoroutines).upload(file, MIME_TYPE, TARGET_PATH)
+ }
+
+ assertEquals(UploadRetryPolicy.FailureKind.OTHER, UploadRetryPolicy.classify(error))
+ }
+
private fun uploader() = ChunkedFileUploader(client, user, progressListener, ncApiCoroutines)
private fun localFile(size: Int): File = tempFolder.newFile("test.txt").apply { writeBytes(ByteArray(size)) }
diff --git a/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderRetryTest.kt b/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderRetryTest.kt
new file mode 100644
index 0000000000..be01ea1646
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderRetryTest.kt
@@ -0,0 +1,124 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.upload.normal
+
+import android.app.Application
+import android.net.Uri
+import androidx.test.core.app.ApplicationProvider
+import com.nextcloud.talk.api.NcApi
+import com.nextcloud.talk.api.NcApiCoroutines
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.models.json.generic.GenericOverall
+import com.nextcloud.talk.upload.UploadRetryPolicy
+import io.reactivex.Observable
+import io.reactivex.android.plugins.RxAndroidPlugins
+import io.reactivex.schedulers.Schedulers
+import kotlinx.coroutines.runBlocking
+import okhttp3.OkHttpClient
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import retrofit2.HttpException
+import retrofit2.Response
+
+/** An error answer of the server is thrown, so [UploadRetryPolicy] can tell an unavailable server from a refusal. */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [33])
+class FileUploaderRetryTest {
+
+ @get:Rule
+ val tempFolder = TemporaryFolder()
+
+ @Before
+ fun setUp() {
+ // The upload observes on the main looper, which a test that blocks on the result never lets run.
+ RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
+ }
+
+ @After
+ fun tearDown() {
+ RxAndroidPlugins.reset()
+ }
+
+ private val ncApi: NcApi = mock()
+ private val ncApiCoroutines: NcApiCoroutines = mock()
+
+ private fun uploader(file: java.io.File) =
+ FileUploader(
+ OkHttpClient.Builder().build(),
+ ApplicationProvider.getApplicationContext(),
+ User(id = 1, userId = "alice", username = "alice", token = "token", baseUrl = "https://cloud.example"),
+ "room",
+ ncApi,
+ file,
+ ncApiCoroutines
+ )
+
+ private fun smallFile() = tempFolder.newFile("photo.jpg").apply { writeBytes(ByteArray(SIZE)) }
+
+ @Test
+ fun `an unavailable server fails the conversation subfolder upload with a retriable error`() {
+ val file = smallFile()
+ runBlocking {
+ whenever(ncApiCoroutines.uploadFile(any(), any(), any()))
+ .thenReturn(Response.error(UNAVAILABLE, "".toResponseBody()))
+ }
+
+ try {
+ runBlocking { uploader(file).uploadToConversationSubfolder(Uri.fromFile(file), "/draft/photo.jpg") }
+ fail("expected an HttpException")
+ } catch (e: HttpException) {
+ assertEquals(UploadRetryPolicy.FailureKind.UNAVAILABLE, UploadRetryPolicy.classify(e))
+ }
+ }
+
+ @Test
+ fun `an unavailable server fails the plain upload with a retriable error`() {
+ val file = smallFile()
+ whenever(ncApi.uploadFile(any(), any(), any()))
+ .thenReturn(Observable.just(Response.error(UNAVAILABLE, "".toResponseBody())))
+
+ try {
+ uploader(file).upload(Uri.fromFile(file), "photo.jpg", "/Talk/photo.jpg", null).blockingFirst()
+ fail("expected an HttpException")
+ } catch (e: HttpException) {
+ assertEquals(UNAVAILABLE, e.code())
+ }
+ }
+
+ @Test
+ fun `a refusal is an error that counts against the limit`() {
+ val file = smallFile()
+ whenever(ncApi.uploadFile(any(), any(), any()))
+ .thenReturn(Observable.just(Response.error(FORBIDDEN, "".toResponseBody())))
+
+ try {
+ uploader(file).upload(Uri.fromFile(file), "photo.jpg", "/Talk/photo.jpg", null).blockingFirst()
+ fail("expected an HttpException")
+ } catch (e: HttpException) {
+ assertTrue(UploadRetryPolicy.classify(e) == UploadRetryPolicy.FailureKind.SERVER)
+ }
+ }
+
+ companion object {
+ private const val SIZE = 16
+ private const val UNAVAILABLE = 503
+ private const val FORBIDDEN = 403
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt
index 600713ab7f..48c20efcc6 100644
--- a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt
+++ b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt
@@ -9,14 +9,29 @@ package com.nextcloud.talk.utils
import android.app.Application
import android.content.ContentResolver
import android.content.Context
+import android.database.MatrixCursor
import android.net.Uri
+import android.provider.OpenableColumns
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Rule
import org.junit.Test
+import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.eq
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
+import java.io.ByteArrayInputStream
+import java.io.IOException
+import java.io.InputStream
// Regression coverage for a crash: the system Photo Picker's ephemeral content:// uris can have
// their read grant revoked at any point after being handed to us (e.g. while a batch upload is
@@ -26,6 +41,11 @@ import org.robolectric.annotation.Config
@Config(application = Application::class, sdk = [33])
class FileUtilsContentResolverTest {
+ @get:Rule
+ val tempFolder = TemporaryFolder()
+
+ private val contentUri = Uri.parse("content://media/picker/0/media/42")
+
@Test
fun `getFileName falls back to the uri path when the content provider query throws`() {
val uri = Uri.parse("content://media/picker/0/com.android.providers.media.photopicker/media/123")
@@ -49,6 +69,124 @@ class FileUtilsContentResolverTest {
assertEquals("image/jpeg", mimeType)
}
+ @Test
+ fun `copyFileToCache keeps a complete copy`() {
+ val context = contextWithStream(ByteArray(COPY_SIZE))
+
+ val copy = FileUtils.copyFileToCache(context, contentUri, "photo.jpg", tempFolder.root)
+
+ assertNotNull(copy)
+ assertEquals(COPY_SIZE.toLong(), copy!!.length())
+ }
+
+ @Test
+ fun `copyFileToCache copies an empty source to an empty file`() {
+ val context = contextWithStream(ByteArray(0))
+
+ val copy = FileUtils.copyFileToCache(context, contentUri, "empty.txt", tempFolder.root)
+
+ assertNotNull(copy)
+ assertEquals(0L, copy!!.length())
+ assertEquals(listOf("empty.txt"), tempFolder.root.list()!!.toList())
+ }
+
+ @Test
+ fun `copyFileToCache returns null for an empty copy of a source that reports content`() {
+ val context = contextWithStream(ByteArray(0), reportedSize = COPY_SIZE.toLong())
+
+ assertNull(FileUtils.copyFileToCache(context, contentUri, "placeholder.jpg", tempFolder.root))
+ assertEquals(0, tempFolder.root.list()!!.size)
+ }
+
+ @Test
+ fun `copyFileToCache returns null when the read grant was revoked`() {
+ val context = mock(Context::class.java)
+ val resolver = mock(ContentResolver::class.java)
+ `when`(context.contentResolver).thenReturn(resolver)
+ `when`(resolver.openInputStream(contentUri)).thenThrow(SecurityException("revoked"))
+
+ assertNull(FileUtils.copyFileToCache(context, contentUri, "photo.jpg", tempFolder.root))
+ assertFalse(java.io.File(tempFolder.root, "photo.jpg").exists())
+ }
+
+ @Test
+ fun `copyFileToCache keeps a complete copy whose size differs from the one the provider reports`() {
+ val context = contextWithStream(ByteArray(COPY_SIZE), reportedSize = COPY_SIZE * 2L)
+
+ val copy = FileUtils.copyFileToCache(context, contentUri, "photo.jpg", tempFolder.root)
+
+ assertEquals(COPY_SIZE.toLong(), copy!!.length())
+ }
+
+ @Test
+ fun `copyFileToCache leaves no partial file when the stream breaks off`() {
+ val context = mock(Context::class.java)
+ val resolver = mock(ContentResolver::class.java)
+ `when`(context.contentResolver).thenReturn(resolver)
+ `when`(resolver.openInputStream(contentUri)).thenReturn(BrokenStream(COPY_SIZE))
+
+ try {
+ FileUtils.copyFileToCache(context, contentUri, "photo.jpg", tempFolder.root)
+ fail("expected the copy to throw")
+ } catch (e: IOException) {
+ // the caller retries
+ }
+ assertFalse(java.io.File(tempFolder.root, "photo.jpg").exists())
+ }
+
+ @Test
+ fun `copyFileToCache leaves no file when the copy dies with a runtime exception`() {
+ val context = mock(Context::class.java)
+ val resolver = mock(ContentResolver::class.java)
+ `when`(context.contentResolver).thenReturn(resolver)
+ `when`(resolver.openInputStream(contentUri)).thenReturn(CrashingStream(COPY_SIZE))
+
+ try {
+ FileUtils.copyFileToCache(context, contentUri, "photo.jpg", tempFolder.root)
+ fail("expected the copy to throw")
+ } catch (e: IllegalStateException) {
+ // the caller retries
+ }
+ assertEquals(0, tempFolder.root.list()!!.size)
+ }
+
+ @Test
+ fun `copyFileToCache copies again over an empty file left by an earlier run`() {
+ java.io.File(tempFolder.root, "photo.jpg").writeBytes(ByteArray(0))
+ val context = contextWithStream(ByteArray(COPY_SIZE))
+
+ val copy = FileUtils.copyFileToCache(context, contentUri, "photo.jpg", tempFolder.root)
+
+ assertEquals(COPY_SIZE.toLong(), copy!!.length())
+ assertEquals(listOf("photo.jpg"), tempFolder.root.list()!!.toList())
+ }
+
+ @Test
+ fun `ensureInDir copies a file from outside and keeps one that is already inside`() {
+ val dir = tempFolder.newFolder("workspace")
+ val outside = tempFolder.newFile("camera.jpg").apply { writeBytes(ByteArray(COPY_SIZE)) }
+ val inside = java.io.File(dir, "inside.jpg").apply { writeBytes(ByteArray(COPY_SIZE)) }
+
+ val copy = FileUtils.ensureInDir(outside, dir)
+
+ assertEquals(dir.canonicalFile, copy.canonicalFile.parentFile)
+ assertEquals(COPY_SIZE.toLong(), copy.length())
+ assertTrue(outside.exists())
+ assertEquals(inside, FileUtils.ensureInDir(inside, dir))
+ }
+
+ private fun contextWithStream(bytes: ByteArray, reportedSize: Long? = null): Context {
+ val context = mock(Context::class.java)
+ val resolver = mock(ContentResolver::class.java)
+ `when`(context.contentResolver).thenReturn(resolver)
+ `when`(resolver.openInputStream(contentUri)).thenReturn(ByteArrayInputStream(bytes))
+ if (reportedSize != null) {
+ val cursor = MatrixCursor(arrayOf(OpenableColumns.SIZE)).apply { addRow(arrayOf(reportedSize)) }
+ `when`(resolver.query(eq(contentUri), any(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(cursor)
+ }
+ return context
+ }
+
private fun mockContextWhoseResolverThrows(uri: Uri): Context {
val context = mock(Context::class.java)
val resolver = mock(ContentResolver::class.java)
@@ -57,4 +195,23 @@ class FileUtilsContentResolverTest {
.thenThrow(SecurityException("permission revoked"))
return context
}
+
+ /** Delivers [size] bytes, then fails like a dropped connection. */
+ private class BrokenStream(private var size: Int) : InputStream() {
+ override fun read(): Int {
+ if (size-- <= 0) throw IOException("stream broke off")
+ return 0
+ }
+ }
+
+ private class CrashingStream(private var size: Int) : InputStream() {
+ override fun read(): Int {
+ if (size-- <= 0) throw IllegalStateException("provider crashed")
+ return 0
+ }
+ }
+
+ companion object {
+ private const val COPY_SIZE = 64
+ }
}