diff --git a/library/src/androidTest/java/com/owncloud/android/lib/resources/files/DownloadFileRemoteOperationIT.kt b/library/src/androidTest/java/com/owncloud/android/lib/resources/files/DownloadFileRemoteOperationIT.kt index 0485f2993..d3e8c2f76 100644 --- a/library/src/androidTest/java/com/owncloud/android/lib/resources/files/DownloadFileRemoteOperationIT.kt +++ b/library/src/androidTest/java/com/owncloud/android/lib/resources/files/DownloadFileRemoteOperationIT.kt @@ -8,7 +8,15 @@ */ package com.owncloud.android.lib.resources.files +import com.nextcloud.common.NextcloudClient import com.owncloud.android.AbstractIT +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.ResponseBody +import okio.Buffer +import okio.BufferedSource +import okio.ForwardingSource +import okio.buffer import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertSame @@ -167,4 +175,70 @@ class DownloadFileRemoteOperationIT : AbstractIT() { assertTrue("Downloaded file should exist at expected path", expectedFile.exists()) assertTrue("Downloaded file should not be empty", expectedFile.length() >= 0) } + + @Test + fun downloadLargeFileSucceedsWithNoCallTimeout() { + val filePath = createFile("large_no_call_timeout", 1000) + val remotePath = "/large_no_call_timeout.txt" + assertTrue( + UploadFileRemoteOperation(filePath, remotePath, "text/plain", RANDOM_MTIME) + .execute(client) + .isSuccess + ) + + val slowOkHttpClient = + nextcloudClient.client + .newBuilder() + .addInterceptor(ChunkDelayInterceptor(delayMs = 100)) + .build() + val slowNextcloudClient = + NextcloudClient( + url, + nextcloudClient.getUserIdPlain(), + nextcloudClient.credentials, + slowOkHttpClient, + nextcloudClient.context + ) + + assertTrue( + DownloadFileRemoteOperation(remotePath, cacheDir) + .execute(slowNextcloudClient) + .isSuccess + ) + + assertEquals(File(filePath).length(), File(cacheDir + remotePath).length()) + } + + /** + * Used for create delay for test + */ + private class ChunkDelayInterceptor( + private val delayMs: Long + ) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + val body = response.body + val slowSource = + object : ForwardingSource(body.source()) { + override fun read( + sink: Buffer, + byteCount: Long + ): Long { + Thread.sleep(delayMs) + return super.read(sink, byteCount) + } + } + val slowBody = + object : ResponseBody() { + private val bufferedSource: BufferedSource = slowSource.buffer() + + override fun contentType() = body.contentType() + + override fun contentLength() = body.contentLength() + + override fun source() = bufferedSource + } + return response.newBuilder().body(slowBody).build() + } + } } diff --git a/library/src/main/java/com/nextcloud/common/NextcloudClient.kt b/library/src/main/java/com/nextcloud/common/NextcloudClient.kt index 830822308..7f51e6437 100644 --- a/library/src/main/java/com/nextcloud/common/NextcloudClient.kt +++ b/library/src/main/java/com/nextcloud/common/NextcloudClient.kt @@ -1,7 +1,8 @@ /* * Nextcloud Android Library * - * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2019-2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2019-2024 Tobias Kaminsky * SPDX-FileCopyrightText: 2023 Elv1zz * SPDX-FileCopyrightText: 2022 Álvaro Brey @@ -207,6 +208,18 @@ class NextcloudClient private constructor( } } + fun withSessionTimeOut(sessionTimeOut: SessionTimeOut): NextcloudClient { + val newClient = + client + .newBuilder() + .readTimeout(sessionTimeOut.readTimeOut.toLong(), TimeUnit.MILLISECONDS) + .connectTimeout(sessionTimeOut.connectionTimeOut.toLong(), TimeUnit.MILLISECONDS) + // needed to prevent cancellation, seems like default value not applied + .callTimeout(0, TimeUnit.MILLISECONDS) + .build() + return NextcloudClient(delegate, credentials, newClient, context) + } + fun getUserIdEncoded(): String = delegate.userIdEncoded!! fun getUserIdPlain(): String = delegate.userId!! diff --git a/library/src/main/java/com/owncloud/android/lib/resources/files/DownloadFileRemoteOperation.kt b/library/src/main/java/com/owncloud/android/lib/resources/files/DownloadFileRemoteOperation.kt index 0500c1c7a..734074c75 100644 --- a/library/src/main/java/com/owncloud/android/lib/resources/files/DownloadFileRemoteOperation.kt +++ b/library/src/main/java/com/owncloud/android/lib/resources/files/DownloadFileRemoteOperation.kt @@ -9,6 +9,8 @@ package com.owncloud.android.lib.resources.files import android.os.Build import androidx.annotation.RequiresApi import com.nextcloud.common.NextcloudClient +import com.nextcloud.common.SessionTimeOut +import com.nextcloud.common.defaultSessionTimeOut import com.nextcloud.operations.GetMethod import com.owncloud.android.lib.common.network.OnDatatransferProgressListener import com.owncloud.android.lib.common.network.WebdavUtils @@ -27,128 +29,162 @@ import java.util.concurrent.atomic.AtomicBoolean @Suppress("NestedBlockDepth", "TooGenericExceptionCaught", "ThrowsCount") @RequiresApi(Build.VERSION_CODES.O) -class DownloadFileRemoteOperation( - private val remotePath: String, - private val temporalFolderPath: String? -) : RemoteOperation() { - private val dataTransferListeners = ConcurrentHashMap.newKeySet() - private val cancellationRequested = AtomicBoolean(false) - var modificationTimestamp: Long = 0 - private set - var etag: String = "" - private set - - @Suppress("DEPRECATION") - override fun run(client: NextcloudClient): RemoteOperationResult { - val targetPath = Paths.get(tmpPath) - return try { - val parent = targetPath.parent ?: throw IOException("No parent directory for: $targetPath") - Files.createDirectories(parent) - val getMethod = GetMethod(client.getFilesDavUri(remotePath), false) - val status = downloadFile(getMethod, client, targetPath) - RemoteOperationResult(isSuccess(status), getMethod).also { - Log_OC.i(TAG, "Download of $remotePath to $targetPath: ${it.logMessage}") - } - } catch (e: Exception) { - RemoteOperationResult(e).also { - Log_OC.e(TAG, "Download of $remotePath to $targetPath: ${it.logMessage}", e) +class DownloadFileRemoteOperation + @JvmOverloads + constructor( + private val remotePath: String, + private val temporalFolderPath: String?, + private val fileSizeInBytes: Long? = null + ) : RemoteOperation() { + private val dataTransferListeners = ConcurrentHashMap.newKeySet() + private val cancellationRequested = AtomicBoolean(false) + var modificationTimestamp: Long = 0 + private set + var etag: String = "" + private set + + @Suppress("DEPRECATION") + override fun run(client: NextcloudClient): RemoteOperationResult { + val targetPath = Paths.get(tmpPath) + return try { + val parent = targetPath.parent ?: throw IOException("No parent directory for: $targetPath") + Files.createDirectories(parent) + val getMethod = GetMethod(client.getFilesDavUri(remotePath), false) + val status = downloadFile(getMethod, client, targetPath) + RemoteOperationResult(isSuccess(status), getMethod).also { + Log_OC.i(TAG, "Download of $remotePath to $targetPath: ${it.logMessage}") + } + } catch (e: Exception) { + RemoteOperationResult(e).also { + Log_OC.e(TAG, "Download of $remotePath to $targetPath: ${it.logMessage}", e) + } } } - } - // region private methods - @Throws(IOException::class, OperationCancelledException::class, CreateLocalFileException::class) - private fun downloadFile( - getMethod: GetMethod, - client: NextcloudClient, - targetPath: Path - ): Int { - val status = client.execute(getMethod) - if (!isSuccess(status)) { - getMethod.releaseConnection() + // region private methods + @Throws(IOException::class, OperationCancelledException::class, CreateLocalFileException::class) + private fun downloadFile( + getMethod: GetMethod, + client: NextcloudClient, + targetPath: Path + ): Int { + val sessionTimeOut = calculateSessionTimeOut(fileSizeInBytes) + val downloadClient = client.withSessionTimeOut(sessionTimeOut) + val status = downloadClient.execute(getMethod) + if (!isSuccess(status)) { + getMethod.releaseConnection() + return status + } + + try { + writeResponseToFile(getMethod, targetPath) + readMetadata(getMethod) + } finally { + getMethod.releaseConnection() + } + return status } - try { - writeResponseToFile(getMethod, targetPath) - readMetadata(getMethod) - } finally { - getMethod.releaseConnection() + @Suppress("ReturnCount") + private fun calculateSessionTimeOut(fileSizeInBytes: Long?): SessionTimeOut { + fileSizeInBytes ?: return defaultSessionTimeOut + if (fileSizeInBytes <= 0) return defaultSessionTimeOut + val readTimeOut = + (READ_TIMEOUT_PER_GB * fileSizeInBytes / BYTES_PER_GB_LONG) + .coerceIn(READ_TIMEOUT_MIN, READ_TIMEOUT_MAX) + .toInt() + return SessionTimeOut(readTimeOut = readTimeOut, connectionTimeOut = CONNECTION_TIMEOUT) } - return status - } - - private fun writeResponseToFile( - getMethod: GetMethod, - targetPath: Path - ) { - val responseStream = - getMethod.getResponseBodyAsStream() - ?: throw IOException("Empty response body for $remotePath") - - val outputStream = - try { - Files.newOutputStream(targetPath) - } catch (ex: IOException) { - Log_OC.e(TAG, "Error creating file $targetPath", ex) - throw CreateLocalFileException(targetPath.toString(), ex) - } catch (ex: SecurityException) { - Log_OC.e(TAG, "Error creating file $targetPath", ex) - throw CreateLocalFileException(targetPath.toString(), ex) - } + private fun writeResponseToFile( + getMethod: GetMethod, + targetPath: Path + ) { + val responseStream = + getMethod.getResponseBodyAsStream() + ?: throw IOException("Empty response body for $remotePath") + + val outputStream = + try { + Files.newOutputStream(targetPath) + } catch (ex: IOException) { + Log_OC.e(TAG, "Error creating file $targetPath", ex) + throw CreateLocalFileException(targetPath.toString(), ex) + } catch (ex: SecurityException) { + Log_OC.e(TAG, "Error creating file $targetPath", ex) + throw CreateLocalFileException(targetPath.toString(), ex) + } - BufferedInputStream(responseStream).use { bis -> - outputStream.use { fos -> - val buffer = ByteArray(BUFFER_SIZE) - var bytesRead: Int - while (bis.read(buffer).also { bytesRead = it } != -1) { - if (cancellationRequested.get()) throw OperationCancelledException() - fos.write(buffer, 0, bytesRead) + val totalToTransfer = fileSizeInBytes ?: 0L + var totalBytesRead = 0L + + BufferedInputStream(responseStream).use { bis -> + outputStream.use { fos -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + while (bis.read(buffer).also { bytesRead = it } != -1) { + if (cancellationRequested.get()) throw OperationCancelledException() + fos.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + dataTransferListeners.forEach { listener -> + listener.onTransferProgress( + bytesRead.toLong(), + totalBytesRead, + totalToTransfer, + targetPath.toString() + ) + } + } } } } - } - private fun readMetadata(getMethod: GetMethod) { - val modificationTime = - getMethod.getResponseHeader("Last-Modified") - ?: getMethod.getResponseHeader("last-modified") + private fun readMetadata(getMethod: GetMethod) { + val modificationTime = + getMethod.getResponseHeader("Last-Modified") + ?: getMethod.getResponseHeader("last-modified") - if (modificationTime != null) { - modificationTimestamp = WebdavUtils.parseResponseDate(modificationTime)?.time ?: 0 - } else { - Log_OC.e(TAG, "Could not read modification time from response downloading $remotePath") - } + if (modificationTime != null) { + modificationTimestamp = WebdavUtils.parseResponseDate(modificationTime)?.time ?: 0 + } else { + Log_OC.e(TAG, "Could not read modification time from response downloading $remotePath") + } - etag = WebdavUtils.getEtagFromResponse(getMethod) - if (etag.isEmpty()) { - Log_OC.e(TAG, "Could not read eTag from response downloading $remotePath") + etag = WebdavUtils.getEtagFromResponse(getMethod) + if (etag.isEmpty()) { + Log_OC.e(TAG, "Could not read eTag from response downloading $remotePath") + } } - } - private fun isSuccess(status: Int) = (status == HttpStatus.SC_OK) + private fun isSuccess(status: Int) = (status == HttpStatus.SC_OK) - private val tmpPath: String - get() = temporalFolderPath + remotePath - // endregion + private val tmpPath: String + get() = temporalFolderPath + remotePath + // endregion - // region public methods - fun addProgressListener(listener: OnDatatransferProgressListener) { - dataTransferListeners.add(listener) - } + // region public methods + fun addProgressListener(listener: OnDatatransferProgressListener) { + dataTransferListeners.add(listener) + } - fun removeProgressListener(listener: OnDatatransferProgressListener) { - dataTransferListeners.remove(listener) - } + fun removeProgressListener(listener: OnDatatransferProgressListener) { + dataTransferListeners.remove(listener) + } - fun cancel() { - cancellationRequested.set(true) - } - // endregion + fun cancel() { + cancellationRequested.set(true) + } + // endregion + + companion object { + private val TAG = DownloadFileRemoteOperation::class.java.simpleName + private const val BUFFER_SIZE = 4096 - companion object { - private val TAG = DownloadFileRemoteOperation::class.java.simpleName - private const val BUFFER_SIZE = 4096 + private const val BYTES_PER_GB_LONG = 1_000_000_000L + private const val READ_TIMEOUT_MIN = 60 * 1000L // 1 min + private const val READ_TIMEOUT_PER_GB = 3 * 60 * 1000L // 3 min per GB + private const val READ_TIMEOUT_MAX = 30 * 60 * 1000L // 30 min cap + private const val CONNECTION_TIMEOUT = 15 * 1000 // 15 s + } } -}