Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 08c96061 authored by Vincent Bourgmayer's avatar Vincent Bourgmayer
Browse files

refactor(DownloadFileOperation): rewrite DownloadFileOperation in kotlin

parent c2468ccb
Loading
Loading
Loading
Loading
+15 −5
Original line number Diff line number Diff line
/*
 * 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 <https://www.gnu.org/licenses/>.
 *
 */
package foundation.e.drive.models;

+0 −230
Original line number Diff line number Diff line
/*
 * 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));
    }
}
+238 −0
Original line number Diff line number Diff line
/*
 * 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 <https://www.gnu.org/licenses/>.
 *
 */
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<Any>() {

    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<Any> {
        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
        }
    }
}
+60 −6
Original line number Diff line number Diff line
/*
 * 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 <https://www.gnu.org/licenses/>.
 *
 */
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)

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