Skip to content

Commit d94465b

Browse files
committed
feat(upload): honor TUS capabilities and refine fallback logic
- Read filesTusSupport from capabilities and pass into TUS flow - Respect server maxChunkSize and HTTP method override during PATCH - Prefer TUS for large files; on failure, force chunked fallback for large files when TUS is advertised, even if legacy chunking is off - Expanded logging for chosen chunk sizes and overrides - Applied to ContentUri and FileSystem upload workers Why: improve interoperability with varying servers/proxies, respect server limits, and increase reliability of large uploads.
1 parent 49a2374 commit d94465b

File tree

15 files changed

+1389
-26
lines changed

15 files changed

+1389
-26
lines changed

opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import eu.opencloud.android.domain.transfers.model.TransferResult
4444
import eu.opencloud.android.domain.transfers.model.TransferStatus
4545
import eu.opencloud.android.extensions.isContentUri
4646
import eu.opencloud.android.extensions.parseError
47+
import eu.opencloud.android.domain.capabilities.model.OCCapability
4748
import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
4849
import eu.opencloud.android.lib.common.OpenCloudAccount
4950
import eu.opencloud.android.lib.common.OpenCloudClient
@@ -257,13 +258,14 @@ class UploadFileFromContentUriWorker(
257258
)
258259
)
259260
val isChunkingAllowed = capabilitiesForAccount != null && capabilitiesForAccount.isChunkingAllowed()
261+
val tusSupport = capabilitiesForAccount?.filesTusSupport
260262
Timber.d("Chunking is allowed: %s, and file size is greater than the minimum chunk size: %s", isChunkingAllowed, fileSize > CHUNK_SIZE)
261263

