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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions library/src/main/java/com/nextcloud/common/HTTPCodes.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Nextcloud Android Library
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: MIT
*/

package com.nextcloud.common

object HTTPCodes {
const val STATUS_OK = 200
const val STATUS_UNAUTHORIZED = 401
const val STATUS_FORBIDDEN = 403
const val STATUS_NOT_FOUND = 404
const val STATUS_REQUEST_TIMEOUT = 408
const val STATUS_TOO_MANY_REQUESTS = 429
const val STATUS_INTERNAL_SERVER_ERROR = 500
const val STATUS_BAD_GATEWAY = 502
const val STATUS_SERVICE_UNAVAILABLE = 503
const val STATUS_GATEWAY_TIMEOUT = 504

val RETRYABLE_HTTP_CODES =
setOf(
STATUS_REQUEST_TIMEOUT,
STATUS_TOO_MANY_REQUESTS,
STATUS_INTERNAL_SERVER_ERROR,
STATUS_BAD_GATEWAY,
STATUS_SERVICE_UNAVAILABLE,
STATUS_GATEWAY_TIMEOUT
)
}
13 changes: 10 additions & 3 deletions library/src/main/java/com/nextcloud/common/SessionTimeOut.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@

package com.nextcloud.common

import com.nextcloud.common.SessionTimeOut.Companion.DEFAULT_CONNECTION_TIME_OUT
import com.nextcloud.common.SessionTimeOut.Companion.DEFAULT_READ_TIME_OUT

data class SessionTimeOut(
val readTimeOut: Int,
val connectionTimeOut: Int
)
) {
companion object {
const val DEFAULT_READ_TIME_OUT = 60_000
const val DEFAULT_CONNECTION_TIME_OUT = 15_000
}
}

@Suppress("Detekt.MagicNumber")
val defaultSessionTimeOut = SessionTimeOut(60000, 15000)
val defaultSessionTimeOut = SessionTimeOut(DEFAULT_READ_TIME_OUT, DEFAULT_CONNECTION_TIME_OUT)
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Nextcloud Android Library
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: MIT
*/

package com.nextcloud.extensions

import com.nextcloud.common.HTTPCodes
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import java.io.InterruptedIOException
import java.net.SocketException

