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() + } +}