diff --git a/library/src/main/java/com/nextcloud/common/HTTPCodes.kt b/library/src/main/java/com/nextcloud/common/HTTPCodes.kt new file mode 100644 index 0000000000..0bfea628f5 --- /dev/null +++ b/library/src/main/java/com/nextcloud/common/HTTPCodes.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud Android Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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 + ) +} diff --git a/library/src/main/java/com/nextcloud/common/SessionTimeOut.kt b/library/src/main/java/com/nextcloud/common/SessionTimeOut.kt index 64c2ff91c8..4cab7448b5 100644 --- a/library/src/main/java/com/nextcloud/common/SessionTimeOut.kt +++ b/library/src/main/java/com/nextcloud/common/SessionTimeOut.kt @@ -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) diff --git a/library/src/main/java/com/nextcloud/extensions/RemoteOperationResultExtensions.kt b/library/src/main/java/com/nextcloud/extensions/RemoteOperationResultExtensions.kt new file mode 100644 index 0000000000..791b20b95c --- /dev/null +++ b/library/src/main/java/com/nextcloud/extensions/RemoteOperationResultExtensions.kt @@ -0,0 +1,28 @@ +/* + * Nextcloud Android Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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.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 +} diff --git a/library/src/main/java/com/owncloud/android/lib/resources/files/ExistenceCheckRemoteOperation.java b/library/src/main/java/com/owncloud/android/lib/resources/files/ExistenceCheckRemoteOperation.java deleted file mode 100644 index fe6a0664b8..0000000000 --- a/library/src/main/java/com/owncloud/android/lib/resources/files/ExistenceCheckRemoteOperation.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Nextcloud Android Library - * - * SPDX-FileCopyrightText: 2015 ownCloud Inc. - * SPDX-License-Identifier: MIT - */ -package com.owncloud.android.lib.resources.files; - -import android.content.Context; - -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 org.apache.commons.httpclient.HttpStatus; -import org.apache.commons.httpclient.methods.HeadMethod; - -/** - * Operation to check the existence or absence of a path in a remote server. - * - * @author David A. Velasco - */ -public class ExistenceCheckRemoteOperation extends RemoteOperation { - - /** Maximum time to wait for a response from the server in MILLISECONDs. */ - public static final int TIMEOUT = 50000; - - private static final String TAG = ExistenceCheckRemoteOperation.class.getSimpleName(); - - private String mPath; - private boolean mSuccessIfAbsent; - - /** Sequence of redirections followed. Available only after executing the operation */ - private RedirectionPath mRedirectionPath = null; - // TODO move to {@link RemoteOperation}, that needs a nice refactoring - - /** - * Full constructor. Success of the operation will depend upon the value of successIfAbsent. - * - * @param remotePath Path to append to the URL owned by the client instance. - * @param successIfAbsent When 'true', the operation finishes in success if the path does - * NOT exist in the remote server (HTTP 404). - */ - public ExistenceCheckRemoteOperation(String remotePath, boolean successIfAbsent) { - mPath = (remotePath != null) ? remotePath : ""; - mSuccessIfAbsent = successIfAbsent; - } - - /** - * Full constructor. Success of the operation will depend upon the value of successIfAbsent. - * - * @param remotePath Path to append to the URL owned by the client instance. - * @param context Android application context. - * @param successIfAbsent When 'true', the operation finishes in success if the path does - * NOT exist in the remote server (HTTP 404). - * @deprecated - */ - @Deprecated - public ExistenceCheckRemoteOperation(String remotePath, Context context, boolean successIfAbsent) { - this(remotePath, successIfAbsent); - } - - @Override - protected RemoteOperationResult run(OwnCloudClient client) { - RemoteOperationResult result = null; - HeadMethod head = null; - boolean previousFollowRedirects = client.isFollowRedirects(); - try { - if (client.getCredentials() instanceof OwnCloudAnonymousCredentials) { - head = new HeadMethod(client.getDavUri().toString()); - } else { - head = new HeadMethod(client.getFilesDavUri(mPath)); - } - client.setFollowRedirects(false); - int status = client.executeMethod(head, TIMEOUT, TIMEOUT); - if (previousFollowRedirects) { - mRedirectionPath = client.followRedirection(head); - status = mRedirectionPath.getLastStatus(); - } - client.exhaustResponse(head.getResponseBodyAsStream()); - boolean success = ((status == HttpStatus.SC_OK || status == HttpStatus.SC_UNAUTHORIZED || status == HttpStatus.SC_FORBIDDEN) && !mSuccessIfAbsent) || - (status == HttpStatus.SC_NOT_FOUND && mSuccessIfAbsent); - result = new RemoteOperationResult( - success, - status, - head.getStatusText(), - head.getResponseHeaders() - ); - Log_OC.d(TAG, "Existence check for " + client.getFilesDavUri(mPath) + " targeting for " + - (mSuccessIfAbsent ? " absence " : " existence ") + - "finished with HTTP status " + status + (!success ? "(FAIL)" : "")); - - } catch (Exception e) { - result = new RemoteOperationResult(e); - Log_OC.e(TAG, "Existence check for " + client.getFilesDavUri(mPath) + " targeting for " + - (mSuccessIfAbsent ? " absence " : " existence ") + ": " + - result.getLogMessage(), result.getException()); - - } finally { - if (head != null) - head.releaseConnection(); - client.setFollowRedirects(previousFollowRedirects); - } - return result; - } - - - /** - * Gets the sequence of redirections followed during the execution of the operation. - * - * @return Sequence of redirections followed, if any, or NULL if the operation was not executed. - */ - public RedirectionPath getRedirectionPath() { - return mRedirectionPath; - } - - /** - * @return 'True' if the operation was executed and at least one redirection was followed. - */ - public boolean wasRedirected() { - return (mRedirectionPath != null && mRedirectionPath.getRedirectionsCount() > 0); - } -} diff --git a/library/src/main/java/com/owncloud/android/lib/resources/files/ExistenceCheckRemoteOperation.kt b/library/src/main/java/com/owncloud/android/lib/resources/files/ExistenceCheckRemoteOperation.kt new file mode 100644 index 0000000000..922ff3e465 --- /dev/null +++ b/library/src/main/java/com/owncloud/android/lib/resources/files/ExistenceCheckRemoteOperation.kt @@ -0,0 +1,140 @@ +/* + * Nextcloud Android Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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 + @JvmOverloads + constructor( + remotePath: String?, + private val successIfAbsent: Boolean, + private val retryPolicy: RetryPolicy = RetryPolicy.DEFAULT + ) : RemoteOperation() { + 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 { + 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 { + 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( + 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(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 + } + } diff --git a/library/src/main/java/com/owncloud/android/lib/resources/files/model/RetryPolicy.kt b/library/src/main/java/com/owncloud/android/lib/resources/files/model/RetryPolicy.kt new file mode 100644 index 0000000000..5287b063a7 --- /dev/null +++ b/library/src/main/java/com/owncloud/android/lib/resources/files/model/RetryPolicy.kt @@ -0,0 +1,28 @@ +/* + * Nextcloud Android Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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) + } +}