@Suppress("ReturnCount")
fun RemoteOperationResult<Void>.isTransientFailure(): Boolean {
if (isSuccess) return false

exception?.let { ex ->
return ex is SocketException ||
ex is InterruptedIOException ||
ex.cause is SocketException ||
ex.cause is InterruptedIOException ||
ex.message?.contains("transport", ignoreCase = true) == true
}

return httpCode in HTTPCodes.RETRYABLE_HTTP_CODES
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
* Nextcloud Android Library
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: MIT
*/
package com.owncloud.android.lib.resources.files

import com.nextcloud.common.HTTPCodes
import com.nextcloud.common.SessionTimeOut
import com.nextcloud.common.SessionTimeOut.Companion.DEFAULT_READ_TIME_OUT
import com.nextcloud.extensions.isTransientFailure
import com.owncloud.android.lib.common.OwnCloudAnonymousCredentials
import com.owncloud.android.lib.common.OwnCloudClient
import com.owncloud.android.lib.common.network.RedirectionPath
import com.owncloud.android.lib.common.operations.RemoteOperation
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.lib.resources.files.model.RetryPolicy
import org.apache.commons.httpclient.methods.HeadMethod

/**
* Checks for the existence (or absence) of a remote path via HTTP HEAD.
*
* Retries automatically on transient transport errors (e.g. "Unrecovered transport
* exception") using truncated exponential back-off so we don't hammer the server.
*
* @param remotePath DAV path to check.
* @param successIfAbsent When `true` a 404 is treated as success (absence check).
* @param retryPolicy Controls how many times and how long to retry.
*/
class ExistenceCheckRemoteOperation
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try to achieve on higher level in clients instead of individual child remote operation.

@JvmOverloads
constructor(
remotePath: String?,
private val successIfAbsent: Boolean,
private val retryPolicy: RetryPolicy = RetryPolicy.DEFAULT
) : RemoteOperation<Void>() {
private val path: String = remotePath.orEmpty()
private var redirectionPath: RedirectionPath? = null

fun getRedirectionPath(): RedirectionPath? = redirectionPath

fun wasRedirected(): Boolean = redirectionPath?.let { it.redirectionsCount > 0 } ?: false

override fun run(client: OwnCloudClient): RemoteOperationResult<Void> {
var lastResult = attemptCheck(client)
var delayMs = retryPolicy.initialDelayMs

for (attempt in 1..retryPolicy.maxAttempts) {
if (!lastResult.isTransientFailure()) break

if (attempt < retryPolicy.maxAttempts) {
Log_OC.w(
TAG,
"Attempt $attempt/${retryPolicy.maxAttempts} failed with " +
"${lastResult.httpCode} (${lastResult.logMessage}). " +
"Retrying in ${delayMs}ms…"
)
Thread.sleep(delayMs)
delayMs =
(delayMs * retryPolicy.backoffMultiplier)
.toLong()
.coerceAtMost(retryPolicy.maxDelayMs)
}

lastResult = attemptCheck(client)
}

return lastResult
}

@Suppress("TooGenericExceptionCaught")
private fun attemptCheck(client: OwnCloudClient): RemoteOperationResult<Void> {
val previousFollowRedirects = client.isFollowRedirects
var head: HeadMethod? = null

return try {
head =
if (client.credentials is OwnCloudAnonymousCredentials) {
HeadMethod(client.davUri.toString())
} else {
HeadMethod(client.getFilesDavUri(path))
}

client.isFollowRedirects = false

val sessionTimeOut = SessionTimeOut(DEFAULT_READ_TIME_OUT, DEFAULT_READ_TIME_OUT)
head.params.soTimeout = sessionTimeOut.readTimeOut
var status = client.executeMethod(head, sessionTimeOut.readTimeOut, sessionTimeOut.connectionTimeOut)

if (previousFollowRedirects) {
redirectionPath = client.followRedirection(head)
status = redirectionPath!!.lastStatus
}

client.exhaustResponse(head.responseBodyAsStream)

val success =
(
(
status == HTTPCodes.STATUS_OK || status == HTTPCodes.STATUS_UNAUTHORIZED ||
status == HTTPCodes.STATUS_FORBIDDEN
) && !successIfAbsent
) ||
(status == HTTPCodes.STATUS_NOT_FOUND && successIfAbsent)

RemoteOperationResult<Void>(
success,
status,
head.statusText,
head.responseHeaders
).also {
Log_OC.d(
TAG,
"Existence check for ${client.getFilesDavUri(path)} " +
"targeting ${if (successIfAbsent) "absence" else "existence"} " +
"finished with HTTP $status${if (!success) " (FAIL)" else ""}"
)
}
} catch (e: Exception) {
RemoteOperationResult<Void>(e).also {
Log_OC.e(
TAG,
"Existence check for ${client.getFilesDavUri(path)} " +
"targeting ${if (successIfAbsent) "absence" else "existence"}: " +
it.logMessage,
it.exception
)
}
} finally {
head?.releaseConnection()
client.isFollowRedirects = previousFollowRedirects
}
}

companion object {
private val TAG: String = ExistenceCheckRemoteOperation::class.java.simpleName
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Nextcloud Android Library
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: MIT
*/

package com.owncloud.android.lib.resources.files.model

data class RetryPolicy(
val maxAttempts: Int = 3,
val initialDelayMs: Long = 500L,
val maxDelayMs: Long = 8_000L,
val backoffMultiplier: Double = 2.0
) {
init {
require(maxAttempts >= 1) { "maxAttempts must be >= 1" }
require(initialDelayMs >= 0) { "initialDelayMs must be >= 0" }
}

companion object {
@JvmField
val DEFAULT = RetryPolicy()

@JvmField
val NONE = RetryPolicy(maxAttempts = 1)
}
}
Loading