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 94840010b8..0485f2993b 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 @@ -1,38 +1,170 @@ /* * Nextcloud Android Library * - * SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2021-2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2021 Tobias Kaminsky * SPDX-License-Identifier: MIT */ package com.owncloud.android.lib.resources.files import com.owncloud.android.AbstractIT +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test import java.io.File +@Suppress("Detekt.MagicNumber") class DownloadFileRemoteOperationIT : AbstractIT() { + private val cacheDir get() = context.externalCacheDir?.absolutePath + @Test fun download() { val filePath = createFile("download") val remotePath = "/download.jpg" assertTrue( - @Suppress("Detekt.MagicNumber") UploadFileRemoteOperation(filePath, remotePath, "image/jpg", 1464818400) .execute(client) .isSuccess ) assertTrue( - DownloadFileRemoteOperation(remotePath, context.externalCacheDir?.absolutePath) + DownloadFileRemoteOperation(remotePath, cacheDir) .execute(nextcloudClient) .isSuccess ) val oldFile = File(filePath) - val newFile = File(context.externalCacheDir?.absolutePath + remotePath) + val newFile = File(cacheDir + remotePath) assertSame(oldFile.length(), newFile.length()) } + + @Test + fun downloadLargeFile() { + val filePath = createFile("large_download", 1000) + val remotePath = "/large_download.txt" + assertTrue( + UploadFileRemoteOperation(filePath, remotePath, "text/plain", RANDOM_MTIME) + .execute(client) + .isSuccess + ) + + assertTrue( + DownloadFileRemoteOperation(remotePath, cacheDir) + .execute(nextcloudClient) + .isSuccess + ) + + val originalFile = File(filePath) + val downloadedFile = File(cacheDir + remotePath) + assertEquals(originalFile.length(), downloadedFile.length()) + } + + @Test + fun downloadNonExistentFile() { + val result = + DownloadFileRemoteOperation("/nonexistent_file_12345.txt", cacheDir) + .execute(nextcloudClient) + + assertFalse(result.isSuccess) + } + + @Test + fun downloadAndVerifyMetadata() { + val filePath = createFile("metadata_download") + val remotePath = "/metadata_download.jpg" + assertTrue( + UploadFileRemoteOperation(filePath, remotePath, "image/jpg", RANDOM_MTIME) + .execute(client) + .isSuccess + ) + + val operation = DownloadFileRemoteOperation(remotePath, cacheDir) + assertTrue(operation.execute(nextcloudClient).isSuccess) + + assertTrue("ETag should not be empty after download", operation.etag.isNotEmpty()) + assertTrue("Modification timestamp should be positive after download", operation.modificationTimestamp > 0) + } + + @Test + fun downloadMultipleFiles() { + val filePath1 = createFile("multi_download1") + val remotePath1 = "/multi_download1.jpg" + val filePath2 = createFile("multi_download2") + val remotePath2 = "/multi_download2.jpg" + + assertTrue( + UploadFileRemoteOperation(filePath1, remotePath1, "image/jpg", RANDOM_MTIME) + .execute(client) + .isSuccess + ) + assertTrue( + UploadFileRemoteOperation(filePath2, remotePath2, "image/jpg", RANDOM_MTIME) + .execute(client) + .isSuccess + ) + + assertTrue( + DownloadFileRemoteOperation(remotePath1, cacheDir) + .execute(nextcloudClient) + .isSuccess + ) + assertTrue( + DownloadFileRemoteOperation(remotePath2, cacheDir) + .execute(nextcloudClient) + .isSuccess + ) + + val downloaded1 = File(cacheDir + remotePath1) + val downloaded2 = File(cacheDir + remotePath2) + assertTrue(downloaded1.exists()) + assertTrue(downloaded2.exists()) + assertEquals(File(filePath1).length(), downloaded1.length()) + assertEquals(File(filePath2).length(), downloaded2.length()) + } + + @Test + fun downloadAndVerifyContent() { + val filePath = createFile("content_download", 50) + val remotePath = "/content_download.txt" + assertTrue( + UploadFileRemoteOperation(filePath, remotePath, "text/plain", RANDOM_MTIME) + .execute(client) + .isSuccess + ) + + assertTrue( + DownloadFileRemoteOperation(remotePath, cacheDir) + .execute(nextcloudClient) + .isSuccess + ) + + val originalFile = File(filePath) + val downloadedFile = File(cacheDir + remotePath) + assertTrue(downloadedFile.exists()) + assertTrue(originalFile.readBytes().contentEquals(downloadedFile.readBytes())) + } + + @Test + fun downloadedFileExistsAtExpectedPath() { + val filePath = createFile("path_check") + val remotePath = "/path_check.jpg" + assertTrue( + UploadFileRemoteOperation(filePath, remotePath, "image/jpg", RANDOM_MTIME) + .execute(client) + .isSuccess + ) + + assertTrue( + DownloadFileRemoteOperation(remotePath, cacheDir) + .execute(nextcloudClient) + .isSuccess + ) + + val expectedFile = File(cacheDir + remotePath) + assertTrue("Downloaded file should exist at expected path", expectedFile.exists()) + assertTrue("Downloaded file should not be empty", expectedFile.length() >= 0) + } } diff --git a/library/src/main/java/com/owncloud/android/lib/resources/files/DownloadFileRemoteOperation.java b/library/src/main/java/com/owncloud/android/lib/resources/files/DownloadFileRemoteOperation.java deleted file mode 100644 index 8c23d3738b..0000000000 --- a/library/src/main/java/com/owncloud/android/lib/resources/files/DownloadFileRemoteOperation.java +++ /dev/null @@ -1,195 +0,0 @@ -/* - * Nextcloud Android Library - * - * SPDX-FileCopyrightText: 2015 ownCloud Inc. - * SPDX-License-Identifier: MIT - */ -package com.owncloud.android.lib.resources.files; - -import com.nextcloud.common.NextcloudClient; -import com.nextcloud.operations.GetMethod; -import com.owncloud.android.lib.common.network.OnDatatransferProgressListener; -import com.owncloud.android.lib.common.network.WebdavUtils; -import com.owncloud.android.lib.common.operations.OperationCancelledException; -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 java.io.BufferedInputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.util.Date; -import java.util.HashSet; -import java.util.Iterator; -import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * Remote operation performing the download of a remote file in the ownCloud server. - * - * @author David A. Velasco - * @author masensio - */ - -public class DownloadFileRemoteOperation extends RemoteOperation { - - private static final String TAG = DownloadFileRemoteOperation.class.getSimpleName(); - - private Set mDataTransferListeners = new HashSet<>(); - private final AtomicBoolean mCancellationRequested = new AtomicBoolean(false); - private long modificationTimestamp = 0; - private String eTag = ""; - private GetMethod getMethod; - - private String remotePath; - private String temporalFolderPath; - - /** - * @param remotePath which file to download - * @param temporalFolderPath temporal folder where file is stored, to avoid conflicts it use full remote path - */ - public DownloadFileRemoteOperation(String remotePath, String temporalFolderPath) { - this.remotePath = remotePath; - this.temporalFolderPath = temporalFolderPath; - } - - @Override - public RemoteOperationResult run(NextcloudClient client) { - RemoteOperationResult result; - - /// download will be performed to a temporal file, then moved to the final location - File tmpFile = new File(getTmpPath()); - - /// perform the download - try { - tmpFile.getParentFile().mkdirs(); - int status = downloadFile(client, tmpFile); - result = new RemoteOperationResult(isSuccess(status), getMethod); - Log_OC.i(TAG, "Download of " + remotePath + " to " + getTmpPath() + ": " + - result.getLogMessage()); - - } catch (Exception e) { - result = new RemoteOperationResult(e); - Log_OC.e(TAG, "Download of " + remotePath + " to " + getTmpPath() + ": " + - result.getLogMessage(), e); - } - - return result; - } - - - private int downloadFile(NextcloudClient client, File targetFile) throws IOException, OperationCancelledException, CreateLocalFileException { - int status; - boolean savedFile = false; - getMethod = new GetMethod(client.getFilesDavUri(remotePath), false); - Iterator it; - - FileOutputStream fos = null; - try { - status = client.execute(getMethod); - if (isSuccess(status)) { - try { - targetFile.createNewFile(); - } catch (IOException | SecurityException ex) { - Log_OC.e(TAG, "Error creating file " + targetFile.getAbsolutePath(), ex); - throw new CreateLocalFileException(targetFile.getPath(), ex); - } - BufferedInputStream bis = new BufferedInputStream(getMethod.getResponseBodyAsStream()); - fos = new FileOutputStream(targetFile); - long transferred = 0; - - String contentLength = getMethod.getResponseHeader("Content-Length"); - long totalToTransfer = (contentLength != null) ? Long.parseLong(contentLength) : 0; - - byte[] bytes = new byte[4096]; - int readResult; - while ((readResult = bis.read(bytes)) != -1) { - synchronized (mCancellationRequested) { - if (mCancellationRequested.get()) { - throw new OperationCancelledException(); - } - } - fos.write(bytes, 0, readResult); - transferred += readResult; - synchronized (mDataTransferListeners) { - it = mDataTransferListeners.iterator(); - while (it.hasNext()) { - it.next().onTransferProgress(readResult, transferred, totalToTransfer, - targetFile.getName()); - } - } - } - // Check if the file is completed - // if transfer-encoding: chunked we cannot check if the file is complete - String transferEncodingHeader = getMethod.getResponseHeader("Transfer-Encoding"); - boolean transferEncoding = false; - - if (transferEncodingHeader != null) { - transferEncoding = "chunked".equals(transferEncodingHeader); - } - - if (transferred == totalToTransfer || transferEncoding) { - savedFile = true; - String modificationTime = getMethod.getResponseHeader("Last-Modified"); - if (modificationTime == null) { - modificationTime = getMethod.getResponseHeader("last-modified"); - } - if (modificationTime != null) { - Date d = WebdavUtils.parseResponseDate(modificationTime); - modificationTimestamp = (d != null) ? d.getTime() : 0; - } else { - Log_OC.e(TAG, "Could not read modification time from response downloading " + remotePath); - } - - eTag = WebdavUtils.getEtagFromResponse(getMethod); - if (eTag.length() == 0) { - Log_OC.e(TAG, "Could not read eTag from response downloading " + remotePath); - } - - } - } - } finally { - if (fos != null) fos.close(); - if (!savedFile && targetFile.exists()) { - targetFile.delete(); - } - getMethod.releaseConnection(); // let the connection available for other methods - } - return status; - } - - private boolean isSuccess(int status) { - return (status == HttpStatus.SC_OK); - } - - private String getTmpPath() { - return temporalFolderPath + remotePath; - } - - public void addDatatransferProgressListener(OnDatatransferProgressListener listener) { - synchronized (mDataTransferListeners) { - mDataTransferListeners.add(listener); - } - } - - public void removeDatatransferProgressListener(OnDatatransferProgressListener listener) { - synchronized (mDataTransferListeners) { - mDataTransferListeners.remove(listener); - } - } - - public void cancel() { - mCancellationRequested.set(true); // atomic set; there is no need of synchronizing it - } - - public long getModificationTimestamp() { - return modificationTimestamp; - } - - public String getEtag() { - return eTag; - } -} 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 new file mode 100644 index 0000000000..0500c1c7a2 --- /dev/null +++ b/library/src/main/java/com/owncloud/android/lib/resources/files/DownloadFileRemoteOperation.kt @@ -0,0 +1,154 @@ +/* + * Nextcloud Android Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: MIT + */ +package com.owncloud.android.lib.resources.files + +import android.os.Build +import androidx.annotation.RequiresApi +import com.nextcloud.common.NextcloudClient +import com.nextcloud.operations.GetMethod +import com.owncloud.android.lib.common.network.OnDatatransferProgressListener +import com.owncloud.android.lib.common.network.WebdavUtils +import com.owncloud.android.lib.common.operations.OperationCancelledException +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 java.io.BufferedInputStream +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.concurrent.ConcurrentHashMap +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) + } + } + } + + // 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() + return status + } + + try { + writeResponseToFile(getMethod, targetPath) + readMetadata(getMethod) + } finally { + getMethod.releaseConnection() + } + + 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) + } + + 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) + } + } + } + } + + 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") + } + + 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 val tmpPath: String + get() = temporalFolderPath + remotePath + // endregion + + // region public methods + fun addProgressListener(listener: OnDatatransferProgressListener) { + dataTransferListeners.add(listener) + } + + fun removeProgressListener(listener: OnDatatransferProgressListener) { + dataTransferListeners.remove(listener) + } + + fun cancel() { + cancellationRequested.set(true) + } + // endregion + + companion object { + private val TAG = DownloadFileRemoteOperation::class.java.simpleName + private const val BUFFER_SIZE = 4096 + } +} diff --git a/sample_client/src/main/java/com/owncloud/android/lib/sampleclient/MainActivity.java b/sample_client/src/main/java/com/owncloud/android/lib/sampleclient/MainActivity.java index b5532b7965..b334fc185f 100644 --- a/sample_client/src/main/java/com/owncloud/android/lib/sampleclient/MainActivity.java +++ b/sample_client/src/main/java/com/owncloud/android/lib/sampleclient/MainActivity.java @@ -14,6 +14,7 @@ import android.content.res.AssetManager; import android.graphics.drawable.BitmapDrawable; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.util.Log; @@ -22,6 +23,8 @@ import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.RequiresApi; + import com.owncloud.android.lib.common.OwnCloudClient; import com.owncloud.android.lib.common.OwnCloudClientFactory; import com.owncloud.android.lib.common.OwnCloudCredentialsFactory; @@ -126,7 +129,9 @@ public void onClickHandler(View button) { startRemoteDeletion(); break; case R.id.button_download: - startDownload(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startDownload(); + } break; case R.id.button_delete_local: startLocalDeletion(); @@ -169,6 +174,7 @@ private void startRemoteDeletion() { removeOperation.execute(mClient, this, mHandler); } + @RequiresApi(api = Build.VERSION_CODES.O) private void startDownload() { File downFolder = new File(getCacheDir(), getString(R.string.download_folder_path)); downFolder.mkdir(); @@ -176,7 +182,7 @@ private void startDownload() { File fileToUpload = upFolder.listFiles()[0]; String remotePath = FileUtils.PATH_SEPARATOR + fileToUpload.getName(); DownloadFileRemoteOperation downloadOperation = new DownloadFileRemoteOperation(remotePath, downFolder.getAbsolutePath()); - downloadOperation.addDatatransferProgressListener(this); + downloadOperation.addProgressListener(this); downloadOperation.execute(mClient, this, mHandler); }