diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/ChunkFromFileRequestBody.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/ChunkFromFileRequestBody.kt index b6c8421120..47b5f4affc 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/ChunkFromFileRequestBody.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/ChunkFromFileRequestBody.kt @@ -88,6 +88,7 @@ class ChunkFromFileRequestBody( } } catch (exception: Exception) { Timber.e(exception, "Transferred " + alreadyTransferred + " bytes from a total of " + file.length()) + throw exception } } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt index b482fc5f21..ab0329ec1f 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt @@ -46,26 +46,29 @@ class CreateTusUploadRemoteOperation( Base64.encodeToString(bytes, Base64.NO_WRAP) } - override fun run(client: OpenCloudClient): RemoteOperationResult = try { - // Determine TUS endpoint URL based on provided parameters - val targetFileUrl = if (!tusUrl.isNullOrBlank()) { - tusUrl - } else { - val baseCollection = (collectionUrlOverride - ?: client.userFilesWebDavUri.toString()).trim() - // Remove trailing slash - OpenCloud expects no slash on space endpoints - val resolvedCollection = buildCollectionUrl(baseCollection, remotePath).trimEnd('/') - Timber.d("TUS resolved collection: %s", resolvedCollection) - resolvedCollection - } + override fun run(client: OpenCloudClient): RemoteOperationResult { + var creationUploadFile: RandomAccessFile? = null + return try { + // Determine TUS endpoint URL based on provided parameters + val targetFileUrl = if (!tusUrl.isNullOrBlank()) { + tusUrl + } else { + val baseCollection = (collectionUrlOverride + ?: client.userFilesWebDavUri.toString()).trim() + // Remove trailing slash - OpenCloud expects no slash on space endpoints + val resolvedCollection = buildCollectionUrl(baseCollection, remotePath).trimEnd('/') + Timber.d("TUS resolved collection: %s", resolvedCollection) + resolvedCollection + } Timber.d("TUS Creation URL: %s", targetFileUrl) // Prepare request body first val postBody: RequestBody = if (useCreationWithUpload && (firstChunkSize ?: 0L) > 0L) { // creation-with-upload: include first chunk - // Don't use .use{} here - the channel must stay open for OkHttp to read + // Keep the channel open until OkHttp finishes reading the body. val raf = RandomAccessFile(file, "r") + creationUploadFile = raf val channel: FileChannel = raf.channel ChunkFromFileRequestBody( file = file, @@ -158,10 +161,17 @@ class CreateTusUploadRemoteOperation( Timber.w("TUS creation failed with status: %d", status) RemoteOperationResult(postMethod).apply { data = null } } - } catch (e: Exception) { - val result = RemoteOperationResult(e) - Timber.e(e, "TUS creation operation failed") - result + } catch (e: Exception) { + val result = RemoteOperationResult(e) + Timber.e(e, "TUS creation operation failed") + result + } finally { + try { + creationUploadFile?.close() + } catch (e: Exception) { + Timber.w(e, "Failed to close TUS creation upload file") + } + } } private fun isSuccess(status: Int) = diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt index 944c7155ae..c9153d2130 100644 --- a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt @@ -8,12 +8,14 @@ import eu.opencloud.android.lib.common.OpenCloudAccount import eu.opencloud.android.lib.common.OpenCloudClient import eu.opencloud.android.lib.common.accounts.AccountUtils import eu.opencloud.android.lib.common.authentication.OpenCloudCredentialsFactory +import eu.opencloud.android.lib.common.network.ChunkFromFileRequestBody import eu.opencloud.android.lib.common.operations.RemoteOperationResult import eu.opencloud.android.lib.resources.files.tus.CreateTusUploadRemoteOperation.Base64Encoder import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest +import okio.Buffer import org.junit.After import org.junit.Assert.* import org.junit.Before @@ -21,6 +23,8 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import java.io.File +import java.io.RandomAccessFile +import java.nio.channels.ClosedChannelException import java.util.Base64 @RunWith(RobolectricTestRunner::class) @@ -245,6 +249,32 @@ class TusIntegrationTest { // creation-with-upload sends Content-Type and Content-Length for the chunk assertEquals("application/offset+octet-stream", postReq.getHeader("Content-Type")) assertEquals(firstChunkSize.toString(), postReq.getHeader("Content-Length")) + assertTrue("Local file should be deletable after TUS creation-with-upload", localFile.delete()) + } + + @Test + fun chunk_body_propagates_channel_failures() { + val localFile = File.createTempFile("tus", ".bin").apply { + writeBytes(ByteArray(10) { it.toByte() }) + } + val raf = RandomAccessFile(localFile, "r") + val body = ChunkFromFileRequestBody( + file = localFile, + contentType = null, + channel = raf.channel, + chunkSize = 5 + ) + + raf.close() + + try { + body.writeTo(Buffer()) + fail("Expected closed channel failure") + } catch (expected: ClosedChannelException) { + // Expected failure must reach OkHttp so the upload can fail. + } finally { + localFile.delete() + } } @Test