Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
}
15 changes: 14 additions & 1 deletion library/src/main/java/com/nextcloud/common/NextcloudClient.kt
Original file line number Diff line number Diff line change
@@ -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 <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2019-2024 Tobias Kaminsky
* SPDX-FileCopyrightText: 2023 Elv1zz <elv1zz.git@gmail.com>
* SPDX-FileCopyrightText: 2022 Álvaro Brey <alvaro@alvarobrey.com>
Expand Down Expand Up @@ -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!!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Any>() {
private val dataTransferListeners = ConcurrentHashMap.newKeySet<OnDatatransferProgressListener>()
private val cancellationRequested = AtomicBoolean(false)
var modificationTimestamp: Long = 0
private set
var etag: String = ""
private set

@Suppress("DEPRECATION")
override fun run(client: NextcloudClient): RemoteOperationResult<Any> {
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<Any>(isSuccess(status), getMethod).also {
Log_OC.i(TAG, "Download of $remotePath to $targetPath: ${it.logMessage}")
}
} catch (e: Exception) {
RemoteOperationResult<Any>(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<Any>() {
private val dataTransferListeners = ConcurrentHashMap.newKeySet<OnDatatransferProgressListener>()
private val cancellationRequested = AtomicBoolean(false)
var modificationTimestamp: Long = 0
private set
var etag: String = ""
private set

@Suppress("DEPRECATION")
override fun run(client: NextcloudClient): RemoteOperationResult<Any> {
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<Any>(isSuccess(status), getMethod).also {
Log_OC.i(TAG, "Download of $remotePath to $targetPath: ${it.logMessage}")
}
} catch (e: Exception) {
RemoteOperationResult<Any>(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
}
}
}
Loading