262264
// Prefer TUS for large files: optimistically try TUS and fall back on failure
263265
val usedTus = if (fileSize > CHUNK_SIZE) {
264266
Timber.d("Attempting TUS for large upload (size=%d, threshold=%d)", fileSize, CHUNK_SIZE)
265267
val ok = try {
266-
uploadTusFile(client)
268+
uploadTusFile(client, tusSupport)
267269
true
268270
} catch (e: Exception) {
269271
Timber.w(e, "TUS flow failed, will fallback to existing upload methods")
@@ -277,7 +279,9 @@ class UploadFileFromContentUriWorker(
277279
}
278280

279281
if (!usedTus) {
280-
if (isChunkingAllowed && fileSize > CHUNK_SIZE) {
282+
val shouldForceChunkedFallback = !isChunkingAllowed && fileSize > CHUNK_SIZE && tusSupport != null
283+
val useChunkedFallback = (isChunkingAllowed && fileSize > CHUNK_SIZE) || shouldForceChunkedFallback
284+
if (useChunkedFallback) {
281285
uploadChunkedFile(client)
282286
} else {
283287
uploadPlainFile(client)
@@ -286,7 +290,7 @@ class UploadFileFromContentUriWorker(
286290
removeCacheFile()
287291
}
288292

289-
private fun uploadTusFile(client: OpenCloudClient) {
293+
private fun uploadTusFile(client: OpenCloudClient, tusSupport: OCCapability.TusSupport?) {
290294
Timber.i("Starting TUS upload for %s (size=%d)", uploadPath, fileSize)
291295

292296
// 1) Create or resume session
@@ -339,9 +343,13 @@ class UploadFileFromContentUriWorker(
339343
}
340344
Timber.d("TUS resume offset: %d / %d", offset, fileSize)
341345

342-
// Use fixed chunk size if server max is unknown
343-
val serverMaxChunk: Long? = null
344-
Timber.d("TUS using fixed chunk size: %d", CHUNK_SIZE)
346+
val serverMaxChunk: Long? = tusSupport?.maxChunkSize?.takeIf { it > 0 }?.toLong()
347+
Timber.d(
348+
"TUS chunk preferences: clientChunk=%d serverMax=%s httpOverride=%s",
349+
CHUNK_SIZE,
350+
serverMaxChunk,
351+
tusSupport?.httpMethodOverride
352+
)
345353

346354
// 3) PATCH loop
347355
while (offset < fileSize) {
@@ -353,7 +361,8 @@ class UploadFileFromContentUriWorker(
353361
localPath = cachePath,
354362
uploadUrl = tusUrl,
355363
offset = offset,
356-
chunkSize = toSend
364+
chunkSize = toSend,
365+
httpMethodOverride = tusSupport?.httpMethodOverride,
357366
).apply {
358367
addDataTransferProgressListener(this@UploadFileFromContentUriWorker)
359368
}

opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import androidx.work.workDataOf
3030
import eu.opencloud.android.R
3131
import eu.opencloud.android.data.executeRemoteOperation
3232
import eu.opencloud.android.domain.automaticuploads.model.UploadBehavior
33+
import eu.opencloud.android.domain.capabilities.model.OCCapability
3334
import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
3435
import eu.opencloud.android.domain.exceptions.LocalFileNotFoundException
3536
import eu.opencloud.android.domain.exceptions.UnauthorizedException
@@ -145,7 +146,7 @@ class UploadFileFromFileSystemWorker(
145146
return md.digest().joinToString("") { b -> "%02x".format(b) }
146147
}
147148

148-
private fun uploadViaTus(client: OpenCloudClient): Boolean {
149+
private fun uploadViaTus(client: OpenCloudClient, tusSupport: OCCapability.TusSupport?): Boolean {
149150
try {
150151
Timber.d("TUS: entering uploadViaTus for %s size=%d", uploadPath, fileSize)
151152
// 1) Create or reuse TUS upload URL
@@ -206,7 +207,13 @@ class UploadFileFromFileSystemWorker(
206207
// 3) PATCH loop with basic retry/resume on transient failures
207208
var consecutiveFailures = 0
208209
val maxRetries = 5
209-
val serverMaxChunk: Long? = null
210+
val serverMaxChunk: Long? = tusSupport?.maxChunkSize?.takeIf { it > 0 }?.toLong()
211+
Timber.d(
212+
"TUS chunk preferences: clientChunk=%d serverMax=%s httpOverride=%s",
213+
CHUNK_SIZE,
214+
serverMaxChunk,
215+
tusSupport?.httpMethodOverride
216+
)
210217
while (offset < fileSize) {
211218
val remaining = fileSize - offset
212219
val limitByServer = serverMaxChunk ?: Long.MAX_VALUE
@@ -218,6 +225,7 @@ class UploadFileFromFileSystemWorker(
218225
uploadUrl = tusUrl,
219226
offset = offset,
220227
chunkSize = chunk,
228+
httpMethodOverride = tusSupport?.httpMethodOverride,
221229
).apply {
222230
addDataTransferProgressListener(this@UploadFileFromFileSystemWorker)
223231
}
@@ -391,12 +399,13 @@ class UploadFileFromFileSystemWorker(
391399
)
392400
)
393401
val isChunkingAllowed = capabilitiesForAccount != null && capabilitiesForAccount.isChunkingAllowed()
402+
val tusSupport = capabilitiesForAccount?.filesTusSupport
394403
Timber.d("Chunking is allowed: %s, and file size is greater than the minimum chunk size: %s", isChunkingAllowed, fileSize > CHUNK_SIZE)
395404

396405
// Prefer TUS for large files: optimistically try TUS create and let it fail fast if unsupported
397406
val usedTus = if (fileSize > CHUNK_SIZE) {
398407
Timber.d("Attempting TUS for large upload (size=%d, threshold=%d)", fileSize, CHUNK_SIZE)
399-
val ok = uploadViaTus(client)
408+
val ok = uploadViaTus(client, tusSupport)
400409
Timber.d("TUS attempt result: %s", if (ok) "success" else "failed")
401410
ok
402411
} else {
@@ -405,8 +414,15 @@ class UploadFileFromFileSystemWorker(
405414
}
406415

407416
if (!usedTus) {
408-
Timber.d("Proceeding without TUS: %s", if (isChunkingAllowed && fileSize > CHUNK_SIZE) "chunked WebDAV" else "plain WebDAV")
409-
if (isChunkingAllowed && fileSize > CHUNK_SIZE) {
417+
val shouldForceChunkedFallback = !isChunkingAllowed && fileSize > CHUNK_SIZE && tusSupport != null
418+
val useChunkedFallback = (isChunkingAllowed && fileSize > CHUNK_SIZE) || shouldForceChunkedFallback
419+
Timber.d(
420+
"Proceeding without TUS: %s (forcedChunkFallback=%s, httpOverride=%s)",
421+
if (useChunkedFallback) "chunked WebDAV" else "plain WebDAV",
422+
shouldForceChunkedFallback,
423+
tusSupport?.httpMethodOverride
424+
)
425+
if (useChunkedFallback) {
410426
uploadChunkedFile(client)
411427
} else {
412428
uploadPlainFile(client)

opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpConstants.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public class HttpConstants {
4545
public static final String OC_TOTAL_LENGTH_HEADER = "OC-Total-Length";
4646
public static final String OC_X_OC_MTIME_HEADER = "X-OC-Mtime";
4747
public static final String OC_X_REQUEST_ID = "X-Request-ID";
48+
public static final String X_HTTP_METHOD_OVERRIDE = "X-HTTP-Method-Override";
4849
public static final String LOCATION_HEADER = "Location";
4950
public static final String LOCATION_HEADER_LOWER = "location";
5051
public static final String CONTENT_TYPE_URLENCODED_UTF8 = "application/x-www-form-urlencoded; charset=utf-8";

opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package eu.opencloud.android.lib.resources.files.tus
22

33
import eu.opencloud.android.lib.common.OpenCloudClient
44
import eu.opencloud.android.lib.common.http.HttpConstants
5+
import eu.opencloud.android.lib.common.http.methods.HttpBaseMethod
56
import eu.opencloud.android.lib.common.http.methods.nonwebdav.PatchMethod
7+
import eu.opencloud.android.lib.common.http.methods.nonwebdav.PostMethod
68
import eu.opencloud.android.lib.common.network.ChunkFromFileRequestBody
79
import eu.opencloud.android.lib.common.network.OnDatatransferProgressListener
810
import eu.opencloud.android.lib.common.operations.OperationCancelledException
@@ -16,6 +18,7 @@ import java.io.File
1618
import java.io.RandomAccessFile
1719
import java.net.URL
1820
import java.nio.channels.FileChannel
21+
import java.util.Locale
1922
import java.util.concurrent.atomic.AtomicBoolean
2023

2124
/**
@@ -28,11 +31,12 @@ class PatchTusUploadChunkRemoteOperation(
2831
private val uploadUrl: String,
2932
private val offset: Long,
3033
private val chunkSize: Long,
34+
private val httpMethodOverride: String? = null,
3135
) : RemoteOperation<Long>() {
3236

3337
private val cancellationRequested = AtomicBoolean(false)
3438
private val dataTransferListeners: MutableSet<OnDatatransferProgressListener> = HashSet()
35-
private var patchMethod: PatchMethod? = null
39+
private var activeMethod: HttpBaseMethod? = null
3640

3741
override fun run(client: OpenCloudClient): RemoteOperationResult<Long> =
3842
try {
@@ -52,23 +56,40 @@ class PatchTusUploadChunkRemoteOperation(
5256
return RemoteOperationResult<Long>(OperationCancelledException())
5357
}
5458

55-
patchMethod = PatchMethod(URL(uploadUrl), body).apply {
59+
val method = when (httpMethodOverride?.uppercase(Locale.ROOT)) {
60+
"POST" -> PostMethod(URL(uploadUrl), body).apply {
61+
setRequestHeader(HttpConstants.X_HTTP_METHOD_OVERRIDE, "PATCH")
62+
}
63+
else -> PatchMethod(URL(uploadUrl), body)
64+
}.apply {
5665
setRequestHeader(HttpConstants.TUS_RESUMABLE, HttpConstants.TUS_RESUMABLE_VERSION_1_0_0)
5766
setRequestHeader(HttpConstants.UPLOAD_OFFSET, offset.toString())
5867
setRequestHeader(HttpConstants.CONTENT_TYPE_HEADER, HttpConstants.CONTENT_TYPE_OFFSET_OCTET_STREAM)
5968
}
6069

61-
val status = client.executeHttpMethod(patchMethod)
62-
Timber.d("Patch TUS upload chunk - $status${if (!isSuccess(status)) "(FAIL)" else ""}")
70+
activeMethod = method
71+
72+
val status = client.executeHttpMethod(method)
73+
Timber.d(
74+
"Patch TUS upload chunk via %s - %d%s",
75+
method.javaClass.simpleName,
76+
status,
77+
if (!isSuccess(status)) " (FAIL)" else ""
78+
)
6379

6480
if (isSuccess(status)) {
65-
val newOffset = patchMethod!!.getResponseHeader(HttpConstants.UPLOAD_OFFSET)?.toLongOrNull()
66-
if (newOffset != null) RemoteOperationResult<Long>(ResultCode.OK).apply { data = newOffset }
67-
else RemoteOperationResult<Long>(patchMethod).apply { data = -1L }
68-
} else RemoteOperationResult<Long>(patchMethod)
81+
val newOffset = method.getResponseHeader(HttpConstants.UPLOAD_OFFSET)?.toLongOrNull()
82+
if (newOffset != null) {
83+
RemoteOperationResult<Long>(ResultCode.OK).apply { data = newOffset }
84+
} else {
85+
RemoteOperationResult<Long>(method).apply { data = -1L }
86+
}
87+
} else {
88+
RemoteOperationResult<Long>(method)
89+
}
6990
}
7091
} catch (e: Exception) {
71-
val result = if (patchMethod?.isAborted == true) {
92+
val result = if (activeMethod?.isAborted == true) {
7293
RemoteOperationResult<Long>(OperationCancelledException())
7394
} else RemoteOperationResult<Long>(e)
7495
Timber.e(result.exception, "Patch TUS upload chunk failed: ${result.logMessage}")
@@ -86,7 +107,7 @@ class PatchTusUploadChunkRemoteOperation(
86107
fun cancel() {
87108
synchronized(cancellationRequested) {
88109
cancellationRequested.set(true)
89-
patchMethod?.abort()
110+
activeMethod?.abort()
90111
}
91112
}
92113

opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/RemoteCapability.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ data class RemoteCapability(
7373
var filesVersioning: CapabilityBooleanType = CapabilityBooleanType.UNKNOWN,
7474
val filesPrivateLinks: CapabilityBooleanType = CapabilityBooleanType.UNKNOWN,
7575
val filesAppProviders: List<RemoteAppProviders>?,
76+
val filesTusSupport: TusSupport?,
7677

7778
// Spaces
7879
val spaces: RemoteSpaces?,
@@ -118,6 +119,14 @@ data class RemoteCapability(
118119
val newUrl: String?,
119120
)
120121

122+
data class TusSupport(
123+
val version: String?,
124+
val resumable: String?,
125+
val extension: String?,
126+
val maxChunkSize: Int?,
127+
val httpMethodOverride: String?,
128+
)
129+
121130
data class RemoteSpaces(
122131
val enabled: Boolean,
123132
val projects: Boolean,

opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/responses/CapabilityResponse.kt

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ data class CapabilityResponse(
8383
filesPrivateLinks = capabilities?.fileCapabilities?.privateLinks?.let { CapabilityBooleanType.fromBooleanValue(it) }
8484
?: CapabilityBooleanType.UNKNOWN,
8585
filesAppProviders = capabilities?.fileCapabilities?.appProviders?.map { it.toAppProviders() },
86+
filesTusSupport = capabilities?.fileCapabilities?.tusSupport?.toTusSupport(),
8687
filesSharingFederationIncoming =
8788
CapabilityBooleanType.fromBooleanValue(capabilities?.fileSharingCapabilities?.fileSharingFederation?.incoming),
8889
filesSharingFederationOutgoing =
@@ -187,7 +188,9 @@ data class FileCapabilities(
187188
val versioning: Boolean?,
188189
val privateLinks: Boolean?,
189190
@Json(name = "app_providers")
190-
val appProviders: List<AppProvider>?
191+
val appProviders: List<AppProvider>?,
192+
@Json(name = "tus_support")
193+
val tusSupport: TusSupport?
191194
)
192195

193196
@JsonClass(generateAdapter = true)
@@ -206,6 +209,25 @@ data class AppProvider(
206209
fun toAppProviders() = RemoteAppProviders(enabled, version, appsUrl, openUrl, openWebUrl, newUrl)
207210
}
208211

212+
@JsonClass(generateAdapter = true)
213+
data class TusSupport(
214+
val version: String?,
215+
val resumable: String?,
216+
val extension: String?,
217+
@Json(name = "max_chunk_size")
218+
val maxChunkSize: Int?,
219+
@Json(name = "http_method_override")
220+
val httpMethodOverride: String?
221+
) {
222+
fun toTusSupport() = RemoteCapability.TusSupport(
223+
version = version,
224+
resumable = resumable,
225+
extension = extension,
226+
maxChunkSize = maxChunkSize,
227+
httpMethodOverride = httpMethodOverride,
228+
)
229+
}
230+
209231
@JsonClass(generateAdapter = true)
210232
data class DavCapabilities(
211233
val chunking: String?

0 commit comments

Comments
 (0)