From 15e5790d35cc63aecf26ee910b015af50bcb0e65 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 19:35:00 +0000 Subject: [PATCH 01/29] fix(upload): resume chunked uploads from the parts the server already has A chunked upload restarted from zero after every interruption, although the parts it had sent were still on the server: - The PROPFIND on the upload folder named no properties. isFile is set only from a resourcetype property, so it stayed false for every member and the list of uploaded parts was always empty. The request now asks for resourcetype, getcontentlength and displayname. - Parts are accepted only when their name is well formed and their size matches the name. A wrong or foreign entry is uploaded again or ignored. - Only the missing parts are sent. - The assembling MOVE sends OC-Total-Length, so the server can check that the parts add up to the file. - A MOVE the server rejects with 400 removes the parts once and sends the file again. The caller supplies the "already restarted" mark. - stop() interrupts the upload between two parts and keeps the parts on the server. Only abortUpload() removes them. - upload() no longer swallows exceptions. A failed request is thrown (an HttpException for an HTTP error, an IOException for a network error) and the parts stay on the server. A lost network while listing the parts is thrown too instead of being taken for "no parts", which would send the whole file again. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../upload/chunked/ChunkedFileUploader.kt | 175 +++++--- .../chunked/ChunkedFileUploaderAbortTest.kt | 2 +- .../chunked/ChunkedFileUploaderResumeTest.kt | 382 ++++++++++++++++++ .../upload/chunked/ChunkedFileUploaderTest.kt | 16 +- 4 files changed, 511 insertions(+), 64 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt 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..b51ee00299 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 @@ -46,17 +47,22 @@ 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 private var uploadFolderUri: String = "" + + @Volatile private var isUploadAborted = false init { @@ -65,50 +71,60 @@ 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 { - try { - var isUploadSuccessful = true - uploadFolderUri = remoteChunkUrl + "/" + FileUtils.md5Sum(localFile) - val davResource = DavResource( - okHttpClientNoRedirects!!, - uploadFolderUri.toHttpUrlOrNull()!! - ) + uploadFolderUri = folderUriOf(localFile) + return try { + uploadParts(localFile, mimeType, targetPath) + } 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. + if (e.code != HTTP_BAD_REQUEST || isRestarted()) { + throw e + } + Log.w(TAG, "Server rejected the assembly, uploading the file again", e) + markRestarted() + deleteUploadFolder() + uploadParts(localFile, mimeType, targetPath) + } + } + + private fun folderUriOf(localFile: File) = remoteChunkUrl + "/" + FileUtils.md5Sum(localFile) + + private fun uploadParts(localFile: File, mimeType: MediaType?, targetPath: String): Boolean { + val davResource = DavResource( + okHttpClientNoRedirects!!, + uploadFolderUri.toHttpUrlOrNull()!! + ) - createFolder(davResource) + createFolder(davResource) - val chunksOnServer: MutableList = getUploadedChunks(davResource, uploadFolderUri) - Log.d(TAG, "chunksOnServer: " + chunksOnServer.size) + val chunksOnServer: MutableList = getUploadedChunks(davResource, uploadFolderUri, localFile.length()) + Log.d(TAG, "chunksOnServer: " + chunksOnServer.size) - val missingChunks: List = checkMissingChunks(chunksOnServer, localFile.length()) - Log.d(TAG, "missingChunks: " + missingChunks.size) + val missingChunks: List = checkMissingChunks(chunksOnServer, localFile.length()) + Log.d(TAG, "missingChunks: " + missingChunks.size) - for (missingChunk in missingChunks) { - if (isUploadAborted) { - isUploadSuccessful = false - break - } - uploadChunk(localFile, uploadFolderUri, mimeType, missingChunk, missingChunk.length()) + for (missingChunk in missingChunks) { + if (isUploadAborted) { + break } + uploadChunk(localFile, uploadFolderUri, mimeType, missingChunk, missingChunk.length()) + } - if (isUploadSuccessful) { - assembleChunks(uploadFolderUri, targetPath) - } - return isUploadSuccessful - } catch (e: IOException) { - throw e - } catch (e: Exception) { - Log.e(TAG, "Something went wrong in ChunkedFileUploader", e) - return false + val isComplete = !isUploadAborted + if (isComplete) { + assembleChunks(uploadFolderUri, targetPath, localFile.length()) } + return isComplete } @Suppress("Detekt.ThrowsCount") @@ -134,14 +150,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 +175,14 @@ 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) { + throw e + } + // 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,20 +201,29 @@ 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() var start: Long = 0 while (start <= length) { @@ -306,7 +339,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 +349,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 +385,20 @@ class ChunkedFileUploader( } /** - * 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. + * Interrupts a running [upload] between two parts, 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 + } + + /** + * 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 +424,14 @@ class ChunkedFileUploader( } } + 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 +467,10 @@ class ChunkedFileUploader( remoteFileBrowserItem.modifiedTimestamp = property.lastModified } + is GetContentLength -> { + remoteFileBrowserItem.size = property.contentLength + } + is GetContentType -> { remoteFileBrowserItem.mimeType = property.type } @@ -448,5 +506,10 @@ 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 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/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderAbortTest.kt b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderAbortTest.kt index 6bfbbd747e..31df335f08 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 @@ -139,7 +139,7 @@ class ChunkedFileUploaderAbortTest { server.enqueue(MockResponse().setResponseCode(NOT_FOUND)) server.enqueue(MockResponse().setResponseCode(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..0fe6309b91 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt @@ -0,0 +1,382 @@ +/* + * 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 okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okhttp3.mockwebserver.SocketPolicy +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +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 +import java.io.File +import java.io.IOException +import java.util.Collections +import java.util.concurrent.TimeUnit + +@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 + + /** 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 -> MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST) + propfindResponseCode != null -> MockResponse().setResponseCode(propfindResponseCode!!) + else -> propfindResponse(request.path!!) + } + "MOVE" -> MockResponse().setResponseCode( + if (moveResponseCodes.isEmpty()) CREATED else moveResponseCodes.removeAt(0) + ) + "PUT" -> if (putDisconnects) { + MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST) + } else { + MockResponse().setResponseCode(putResponseCode) + } + "MKCOL" -> MockResponse().setResponseCode(METHOD_NOT_ALLOWED) + else -> MockResponse().setResponseCode(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.shutdown() + } + + @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.getHeader("OC-Total-Length")) + assertTrue(move.path!!.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 `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 `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 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 `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 `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 `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 putPaths(): List = + requests.filter { it.method == "PUT" }.map { "/" + it.path!!.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() + .setResponseCode(MULTI_STATUS) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody(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 + } +} 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 0e2e0613d5..d965419a0b 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,6 +7,7 @@ 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 okhttp3.MediaType.Companion.toMediaType @@ -16,7 +17,6 @@ import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.SocketPolicy 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 +101,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().setResponseCode(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,9 +123,10 @@ class ChunkedFileUploaderTest { server.enqueue(MockResponse().setResponseCode(CREATED)) server.enqueue(MockResponse().setResponseCode(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 }) } From bfe836629318e9958ac6ccc4d2d5b1db9e48c86b Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 19:35:00 +0000 Subject: [PATCH 02/29] fix(upload): prepare the file of an upload once and keep it between runs The folder of the parts on the server is keyed by the name, the modification time and the length of the file. UploadAndShareFilesWorker copied the content uri to the cache and compressed it again on every run. Each run produced a file with a new modification time, so a retry used another folder and sent the whole file again. The worker now prepares the file once into a directory of its own work (cacheDir/uploads/) and uploads the same file on every run: - UploadWorkspace keeps the prepared file and the "parts were removed once" mark, and holds a lock, because WorkManager does not interrupt the thread of a stopped worker and an old run can still be busy. - A workspace stays after a stop or before a retry. It is removed when the work ends, and the workspaces of finished or unknown works are removed before the next upload. - A run that is stopped while the file is prepared does not store the half-finished file. - A copy of a content uri that could not be read completely is not used as the file to upload. - onStopped() keeps the parts on the server when the system or the network stopped the work. The parts are removed only when the user cancelled it. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/jobs/UploadAndShareFilesWorker.kt | 135 +++++++++++++--- .../nextcloud/talk/upload/UploadWorkspace.kt | 151 +++++++++++++++++ .../com/nextcloud/talk/utils/FileUtils.kt | 70 ++++++-- .../nextcloud/talk/utils/ImageCompressor.kt | 8 +- .../nextcloud/talk/utils/VideoCompressor.kt | 11 +- .../talk/upload/UploadWorkspaceTest.kt | 152 ++++++++++++++++++ .../utils/FileUtilsContentResolverTest.kt | 77 +++++++++ 7 files changed, 557 insertions(+), 47 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt create mode 100644 app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt 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..c9bf85ffe6 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -39,6 +39,8 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.chatpostattachment.PostConversationAttachmentRequestDto import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachmentDataDto import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ProbeConversationAttachmentRequestDto +import com.nextcloud.talk.upload.PreparedUpload +import com.nextcloud.talk.upload.UploadWorkspace import com.nextcloud.talk.upload.chunked.ChunkedFileUploader import com.nextcloud.talk.upload.chunked.OnDataTransferProgressListener import com.nextcloud.talk.upload.normal.FileUploader @@ -70,6 +72,7 @@ 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), @@ -112,6 +115,8 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private var internalConversationId: String? = null private var uploadDisposable: Disposable? = null private var uploadLatch: CountDownLatch? = null + private lateinit var workspace: UploadWorkspace + private var keepWorkspace = false /** * WorkManager's own `isStopped`/`onStopped()` only becomes true/fires after @@ -128,10 +133,21 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) } + 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) } + // A run that is stopped, or will be tried again, resumes from the prepared file. + if (!keepWorkspace) { + workspace.delete() + } + workspace.unlock() } } @@ -162,12 +178,16 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa require(sourceFile.isNotEmpty()) checkNotNull(roomToken) - var sourceFileUri = sourceFile.toUri() - fileName = FileUtils.getFileName(sourceFileUri, context) - file = FileUtils.getFileFromUri(context, sourceFileUri) + val originalUri = sourceFile.toUri() + fileName = FileUtils.getFileName(originalUri, context) + deleteFinishedWorkspaces() - if (inputData.getBoolean(COMPRESS_IMAGES, false)) { - sourceFileUri = compressMediaIfPossible(sourceFileUri) + val prepared = workspace.prepareOnce { dir -> prepareFile(originalUri, dir) } ?: return failUpload() + file = prepared.file + fileName = prepared.fileName + val sourceFileUri = Uri.fromFile(prepared.file) + if (isStopped || isCancelled()) { + return stoppedResult() } val remotePath = getRemotePath(currentUser) @@ -187,7 +207,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa if (uploadSuccess && (isStopped || isCancelled())) { // Cancelled right as the upload finished - don't share a cancelled upload. - return Result.failure() + return stoppedResult() } if (uploadSuccess) { @@ -202,8 +222,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa Log.e(TAG, "Share operation failed after upload") return failUpload() } 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") @@ -218,13 +237,17 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa e ) if (isStopped || isCancelled()) { - Result.failure() + stoppedResult() } else if (runAttemptCount < MAX_UPLOAD_ATTEMPTS - 1) { + keepWorkspace = true Result.retry() } else { failUpload() } } catch (e: Exception) { + if (isStopped || isCancelled()) { + return stoppedResult() + } Log.e(TAG, "Something went wrong when trying to upload file", e) failUpload() } @@ -245,23 +268,71 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } /** - * Replaces [file] and [fileName] with a compressed copy if [sourceFileUri] points to a - * compressible image or video, returning the [Uri] that should be uploaded. + * Copies the source into [dir] and, when the user asked for it, compresses it there. The result is stored by + * the workspace, so every later run of this work uploads the same file: the chunk folder on the server is keyed + * by the file, and a fresh copy or compression would change that key and start the upload from zero. */ - @Suppress("ReturnCount") - private fun compressMediaIfPossible(sourceFileUri: Uri): Uri { - val originalFile = file ?: return sourceFileUri + private fun prepareFile(sourceFileUri: Uri, dir: File): PreparedUpload? { + val original = FileUtils.getFileFromUri(context, sourceFileUri, 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 a 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") + + /** Removes the workspaces of works that are finished or unknown to WorkManager, e.g. after a killed process. */ + @Suppress("Detekt.TooGenericExceptionCaught") + private fun deleteFinishedWorkspaces() { + UploadWorkspace.deleteFinished(File(context.cacheDir, 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 a compressed copy of [originalFile] inside [dir] if [sourceFileUri] points to a compressible image or + * video, or null when there is nothing to compress. + */ + 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) + } + + /** + * 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 { + keepWorkspace = !isCancelled() + return Result.failure() } private fun uploadFile( @@ -277,12 +348,14 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa 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() + val mimeType = FileUtils.resolveMimeType(context, sourceFileUri)?.toMediaTypeOrNull() chunkedFileUploader = ChunkedFileUploader( okHttpClient, currentUser, this, - ncApiCoroutines + ncApiCoroutines, + isRestarted = workspace::isRestarted, + markRestarted = workspace::markRestarted ) chunkedFileUploader!!.upload(file!!, mimeType, remotePath) } else { @@ -359,12 +432,14 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa val tempRemotePath = "/$draftFolderPath/$uploadId-$fileName" val uploadSuccess = if (isChunkedUploading) { - val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull() + val mimeType = FileUtils.resolveMimeType(context, sourceFileUri)?.toMediaTypeOrNull() chunkedFileUploader = ChunkedFileUploader( okHttpClient, currentUser, this@UploadAndShareFilesWorker, - ncApiCoroutines + ncApiCoroutines, + isRestarted = workspace::isRestarted, + markRestarted = workspace::markRestarted ) chunkedFileUploader!!.upload(file!!, mimeType, tempRemotePath) } else { @@ -433,9 +508,15 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa chatDao.updateTempMessageSendStatus(convId, refId, status) } + /** + * The user's cancel removes the parts from the server. Any other stop (the network is gone, the system stops + * the work) only interrupts the upload and keeps them, so the next run uploads what is still missing. + */ override fun onStopped() { - if (file != null && isChunkedUploading) { + if (isCancelled()) { chunkedFileUploader?.abortUpload {} + } else { + chunkedFileUploader?.stop() } uploadDisposable?.dispose() uploadLatch?.countDown() @@ -485,6 +566,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private const val COMPRESS_IMAGES = "COMPRESS_IMAGES" private const val ALLOW_UPDATE = "ALLOW_UPDATE" private const val CHUNK_UPLOAD_THRESHOLD_SIZE: Long = 1024 * 1024 + private const val WORKSPACE_DIR = "uploads" // Total attempts allowed for a single upload (1 initial run + retries) before giving up on a // transient network failure and marking the placeholder FAILED. @@ -596,6 +678,9 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private fun uploadQueueName(internalConversationId: String) = "upload_queue_$internalConversationId" + private fun workspaceDir(context: Context, workId: UUID) = + File(File(context.cacheDir, WORKSPACE_DIR), workId.toString()) + // 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. 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..88bb86b5a9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt @@ -0,0 +1,151 @@ +/* + * 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 + +/** 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), whether the parts on the server were already removed once, 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. + */ +class UploadWorkspace(private val dir: File) { + + private val preparedFile = File(dir, PREPARED_FILE) + private val lockFile = File(dir, LOCK_FILE) + private val restartedFile = File(dir, RESTARTED_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. + */ + fun prepareOnce(prepare: (File) -> PreparedUpload?): PreparedUpload? { + stored()?.let { return it } + resetFiles() + dir.mkdirs() + return prepare(dir)?.also { write(preparedFile, "${it.file.absolutePath}\n${it.fileName}") } + } + + /** 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") + } + + /** 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 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 LOCK_FILE = "lock" + private const val RESTARTED_FILE = "restarted" + private const val TMP_SUFFIX = ".tmp" + + /** Not part of the prepared file: survive a new preparation. */ + private val KEPT_FILES = setOf(LOCK_FILE, RESTARTED_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/utils/FileUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt index 9e44d9f140..756630e63a 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt @@ -19,10 +19,10 @@ 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 @@ -155,7 +155,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 +163,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") @@ -182,27 +182,43 @@ object FileUtils { } @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()) { Log.d(TAG, "file is already in cache") + true } else { - val outputStream = FileOutputStream(cachedFile) + copyToFile(context, sourceFileUri, cachedFile) + } + if (!ready) { + cachedFile.delete() + } + return cachedFile.takeIf { ready } + } + + /** 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 +228,33 @@ object FileUtils { Log.w(TAG, "no longer permitted to read $sourceFileUri", e) } } - return cachedFile + if (copied) { + // 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") + } + } + return copied && isCompleteCopy(target.length()) } + /** An empty copy is a broken copy; a stream that broke off throws and never gets here. */ + internal fun isCompleteCopy(copiedLength: Long): Boolean = copiedLength > 0 + + private 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/upload/UploadWorkspaceTest.kt b/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt new file mode 100644 index 0000000000..c89fb0e333 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt @@ -0,0 +1,152 @@ +/* + * 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 + +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 `delete removes the prepared file and the marks`() { + val ws = workspace() + val prepared = ws.prepareOnce { create(it, "photo.jpg", 1_000) }!! + ws.markRestarted() + ws.delete() + + assertFalse(prepared.file.exists()) + assertFalse(workspace().isRestarted()) + 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 restart mark survives a new preparation after the prepared file is gone`() { + val ws = workspace() + val prepared = ws.prepareOnce { create(it, "a.jpg", 1_000) }!! + assertFalse(ws.isRestarted()) + ws.markRestarted() + prepared.file.delete() + + workspace().prepareOnce { create(it, "a.jpg", 2_000) } + + assertTrue(workspace().isRestarted()) + } + + companion object { + private const val SIZE = 16 + } +} 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..dc7ac8147a 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,26 @@ 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.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 // 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 +38,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 +66,62 @@ 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 returns null and leaves no file when the source is empty`() { + val context = contextWithStream(ByteArray(0)) + + assertNull(FileUtils.copyFileToCache(context, contentUri, "photo.jpg", tempFolder.root)) + assertFalse(java.io.File(tempFolder.root, "photo.jpg").exists()) + } + + @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 `a copy is complete when it is not empty`() { + assertTrue(FileUtils.isCompleteCopy(10)) + assertFalse(FileUtils.isCompleteCopy(0)) + } + + 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 +130,8 @@ class FileUtilsContentResolverTest { .thenThrow(SecurityException("permission revoked")) return context } + + companion object { + private const val COPY_SIZE = 64 + } } From a40ecad37220863c741eef0736e651a4bfa5ed97 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 20:03:38 +0000 Subject: [PATCH 03/29] fix(upload): keep the restart mark and the upload files safe Remove the rejected parts before marking the upload as restarted: when the DELETE fails, the retry must still be allowed to remove them. Keep the upload workspace in noBackupFilesDir instead of cacheDir. The system may clear the cache under storage pressure while an upload waits for the network. A new copy of the file changes its resume key and leaves the parts on the server orphaned, and after a process restart the content URI may no longer be readable. Finished workspaces are still removed by the worker. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../talk/jobs/UploadAndShareFilesWorker.kt | 4 +-- .../upload/chunked/ChunkedFileUploader.kt | 2 +- .../chunked/ChunkedFileUploaderResumeTest.kt | 27 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) 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 c9bf85ffe6..8efeeced5d 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -296,7 +296,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa /** Removes the workspaces of works that are finished or unknown to WorkManager, e.g. after a killed process. */ @Suppress("Detekt.TooGenericExceptionCaught") private fun deleteFinishedWorkspaces() { - UploadWorkspace.deleteFinished(File(context.cacheDir, WORKSPACE_DIR)) { name -> + UploadWorkspace.deleteFinished(File(context.noBackupFilesDir, WORKSPACE_DIR)) { name -> try { val info = WorkManager.getInstance(context).getWorkInfoById(UUID.fromString(name)).get() info != null && !info.state.isFinished @@ -679,7 +679,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private fun uploadQueueName(internalConversationId: String) = "upload_queue_$internalConversationId" private fun workspaceDir(context: Context, workId: UUID) = - File(File(context.cacheDir, WORKSPACE_DIR), workId.toString()) + File(File(context.noBackupFilesDir, WORKSPACE_DIR), workId.toString()) // 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 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 b51ee00299..c842591045 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 @@ -91,8 +91,8 @@ class ChunkedFileUploader( throw e } Log.w(TAG, "Server rejected the assembly, uploading the file again", e) - markRestarted() deleteUploadFolder() + markRestarted() uploadParts(localFile, mimeType, targetPath) } } 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 index 0fe6309b91..f700450625 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt @@ -56,6 +56,7 @@ class ChunkedFileUploaderResumeTest { 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()) @@ -80,6 +81,11 @@ class ChunkedFileUploaderResumeTest { MockResponse().setResponseCode(putResponseCode) } "MKCOL" -> MockResponse().setResponseCode(METHOD_NOT_ALLOWED) + "DELETE" -> if (deleteDisconnects) { + MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST) + } else { + MockResponse().setResponseCode(CREATED) + } else -> MockResponse().setResponseCode(CREATED) } } @@ -265,6 +271,27 @@ class ChunkedFileUploaderResumeTest { 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 From cce7ce7d228aecf6b91c00cce13eb7321c141114 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 20:41:52 +0000 Subject: [PATCH 04/29] fix(upload): do not start a chunked upload after a stop A stop that came after the last check of doUpload() and before the chunked uploader was assigned found no uploader in onStopped(), so the upload ran on to the end. Assign the uploader first and check for a stop after that: an earlier stop is seen by the check, a later one reaches the uploader. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../talk/jobs/UploadAndShareFilesWorker.kt | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) 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 8efeeced5d..70ecfe2c26 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -61,6 +61,7 @@ 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 @@ -110,7 +111,10 @@ 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 @@ -335,6 +339,28 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa return Result.failure() } + 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) + } + private fun uploadFile( sourceFileUri: Uri, metaData: String?, @@ -349,15 +375,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } else if (isChunkedUploading) { Log.d(TAG, "starting chunked upload because size is " + file!!.length()) val mimeType = FileUtils.resolveMimeType(context, sourceFileUri)?.toMediaTypeOrNull() - chunkedFileUploader = ChunkedFileUploader( - okHttpClient, - currentUser, - this, - ncApiCoroutines, - isRestarted = workspace::isRestarted, - markRestarted = workspace::markRestarted - ) - chunkedFileUploader!!.upload(file!!, mimeType, remotePath) + startChunkedUpload(newChunkedUploader(), mimeType, remotePath) } else { Log.d(TAG, "starting normal upload (not chunked) of $fileName") val observable = FileUploader( @@ -433,15 +451,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa val uploadSuccess = if (isChunkedUploading) { val mimeType = FileUtils.resolveMimeType(context, sourceFileUri)?.toMediaTypeOrNull() - chunkedFileUploader = ChunkedFileUploader( - okHttpClient, - currentUser, - this@UploadAndShareFilesWorker, - ncApiCoroutines, - isRestarted = workspace::isRestarted, - markRestarted = workspace::markRestarted - ) - chunkedFileUploader!!.upload(file!!, mimeType, tempRemotePath) + startChunkedUpload(newChunkedUploader(), mimeType, tempRemotePath) } else { FileUploader(okHttpClient, context, currentUser, roomToken, ncApi, file!!, ncApiCoroutines) .uploadToConversationSubfolder(sourceFileUri, tempRemotePath) From e6fb95afa062390f74d49ad55822e27f64a4975a Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 21:13:23 +0000 Subject: [PATCH 05/29] fix(upload): remember a cancel after doWork() removed its marker doWork() removes the cancel marker of its reference id when it ends, but WorkManager may call onStopped() after that. onStopped() then saw no cancel and only stopped the uploader, so the parts stayed on the server. Latch the cancel once it is seen, and check it once more right before the marker is removed. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../nextcloud/talk/jobs/UploadAndShareFilesWorker.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 70ecfe2c26..eced926f6e 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -122,14 +122,23 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private lateinit var workspace: UploadWorkspace private var keepWorkspace = false + @Volatile + private var cancelSeen = false + /** * 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. + * Once seen, the cancel is latched: doWork() removes the marker, and onStopped() can still come after that. */ - private fun isCancelled(): Boolean = referenceId?.let { cancelledReferenceIds.contains(it) } == true + private fun isCancelled(): Boolean { + if (referenceId?.let { cancelledReferenceIds.contains(it) } == true) { + cancelSeen = true + } + return cancelSeen + } override fun doWork(): Result { // Invariant: dependencies that are already set (a test worker factory) are not overwritten. @@ -146,6 +155,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa try { return doUpload() } finally { + isCancelled() // latches a cancel that came after the last check, before its marker is removed referenceId?.let { cancelledReferenceIds.remove(it) } // A run that is stopped, or will be tried again, resumes from the prepared file. if (!keepWorkspace) { From b9d524552d326068118cf71940320535a3cad4d9 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Tue, 13 Oct 2026 05:13:46 +0000 Subject: [PATCH 06/29] fix(upload): address review findings on resume and cancel - Delete the workspace of a cancelled run even when it was marked to be kept. - Copy a file:// source into the workspace, so the prepared upload does not depend on the caller's cache file. - Fix the upload folder before the uploader is published, so a cancel that arrives early can still remove the parts; abort at once if it already came. - Restart after HTTP 400 only when the assembly was rejected, not a part. - Sort the listed parts before looking for gaps. - Treat 401, 403, 408 and 429 while listing the parts as errors, not as an empty folder. - Delete a partial copy when the stream breaks off. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/jobs/UploadAndShareFilesWorker.kt | 14 ++++-- .../upload/chunked/ChunkedFileUploader.kt | 47 ++++++++++++++----- .../com/nextcloud/talk/utils/FileUtils.kt | 25 +++++++++- .../chunked/ChunkedFileUploaderAbortTest.kt | 13 +++++ .../chunked/ChunkedFileUploaderResumeTest.kt | 40 ++++++++++++++++ .../utils/FileUtilsContentResolverTest.kt | 41 ++++++++++++++++ 6 files changed, 163 insertions(+), 17 deletions(-) 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 eced926f6e..4be4d9c841 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -155,10 +155,12 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa try { return doUpload() } finally { - isCancelled() // latches a cancel that came after the last check, before its marker is removed + // Latches a cancel that came after the last check, before its marker is removed. + val cancelled = isCancelled() referenceId?.let { cancelledReferenceIds.remove(it) } // A run that is stopped, or will be tried again, resumes from the prepared file. - if (!keepWorkspace) { + // A cancelled one never does. + if (!keepWorkspace || cancelled) { workspace.delete() } workspace.unlock() @@ -287,7 +289,8 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa * by the file, and a fresh copy or compression would change that key and start the upload from zero. */ private fun prepareFile(sourceFileUri: Uri, dir: File): PreparedUpload? { - val original = FileUtils.getFileFromUri(context, sourceFileUri, dir) ?: return null + 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) @@ -364,8 +367,13 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa * the uploader through [onStopped]. */ private fun startChunkedUpload(uploader: ChunkedFileUploader, mimeType: MediaType?, path: String): Boolean { + // The folder has to be known before onStopped can reach the uploader, or an abort has nothing to remove. + uploader.prepare(file!!) chunkedFileUploader = uploader if (isStopped || isCancelled()) { + if (isCancelled()) { + uploader.abortUpload {} + } return false } return uploader.upload(file!!, mimeType, path) 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 c842591045..db673dffbc 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 @@ -60,6 +60,8 @@ class ChunkedFileUploader( private var okHttpClientNoRedirects: OkHttpClient? = null private var remoteChunkUrl: String + + @Volatile private var uploadFolderUri: String = "" @Volatile @@ -81,25 +83,46 @@ class ChunkedFileUploader( */ @Throws(IOException::class) fun upload(localFile: File, mimeType: MediaType?, targetPath: String): Boolean { - uploadFolderUri = folderUriOf(localFile) - return try { - uploadParts(localFile, mimeType, targetPath) + 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 { + 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() - uploadParts(localFile, mimeType, targetPath) + 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) } } private fun folderUriOf(localFile: File) = remoteChunkUrl + "/" + FileUtils.md5Sum(localFile) - private fun uploadParts(localFile: File, mimeType: MediaType?, targetPath: String): Boolean { + /** @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()!! @@ -119,12 +142,7 @@ class ChunkedFileUploader( } uploadChunk(localFile, uploadFolderUri, mimeType, missingChunk, missingChunk.length()) } - - val isComplete = !isUploadAborted - if (isComplete) { - assembleChunks(uploadFolderUri, targetPath, localFile.length()) - } - return isComplete + return !isUploadAborted } @Suppress("Detekt.ThrowsCount") @@ -176,9 +194,10 @@ class ChunkedFileUploader( Unit } } catch (e: DavException) { - if (e is HttpException && e.code >= HTTP_SERVER_ERROR) { + 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 @@ -225,9 +244,10 @@ class ChunkedFileUploader( 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) { @@ -508,6 +528,7 @@ class ChunkedFileUploader( 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/utils/FileUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt index 756630e63a..e1e531835a 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt @@ -181,6 +181,23 @@ 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, @@ -200,7 +217,13 @@ object FileUtils { Log.d(TAG, "file is already in cache") true } else { - copyToFile(context, sourceFileUri, cachedFile) + try { + copyToFile(context, sourceFileUri, cachedFile) + } catch (e: IOException) { + // A later call would take the partial file for a finished copy. + cachedFile.delete() + throw e + } } if (!ready) { cachedFile.delete() 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 31df335f08..7a47c2474d 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().setResponseCode(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() 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 index f700450625..b2a5f39a12 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt @@ -305,6 +305,37 @@ class ChunkedFileUploaderResumeTest { 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 assembly rejected twice is an error`() { moveResponseCodes.addAll(listOf(BAD_REQUEST, BAD_REQUEST)) @@ -340,6 +371,15 @@ class ChunkedFileUploaderResumeTest { 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()) 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 dc7ac8147a..3f858be3bc 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt @@ -17,6 +17,7 @@ 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 @@ -29,6 +30,8 @@ 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 @@ -104,6 +107,36 @@ class FileUtilsContentResolverTest { 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 `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)) + } + @Test fun `a copy is complete when it is not empty`() { assertTrue(FileUtils.isCompleteCopy(10)) @@ -131,6 +164,14 @@ class FileUtilsContentResolverTest { 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 + } + } + companion object { private const val COPY_SIZE = 64 } From 4107b52ed51af508d8ca7c3f1ed4dcf9b4bbde64 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Tue, 13 Oct 2026 05:52:40 +0000 Subject: [PATCH 07/29] fix(upload): copy a content uri aside and rename it after success copyFileToCache returned any existing target, including the leftover of a copy that died half way, and removed the target only on IOException. - Copy into a temporary file next to the target and rename it after a complete copy; remove the temporary file on any exception. - Treat an empty existing target as no cache hit and copy again. Assisted-by: Claude-Code:claude-sonnet-5-5 Signed-off-by: Krainov Gleb --- .../com/nextcloud/talk/utils/FileUtils.kt | 21 +++++++----- .../utils/FileUtilsContentResolverTest.kt | 34 +++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) 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 e1e531835a..7541fd3a1b 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt @@ -28,6 +28,7 @@ object FileUtils { 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. @@ -213,17 +214,11 @@ object FileUtils { return null } - val ready = if (cachedFile.exists()) { + val ready = if (cachedFile.exists() && isCompleteCopy(cachedFile.length())) { Log.d(TAG, "file is already in cache") true } else { - try { - copyToFile(context, sourceFileUri, cachedFile) - } catch (e: IOException) { - // A later call would take the partial file for a finished copy. - cachedFile.delete() - throw e - } + copyAside(context, sourceFileUri, cachedFile) } if (!ready) { cachedFile.delete() @@ -231,6 +226,16 @@ object FileUtils { 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 { 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 3f858be3bc..d78490f26a 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt @@ -123,6 +123,33 @@ class FileUtilsContentResolverTest { 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") @@ -172,6 +199,13 @@ class FileUtilsContentResolverTest { } } + 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 } From 26fc4ab4389cd943ea06ad630845a89669d8282a Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Fri, 16 Oct 2026 15:12:44 +0000 Subject: [PATCH 08/29] fix(upload): prepare the file in a subdirectory of the workspace A file named like a file of the workspace itself (prepared, lock, restarted, or a name ending in .tmp) was copied over that file or was replaced by it, so the upload sent a damaged file or lost its marks. - Hand the prepare step a subdirectory of the workspace, so the user's file name cannot meet a file of the workspace. Assisted-by: Claude-Code:claude-sonnet-5-5 Signed-off-by: Krainov Gleb --- .../nextcloud/talk/upload/UploadWorkspace.kt | 7 +++++-- .../talk/upload/UploadWorkspaceTest.kt | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt b/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt index 88bb86b5a9..8b47cc9148 100644 --- a/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt +++ b/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt @@ -76,12 +76,14 @@ class UploadWorkspace(private val dir: File) { /** * 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() - dir.mkdirs() - return prepare(dir)?.also { write(preparedFile, "${it.file.absolutePath}\n${it.fileName}") } + val filesDir = File(dir, FILES_DIR).apply { mkdirs() } + return prepare(filesDir)?.also { write(preparedFile, "${it.file.absolutePath}\n${it.fileName}") } } /** Whether the parts were already removed once after the server rejected the assembly. */ @@ -121,6 +123,7 @@ class UploadWorkspace(private val dir: File) { private const val LOCK_FILE = "lock" private const val RESTARTED_FILE = "restarted" 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(LOCK_FILE, RESTARTED_FILE) diff --git a/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt b/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt index c89fb0e333..02a5702418 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt @@ -84,6 +84,26 @@ class UploadWorkspaceTest { } } + @Test + fun `a file named like a file of the workspace stays intact and is found again`() { + for (name in listOf("prepared", "lock", "restarted", "prepared.tmp", "restarted.tmp", "file")) { + 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() + assertFalse(workspace(name).tryLock()) + + assertEquals(SIZE.toLong(), prepared.file.length()) + assertEquals(prepared, workspace(name).prepareOnce { null }) + assertEquals(SIZE.toLong(), prepared.file.length()) + assertTrue(workspace(name).isRestarted()) + assertFalse(workspace(name).tryLock()) + ws.unlock() + assertTrue(workspace(name).tryLock()) + } + } + @Test fun `delete removes the prepared file and the marks`() { val ws = workspace() From 0f1e2987b9be1c689cf706720c371505a3fbdbb4 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Fri, 16 Oct 2026 15:24:52 +0000 Subject: [PATCH 09/29] fix(upload): copy an empty file instead of failing it isCompleteCopy treated a copy of 0 bytes as broken, so an empty file could not be sent. A stream that breaks off throws, so a finished copy is complete whatever its size. - Accept an empty copy of an empty source. - Still refuse an empty copy of a source that reports content (a cloud placeholder that was not loaded). - Still copy again over an empty file of an earlier run. Assisted-by: Claude-Code:claude-sonnet-5-5 Signed-off-by: Krainov Gleb --- .../com/nextcloud/talk/utils/FileUtils.kt | 22 +++++++++--------- .../utils/FileUtilsContentResolverTest.kt | 23 +++++++++++-------- 2 files changed, 25 insertions(+), 20 deletions(-) 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 7541fd3a1b..08cb56a6fb 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt @@ -214,7 +214,7 @@ object FileUtils { return null } - val ready = if (cachedFile.exists() && isCompleteCopy(cachedFile.length())) { + val ready = if (cachedFile.exists() && cachedFile.length() > 0) { Log.d(TAG, "file is already in cache") true } else { @@ -256,19 +256,19 @@ object FileUtils { Log.w(TAG, "no longer permitted to read $sourceFileUri", e) } } - if (copied) { - // 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") - } + 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") } - return copied && isCompleteCopy(target.length()) + // 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 } - /** An empty copy is a broken copy; a stream that broke off throws and never gets here. */ - internal fun isCompleteCopy(copiedLength: Long): Boolean = copiedLength > 0 - private fun querySize(context: Context, uri: Uri): Long? = try { context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor -> 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 d78490f26a..48c20efcc6 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt @@ -80,11 +80,22 @@ class FileUtilsContentResolverTest { } @Test - fun `copyFileToCache returns null and leaves no file when the source is empty`() { + fun `copyFileToCache copies an empty source to an empty file`() { val context = contextWithStream(ByteArray(0)) - assertNull(FileUtils.copyFileToCache(context, contentUri, "photo.jpg", tempFolder.root)) - assertFalse(java.io.File(tempFolder.root, "photo.jpg").exists()) + 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 @@ -164,12 +175,6 @@ class FileUtilsContentResolverTest { assertEquals(inside, FileUtils.ensureInDir(inside, dir)) } - @Test - fun `a copy is complete when it is not empty`() { - assertTrue(FileUtils.isCompleteCopy(10)) - assertFalse(FileUtils.isCompleteCopy(0)) - } - private fun contextWithStream(bytes: ByteArray, reportedSize: Long? = null): Context { val context = mock(Context::class.java) val resolver = mock(ContentResolver::class.java) From 6a17889117b83bf7d8f948e4b8f70733448edca9 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Fri, 16 Oct 2026 15:24:53 +0000 Subject: [PATCH 10/29] fix(upload): try an upload again after a short server outage dav4jvm and Retrofit report a refused request as HttpException, which is not an IOException, so a 503 (maintenance mode) ended the upload as failed at once. - Retry on 408, 429 and 5xx like on a network error, up to the same number of attempts; not on 501, 505 and 507, which do not go away. - Other refusals still fail the upload. Assisted-by: Claude-Code:claude-sonnet-5-5 Signed-off-by: Krainov Gleb --- .../talk/jobs/UploadAndShareFilesWorker.kt | 76 ++++++++++++++----- .../jobs/UploadAndShareFilesWorkerTest.kt | 22 ++++++ .../chunked/ChunkedFileUploaderResumeTest.kt | 14 ++++ 3 files changed, 92 insertions(+), 20 deletions(-) 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 4be4d9c841..4127c37acb 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -28,6 +28,7 @@ import androidx.work.WorkManager import androidx.work.WorkRequest import androidx.work.Worker import androidx.work.WorkerParameters +import at.bitfire.dav4jvm.exception.HttpException import autodagger.AutoInjector import com.nextcloud.talk.R import com.nextcloud.talk.api.NcApi @@ -243,32 +244,42 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa 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()) { - stoppedResult() - } else if (runAttemptCount < MAX_UPLOAD_ATTEMPTS - 1) { - keepWorkspace = true - Result.retry() - } else { - failUpload() - } } catch (e: Exception) { - if (isStopped || isCancelled()) { - return stoppedResult() + if (isRetryable(e)) { + // Transient failures (connection reset, timeout, dropped Wi-Fi, a short outage of the server, ...) + // 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. + retryOrFail(e) + } else { + failOnException(e) } - Log.e(TAG, "Something went wrong when trying to upload file", e) + } + } + + private fun retryOrFail(e: Exception): Result { + Log.w( + TAG, + "Upload interrupted, trying again later (attempt ${runAttemptCount + 1}/$MAX_UPLOAD_ATTEMPTS)", + e + ) + return if (isStopped || isCancelled()) { + stoppedResult() + } else if (runAttemptCount < MAX_UPLOAD_ATTEMPTS - 1) { + keepWorkspace = true + Result.retry() + } else { failUpload() } } + private fun failOnException(e: Exception): Result { + if (isStopped || isCancelled()) { + return stoppedResult() + } + Log.e(TAG, "Something went wrong when trying to upload file", e) + return failUpload() + } + // 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. @Suppress("Detekt.TooGenericExceptionCaught") @@ -599,8 +610,33 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa // 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 HTTP_REQUEST_TIMEOUT = 408 + private const val HTTP_TOO_MANY_REQUESTS = 429 + private const val HTTP_SERVER_ERROR = 500 + private val PERMANENT_SERVER_ERRORS = setOf(501, 505, 507) const val REQUEST_PERMISSION = 3123 + /** + * Whether [e] is a lost connection or a refusal that says "not now" rather than "no". dav4jvm and Retrofit + * report a refused request as an HttpException, which is not an IOException. + */ + internal fun isRetryable(e: Throwable): Boolean = + when (e) { + is IOException -> true + is HttpException -> isTransientHttpError(e.code) + is retrofit2.HttpException -> isTransientHttpError(e.code()) + else -> false + } + + /** + * 5xx, 408 and 429 are tried again. Not 501, 505 and 507: a quota that is full or a server that cannot + * do the request does not change within the few attempts. + */ + internal fun isTransientHttpError(code: Int): Boolean = + (code >= HTTP_SERVER_ERROR && code !in PERMANENT_SERVER_ERRORS) || + code == HTTP_REQUEST_TIMEOUT || + code == HTTP_TOO_MANY_REQUESTS + // 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. diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerTest.kt index bdd55d53df..c365ba4be3 100644 --- a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerTest.kt +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerTest.kt @@ -20,13 +20,19 @@ import androidx.work.testing.WorkManagerTestInitHelper import com.nextcloud.talk.data.database.dao.ChatMessagesDao import com.nextcloud.talk.data.database.model.SendStatus import com.nextcloud.talk.users.UserManager +import okhttp3.ResponseBody.Companion.toResponseBody 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.mock import org.mockito.kotlin.verify import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import retrofit2.HttpException +import retrofit2.Response +import java.io.IOException import java.util.UUID /** @@ -56,6 +62,22 @@ class UploadAndShareFilesWorkerTest { verify(chatDao).updateTempMessageSendStatus(CONVERSATION_ID, "ref-b", SendStatus.FAILED) } + @Test + fun `a short outage of the server and a lost connection are tried again and a refusal is not`() { + for (code in listOf(408, 429, 500, 502, 503, 504)) { + assertTrue("$code", UploadAndShareFilesWorker.isTransientHttpError(code)) + assertTrue("retrofit $code", UploadAndShareFilesWorker.isRetryable(retrofitError(code))) + } + for (code in listOf(400, 401, 403, 404, 409, 412, 501, 505, 507)) { + assertFalse("$code", UploadAndShareFilesWorker.isTransientHttpError(code)) + assertFalse("retrofit $code", UploadAndShareFilesWorker.isRetryable(retrofitError(code))) + } + assertTrue(UploadAndShareFilesWorker.isRetryable(IOException("connection reset"))) + assertFalse(UploadAndShareFilesWorker.isRetryable(IllegalStateException("bug"))) + } + + private fun retrofitError(code: Int) = HttpException(Response.error(code, "".toResponseBody())) + private fun upload(referenceId: String): UUID = UploadAndShareFilesWorker.upload( userId = ACCOUNT_ID, 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 index b2a5f39a12..7021c9f9c0 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt @@ -10,6 +10,7 @@ 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.jobs.UploadAndShareFilesWorker import okhttp3.OkHttpClient import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse @@ -20,6 +21,7 @@ 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 @@ -187,6 +189,17 @@ class ChunkedFileUploaderResumeTest { 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") } + assertTrue(UploadAndShareFilesWorker.isRetryable(unavailable)) + + putResponseCode = BAD_REQUEST + val rejected = assertThrows(HttpException::class.java) { uploader.upload(file, null, "/Talk/video.mp4") } + assertFalse(UploadAndShareFilesWorker.isRetryable(rejected)) + } + @Test fun `a lost network while sending a part is an error and keeps the parts on the server`() { putDisconnects = true @@ -445,5 +458,6 @@ class ChunkedFileUploaderResumeTest { private const val NOT_FOUND = 404 private const val BAD_REQUEST = 400 private const val SERVER_ERROR = 500 + private const val SERVICE_UNAVAILABLE = 503 } } From f9f59288294ec2548926db8b6580134495a55660 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 19:38:58 +0000 Subject: [PATCH 11/29] fix(upload): give the chunked uploader its own dispatcher stop() cancels the calls of the uploader to interrupt a request that is waiting on a dead network. The uploader built its client with okHttpClient.newBuilder(), which shares the Dispatcher of the application client. Cancelling all calls of that dispatcher would also cancel the chat requests, the signaling websocket and the other uploads. The uploader now builds its client with a Dispatcher of its own, so stop() and abortUpload() cancel only its own calls. Also: - abortUpload(localFile) removes the parts of an earlier run of a file for an uploader that has not started an upload, e.g. when the user cancels while the work waits for a retry. - deleteUploadedFile(path) removes a file that was already assembled on the server, e.g. when the user cancels between the upload and the share. - abortUpload() catches every exception: it runs inside the WorkManager cancellation handler, where any exception crashes the process. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../upload/chunked/ChunkedFileUploader.kt | 34 +++++++++++- .../chunked/ChunkedFileUploaderResumeTest.kt | 55 +++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) 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 db673dffbc..f5b914ea60 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 @@ -36,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 @@ -340,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)) @@ -405,12 +410,13 @@ class ChunkedFileUploader( } /** - * Interrupts a running [upload] between two parts, 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. + * 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() } /** @@ -444,6 +450,28 @@ class ChunkedFileUploader( } } + /** Removes the parts of an earlier run of [localFile], for an uploader that has not started an upload. */ + 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 { _ -> } 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 index 7021c9f9c0..576f490f3f 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt @@ -11,7 +11,12 @@ import at.bitfire.dav4jvm.exception.HttpException import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.jobs.UploadAndShareFilesWorker +import com.nextcloud.talk.utils.FileUtils +import okhttp3.Call +import okhttp3.Callback import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -233,6 +238,31 @@ class ChunkedFileUploaderResumeTest { 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().setSocketPolicy(SocketPolicy.NO_RESPONSE) + } + 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.shutdown() + } + } + @Test fun `a rejected assembly removes the parts once and uploads the file again`() { moveResponseCodes.add(BAD_REQUEST) @@ -349,6 +379,21 @@ class ChunkedFileUploaderResumeTest { } } + @Test + fun `an uploaded file is removed from the server`() { + uploader.deleteUploadedFile("/Talk/video.mp4") + + val delete = requests.single { it.method == "DELETE" } + assertTrue(delete.path!!.endsWith("/files/alice/Talk/video.mp4")) + } + + @Test + fun `removing an uploaded file never throws`() { + server.shutdown() + + uploader.deleteUploadedFile("/Talk/video.mp4") + } + @Test fun `an assembly rejected twice is an error`() { moveResponseCodes.addAll(listOf(BAD_REQUEST, BAD_REQUEST)) @@ -375,6 +420,16 @@ class ChunkedFileUploaderResumeTest { 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.path!!.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())) From 41da4cba10f0cf9619538a4e3f79502b46630268 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 19:39:10 +0000 Subject: [PATCH 12/29] fix(upload): keep an upload running in a foreground service and retry it by the kind of its error A file upload stopped when the screen went off or the network was lost, and the worker gave up after four attempts, whatever the reason of the stop. Foreground service - The worker runs as a foreground work of type dataSync with an ongoing notification that shows the progress and a Cancel action. The manifest gets the FOREGROUND_SERVICE_DATA_SYNC permission, the dataSync type of the WorkManager SystemForegroundService and a non-exported cancel receiver. When the system refuses to start the service from the background (Android 12+), the upload continues as a regular work. Retry by the kind of the error (UploadRetryPolicy) - A lost network, a dropped TLS connection, 5xx (except 507), 408 and 429 are retried without a limit and do not count as an attempt: the work waits for the network and resumes from the parts on the server. - Only a refusal of the server (4xx, 507, certificate errors) counts, up to four times. The count is kept in the workspace. - A work that ends for good removes its parts from the server and shows the failed notification. It ends as success, because a failed work makes WorkManager fail the uploads queued behind it in the same conversation. - Backoff is linear. Exponential backoff with an unlimited number of attempts grows to hours. - The upload of a small file throws its HTTP errors, so the same policy applies to it. Stages and cancel - The stages "uploaded" and "shared", the id of the upload and the cancel flag are kept in the workspace. A run after a retry or a stop does not upload the file or share it a second time. - The user's cancel (chat or notification) only sets a flag in the workspace of the work. The worker sees it between its stages and in the progress callback, interrupts the upload, removes the parts, the assembled file and the placeholder message. WorkManager is not asked to cancel the work: a cancelled work in the queue of a conversation makes the next APPEND_OR_REPLACE delete the other works of that queue, even running ones. The work is found by its id or, after a restart of the app, by its tag. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- app/src/main/AndroidManifest.xml | 9 + .../talk/chat/viewmodels/ChatViewModel.kt | 11 +- .../talk/jobs/UploadAndShareFilesWorker.kt | 501 ++++++++++-------- .../talk/receivers/UploadCancelReceiver.kt | 63 +++ .../talk/upload/UploadNotification.kt | 60 +++ .../talk/upload/UploadProgressThrottle.kt | 32 ++ .../talk/upload/UploadRetryPolicy.kt | 115 ++++ .../nextcloud/talk/upload/UploadWorkspace.kt | 77 ++- .../talk/upload/normal/FileUploader.kt | 9 +- .../jobs/UploadAndShareFilesWorkerTest.kt | 22 - .../talk/upload/UploadProgressThrottleTest.kt | 52 ++ .../talk/upload/UploadRetryPolicyTest.kt | 165 ++++++ .../talk/upload/UploadWorkspaceTest.kt | 120 ++++- .../chunked/ChunkedFileUploaderResumeTest.kt | 14 +- .../talk/upload/normal/FileUploaderTest.kt | 124 +++++ 15 files changed, 1119 insertions(+), 255 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/receivers/UploadCancelReceiver.kt create mode 100644 app/src/main/java/com/nextcloud/talk/upload/UploadNotification.kt create mode 100644 app/src/main/java/com/nextcloud/talk/upload/UploadProgressThrottle.kt create mode 100644 app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt create mode 100644 app/src/test/java/com/nextcloud/talk/upload/UploadProgressThrottleTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderTest.kt 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. + val workId = uploadReferenceToWorkId.remove(referenceId) + viewModelScope.launch(Dispatchers.IO) { UploadAndShareFilesWorker.cancelUpload(referenceId, workId) } viewModelScope.launch { 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 4127c37acb..4f15b76f58 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,13 +23,13 @@ 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 at.bitfire.dav4jvm.exception.HttpException import autodagger.AutoInjector import com.nextcloud.talk.R import com.nextcloud.talk.api.NcApi @@ -40,10 +41,13 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.chatpostattachment.PostConversationAttachmentRequestDto import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachmentDataDto import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ProbeConversationAttachmentRequestDto -import com.nextcloud.talk.upload.PreparedUpload -import com.nextcloud.talk.upload.UploadWorkspace 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.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 @@ -67,9 +71,7 @@ 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 @@ -121,25 +123,18 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa 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 - - @Volatile - private var cancelSeen = 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. - * Once seen, the cancel is latched: doWork() removes the marker, and onStopped() can still come after that. + * 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 { - if (referenceId?.let { cancelledReferenceIds.contains(it) } == true) { - cancelSeen = true - } - return cancelSeen - } + 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. @@ -147,6 +142,7 @@ 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()) { @@ -156,12 +152,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa try { return doUpload() } finally { - // Latches a cancel that came after the last check, before its marker is removed. - val cancelled = isCancelled() - referenceId?.let { cancelledReferenceIds.remove(it) } - // A run that is stopped, or will be tried again, resumes from the prepared file. - // A cancelled one never does. - if (!keepWorkspace || cancelled) { + if (!keepWorkspace) { workspace.delete() } workspace.unlock() @@ -177,14 +168,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)!! @@ -195,49 +190,45 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa require(sourceFile.isNotEmpty()) checkNotNull(roomToken) - val originalUri = sourceFile.toUri() - fileName = FileUtils.getFileName(originalUri, context) + val sourceFileUri = sourceFile.toUri() + fileName = FileUtils.getFileName(sourceFileUri, context) deleteFinishedWorkspaces() - val prepared = workspace.prepareOnce { dir -> prepareFile(originalUri, dir) } ?: return failUpload() - file = prepared.file - fileName = prepared.fileName - val sourceFileUri = Uri.fromFile(prepared.file) + // 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() + } + startForeground() + + 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 remotePath = getRemotePath(currentUser) - 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 stoppedResult() + 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()) { return stoppedResult() } @@ -245,60 +236,152 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa Log.e(TAG, "Something went wrong when trying to upload file") failUpload() } catch (e: Exception) { - if (isRetryable(e)) { - // Transient failures (connection reset, timeout, dropped Wi-Fi, a short outage of the server, ...) - // 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. - retryOrFail(e) - } else { - failOnException(e) + 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) { + workspace.registerServerError() + } else { + workspace.serverErrors() + } + return when (UploadRetryPolicy.decide(kind, serverErrors)) { + UploadRetryPolicy.Decision.RETRY -> { + Log.w(TAG, "Upload interrupted ($kind, server errors: $serverErrors), will resume", e) + keepWorkspace = true + Result.retry() + } + + UploadRetryPolicy.Decision.FAIL -> { + Log.e(TAG, "Something went wrong when trying to upload file", e) + failUpload() } } } - private fun retryOrFail(e: Exception): Result { - Log.w( - TAG, - "Upload interrupted, trying again later (attempt ${runAttemptCount + 1}/$MAX_UPLOAD_ATTEMPTS)", - e - ) - return if (isStopped || isCancelled()) { - stoppedResult() - } else if (runAttemptCount < MAX_UPLOAD_ATTEMPTS - 1) { + /** + * 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() - } else { - failUpload() } + + /** + * 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. + */ + private fun cancelledResult(): Result { + abortRemote(deleteUploadedFile = true) + deletePlaceholder() + return Result.success() } - private fun failOnException(e: Exception): Result { - if (isStopped || isCancelled()) { - return stoppedResult() + private fun newChunkedUploader() = + ChunkedFileUploader( + okHttpClient, + currentUser, + this, + ncApiCoroutines, + isRestarted = workspace::isRestarted, + markRestarted = workspace::markRestarted + ) + + /** + * 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() + // No upload ran in this process: remove the parts an earlier run may have left. + val prepared = workspace.prepared()?.file + if (chunkedFileUploader != null) { + uploader.abortUpload {} + } else if (prepared != null && prepared.length() > CHUNK_UPLOAD_THRESHOLD_SIZE) { + uploader.abortUpload(prepared) {} + } + 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) } - Log.e(TAG, "Something went wrong when trying to upload file", e) - return failUpload() } - // 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 deletePlaceholder() { + val refId = referenceId?.takeIf { it.isNotEmpty() } ?: return + val convId = internalConversationId?.takeIf { it.isNotEmpty() } ?: return + chatDao.deleteTempChatMessageIfPending(convId, refId) + } + + @SuppressLint("InlinedApi") @Suppress("Detekt.TooGenericExceptionCaught") - private fun failUpload(): Result { - updatePlaceholderStatus(SendStatus.FAILED) + private fun startForeground() { try { - showFailedToUploadNotification() + setForegroundAsync( + ForegroundInfo( + notificationId, + progressNotification(0), + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + ).get() + isForeground = true } catch (e: Exception) { - // 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) + // 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) } - return Result.success() } - /** - * Copies the source into [dir] and, when the user asked for it, compresses it there. The result is stored by - * the workspace, so every later run of this work uploads the same file: the chunk folder on the server is keyed - * by the file, and a fresh copy or compression would change that key and start the upload from zero. - */ + 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 @@ -312,7 +395,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa return compressed ?: PreparedUpload(original, fileName) } - /** Keeps a stopped run from storing a half-finished file as the prepared one (see [PreparationStopped]). */ + /** Keeps a stopped run from storing its half-finished file as the prepared one (see [PreparationStopped]). */ private fun throwIfStopped() { if (isStopped || isCancelled()) { throw PreparationStopped() @@ -321,7 +404,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private class PreparationStopped : RuntimeException("Stopped while preparing the file") - /** Removes the workspaces of works that are finished or unknown to WorkManager, e.g. after a killed process. */ + // 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 -> @@ -335,6 +418,39 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } } + /** + * 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. + */ + @Suppress("Detekt.TooGenericExceptionCaught") + private fun failUpload(): Result { + abortRemote() + updatePlaceholderStatus(SendStatus.FAILED) + try { + showFailedToUploadNotification() + } catch (e: Exception) { + // 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() + } + /** * 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. @@ -354,57 +470,14 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa return PreparedUpload(compressedFile, compressedFile.name) } - /** - * 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 { - keepWorkspace = !isCancelled() - return Result.failure() - } - - 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 { - // The folder has to be known before onStopped can reach the uploader, or an abort has nothing to remove. - uploader.prepare(file!!) - chunkedFileUploader = uploader - if (isStopped || isCancelled()) { - if (isCancelled()) { - uploader.abortUpload {} - } - return false - } - return uploader.upload(file!!, mimeType, path) - } - - 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 = FileUtils.resolveMimeType(context, sourceFileUri)?.toMediaTypeOrNull() - startChunkedUpload(newChunkedUploader(), mimeType, remotePath) + chunkedFileUploader = newChunkedUploader() + chunkedFileUploader!!.upload(file!!, mimeType, remotePath) } else { Log.d(TAG, "starting normal upload (not chunked) of $fileName") val observable = FileUploader( @@ -449,7 +522,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } private fun uploadUsingConversationSubfolders( - sourceFileUri: Uri, + sourceFileUri: Uri?, metaData: String?, allowUpdate: Boolean ): Boolean = @@ -458,7 +531,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 @@ -476,17 +548,16 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa return@runBlocking false } val predictedName = resolveFinalFileName(fileName, probeResponse.ocs?.data!!) - val tempRemotePath = "/$draftFolderPath/$uploadId-$fileName" + // 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" - val uploadSuccess = 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) + if (workspace.uploadedPath() == null) { + if (!uploadToDraftFolder(checkNotNull(sourceFileUri), tempRemotePath)) { + return@runBlocking false + } + workspace.markUploaded(tempRemotePath, fileName) } - - if (!uploadSuccess || isStopped || isCancelled()) { + if (isCancelled()) { return@runBlocking false } @@ -498,15 +569,23 @@ 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() + chunkedFileUploader = newChunkedUploader() + chunkedFileUploader!!.upload(file!!, mimeType, tempRemotePath) + } else { + FileUploader(okHttpClient, context, currentUser, roomToken, ncApi, file!!, ncApiCoroutines) + .uploadToConversationSubfolder(sourceFileUri, tempRemotePath) } @SuppressLint("CheckResult") @@ -539,6 +618,14 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa override fun onTransferProgress(percentage: Int) { setProgressAsync(Data.Builder().putInt(PROGRESS_KEY, percentage).build()) + if (progressThrottle.shouldUpdate(percentage)) { + if (isCancelled()) { + // Interrupts the upload; doWork then sees the flag and aborts. + chunkedFileUploader?.stop() + } else if (isForeground && !isStopped) { + notificationManager.notify(notificationId, progressNotification(percentage)) + } + } } private fun updatePlaceholderStatus(status: SendStatus) { @@ -548,17 +635,19 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } /** - * The user's cancel removes the parts from the server. Any other stop (the network is gone, the system stops - * the work) only interrupts the upload and keeps them, so the next run uploads what is still missing. + * 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 (isCancelled()) { - chunkedFileUploader?.abortUpload {} - } else { + 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() } @@ -605,43 +694,10 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private const val COMPRESS_IMAGES = "COMPRESS_IMAGES" private const val ALLOW_UPDATE = "ALLOW_UPDATE" private const val CHUNK_UPLOAD_THRESHOLD_SIZE: Long = 1024 * 1024 - private const val WORKSPACE_DIR = "uploads" - // 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 HTTP_REQUEST_TIMEOUT = 408 - private const val HTTP_TOO_MANY_REQUESTS = 429 - private const val HTTP_SERVER_ERROR = 500 - private val PERMANENT_SERVER_ERRORS = setOf(501, 505, 507) + private const val WORKSPACE_DIR = "uploads" const val REQUEST_PERMISSION = 3123 - /** - * Whether [e] is a lost connection or a refusal that says "not now" rather than "no". dav4jvm and Retrofit - * report a refused request as an HttpException, which is not an IOException. - */ - internal fun isRetryable(e: Throwable): Boolean = - when (e) { - is IOException -> true - is HttpException -> isTransientHttpError(e.code) - is retrofit2.HttpException -> isTransientHttpError(e.code()) - else -> false - } - - /** - * 5xx, 408 and 429 are tried again. Not 501, 505 and 507: a quota that is full or a server that cannot - * do the request does not change within the few attempts. - */ - internal fun isTransientHttpError(code: Int): Boolean = - (code >= HTTP_SERVER_ERROR && code !in PERMANENT_SERVER_ERRORS) || - code == HTTP_REQUEST_TIMEOUT || - code == HTTP_TOO_MANY_REQUESTS - - // 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 @@ -712,13 +768,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 ) @@ -742,15 +803,37 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private fun uploadQueueName(internalConversationId: String) = "upload_queue_$internalConversationId" + private fun referenceTag(referenceId: String) = "upload_ref:$referenceId" + private fun workspaceDir(context: Context, workId: UUID) = File(File(context.noBackupFilesDir, WORKSPACE_DIR), workId.toString()) - // 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) + /** + * 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..20b11726e9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt @@ -0,0 +1,115 @@ +/* + * 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. + * Only 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 + + 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 or a server that is temporarily unavailable, retried without a limit. + * 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, 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 treated like a lost network: the upload waits and goes on without a limit. 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. + */ + fun classify(error: Throwable): FailureKind { + val causes = causeChain(error) + val httpCode = causes.firstNotNullOfOrNull(::httpCodeOf) + return when { + httpCode != null -> if (isTransientHttpCode(httpCode)) FailureKind.NETWORK else FailureKind.SERVER + isCertificateProblem(causes) -> FailureKind.SERVER + causes.any { it is IOException && it !is FileNotFoundException } -> 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 + */ + fun decide(kind: FailureKind, serverErrorCount: Int): Decision = + when { + kind == FailureKind.NETWORK -> 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 index 8b47cc9148..6831f013ce 100644 --- a/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt +++ b/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt @@ -11,21 +11,28 @@ import java.io.IOException import java.io.RandomAccessFile import java.nio.channels.FileLock import java.nio.channels.OverlappingFileLockException +import java.util.UUID /** 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), whether the parts on the server were already removed once, and a lock that keeps two runs of the same - * work and the cleanup of dead workspaces apart. + * 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) @@ -86,6 +93,31 @@ class UploadWorkspace(private val dir: File) { 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() } + + fun markUploaded(path: String, fileName: String) { + dir.mkdirs() + write(uploadedFile, "$path\n$fileName") + } + + private fun uploadedLines(): List? = runCatching { uploadedFile.readLines() }.getOrNull() + /** Whether the parts were already removed once after the server rejected the assembly. */ fun isRestarted(): Boolean = restartedFile.exists() @@ -94,6 +126,30 @@ class UploadWorkspace(private val dir: File) { write(restartedFile, "1") } + 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() @@ -112,6 +168,8 @@ class UploadWorkspace(private val dir: File) { } } + 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) @@ -120,13 +178,26 @@ class UploadWorkspace(private val dir: 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 TMP_SUFFIX = ".tmp" private const val FILES_DIR = "file" /** Not part of the prepared file: survive a new preparation. */ - private val KEPT_FILES = setOf(LOCK_FILE, RESTARTED_FILE) + private val KEPT_FILES = setOf( + SERVER_ERRORS_FILE, + CANCELLED_FILE, + LOCK_FILE, + UPLOAD_ID_FILE, + UPLOADED_FILE, + SHARED_FILE, + RESTARTED_FILE + ) /** * Removes the workspaces of uploads that are no longer alive, e.g. left behind by a killed process. 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 c281f0f4f2..59b610411e 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.OkHttpClient import okhttp3.Protocol import okhttp3.RequestBody 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 = @@ -86,7 +91,7 @@ class FileUploader( ) { createDavResource(sourceFileUri, fileName, remotePath, metaData) } else { - Observable.just(false) + Observable.error(RetrofitHttpException(response)) } } } diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerTest.kt index c365ba4be3..bdd55d53df 100644 --- a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerTest.kt +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerTest.kt @@ -20,19 +20,13 @@ import androidx.work.testing.WorkManagerTestInitHelper import com.nextcloud.talk.data.database.dao.ChatMessagesDao import com.nextcloud.talk.data.database.model.SendStatus import com.nextcloud.talk.users.UserManager -import okhttp3.ResponseBody.Companion.toResponseBody 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.mock import org.mockito.kotlin.verify import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import retrofit2.HttpException -import retrofit2.Response -import java.io.IOException import java.util.UUID /** @@ -62,22 +56,6 @@ class UploadAndShareFilesWorkerTest { verify(chatDao).updateTempMessageSendStatus(CONVERSATION_ID, "ref-b", SendStatus.FAILED) } - @Test - fun `a short outage of the server and a lost connection are tried again and a refusal is not`() { - for (code in listOf(408, 429, 500, 502, 503, 504)) { - assertTrue("$code", UploadAndShareFilesWorker.isTransientHttpError(code)) - assertTrue("retrofit $code", UploadAndShareFilesWorker.isRetryable(retrofitError(code))) - } - for (code in listOf(400, 401, 403, 404, 409, 412, 501, 505, 507)) { - assertFalse("$code", UploadAndShareFilesWorker.isTransientHttpError(code)) - assertFalse("retrofit $code", UploadAndShareFilesWorker.isRetryable(retrofitError(code))) - } - assertTrue(UploadAndShareFilesWorker.isRetryable(IOException("connection reset"))) - assertFalse(UploadAndShareFilesWorker.isRetryable(IllegalStateException("bug"))) - } - - private fun retrofitError(code: Int) = HttpException(Response.error(code, "".toResponseBody())) - private fun upload(referenceId: String): UUID = UploadAndShareFilesWorker.upload( userId = ACCOUNT_ID, 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..19d283e3dd --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt @@ -0,0 +1,165 @@ +/* + * 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 like a lost network`() { + listOf(500, 502, 503, 504, 408, 429).forEach { + assertEquals("code $it", FailureKind.NETWORK, UploadRetryPolicy.classify(DavHttpException(it, "x"))) + } + listOf(500, 502, 503, 504, 408, 429).forEach { + assertEquals("retrofit $it", FailureKind.NETWORK, 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.NETWORK, 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 `network errors never use up attempts`() { + assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.NETWORK, 0)) + } + + @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)) + } + assertEquals( + Decision.FAIL, + UploadRetryPolicy.decide(FailureKind.SERVER, UploadRetryPolicy.MAX_SERVER_ERRORS) + ) + } + + @Test + fun `other errors fail at once`() { + assertEquals(Decision.FAIL, UploadRetryPolicy.decide(FailureKind.OTHER, 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 index 02a5702418..faba3b8442 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt @@ -17,6 +17,7 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +@Suppress("TooManyFunctions") class UploadWorkspaceTest { @get:Rule @@ -86,18 +87,31 @@ class UploadWorkspaceTest { @Test fun `a file named like a file of the workspace stays intact and is found again`() { - for (name in listOf("prepared", "lock", "restarted", "prepared.tmp", "restarted.tmp", "file")) { + 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() + val uploadId = ws.uploadId() assertFalse(workspace(name).tryLock()) - assertEquals(SIZE.toLong(), prepared.file.length()) - assertEquals(prepared, workspace(name).prepareOnce { null }) - assertEquals(SIZE.toLong(), prepared.file.length()) - assertTrue(workspace(name).isRestarted()) + 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()) assertFalse(workspace(name).tryLock()) ws.unlock() assertTrue(workspace(name).tryLock()) @@ -105,14 +119,41 @@ class UploadWorkspaceTest { } @Test - fun `delete removes the prepared file and the marks`() { + 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.markRestarted() + ws.registerServerError() ws.delete() assertFalse(prepared.file.exists()) - assertFalse(workspace().isRestarted()) + assertEquals(0, workspace().serverErrors()) assertNull(workspace().prepareOnce { null }) } @@ -154,19 +195,78 @@ class UploadWorkspaceTest { } @Test - fun `the restart mark survives a new preparation after the prepared file is gone`() { + 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 `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) }!! - assertFalse(ws.isRestarted()) + 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()) + } + 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" + ) } } 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 index 576f490f3f..a1ea436080 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt @@ -10,7 +10,7 @@ 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.jobs.UploadAndShareFilesWorker +import com.nextcloud.talk.upload.UploadRetryPolicy import com.nextcloud.talk.utils.FileUtils import okhttp3.Call import okhttp3.Callback @@ -198,11 +198,19 @@ class ChunkedFileUploaderResumeTest { 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") } - assertTrue(UploadAndShareFilesWorker.isRetryable(unavailable)) + assertEquals(UploadRetryPolicy.FailureKind.NETWORK, UploadRetryPolicy.classify(unavailable)) + assertEquals( + UploadRetryPolicy.Decision.RETRY, + UploadRetryPolicy.decide(UploadRetryPolicy.classify(unavailable), UploadRetryPolicy.MAX_SERVER_ERRORS) + ) putResponseCode = BAD_REQUEST val rejected = assertThrows(HttpException::class.java) { uploader.upload(file, null, "/Talk/video.mp4") } - assertFalse(UploadAndShareFilesWorker.isRetryable(rejected)) + assertEquals(UploadRetryPolicy.FailureKind.SERVER, UploadRetryPolicy.classify(rejected)) + assertEquals( + UploadRetryPolicy.Decision.FAIL, + UploadRetryPolicy.decide(UploadRetryPolicy.classify(rejected), UploadRetryPolicy.MAX_SERVER_ERRORS) + ) } @Test diff --git a/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderTest.kt b/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderTest.kt new file mode 100644 index 0000000000..a88df74b5e --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderTest.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 FileUploaderTest { + + @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.NETWORK, 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 + } +} From c56aceba5e90d09ea5a70ecdae3927a0e9b490e0 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 20:03:53 +0000 Subject: [PATCH 13/29] fix(upload): send the cancel request from the application scope The cancel flag is the only way to stop an upload. When the user taps cancel and leaves the chat at once, the cleared ViewModel could drop the viewModelScope jobs before they set the flag and removed the placeholder, so the cancelled file would still be shared. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index dcc51dadad..63c7461150 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -434,11 +434,12 @@ class ChatViewModel @AssistedInject constructor( // 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) - viewModelScope.launch(Dispatchers.IO) { + appScope.launch(Dispatchers.IO) { UploadAndShareFilesWorker.cancelUpload(referenceId, workId) } - viewModelScope.launch { + appScope.launch { chatRepository.deleteTempMessageByReferenceId(referenceId) } _uploadProgressMap.update { it - referenceId } From 18e749874d119ea862210e1a714c9161d4722bd4 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 20:41:52 +0000 Subject: [PATCH 14/29] fix(upload): do not start a chunked upload after a stop A stop that came after the last check and before the chunked uploader was assigned found no uploader in onStopped(), so the upload ran on to the end. Assign the uploader first and check for a stop after that: an earlier stop is seen by the check, a later one reaches the uploader. A cancelled work now finds the parts by the prepared file, so they are removed also when the uploader was assigned but did not start. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../talk/jobs/UploadAndShareFilesWorker.kt | 27 +++++++++++++------ .../upload/chunked/ChunkedFileUploader.kt | 2 +- 2 files changed, 20 insertions(+), 9 deletions(-) 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 4f15b76f58..d0017d6164 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -320,6 +320,18 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa 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. @@ -331,12 +343,13 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa return } val uploader = chunkedFileUploader ?: newChunkedUploader() - // No upload ran in this process: remove the parts an earlier run may have left. + // 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 (chunkedFileUploader != null) { - uploader.abortUpload {} - } else if (prepared != null && prepared.length() > CHUNK_UPLOAD_THRESHOLD_SIZE) { + 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()) { @@ -476,8 +489,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } else if (isChunkedUploading) { Log.d(TAG, "starting chunked upload because size is " + file!!.length()) val mimeType = FileUtils.resolveMimeType(context, sourceFileUri)?.toMediaTypeOrNull() - chunkedFileUploader = newChunkedUploader() - chunkedFileUploader!!.upload(file!!, mimeType, remotePath) + startChunkedUpload(newChunkedUploader(), mimeType, remotePath) } else { Log.d(TAG, "starting normal upload (not chunked) of $fileName") val observable = FileUploader( @@ -581,8 +593,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private suspend fun uploadToDraftFolder(sourceFileUri: Uri, tempRemotePath: String): Boolean = if (isChunkedUploading) { val mimeType = FileUtils.resolveMimeType(context, sourceFileUri)?.toMediaTypeOrNull() - chunkedFileUploader = newChunkedUploader() - chunkedFileUploader!!.upload(file!!, mimeType, tempRemotePath) + startChunkedUpload(newChunkedUploader(), mimeType, tempRemotePath) } else { FileUploader(okHttpClient, context, currentUser, roomToken, ncApi, file!!, ncApiCoroutines) .uploadToConversationSubfolder(sourceFileUri, tempRemotePath) 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 f5b914ea60..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 @@ -450,7 +450,7 @@ class ChunkedFileUploader( } } - /** Removes the parts of an earlier run of [localFile], for an uploader that has not started an upload. */ + /** 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) From e01909960afd48d641f46c1a9c17f466ab584713 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Wed, 14 Oct 2026 18:51:44 +0000 Subject: [PATCH 15/29] test(upload): cancelling one upload must not cancel the uploads behind it The uploads of a conversation are chained with APPEND_OR_REPLACE, and WorkManager gives every request chained behind a cancelled or failed one the same state without running it. The new test runs three uploads of one conversation on a real WorkManager and cancels the first, a middle, the last and every one of them, and one by the reference tag as after a restart of the app. The other uploads must finish as SUCCEEDED and their placeholder rows must not stay PENDING. doWork() injects its dependencies only when none are set yet, so the test can give the worker a factory with its own dependencies. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/jobs/UploadCancelCascadeTest.kt | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt 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..e695c55823 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt @@ -0,0 +1,279 @@ +/* + * 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.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.assertFalse +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.Executors + +/** + * 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 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(null) + } + + @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") + } + + 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(File(File(context().noBackupFilesDir, "uploads"), id.toString())).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) + } + assertFalse(ids.isEmpty()) + } + + // 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 + } +} From 38e91bbf2bf4163f60df7f95e46e9e0fa1cba4d6 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Wed, 14 Oct 2026 19:10:02 +0000 Subject: [PATCH 16/29] fix(upload): a failed write in the cancel path must not fail the uploads behind it The cancel of an upload ends the work as success, because a failed work makes WorkManager fail every upload queued behind it in the conversation without running it. cancelledResult() removed the placeholder row without a guard: an exception from the database (e.g. a closed database) left doWork() as an exception, the work ended as FAILED, and the uploads behind the cancelled one ended as FAILED with their rows PENDING for ever. The row is removed by the chat as well, so a failed removal is only logged and the work still ends as success. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/jobs/UploadAndShareFilesWorker.kt | 11 +++++++++-- .../nextcloud/talk/jobs/UploadCancelCascadeTest.kt | 13 +++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) 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 d0017d6164..7d5d50f66b 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -302,11 +302,18 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa * 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. + * 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. */ + @Suppress("Detekt.TooGenericExceptionCaught") private fun cancelledResult(): Result { abortRemote(deleteUploadedFile = true) - deletePlaceholder() + try { + deletePlaceholder() + } catch (e: Exception) { + // 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() } diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt index e695c55823..fdfaedfe08 100644 --- a/app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt @@ -161,6 +161,19 @@ class UploadCancelCascadeTest { 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") + } + private fun assertAllSucceeded(workManager: WorkManager, vararg ids: UUID) { assertEquals( ids.map { WorkInfo.State.SUCCEEDED }, From 48ab95e9e5d44fc3a3051ee30db6fd9634c5f9d2 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Wed, 14 Oct 2026 19:27:50 +0000 Subject: [PATCH 17/29] fix(upload): keep the cancel flag when WorkManager stopped the cancelled run WorkManager ignores the result of a work it stopped (for example when the network is lost) and runs the work again. A user's cancel that came while the stopped run was still winding down made cancelledResult() end the run and the finally block of doWork() delete the workspace together with the cancel flag. The next run did not see the flag, uploaded the file and shared it, although its placeholder was already removed. cancelledResult() keeps the workspace when the run was stopped, as successResult() does. The next run finds the flag, removes the parts and the placeholder again (both are safe to repeat) and deletes the workspace. The same holds for the run that gets a retry because an older run still holds the lock of the workspace. The test stops the running work as WorkManager does, cancels it while the old run is still busy and checks that the flag survives and that the next run is cancelled too. The test restores the application it replaced. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/jobs/UploadAndShareFilesWorker.kt | 4 + .../talk/jobs/UploadCancelCascadeTest.kt | 77 ++++++++++++++++++- 2 files changed, 77 insertions(+), 4 deletions(-) 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 7d5d50f66b..e9561547c6 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -304,9 +304,13 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa * 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() diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt index fdfaedfe08..58b3a5687e 100644 --- a/app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt @@ -15,6 +15,9 @@ 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 @@ -28,7 +31,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Assert.assertNull import org.junit.Test import org.junit.runner.RunWith @@ -43,7 +46,10 @@ 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 @@ -64,6 +70,7 @@ class UploadCancelCascadeTest { // 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 @@ -91,7 +98,7 @@ class UploadCancelCascadeTest { @After fun tearDown() { workerExecutor.shutdownNow() - setSharedApplication(null) + setSharedApplication(previousApplication) } @Test @@ -174,6 +181,69 @@ class UploadCancelCascadeTest { 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 }, @@ -209,7 +279,7 @@ class UploadCancelCascadeTest { referenceId = referenceId, internalConversationId = CONVERSATION_ID ) - UploadWorkspace(File(File(context().noBackupFilesDir, "uploads"), id.toString())).markShared() + UploadWorkspace(workspaceDir(id)).markShared() id } @@ -270,7 +340,6 @@ class UploadCancelCascadeTest { } Thread.sleep(POLL_MILLIS) } - assertFalse(ids.isEmpty()) } // The setter of sharedApplication is protected, and the real application is not started in this test. From 66f28e676986c69e199e42019e1ffed1da41264d Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Thu, 15 Oct 2026 16:20:31 +0000 Subject: [PATCH 18/29] fix(upload): give up an upload the server keeps refusing for 24 hours Answers of 5xx (except 507), 408 and 429 were retried without a limit. They are now a failure kind of their own, UNAVAILABLE, which is retried for 24 hours. The 24 hours count from the first "unavailable" answer after which the upload did not move on. The workspace keeps that time together with the progress it had then; a later answer with more progress (more chunks sent, or the file uploaded) starts the time again. Waiting for the network without an answer of the server is still not limited. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/jobs/UploadAndShareFilesWorker.kt | 32 ++- .../talk/upload/UploadRetryPolicy.kt | 23 +- .../nextcloud/talk/upload/UploadWorkspace.kt | 22 +- .../UploadAndShareFilesWorkerFailureTest.kt | 213 ++++++++++++++++++ .../talk/upload/UploadRetryPolicyTest.kt | 30 ++- .../talk/upload/UploadWorkspaceTest.kt | 22 ++ .../chunked/ChunkedFileUploaderResumeTest.kt | 10 +- .../talk/upload/normal/FileUploaderTest.kt | 2 +- 8 files changed, 331 insertions(+), 23 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt 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 e9561547c6..5a4944bd4e 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -125,6 +125,10 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private lateinit var workspace: UploadWorkspace private val progressThrottle = UploadProgressThrottle() private var isForeground = false + + // The furthest percent of the upload in this run; -1 until the first progress report. + @Volatile + private var furthestProgress = -1 private var keepWorkspace = false private val notificationId = id.hashCode() @@ -272,20 +276,40 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } else { workspace.serverErrors() } - return when (UploadRetryPolicy.decide(kind, serverErrors)) { + val unavailableFor = if (kind == UploadRetryPolicy.FailureKind.UNAVAILABLE) { + unavailableForMillis() + } else { + 0L + } + return when (UploadRetryPolicy.decide(kind, serverErrors, unavailableFor)) { UploadRetryPolicy.Decision.RETRY -> { - Log.w(TAG, "Upload interrupted ($kind, server errors: $serverErrors), will resume", e) + Log.w( + TAG, + "Upload interrupted ($kind, server errors: $serverErrors, unavailable for $unavailableFor ms), " + + "will resume", + e + ) keepWorkspace = true Result.retry() } UploadRetryPolicy.Decision.FAIL -> { - Log.e(TAG, "Something went wrong when trying to upload file", e) + Log.e(TAG, "Something went wrong when trying to upload file (unavailable for $unavailableFor ms)", e) failUpload() } } } + /** + * How long the server has been answering "unavailable" while the upload did not move on. The upload moves on + * when more of it is sent than at the first such answer; a finished upload counts as further than any percent. + */ + private fun unavailableForMillis(): Long { + val now = System.currentTimeMillis() + val progress = if (workspace.uploadedPath() != null) UPLOADED_PROGRESS else furthestProgress + return now - workspace.unavailableSince(now, progress) + } + /** * 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. @@ -639,6 +663,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } override fun onTransferProgress(percentage: Int) { + furthestProgress = maxOf(furthestProgress, percentage) setProgressAsync(Data.Builder().putInt(PROGRESS_KEY, percentage).build()) if (progressThrottle.shouldUpdate(percentage)) { if (isCancelled()) { @@ -715,6 +740,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa const val PROGRESS_KEY = "UPLOAD_PROGRESS" private const val COMPRESS_IMAGES = "COMPRESS_IMAGES" private const val ALLOW_UPDATE = "ALLOW_UPDATE" + private const val UPLOADED_PROGRESS = 101 private const val CHUNK_UPLOAD_THRESHOLD_SIZE: Long = 1024 * 1024 private const val WORKSPACE_DIR = "uploads" diff --git a/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt b/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt index 20b11726e9..7dd412f168 100644 --- a/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt +++ b/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt @@ -23,13 +23,17 @@ 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. - * Only errors answered by the server count against [MAX_SERVER_ERRORS]. + * 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 + private const val MAX_CAUSE_DEPTH = 8 private const val HTTP_REQUEST_TIMEOUT = 408 private const val HTTP_TOO_MANY_REQUESTS = 429 @@ -39,21 +43,24 @@ object UploadRetryPolicy { 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) + private val PERMANENT_SERVER_ERRORS = + setOf(HTTP_NOT_IMPLEMENTED, HTTP_VERSION_NOT_SUPPORTED, HTTP_INSUFFICIENT_STORAGE) /** - * NETWORK: no connection or a server that is temporarily unavailable, retried without a limit. + * 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 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, SERVER, OTHER } + 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 treated like a lost network: the upload waits and goes on without a limit. Other HTTP errors and + * 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. */ @@ -61,7 +68,7 @@ object UploadRetryPolicy { val causes = causeChain(error) val httpCode = causes.firstNotNullOfOrNull(::httpCodeOf) return when { - httpCode != null -> if (isTransientHttpCode(httpCode)) FailureKind.NETWORK else FailureKind.SERVER + httpCode != null -> if (isTransientHttpCode(httpCode)) FailureKind.UNAVAILABLE else FailureKind.SERVER isCertificateProblem(causes) -> FailureKind.SERVER causes.any { it is IOException && it !is FileNotFoundException } -> FailureKind.NETWORK else -> FailureKind.OTHER @@ -105,10 +112,12 @@ object UploadRetryPolicy { /** * @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 */ - fun decide(kind: FailureKind, serverErrorCount: Int): Decision = + fun decide(kind: FailureKind, serverErrorCount: Int, unavailableForMillis: Long): Decision = when { kind == FailureKind.NETWORK -> Decision.RETRY + kind == FailureKind.UNAVAILABLE && unavailableForMillis < MAX_UNAVAILABLE_MILLIS -> 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 index 6831f013ce..d25c3fcb52 100644 --- a/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt +++ b/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt @@ -35,6 +35,7 @@ class UploadWorkspace(private val dir: 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 var lockChannel: RandomAccessFile? = null private var lock: FileLock? = null @@ -118,6 +119,23 @@ class UploadWorkspace(private val dir: File) { private fun uploadedLines(): List? = runCatching { uploadedFile.readLines() }.getOrNull() + /** + * The time since which the server answers "unavailable" without the upload moving on. [progress] says how far + * the upload is. The pair (time, progress) of the first such answer is kept; a later answer with more progress + * starts the time again, because the server took data in between. Returns the time to count from. + */ + fun unavailableSince(now: Long, progress: Int): Long { + val lines = runCatching { unavailableFile.readLines() }.getOrNull() + val since = lines?.getOrNull(0)?.toLongOrNull() + val stored = lines?.getOrNull(1)?.toIntOrNull() + if (since != null && stored != null && progress <= stored) { + return since + } + dir.mkdirs() + write(unavailableFile, "$now\n$progress") + return now + } + /** Whether the parts were already removed once after the server rejected the assembly. */ fun isRestarted(): Boolean = restartedFile.exists() @@ -185,6 +203,7 @@ class UploadWorkspace(private val dir: File) { 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 TMP_SUFFIX = ".tmp" private const val FILES_DIR = "file" @@ -196,7 +215,8 @@ class UploadWorkspace(private val dir: File) { UPLOAD_ID_FILE, UPLOADED_FILE, SHARED_FILE, - RESTARTED_FILE + RESTARTED_FILE, + UNAVAILABLE_FILE ) /** 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..740408c911 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt @@ -0,0 +1,213 @@ +/* + * 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 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.generic.GenericOverall +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 kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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 +import java.io.File +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * 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 spreed: SpreedCapabilityDto = mock() + private val capabilities: CapabilitiesDto = mock { on { spreedCapability } doReturn spreed } + private val user: User = mock { + on { username } 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 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()) + } + + @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, and the upload did not move on since. + UploadWorkspace(workspaceDir(worker.id)).unavailableSince(now() - HOURS_25, UPLOADED_PROGRESS) + + val result = worker.doWork() + + assertEquals(ListenableWorker.Result.success(), result) + assertEquals(SendStatus.FAILED, rows[REFERENCE_ID]?.sendStatus) + assertFalse("the workspace is removed", workspaceDir(worker.id).exists()) + } + + @Test + fun `progress of the upload starts the 24 hours again`() { + val worker = worker() + uploadedAndRefusedByServer(503) + // The old answer was one step before the end of the upload; now the file is uploaded. + UploadWorkspace(workspaceDir(worker.id)).unavailableSince(now() - HOURS_25, UPLOADED_PROGRESS - 1) + + val result = worker.doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus) + } + + /** 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(): UploadAndShareFilesWorker { + rows[REFERENCE_ID] = placeholder() + 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 = mock() + it.okHttpClient = OkHttpClient() + } + } + val input = Data.Builder() + .putLong(KEY_INTERNAL_USER_ID, ACCOUNT_ID) + .putString("DEVICE_SOURCE_FILE", "content://media/photo.jpg") + .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) + .build() + UploadWorkspace(workspaceDir(worker.id)).markUploaded("/Talk/photo.jpg", "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 UPLOADED_PROGRESS = 101 + } +} diff --git a/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt b/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt index 19d283e3dd..211a3b2926 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt @@ -55,12 +55,12 @@ class UploadRetryPolicyTest { } @Test - fun `a server that is unavailable is waited for like a lost network`() { + 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.NETWORK, UploadRetryPolicy.classify(DavHttpException(it, "x"))) + assertEquals("code $it", FailureKind.UNAVAILABLE, UploadRetryPolicy.classify(DavHttpException(it, "x"))) } listOf(500, 502, 503, 504, 408, 429).forEach { - assertEquals("retrofit $it", FailureKind.NETWORK, UploadRetryPolicy.classify(retrofit(it))) + assertEquals("retrofit $it", FailureKind.UNAVAILABLE, UploadRetryPolicy.classify(retrofit(it))) } } @@ -81,7 +81,7 @@ class UploadRetryPolicyTest { @Test fun `an http error wrapped by RxJava is classified by its code`() { - assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classify(RuntimeException(retrofit(503)))) + assertEquals(FailureKind.UNAVAILABLE, UploadRetryPolicy.classify(RuntimeException(retrofit(503)))) assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(RuntimeException(retrofit(403)))) } @@ -142,23 +142,37 @@ class UploadRetryPolicyTest { @Test fun `network errors never use up attempts`() { - assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.NETWORK, 0)) + assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.NETWORK, 0, 0L)) + assertEquals( + Decision.RETRY, + UploadRetryPolicy.decide(FailureKind.NETWORK, 0, UploadRetryPolicy.MAX_UNAVAILABLE_MILLIS * 2) + ) + } + + @Test + fun `an unavailable server is waited for up to 24 hours`() { + val limit = UploadRetryPolicy.MAX_UNAVAILABLE_MILLIS + assertEquals(24L * 60 * 60 * 1000, limit) + assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, 0L)) + assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit - 1)) + assertEquals(Decision.FAIL, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit)) + assertEquals(Decision.FAIL, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit + 1)) } @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)) + assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.SERVER, count, 0L)) } assertEquals( Decision.FAIL, - UploadRetryPolicy.decide(FailureKind.SERVER, UploadRetryPolicy.MAX_SERVER_ERRORS) + UploadRetryPolicy.decide(FailureKind.SERVER, UploadRetryPolicy.MAX_SERVER_ERRORS, 0L) ) } @Test fun `other errors fail at once`() { - assertEquals(Decision.FAIL, UploadRetryPolicy.decide(FailureKind.OTHER, 0)) + assertEquals(Decision.FAIL, UploadRetryPolicy.decide(FailureKind.OTHER, 0, 0L)) } 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 index faba3b8442..58498d41b6 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt @@ -252,6 +252,28 @@ class UploadWorkspaceTest { assertEquals(prepared, workspace().prepared()) } + @Test + fun `the first unavailable answer starts the clock and the same progress keeps it`() { + assertEquals(1_000L, workspace().unavailableSince(1_000L, 40)) + assertEquals(1_000L, workspace().unavailableSince(5_000L, 40)) + assertEquals(1_000L, workspace().unavailableSince(9_000L, 10)) + } + + @Test + fun `more progress of the upload starts the clock again`() { + assertEquals(1_000L, workspace().unavailableSince(1_000L, 40)) + assertEquals(5_000L, workspace().unavailableSince(5_000L, 41)) + assertEquals(5_000L, workspace().unavailableSince(9_000L, 41)) + } + + @Test + fun `the clock survives a new preparation`() { + workspace().unavailableSince(1_000L, 40) + workspace().prepareOnce { create(it, "a.jpg", 1_000) } + + assertEquals(1_000L, workspace().unavailableSince(9_000L, 40)) + } + companion object { private const val SIZE = 16 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 index a1ea436080..e91ddc15ac 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt @@ -198,10 +198,10 @@ class ChunkedFileUploaderResumeTest { 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.NETWORK, UploadRetryPolicy.classify(unavailable)) + assertEquals(UploadRetryPolicy.FailureKind.UNAVAILABLE, UploadRetryPolicy.classify(unavailable)) assertEquals( UploadRetryPolicy.Decision.RETRY, - UploadRetryPolicy.decide(UploadRetryPolicy.classify(unavailable), UploadRetryPolicy.MAX_SERVER_ERRORS) + decideAfterManyServerErrors(unavailable) ) putResponseCode = BAD_REQUEST @@ -209,10 +209,14 @@ class ChunkedFileUploaderResumeTest { assertEquals(UploadRetryPolicy.FailureKind.SERVER, UploadRetryPolicy.classify(rejected)) assertEquals( UploadRetryPolicy.Decision.FAIL, - UploadRetryPolicy.decide(UploadRetryPolicy.classify(rejected), UploadRetryPolicy.MAX_SERVER_ERRORS) + 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 diff --git a/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderTest.kt b/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderTest.kt index a88df74b5e..52a7492468 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderTest.kt @@ -84,7 +84,7 @@ class FileUploaderTest { runBlocking { uploader(file).uploadToConversationSubfolder(Uri.fromFile(file), "/draft/photo.jpg") } fail("expected an HttpException") } catch (e: HttpException) { - assertEquals(UploadRetryPolicy.FailureKind.NETWORK, UploadRetryPolicy.classify(e)) + assertEquals(UploadRetryPolicy.FailureKind.UNAVAILABLE, UploadRetryPolicy.classify(e)) } } From fe91dd995d47d8fb267ec01bcb77401f52ebfb7f Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Thu, 15 Oct 2026 16:23:30 +0000 Subject: [PATCH 19/29] fix(upload): fail at once when the prepared file cannot be read ChunkFromFileRequestBody wraps every error of reading a chunk in a FileNotFoundException, with the real IOException as its cause. classify saw that cause as a lost network and retried for ever. A FileNotFoundException anywhere in the chain is now OTHER; an HTTP code still wins. The file is in noBackupFilesDir, so waiting does not repair it. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/upload/UploadRetryPolicy.kt | 8 ++++++-- .../talk/upload/UploadRetryPolicyTest.kt | 15 +++++++++++++++ .../upload/chunked/ChunkedFileUploaderTest.kt | 19 +++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt b/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt index 7dd412f168..f282c43e75 100644 --- a/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt +++ b/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt @@ -62,7 +62,8 @@ object UploadRetryPolicy { * 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. + * 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) @@ -70,7 +71,10 @@ object UploadRetryPolicy { return when { httpCode != null -> if (isTransientHttpCode(httpCode)) FailureKind.UNAVAILABLE else FailureKind.SERVER isCertificateProblem(causes) -> FailureKind.SERVER - causes.any { it is IOException && it !is FileNotFoundException } -> FailureKind.NETWORK + // 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 } } diff --git a/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt b/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt index 211a3b2926..b6e8b8dbce 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt @@ -140,6 +140,21 @@ class UploadRetryPolicyTest { 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)) 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 d965419a0b..1ae59c209b 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 @@ -10,6 +10,7 @@ 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 okhttp3.mockwebserver.MockResponse @@ -130,6 +131,24 @@ class ChunkedFileUploaderTest { 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().setResponseCode(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)) } From 20a1a5b420b64663e2e23d2a268be806eccd3449 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Thu, 15 Oct 2026 16:27:14 +0000 Subject: [PATCH 20/29] fix(upload): report a failed upload in the output data of the work A failed upload ends as SUCCEEDED on purpose, because WorkManager fails the works chained behind a failed one without running them. An observer of the work could therefore not tell a failure from a delivery, and a placeholder message of an upload that failed before it was inserted stayed PENDING. failUpload now sets KEY_UPLOAD_FAILED in the output data. A cancel does not. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/jobs/UploadAndShareFilesWorker.kt | 10 ++++- .../UploadAndShareFilesWorkerFailureTest.kt | 38 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) 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 5a4944bd4e..80c5718ca0 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -30,6 +30,7 @@ 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 @@ -496,7 +497,7 @@ 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)) } /** @@ -738,6 +739,13 @@ 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. + */ + const val KEY_UPLOAD_FAILED = "UPLOAD_FAILED" private const val COMPRESS_IMAGES = "COMPRESS_IMAGES" private const val ALLOW_UPDATE = "ALLOW_UPDATE" private const val UPLOADED_PROGRESS = 101 diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt index 740408c911..4ad2d2f881 100644 --- a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt @@ -36,6 +36,7 @@ import okhttp3.OkHttpClient import okhttp3.ResponseBody.Companion.toResponseBody 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 @@ -111,7 +112,7 @@ class UploadAndShareFilesWorkerFailureTest { val result = worker.doWork() - assertEquals(ListenableWorker.Result.success(), result) + assertTrue(result is ListenableWorker.Result.Success) assertEquals(SendStatus.FAILED, rows[REFERENCE_ID]?.sendStatus) assertFalse("the workspace is removed", workspaceDir(worker.id).exists()) } @@ -129,6 +130,41 @@ class UploadAndShareFilesWorkerFailureTest { assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus) } + @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) + } + /** 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())) From 7177c30954cf1e018b5d7dfbc50066a59092ff0c Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Thu, 15 Oct 2026 16:31:06 +0000 Subject: [PATCH 21/29] fix(upload): show the upload notification only for files that are uploaded in chunks startForeground() ran before the preparation for every upload, so a 10 KB photo flashed a progress notification. The worker now asks for the size of the source first (File.length() for file://, FileUtils.querySize otherwise) and starts the foreground service only for files above the chunk threshold or of unknown size. An upload whose file is already on the server has one request left and needs no service either. The progress callback already checks isForeground. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/jobs/UploadAndShareFilesWorker.kt | 21 ++++++- .../com/nextcloud/talk/utils/FileUtils.kt | 2 +- .../UploadAndShareFilesWorkerFailureTest.kt | 56 ++++++++++++++++++- 3 files changed, 74 insertions(+), 5 deletions(-) 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 80c5718ca0..ce74a1aa52 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -204,7 +204,9 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa if (workspace.isShared()) { return successResult() } - startForeground() + if (needsForeground(sourceFileUri)) { + startForeground() + } val prepared = if (workspace.uploadedPath() != null) { workspace.prepared() @@ -402,6 +404,23 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa 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. + */ + private fun needsForeground(sourceFileUri: Uri): Boolean { + if (workspace.uploadedPath() != null) { + return false + } + val size = 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() { 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 08cb56a6fb..27460e7d55 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt @@ -269,7 +269,7 @@ object FileUtils { return target.length() > 0 || reported == null || reported <= 0 } - private fun querySize(context: Context, uri: Uri): Long? = + 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) diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt index 4ad2d2f881..ca76609759 100644 --- a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt @@ -10,6 +10,8 @@ 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 @@ -52,6 +54,7 @@ import retrofit2.Response import java.io.File import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger /** * What an upload that fails leaves behind: the state of the work, the placeholder message of the chat and the @@ -63,6 +66,7 @@ import java.util.concurrent.ConcurrentHashMap 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 { @@ -165,14 +169,52 @@ class UploadAndShareFilesWorkerFailureTest { assertEquals(SendStatus.SENT_PENDING_ACK, rows[REFERENCE_ID]?.sendStatus) } + @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()) + } + + 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(): UploadAndShareFilesWorker { + private fun worker( + source: String = "content://media/photo.jpg", + uploaded: Boolean = true + ): UploadAndShareFilesWorker { rows[REFERENCE_ID] = placeholder() + // Without capabilities a run that has to upload ends at once, after the foreground service was asked for. + if (!uploaded) { + whenever(user.capabilities).thenReturn(null) + } val factory = object : WorkerFactory() { override fun createWorker( appContext: Context, @@ -189,7 +231,7 @@ class UploadAndShareFilesWorkerFailureTest { } val input = Data.Builder() .putLong(KEY_INTERNAL_USER_ID, ACCOUNT_ID) - .putString("DEVICE_SOURCE_FILE", "content://media/photo.jpg") + .putString("DEVICE_SOURCE_FILE", source) .putString("ROOM_TOKEN", "room") .putString("CONVERSATION_NAME", "Room") .putString(UploadAndShareFilesWorker.KEY_REFERENCE_ID, REFERENCE_ID) @@ -198,8 +240,14 @@ class UploadAndShareFilesWorkerFailureTest { val worker = TestListenableWorkerBuilder(contextWithStrings()) .setInputData(input) .setWorkerFactory(factory) + .setForegroundUpdater { _, _, _ -> + foregroundRequests.incrementAndGet() + ResolvableFuture.create().also { it.set(null) } + } .build() - UploadWorkspace(workspaceDir(worker.id)).markUploaded("/Talk/photo.jpg", "photo.jpg") + if (uploaded) { + UploadWorkspace(workspaceDir(worker.id)).markUploaded("/Talk/photo.jpg", "photo.jpg") + } return worker } @@ -245,5 +293,7 @@ class UploadAndShareFilesWorkerFailureTest { private const val REFERENCE_ID = "ref-a" private const val HOURS_25 = 25L * 60 * 60 * 1000 private const val UPLOADED_PROGRESS = 101 + private const val SMALL_FILE_SIZE = 10 * 1024 + private const val BIG_FILE_SIZE = 1024 * 1024 + 1 } } From 95f6a68006ecd62bcc8d8304aa2f9c8886040468 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Thu, 15 Oct 2026 16:35:24 +0000 Subject: [PATCH 22/29] fix(upload): remove the draft file of an upload that failed A failed upload removed the parts of a chunked upload but left the assembled file in the draft folder of the conversation. The server moves that file out of the draft folder only when the message is posted, so after a failure nobody claims it. failUpload now deletes it; a file the server already moved answers 404, which deleteUploadedFile swallows. The workspace remembers that the file went to the draft folder, so a failure that comes in a later run knows it too. A file in the attachment folder of Talk (servers without conversation subfolders) is a file of the user and stays. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/jobs/UploadAndShareFilesWorker.kt | 8 +- .../nextcloud/talk/upload/UploadWorkspace.kt | 15 ++- .../UploadAndShareFilesWorkerFailureTest.kt | 98 ++++++++++++++++++- .../talk/upload/UploadWorkspaceTest.kt | 15 +++ 4 files changed, 129 insertions(+), 7 deletions(-) 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 ce74a1aa52..fc8bb0f4b4 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -505,10 +505,14 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa * 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() + abortRemote(deleteUploadedFile = workspace.isDraftUploaded()) updatePlaceholderStatus(SendStatus.FAILED) try { showFailedToUploadNotification() @@ -622,7 +626,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa if (!uploadToDraftFolder(checkNotNull(sourceFileUri), tempRemotePath)) { return@runBlocking false } - workspace.markUploaded(tempRemotePath, fileName) + workspace.markUploaded(tempRemotePath, fileName, draft = true) } if (isCancelled()) { return@runBlocking false diff --git a/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt b/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt index d25c3fcb52..c3eaf2b66a 100644 --- a/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt +++ b/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt @@ -112,11 +112,21 @@ class UploadWorkspace(private val dir: File) { /** 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() } - fun markUploaded(path: String, fileName: String) { + /** + * @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") + 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() /** @@ -204,6 +214,7 @@ class UploadWorkspace(private val dir: File) { private const val LOCK_FILE = "lock" private const val RESTARTED_FILE = "restarted" private const val UNAVAILABLE_FILE = "unavailable" + private const val DRAFT_MARK = "draft" private const val TMP_SUFFIX = ".tmp" private const val FILES_DIR = "file" diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt index ca76609759..6d5357d398 100644 --- a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt @@ -27,7 +27,11 @@ 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.UploadRetryPolicy import com.nextcloud.talk.upload.UploadWorkspace import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID @@ -36,6 +40,11 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient import okhttp3.ResponseBody.Companion.toResponseBody +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -54,6 +63,7 @@ import retrofit2.Response import java.io.File import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger /** @@ -71,6 +81,7 @@ class UploadAndShareFilesWorkerFailureTest { 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 @@ -79,6 +90,8 @@ class UploadAndShareFilesWorkerFailureTest { 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)]) @@ -95,6 +108,11 @@ class UploadAndShareFilesWorkerFailureTest { }.whenever(chatDao).updateTempMessageSendStatus(any(), any(), any()) } + @After + fun tearDown() { + runCatching { server.shutdown() } + } + @Test fun `an unavailable server is waited for and the placeholder stays pending`() { val worker = worker() @@ -169,6 +187,63 @@ class UploadAndShareFilesWorkerFailureTest { 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?.path) + } + + @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().setResponseCode(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) @@ -208,9 +283,22 @@ class UploadAndShareFilesWorkerFailureTest { private fun worker( source: String = "content://media/photo.jpg", - uploaded: Boolean = true + uploaded: Boolean = true, + draft: Boolean = false ): 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) { whenever(user.capabilities).thenReturn(null) @@ -225,7 +313,7 @@ class UploadAndShareFilesWorkerFailureTest { it.userManager = userManager it.chatDao = chatDao it.ncApi = ncApi - it.ncApiCoroutines = mock() + it.ncApiCoroutines = ncApiCoroutines it.okHttpClient = OkHttpClient() } } @@ -246,7 +334,8 @@ class UploadAndShareFilesWorkerFailureTest { } .build() if (uploaded) { - UploadWorkspace(workspaceDir(worker.id)).markUploaded("/Talk/photo.jpg", "photo.jpg") + val path = if (draft) "/Talk/draft/id-photo.jpg" else "/Talk/photo.jpg" + UploadWorkspace(workspaceDir(worker.id)).markUploaded(path, "photo.jpg", draft) } return worker } @@ -293,6 +382,9 @@ class UploadAndShareFilesWorkerFailureTest { private const val REFERENCE_ID = "ref-a" private const val HOURS_25 = 25L * 60 * 60 * 1000 private const val UPLOADED_PROGRESS = 101 + 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/upload/UploadWorkspaceTest.kt b/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt index 58498d41b6..2a877a35f5 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt @@ -209,6 +209,21 @@ class UploadWorkspaceTest { 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() From 25cbdd4f0c7bfae1064201c522fa048ff9196c5f Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Thu, 15 Oct 2026 16:51:35 +0000 Subject: [PATCH 23/29] fix(upload): take the size for the foreground decision from the prepared copy A later run of an upload asked the provider of the source for the size again, an IPC call that can be slow or answer differently, while the copy in the workspace is what is uploaded. The foreground service is now decided on the size of the prepared copy when there is one; the source is asked only before the copy exists. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/jobs/UploadAndShareFilesWorker.kt | 14 ++--- .../UploadAndShareFilesWorkerFailureTest.kt | 51 ++++++++++++++++++- 2 files changed, 57 insertions(+), 8 deletions(-) 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 fc8bb0f4b4..60f180350f 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -407,17 +407,19 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa /** * 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. + * 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 = if (sourceFileUri.scheme == "file") { - sourceFileUri.path?.let { File(it).length() } - } else { - FileUtils.querySize(context, sourceFileUri) - } + 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 } diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt index 6d5357d398..e21c67713a 100644 --- a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt @@ -31,6 +31,7 @@ import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachm 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 @@ -272,6 +273,44 @@ class UploadAndShareFilesWorkerFailureTest { 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() @@ -284,7 +323,9 @@ class UploadAndShareFilesWorkerFailureTest { private fun worker( source: String = "content://media/photo.jpg", uploaded: Boolean = true, - draft: Boolean = false + draft: Boolean = false, + id: UUID? = null, + preparedSize: Int? = null ): UploadAndShareFilesWorker { rows[REFERENCE_ID] = placeholder() if (draft) { @@ -300,7 +341,7 @@ class UploadAndShareFilesWorkerFailureTest { } } // Without capabilities a run that has to upload ends at once, after the foreground service was asked for. - if (!uploaded) { + if (!uploaded && !draft) { whenever(user.capabilities).thenReturn(null) } val factory = object : WorkerFactory() { @@ -328,6 +369,7 @@ class UploadAndShareFilesWorkerFailureTest { val worker = TestListenableWorkerBuilder(contextWithStrings()) .setInputData(input) .setWorkerFactory(factory) + .apply { id?.let { setId(it) } } .setForegroundUpdater { _, _, _ -> foregroundRequests.incrementAndGet() ResolvableFuture.create().also { it.set(null) } @@ -337,6 +379,11 @@ class UploadAndShareFilesWorkerFailureTest { 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 } From 9f3808b84106220682aa53ba0376db44dd3b9c3b Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Thu, 15 Oct 2026 16:54:54 +0000 Subject: [PATCH 24/29] fix(upload): keep the 24 hours from failing an upload that took data The progress for the 24 hours lived in memory of one run, so a run that began after a long pause counted from -1 and failed an upload the server had been taking data from. Now: - the furthest progress is stored in the workspace (written under the progress throttle, only when it grows), and the point of the clock is computed by the workspace: the stored maximum, or "uploaded" once the file is on the server; - the upload fails only after 24 hours AND at least 10 "unavailable" answers, so time without a network does not count as a refusing server; - a clock that went back starts the time again; - a workspace that cannot be written no longer throws out of the work (that failed the work and the uploads queued behind it): the error is logged and the upload is tried again. KEY_UPLOAD_FAILED now says to read the flag from the output data only, because the queue copies the output into the input of the next work. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/jobs/UploadAndShareFilesWorker.kt | 62 ++++++---- .../talk/upload/UploadRetryPolicy.kt | 27 ++++- .../nextcloud/talk/upload/UploadWorkspace.kt | 58 +++++++-- .../UploadAndShareFilesWorkerFailureTest.kt | 114 +++++++++++++++++- .../talk/upload/UploadRetryPolicyTest.kt | 29 +++-- .../talk/upload/UploadWorkspaceTest.kt | 52 ++++++-- 6 files changed, 276 insertions(+), 66 deletions(-) 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 60f180350f..cd73df18b8 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -47,6 +47,7 @@ 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 @@ -126,10 +127,6 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private lateinit var workspace: UploadWorkspace private val progressThrottle = UploadProgressThrottle() private var isForeground = false - - // The furthest percent of the upload in this run; -1 until the first progress report. - @Volatile - private var furthestProgress = -1 private var keepWorkspace = false private val notificationId = id.hashCode() @@ -275,21 +272,22 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } val kind = UploadRetryPolicy.classify(e) val serverErrors = if (kind == UploadRetryPolicy.FailureKind.SERVER) { - workspace.registerServerError() + registerServerError() } else { workspace.serverErrors() } - val unavailableFor = if (kind == UploadRetryPolicy.FailureKind.UNAVAILABLE) { - unavailableForMillis() + val unavailable = if (kind == UploadRetryPolicy.FailureKind.UNAVAILABLE) { + registerUnavailable() } else { - 0L + Unavailable(System.currentTimeMillis(), 0) } - return when (UploadRetryPolicy.decide(kind, serverErrors, unavailableFor)) { + 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), " + - "will resume", + "Upload interrupted ($kind, server errors: $serverErrors, unavailable for $unavailableFor ms, " + + "${unavailable.answers} answers), will resume", e ) keepWorkspace = true @@ -303,15 +301,23 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } } - /** - * How long the server has been answering "unavailable" while the upload did not move on. The upload moves on - * when more of it is sent than at the first such answer; a finished upload counts as further than any percent. - */ - private fun unavailableForMillis(): Long { - val now = System.currentTimeMillis() - val progress = if (workspace.uploadedPath() != null) UPLOADED_PROGRESS else furthestProgress - return now - workspace.unavailableSince(now, progress) - } + // 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. + 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. @@ -689,9 +695,9 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } override fun onTransferProgress(percentage: Int) { - furthestProgress = maxOf(furthestProgress, percentage) 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() @@ -701,6 +707,15 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } } + // 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) { val refId = referenceId ?: return val convId = internalConversationId ?: return @@ -768,12 +783,13 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa /** * 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. + * 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 UPLOADED_PROGRESS = 101 private const val CHUNK_UPLOAD_THRESHOLD_SIZE: Long = 1024 * 1024 private const val WORKSPACE_DIR = "uploads" diff --git a/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt b/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt index f282c43e75..ece4d2fec9 100644 --- a/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt +++ b/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt @@ -34,6 +34,12 @@ object UploadRetryPolicy { /** 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 @@ -48,8 +54,9 @@ object UploadRetryPolicy { /** * 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 without progress of the upload. + * 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. */ @@ -59,8 +66,8 @@ object UploadRetryPolicy { /** * 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 + * 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. @@ -117,11 +124,19 @@ object UploadRetryPolicy { /** * @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): Decision = + fun decide( + kind: FailureKind, + serverErrorCount: Int, + unavailableForMillis: Long, + unavailableAnswers: Int + ): Decision = when { kind == FailureKind.NETWORK -> Decision.RETRY - kind == FailureKind.UNAVAILABLE && unavailableForMillis < MAX_UNAVAILABLE_MILLIS -> 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 index c3eaf2b66a..53a5b54a63 100644 --- a/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt +++ b/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt @@ -13,6 +13,9 @@ 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) @@ -36,6 +39,7 @@ class UploadWorkspace(private val dir: 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 @@ -129,21 +133,45 @@ class UploadWorkspace(private val dir: File) { 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() + /** - * The time since which the server answers "unavailable" without the upload moving on. [progress] says how far - * the upload is. The pair (time, progress) of the first such answer is kept; a later answer with more progress - * starts the time again, because the server took data in between. Returns the time to count from. + * 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 unavailableSince(now: Long, progress: Int): Long { - val lines = runCatching { unavailableFile.readLines() }.getOrNull() - val since = lines?.getOrNull(0)?.toLongOrNull() - val stored = lines?.getOrNull(1)?.toIntOrNull() - if (since != null && stored != null && progress <= stored) { - return since + 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, "$now\n$progress") - return now + 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. */ @@ -214,6 +242,11 @@ class UploadWorkspace(private val dir: File) { 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" @@ -227,7 +260,8 @@ class UploadWorkspace(private val dir: File) { UPLOADED_FILE, SHARED_FILE, RESTARTED_FILE, - UNAVAILABLE_FILE + UNAVAILABLE_FILE, + PROGRESS_FILE ) /** diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt index e21c67713a..4ffc2c2d2a 100644 --- a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt @@ -130,8 +130,10 @@ class UploadAndShareFilesWorkerFailureTest { 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, and the upload did not move on since. - UploadWorkspace(workspaceDir(worker.id)).unavailableSince(now() - HOURS_25, UPLOADED_PROGRESS) + // 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() @@ -141,11 +143,84 @@ class UploadAndShareFilesWorkerFailureTest { } @Test - fun `progress of the upload starts the 24 hours again`() { + 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 `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) - // The old answer was one step before the end of the upload; now the file is uploaded. - UploadWorkspace(workspaceDir(worker.id)).unavailableSince(now() - HOURS_25, UPLOADED_PROGRESS - 1) + // 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() @@ -153,6 +228,31 @@ class UploadAndShareFilesWorkerFailureTest { 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() @@ -428,7 +528,9 @@ class UploadAndShareFilesWorkerFailureTest { 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 UPLOADED_PROGRESS = 101 + 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 diff --git a/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt b/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt index b6e8b8dbce..4edcb6c2f1 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt @@ -157,37 +157,48 @@ class UploadRetryPolicyTest { @Test fun `network errors never use up attempts`() { - assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.NETWORK, 0, 0L)) + assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.NETWORK, 0, 0L, 0)) assertEquals( Decision.RETRY, - UploadRetryPolicy.decide(FailureKind.NETWORK, 0, UploadRetryPolicy.MAX_UNAVAILABLE_MILLIS * 2) + 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)) - assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit - 1)) - assertEquals(Decision.FAIL, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit)) - assertEquals(Decision.FAIL, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit + 1)) + 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)) + assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.SERVER, count, 0L, 0)) } assertEquals( Decision.FAIL, - UploadRetryPolicy.decide(FailureKind.SERVER, UploadRetryPolicy.MAX_SERVER_ERRORS, 0L) + 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)) + 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 index 2a877a35f5..80044606c8 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt @@ -268,25 +268,57 @@ class UploadWorkspaceTest { } @Test - fun `the first unavailable answer starts the clock and the same progress keeps it`() { - assertEquals(1_000L, workspace().unavailableSince(1_000L, 40)) - assertEquals(1_000L, workspace().unavailableSince(5_000L, 40)) - assertEquals(1_000L, workspace().unavailableSince(9_000L, 10)) + 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`() { - assertEquals(1_000L, workspace().unavailableSince(1_000L, 40)) - assertEquals(5_000L, workspace().unavailableSince(5_000L, 41)) - assertEquals(5_000L, workspace().unavailableSince(9_000L, 41)) + 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 clock survives a new preparation`() { - workspace().unavailableSince(1_000L, 40) + 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 new preparation`() { + workspace().recordProgress(40) + workspace().registerUnavailable(1_000L) workspace().prepareOnce { create(it, "a.jpg", 1_000) } - assertEquals(1_000L, workspace().unavailableSince(9_000L, 40)) + assertEquals(Unavailable(1_000L, 2), workspace().registerUnavailable(9_000L)) + workspace().recordProgress(41) + assertEquals(Unavailable(9_500L, 1), workspace().registerUnavailable(9_500L)) } companion object { From e063f7805fa7d0d8a66aec7769d2fcdba4dda0f0 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Thu, 15 Oct 2026 17:01:16 +0000 Subject: [PATCH 25/29] fix(upload): forget the old progress when an upload starts from the beginning After the assembly was rejected (markRestarted) or the prepared file was made again (a new md5, so new chunks), the stored furthest progress, say 100, stayed. The new send goes 0..99 and never passes it, so every "unavailable" answer counted as no progress and the upload failed after 24 hours although the server took data. Both cases are a new send: the progress and the clock of the unavailable answers are removed. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/jobs/UploadAndShareFilesWorker.kt | 3 +- .../nextcloud/talk/upload/UploadWorkspace.kt | 9 ++++ .../UploadAndShareFilesWorkerFailureTest.kt | 17 ++++++++ .../talk/upload/UploadWorkspaceTest.kt | 41 +++++++++++++++++-- 4 files changed, 66 insertions(+), 4 deletions(-) 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 cd73df18b8..cdd9282cbc 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -302,7 +302,8 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } // 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. + // 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() diff --git a/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt b/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt index 53a5b54a63..f234ff201d 100644 --- a/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt +++ b/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt @@ -94,6 +94,8 @@ class UploadWorkspace(private val dir: File) { 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}") } } @@ -180,6 +182,13 @@ class UploadWorkspace(private val dir: File) { 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() diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt index 4ffc2c2d2a..5892f71a85 100644 --- a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt @@ -197,6 +197,23 @@ class UploadAndShareFilesWorkerFailureTest { 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() diff --git a/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt b/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt index 80044606c8..cb76ad61ea 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt @@ -98,10 +98,12 @@ class UploadWorkspaceTest { 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, 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()) @@ -112,6 +114,7 @@ class UploadWorkspaceTest { 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()) @@ -311,16 +314,46 @@ class UploadWorkspaceTest { } @Test - fun `the clock and the progress survive a new preparation`() { + 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", 1_000) } + + 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 @@ -337,5 +370,7 @@ class UploadWorkspaceTest { "unavailable", "progress" ) + + private const val ANSWERS = 10 } } From 52b9b5099cd94a267b68081b72d94ecd577de9ad Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Fri, 16 Oct 2026 16:39:46 +0000 Subject: [PATCH 26/29] test(upload): keep the retry tests of FileUploader in their own file master added its own FileUploaderTest in d1d0cd1fb, so this branch and master both add app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderTest.kt and the pull request cannot be merged. The tests that check how a refusal and an unavailable server of FileUploader are classified move unchanged to FileUploaderRetryTest; only the name of the class differs. Assisted-by: Claude-Code:claude-sonnet-5-5 Signed-off-by: Krainov Gleb --- .../normal/{FileUploaderTest.kt => FileUploaderRetryTest.kt} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename app/src/test/java/com/nextcloud/talk/upload/normal/{FileUploaderTest.kt => FileUploaderRetryTest.kt} (99%) diff --git a/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderTest.kt b/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderRetryTest.kt similarity index 99% rename from app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderTest.kt rename to app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderRetryTest.kt index 52a7492468..be01ea1646 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderRetryTest.kt @@ -40,7 +40,7 @@ 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 FileUploaderTest { +class FileUploaderRetryTest { @get:Rule val tempFolder = TemporaryFolder() From 5d6de8a3b8ae6db70517ca8b3902cbae824b56b4 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Thu, 15 Oct 2026 16:43:46 +0000 Subject: [PATCH 27/29] fix(chat): mark an upload placeholder failed when the worker reports the failure The upload worker ends a failed upload as SUCCEEDED, so the uploads queued behind it still run, and puts UPLOAD_FAILED into its output data. The observer of #6858 only looked at FAILED, so a worker that failed before the placeholder was inserted left it PENDING. Read the flag as well. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/chat/viewmodels/ChatViewModel.kt | 6 +- .../viewmodels/ChatViewModelUploadFileTest.kt | 79 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 8ed744fc2e..6cff9e9bff 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -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/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFileTest.kt b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFileTest.kt index ff0aea2531..33496c803d 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 @@ -8,19 +8,30 @@ package com.nextcloud.talk.chat.viewmodels import android.content.ContentProvider import android.content.ContentValues +import android.content.Context 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.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.impl.WorkManagerImpl +import androidx.work.testing.SynchronousExecutor import androidx.work.testing.WorkManagerTestInitHelper +import androidx.work.workDataOf 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.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.utils.ParticipantPermissions import com.nextcloud.talk.utils.message.SendMessageUtils import kotlinx.coroutines.CoroutineScope @@ -172,6 +183,73 @@ class ChatViewModelUploadFileTest { assertEquals("the placeholder is marked as failed", 0L, placeholderMarkedFailed.count) } + @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) + } + } + private fun queuedUploads(): Int = WorkManager.getInstance(RuntimeEnvironment.getApplication()) .getWorkInfosForUniqueWork("upload_queue_1@room") @@ -283,6 +361,7 @@ class ChatViewModelUploadFileTest { private const val FILE_BYTES = 2048L private const val TIMEOUT_MS = 20_000L private const val POLL_MS = 50L + private const val MARK_WAIT_MS = 3_000L private const val UPLOAD_ID = "upload" } } From d8b4f38e25db475abdfb25931ffcb0facbdfc117 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Tue, 20 Oct 2026 18:31:27 +0000 Subject: [PATCH 28/29] test(upload): move the upload tests of #6828 to the mockwebserver3 API of master master moved the tests to mockwebserver3. The MockResponse, Dispatcher and RecordedRequest calls of the tests that #6828 adds follow it. Assisted-by: Claude-Code:claude-sonnet-5-5 Signed-off-by: Krainov Gleb --- .../UploadAndShareFilesWorkerFailureTest.kt | 24 +++---- .../chunked/ChunkedFileUploaderAbortTest.kt | 2 +- .../chunked/ChunkedFileUploaderResumeTest.kt | 71 ++++++++++--------- .../upload/chunked/ChunkedFileUploaderTest.kt | 2 +- 4 files changed, 52 insertions(+), 47 deletions(-) diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt index 5892f71a85..2244536533 100644 --- a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt @@ -37,14 +37,19 @@ 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 okhttp3.mockwebserver.Dispatcher -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.RecordedRequest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -61,11 +66,6 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import retrofit2.HttpException import retrofit2.Response -import java.io.File -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger /** * What an upload that fails leaves behind: the state of the work, the placeholder message of the chat and the @@ -111,7 +111,7 @@ class UploadAndShareFilesWorkerFailureTest { @After fun tearDown() { - runCatching { server.shutdown() } + runCatching { server.close() } } @Test @@ -316,7 +316,7 @@ class UploadAndShareFilesWorkerFailureTest { 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?.path) + assertEquals("/remote.php/dav/files/alice/Talk/draft/id-photo.jpg", request?.target) } @Test @@ -355,7 +355,7 @@ class UploadAndShareFilesWorkerFailureTest { private fun startServer(): MockWebServer { server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest) = MockResponse().setResponseCode(NO_CONTENT) + override fun dispatch(request: RecordedRequest) = MockResponse(code = NO_CONTENT) } server.start() whenever(user.baseUrl).thenReturn(server.url("/").toString().trimEnd('/')) 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 d9df104fd7..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 @@ -109,7 +109,7 @@ class ChunkedFileUploaderAbortTest { val file = tempFolder.newFile("prepared.txt").apply { writeBytes(ByteArray(FILE_SIZE)) } val uploader = uploader() uploader.prepare(file) - server.enqueue(MockResponse().setResponseCode(NO_CONTENT)) + server.enqueue(MockResponse(code = NO_CONTENT)) uploader.abortUpload { successCalls++ } 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 index e91ddc15ac..10c8425053 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt @@ -12,16 +12,21 @@ 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 okhttp3.mockwebserver.Dispatcher -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.RecordedRequest -import okhttp3.mockwebserver.SocketPolicy import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -37,10 +42,6 @@ import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import java.io.File -import java.io.IOException -import java.util.Collections -import java.util.concurrent.TimeUnit @Suppress("TooManyFunctions") @RunWith(RobolectricTestRunner::class) @@ -75,25 +76,25 @@ class ChunkedFileUploaderResumeTest { requests.add(request) return when (request.method) { "PROPFIND" -> when { - propfindDisconnects -> MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST) - propfindResponseCode != null -> MockResponse().setResponseCode(propfindResponseCode!!) - else -> propfindResponse(request.path!!) + propfindDisconnects -> disconnect() + propfindResponseCode != null -> MockResponse(code = propfindResponseCode!!) + else -> propfindResponse(request.target) } - "MOVE" -> MockResponse().setResponseCode( - if (moveResponseCodes.isEmpty()) CREATED else moveResponseCodes.removeAt(0) + "MOVE" -> MockResponse( + code = if (moveResponseCodes.isEmpty()) CREATED else moveResponseCodes.removeAt(0) ) "PUT" -> if (putDisconnects) { - MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST) + disconnect() } else { - MockResponse().setResponseCode(putResponseCode) + MockResponse(code = putResponseCode) } - "MKCOL" -> MockResponse().setResponseCode(METHOD_NOT_ALLOWED) + "MKCOL" -> MockResponse(code = METHOD_NOT_ALLOWED) "DELETE" -> if (deleteDisconnects) { - MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST) + disconnect() } else { - MockResponse().setResponseCode(CREATED) + MockResponse(code = CREATED) } - else -> MockResponse().setResponseCode(CREATED) + else -> MockResponse(code = CREATED) } } } @@ -116,7 +117,7 @@ class ChunkedFileUploaderResumeTest { @After fun tearDown() { - server.shutdown() + server.close() } @Test @@ -177,8 +178,8 @@ class ChunkedFileUploaderResumeTest { uploader.upload(file, null, "/Talk/video.mp4") val move = requests.single { it.method == "MOVE" } - assertEquals(FILE_SIZE.toString(), move.getHeader("OC-Total-Length")) - assertTrue(move.path!!.endsWith("/.file")) + assertEquals(FILE_SIZE.toString(), move.headers["OC-Total-Length"]) + assertTrue(move.target.endsWith("/.file")) } @Test @@ -254,7 +255,8 @@ class ChunkedFileUploaderResumeTest { 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().setSocketPolicy(SocketPolicy.NO_RESPONSE) + override fun dispatch(request: RecordedRequest) = + MockResponse.Builder().onResponseStart(SocketEffect.Stall).build() } hanging.start() try { @@ -271,7 +273,7 @@ class ChunkedFileUploaderResumeTest { assertFalse(call.isCanceled()) call.cancel() } finally { - hanging.shutdown() + hanging.close() } } @@ -396,12 +398,12 @@ class ChunkedFileUploaderResumeTest { uploader.deleteUploadedFile("/Talk/video.mp4") val delete = requests.single { it.method == "DELETE" } - assertTrue(delete.path!!.endsWith("/files/alice/Talk/video.mp4")) + assertTrue(delete.target.endsWith("/files/alice/Talk/video.mp4")) } @Test fun `removing an uploaded file never throws`() { - server.shutdown() + server.close() uploader.deleteUploadedFile("/Talk/video.mp4") } @@ -439,7 +441,7 @@ class ChunkedFileUploaderResumeTest { fresh.abortUpload(file) {} val delete = requests.single { it.method == "DELETE" } - assertTrue(delete.path!!.endsWith("/" + FileUtils.md5Sum(file))) + assertTrue(delete.target.endsWith("/" + FileUtils.md5Sum(file))) } @Test @@ -482,8 +484,10 @@ class ChunkedFileUploaderResumeTest { 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.path!!.substringAfterLast('/') } + requests.filter { it.method == "PUT" }.map { "/" + it.target.substringAfterLast('/') } private fun propfindResponse(path: String): MockResponse { val folder = path.trimEnd('/') @@ -505,10 +509,11 @@ class ChunkedFileUploaderResumeTest { $members """ - return MockResponse() - .setResponseCode(MULTI_STATUS) - .setHeader("Content-Type", "application/xml; charset=utf-8") - .setBody(body) + return MockResponse( + code = MULTI_STATUS, + headers = Headers.headersOf("Content-Type", "application/xml; charset=utf-8"), + body = body + ) } companion object { 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 279f044491..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 @@ -136,7 +136,7 @@ class ChunkedFileUploaderTest { val file = localFile(SMALL_FILE_SIZE) enqueueFolderCreated() enqueueNoChunksOnServer() - server.enqueue(MockResponse().setResponseCode(CREATED)) + 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") From ae7fff67a6a7a49b332201f3e98598f0895d5b93 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Tue, 20 Oct 2026 20:01:38 +0000 Subject: [PATCH 29/29] test(upload): split the upload view model tests to keep detekt TooManyFunctions quiet ChatViewModelUploadFileTest had 19 functions against the limit of 15. Move what the tests share into ChatViewModelUploadTestBase and the two tests that read the failure of an upload into ChatViewModelUploadFailureTest. No test changes. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../ChatViewModelUploadFailureTest.kt | 100 +++++++ .../viewmodels/ChatViewModelUploadFileTest.kt | 268 +----------------- .../viewmodels/ChatViewModelUploadTestBase.kt | 206 ++++++++++++++ 3 files changed, 307 insertions(+), 267 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFailureTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadTestBase.kt 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 33496c803d..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,108 +6,24 @@ */ package com.nextcloud.talk.chat.viewmodels -import android.content.ContentProvider -import android.content.ContentValues -import android.content.Context -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.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.impl.WorkManagerImpl -import androidx.work.testing.SynchronousExecutor import androidx.work.testing.WorkManagerTestInitHelper -import androidx.work.workDataOf -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.jobs.UploadAndShareFilesWorker -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") @@ -182,186 +98,4 @@ class ChatViewModelUploadFileTest { assertEquals("the placeholder is marked as failed", 0L, placeholderMarkedFailed.count) } - - @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) - } - } - - 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 MARK_WAIT_MS = 3_000L - 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) + } + } +}