diff --git a/app/src/main/java/foundation/e/drive/models/SyncWrapper.java b/app/src/main/java/foundation/e/drive/models/SyncWrapper.java
index 6bbf6221f3ac5a56ce963b8e4b08540a9220883d..3deb0c34d3dab686090d30daeac47a1d537c83b6 100644
--- a/app/src/main/java/foundation/e/drive/models/SyncWrapper.java
+++ b/app/src/main/java/foundation/e/drive/models/SyncWrapper.java
@@ -1,9 +1,19 @@
/*
- * Copyright © MURENA SAS 2022-2023.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the GNU Public License v3.0
- * which accompanies this distribution, and is available at
- * http://www.gnu.org/licenses/gpl.html
+ * Copyright (C) 2024 MURENA SAS
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
*/
package foundation.e.drive.models;
diff --git a/app/src/main/java/foundation/e/drive/synchronization/tasks/DownloadFileOperation.java b/app/src/main/java/foundation/e/drive/synchronization/tasks/DownloadFileOperation.java
deleted file mode 100644
index 7399d54d283499dcc246d3247770a3abb28a86b4..0000000000000000000000000000000000000000
--- a/app/src/main/java/foundation/e/drive/synchronization/tasks/DownloadFileOperation.java
+++ /dev/null
@@ -1,230 +0,0 @@
-/*
- * Copyright © CLEUS SAS 2018-2019.
- * Copyright © MURENA SAS 2022-2023.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the GNU Public License v3.0
- * which accompanies this distribution, and is available at
- * http://www.gnu.org/licenses/gpl.html
- */
-
-package foundation.e.drive.synchronization.tasks;
-
-import static com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.FILE_NOT_FOUND;
-import static com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.FORBIDDEN;
-import static com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.HOST_NOT_AVAILABLE;
-import static com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.INVALID_OVERWRITE;
-import static com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.NO_NETWORK_CONNECTION;
-import static com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.OK;
-import static com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode.WRONG_CONNECTION;
-
-import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
-
-import static foundation.e.drive.utils.AppConstants.CORRUPTED_TIMESTAMP_IN_MILLISECOND;
-
-import android.content.Context;
-import android.media.MediaScannerConnection;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import com.owncloud.android.lib.common.OwnCloudClient;
-import com.owncloud.android.lib.common.operations.RemoteOperation;
-import com.owncloud.android.lib.common.operations.RemoteOperationResult;
-import com.owncloud.android.lib.resources.files.DownloadFileRemoteOperation;
-import com.owncloud.android.lib.resources.files.model.RemoteFile;
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.NoSuchFileException;
-import java.nio.file.Path;
-
-import foundation.e.drive.database.DbHelper;
-import foundation.e.drive.models.SyncedFileState;
-import foundation.e.drive.utils.FileUtils;
-import timber.log.Timber;
-
-/**
- * @author Vincent Bourgmayer
- * Encapsulate a global download process for a file
- * /!\ Doesn't require NextcloudClient yet
- */
-public class DownloadFileOperation extends RemoteOperation {
-
- private final RemoteFile remoteFile;
- private final Context context;
- private final String targetPath;
- private int restartCounter =0;
- private final SyncedFileState syncedFileState;
- private final String previousEtag;
-
- /**
- * Constructor of download operation where syncedFileState is already known
- * @param remoteFile remote file to Download
- * @param syncedFileState SyncedFileState corresponding to remote file
- */
- public DownloadFileOperation(@NonNull RemoteFile remoteFile, @NonNull SyncedFileState syncedFileState, @NonNull Context context) {
- this.remoteFile = remoteFile;
- this.syncedFileState = syncedFileState;
- this.previousEtag = syncedFileState.getLastEtag();
- this.targetPath = syncedFileState.getLocalPath();
- this.context = context;
- }
-
- @SuppressWarnings("deprecation")
- @Override
- @NonNull
- protected RemoteOperationResult run(@NonNull OwnCloudClient ownCloudClient) {
- Timber.v( "run() for %s", remoteFile.getRemotePath());
- if (syncedFileState.getId() == -1) {
- this.syncedFileState.setId(DbHelper.manageSyncedFileStateDB(this.syncedFileState, "INSERT", context));
- }
-
- if (syncedFileState.getLastEtag().equals(remoteFile.getEtag())
- && syncedFileState.getLastModified() > 0L) {
- Timber.v( "%s already up-to-date", remoteFile.getRemotePath());
- return new RemoteOperationResult(RemoteOperationResult.ResultCode.ETAG_UNCHANGED);
- }
-
- final String tmpTargetFolderPath = context.getExternalCacheDir().getAbsolutePath();
- final DownloadFileRemoteOperation downloadOperation = new DownloadFileRemoteOperation(remoteFile.getRemotePath(),
- tmpTargetFolderPath);
-
- //noinspection deprecation
- final RemoteOperationResult downloadResult = downloadOperation.execute(ownCloudClient);
- RemoteOperationResult.ResultCode resultCode;
-
- boolean mustRestart = true;
-
- if (downloadResult.isSuccess()) {
- resultCode = onDownloadSuccess(tmpTargetFolderPath);
-
- if (resultCode == OK || resultCode == FORBIDDEN) {
- mustRestart = false;
- }
-
- } else if (isNetworkDisconnected(downloadResult)) {
- mustRestart = false;
- resultCode = downloadResult.getCode();
- } else {
- Timber.d("Download failed: %s, %s", downloadResult.getCode(), downloadResult.getLogMessage());
- resultCode = RemoteOperationResult.ResultCode.UNKNOWN_ERROR;
- }
-
- if (mustRestart) {
- Timber.v("%s unsuccessfull trial.s of downloading %s", restartCounter, remoteFile.getRemotePath());
- syncedFileState.setLastEtag(this.previousEtag);
- if (this.restartCounter < 3) {
- this.restartCounter += 1;
- return this.run(ownCloudClient);
- } else {
- resultCode = INVALID_OVERWRITE;
- }
- }
-
- //So now, we can update instance of SyncedState and save it to DB
- if (DbHelper.manageSyncedFileStateDB(syncedFileState, "UPDATE", context) <= 0) {
- //todo : define what to do in this case. Is this test even relevant ?
- }
- return new RemoteOperationResult(resultCode);
- }
-
- private boolean isNetworkDisconnected(@NonNull final RemoteOperationResult result) {
- RemoteOperationResult.ResultCode resultCode = result.getCode();
- return resultCode == NO_NETWORK_CONNECTION
- || resultCode == WRONG_CONNECTION
- || resultCode == HOST_NOT_AVAILABLE;
- }
-
- private RemoteOperationResult.ResultCode onDownloadSuccess(String tmpTargetFolderPath) {
- final String tmpFilePath = tmpTargetFolderPath + remoteFile.getRemotePath();
- final File tmpFile = new File(tmpFilePath);
-
- if (!tmpFile.exists()) {
- Timber.d("Missing downloaded temporary file for %s", remoteFile.getRemotePath());
- return FILE_NOT_FOUND;
- }
-
- final long tmpFileSize = tmpFile.length();
- final long remoteFileSize = remoteFile.getLength();
-
- if (tmpFileSize != remoteFileSize) {
- Timber.d("Local file size (%s) and remote file size (%s) of %s doesn't match", tmpFileSize, remoteFileSize, remoteFile.getRemotePath());
- tmpFile.delete();
- return INVALID_OVERWRITE;
- }
-
- final File targetFile = new File(targetPath);
- if (!moveFileToRealLocation(tmpFile, targetFile)) {
- Timber.d("Failed to move %s to %s", tmpFile.getAbsolutePath(), targetPath);
- return FORBIDDEN;
- }
-
- if (remoteFile.getModifiedTimestamp() < CORRUPTED_TIMESTAMP_IN_MILLISECOND) {
- targetFile.setLastModified(remoteFile.getModifiedTimestamp());
- }
- syncedFileState.setLastModified(targetFile.lastModified());
- syncedFileState.setLastEtag(remoteFile.getEtag());
-
- doActionMediaScannerConnectionScanFile(context, syncedFileState.getLocalPath()); //required to make Gallery show new image
- return OK;
- }
-
- /**
- * Move the temporary file downloaded to its final location
- * @param tmpFile Temporary file that has just been downloaded
- * @param targetFile real location of the file
- * @return true if success, false otherwise
- */
- private boolean moveFileToRealLocation(File tmpFile, File targetFile) {
- final File targetDir = targetFile.getParentFile();
- if (targetDir == null) return false;
- try {
- if (!targetDir.exists()) targetDir.mkdirs();
-
- final Path targetPath = targetFile.toPath();
- final Path tmpPath = tmpFile.toPath();
-
- final Path copyResult = Files.copy(tmpPath, targetPath, REPLACE_EXISTING);
- final File copyResultFile = copyResult.toFile();
-
- if (copyResultFile.length() == tmpFile.length()) {
- tmpFile.delete();
- return true;
- }
-
- copyResultFile.delete();
- } catch (NoSuchFileException exception) {
- Timber.w(exception);
- } catch (IOException | SecurityException | NullPointerException exception) {
- Timber.e(exception);
- }
- return false;
- }
-
- /**
- * Allow to read remote file path from the operation
- * @return path of a remote file
- */
- @Nullable
- public String getRemoteFilePath() {
- return remoteFile.getRemotePath();
- }
-
-
- /**
- * Update Gallery for showing the file in parameter
- *
- * @param context Calling service context
- * @param filePath String containing path the file to update by mediaScanner
- */
- private void doActionMediaScannerConnectionScanFile(@NonNull Context context, @NonNull final String filePath) {
- Timber.v("doActionMediaScannerConnectionScanFile( %s )", filePath);
- final String[] filePathArray = new String[] { filePath };
- final String[] mimeType = new String[] {FileUtils.getMimeType(new File(filePath))};
- MediaScannerConnection.scanFile(context,
- filePathArray,
- mimeType,
- (path, uri) -> Timber.tag("MediaScannerConnection")
- .v("file %s was scanned with success: %s ", path, uri));
- }
-}
diff --git a/app/src/main/java/foundation/e/drive/synchronization/tasks/DownloadFileOperation.kt b/app/src/main/java/foundation/e/drive/synchronization/tasks/DownloadFileOperation.kt
new file mode 100644
index 0000000000000000000000000000000000000000..81ba508fe5e9fa439446890514c2f12560b01c75
--- /dev/null
+++ b/app/src/main/java/foundation/e/drive/synchronization/tasks/DownloadFileOperation.kt
@@ -0,0 +1,238 @@
+/*
+ * Copyright (C) 2024 MURENA SAS
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+package foundation.e.drive.synchronization.tasks
+
+import android.content.Context
+import android.media.MediaScannerConnection
+
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
+import com.owncloud.android.lib.resources.files.DownloadFileRemoteOperation
+import com.owncloud.android.lib.resources.files.model.RemoteFile
+import foundation.e.drive.database.DbHelper
+import foundation.e.drive.models.SyncedFileState
+import foundation.e.drive.utils.AppConstants.CORRUPTED_TIMESTAMP_IN_MILLISECOND
+import foundation.e.drive.utils.FileUtils
+import timber.log.Timber
+import java.io.File
+import java.util.stream.IntStream.range
+
+/**
+ * @author Vincent Bourgmayer
+ * Encapsulates a global download process for a remote file ⚠️ Ignores
+ * deprecation about download with OwncloudClient because NC lib doesn't
+ * implement it with NextcloudClient
+ */
+class DownloadFileOperation(private val remoteFile: RemoteFile,
+ private val fileState: SyncedFileState,
+ private val context: Context): RemoteOperation() {
+
+ private val previousEtag: String = fileState.lastEtag
+ private val targetFilePath = fileState.localPath
+
+ companion object {
+ private const val MAX_DOWNLOAD_TRIAL = 3
+ }
+
+ @Suppress("OVERRIDE_DEPRECATION")
+ override fun run(client: OwnCloudClient): RemoteOperationResult {
+ Timber.v("DownloadFileOperation.run() for ${remoteFile.remotePath} ")
+ persistIfNewSyncedFileState()
+
+ val canStartResultCode = checkStartCondition()
+ if (canStartResultCode != ResultCode.OK) {
+ return RemoteOperationResult(canStartResultCode)
+ }
+
+ val tmpDirectoryPath = context.externalCacheDir!!.absolutePath
+ val tmpFile = File(tmpDirectoryPath + remoteFile.remotePath)
+
+ val downloadResult = downloadFile(tmpDirectoryPath, tmpFile, client)
+
+ if (downloadResult == ResultCode.OK) {
+ val targetFile = File(targetFilePath)
+ setRemoteTimestampOnFile(targetFile)
+
+ makeGalleryDetectTheFile(context, targetFilePath)
+ updateSyncedFileStateInDb(targetFile.lastModified())
+ }
+
+ return RemoteOperationResult(downloadResult)
+ }
+
+ private fun checkStartCondition(): ResultCode {
+ var resultCode = ResultCode.OK
+
+ if (isFileUpToDate()) {
+ Timber.d( "${remoteFile.remotePath} already up-to-date")
+ resultCode = ResultCode.ETAG_UNCHANGED
+ } else if (context.externalCacheDir == null) {
+ Timber.d( "context.externalCacheDir is null")
+ resultCode = ResultCode.FORBIDDEN
+ }
+
+ return resultCode
+ }
+
+ /**
+ * Makes device be aware of this file instantly
+ *
+ * @param context Calling service context
+ * @param filePath String containing path the file to update by mediaScanner
+ */
+ private fun makeGalleryDetectTheFile(context: Context, filePath: String) {
+ Timber.v("makeGalleryDetectTheFile( $filePath )" )
+
+ MediaScannerConnection.scanFile(context,
+ arrayOf(filePath),
+ arrayOf(remoteFile.mimeType)) {
+ path, uri -> Timber
+ .v("file %s was scanned with success: %s ", path, uri)
+ }
+ }
+
+ private fun setRemoteTimestampOnFile(targetFile: File) {
+ if (remoteFile.modifiedTimestamp < CORRUPTED_TIMESTAMP_IN_MILLISECOND) {
+ try {
+ targetFile.setLastModified(remoteFile.modifiedTimestamp)
+ } catch(securityException: SecurityException) {
+ Timber.w(securityException, "Can't update last modified timestamp")
+ } catch (illegalArgumentException: IllegalArgumentException) {
+ Timber.w(illegalArgumentException,
+ "remoteFile's lastModifiedTimestamp : ${remoteFile.modifiedTimestamp}")
+ }
+ }
+ }
+
+ private fun updateSyncedFileStateInDb(fileLastModifiedTimestamp: Long) {
+ fileState.lastModified = fileLastModifiedTimestamp
+ fileState.lastEtag = remoteFile.etag!! //already checked in "isFileUpToDate()"
+
+ DbHelper.manageSyncedFileStateDB(fileState, "UPDATE", context)
+ // todo : define what to do in this case. Is this test even relevant ?
+ // replace the above line with the following
+ //
+ // val result = DbHelper.manageSyncedFileStateDB(fileState, "UPDATE", context)
+ // if (result <= 0) {
+ // ....
+ // }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun downloadFile(tmpDirPath: String, tmpFile: File, client: OwnCloudClient): ResultCode {
+ var lastResultCode: ResultCode = ResultCode.UNKNOWN_ERROR
+
+ for (trialCount in range(1, MAX_DOWNLOAD_TRIAL)) {
+ fileState.lastEtag = previousEtag
+
+ val downloadFileOperation = DownloadFileRemoteOperation(remoteFile.remotePath, tmpDirPath)
+ val downloadResult = downloadFileOperation.execute(client)
+
+ lastResultCode = if (downloadResult.isSuccess) {
+ isFileValid(tmpFile)
+ } else {
+ downloadResult.code
+ }
+
+ if (lastResultCode != ResultCode.OK) {
+ Timber.d("Download trial: $trialCount failed: ${downloadResult.code}, ${downloadResult.logMessage}")
+ }
+
+ if (!deserveRetry(lastResultCode)) {
+ break
+ }
+ }
+
+ if (lastResultCode == ResultCode.OK
+ && !moveTmpFileToRealLocation(tmpFile)) {
+ return ResultCode.FORBIDDEN
+ }
+
+ return lastResultCode
+ }
+
+ private fun deserveRetry(resultCode: ResultCode?): Boolean {
+ return (resultCode == null
+ || (resultCode != ResultCode.OK && !isNetworkFailure(resultCode)))
+ }
+
+ private fun isFileValid(tmpFile: File): ResultCode {
+ if (!tmpFile.exists()) {
+ Timber.d("Missing downloaded temporary file for ${remoteFile.remotePath}")
+ return ResultCode.FILE_NOT_FOUND
+ }
+
+ if (!hasExpectedFileSize(tmpFile)) {
+ tmpFile.delete()
+ return ResultCode.INVALID_OVERWRITE
+ }
+ return ResultCode.OK
+ }
+
+ /**
+ * Moves the temporary file downloaded to its final location
+ * @param tmpFile Temporary file that has just been downloaded
+ * @return true if success, false otherwise
+ */
+ private fun moveTmpFileToRealLocation(tmpFile: File): Boolean {
+ val targetFile = File(targetFilePath)
+ val targetDir = targetFile.parentFile ?: return false
+
+ return if (FileUtils.ensureDirectoryExists(targetDir)) {
+ FileUtils.copyFile(tmpFile, targetFile)
+ } else {
+ Timber.d("Failed to move ${tmpFile.absolutePath} to $targetFilePath")
+ false
+ }
+ }
+
+ private fun hasExpectedFileSize(tmpFile: File): Boolean {
+ val remoteFileSize = remoteFile.length
+ val tmpFileSize = tmpFile.length()
+ if (tmpFileSize != remoteFileSize) {
+ Timber.d("Local's size $tmpFileSize and remote's size $remoteFileSize " +
+ "of ${remoteFile.remotePath} don't match")
+ return false
+ }
+ return true
+ }
+
+ /**
+ * Checks if failed download is due to network issue
+ */
+ private fun isNetworkFailure(resultCode: ResultCode): Boolean {
+ return resultCode in listOf(ResultCode.NO_NETWORK_CONNECTION,
+ ResultCode.HOST_NOT_AVAILABLE,
+ ResultCode.WRONG_CONNECTION)
+ }
+
+ private fun isFileUpToDate(): Boolean {
+ return !remoteFile.etag.isNullOrEmpty()
+ && fileState.lastEtag == remoteFile.etag
+ && fileState.lastModified > 0L
+ }
+
+ private fun persistIfNewSyncedFileState() {
+ if (fileState.id == -1) {
+ val id = DbHelper.manageSyncedFileStateDB(fileState, "INSERT", context)
+ fileState.id = id
+ }
+ }
+}
diff --git a/app/src/main/java/foundation/e/drive/utils/FileUtils.kt b/app/src/main/java/foundation/e/drive/utils/FileUtils.kt
index 2a7c87eeb09da59355285e21f1f77632452239cf..14df3210e7589f5aba4dc3bfbe1195bd81e87709 100644
--- a/app/src/main/java/foundation/e/drive/utils/FileUtils.kt
+++ b/app/src/main/java/foundation/e/drive/utils/FileUtils.kt
@@ -1,9 +1,19 @@
/*
- * Copyright © MURENA SAS 2023.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the GNU Public License v3.0
- * which accompanies this distribution, and is available at
- * http://www.gnu.org/licenses/gpl.html
+ * Copyright (C) 2023-2024 MURENA SAS
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
*/
package foundation.e.drive.utils
@@ -13,6 +23,9 @@ import timber.log.Timber
import java.io.File
import java.io.IOException
import java.net.URLConnection
+import java.nio.file.FileSystemException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
object FileUtils {
/**
@@ -72,4 +85,45 @@ object FileUtils {
@JvmStatic
fun isNotPartFile(file: File) = !isPartFile(file)
-}
\ No newline at end of file
+
+ @JvmStatic
+ fun copyFile(source: File, target: File): Boolean {
+ var success = false
+ try {
+ val copyResult = Files.copy(
+ source.toPath(),
+ target.toPath(),
+ StandardCopyOption.REPLACE_EXISTING
+ )
+ val copiedFile = copyResult.toFile()
+ success = verifyCopyOperation(copiedFile, source)
+
+ if (success) {
+ source.delete()
+ } else {
+ copiedFile.delete()
+ }
+
+ } catch (fileSystemException: FileSystemException) {
+ Timber.w(fileSystemException)
+ } catch (unsupportedOperationException: UnsupportedOperationException ) {
+ Timber.e(unsupportedOperationException)
+ } catch (ioException: IOException) {
+ Timber.e(ioException)
+ }
+ return success
+ }
+
+ @JvmStatic
+ private fun verifyCopyOperation(copiedFile: File, tmpFile: File): Boolean {
+ return copiedFile.length() == tmpFile.length()
+ }
+
+ @JvmStatic
+ fun ensureDirectoryExists(directory: File): Boolean {
+ if (!directory.exists()) {
+ directory.mkdirs()
+ }
+ return directory.exists()
+ }
+}