android/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation....

526 lines
21 KiB
Java

/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-FileCopyrightText: 2018-2023 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2018 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2016 ownCloud Inc.
* SPDX-FileCopyrightText: 2012-2013 David A. Velasco <dvelasco@solidgear.es>
* SPDX-License-Identifier: GPL-2.0-only AND AGPL-3.0-or-later
*/
package com.owncloud.android.operations;
import android.content.Context;
import android.content.Intent;
import android.text.TextUtils;
import com.nextcloud.client.account.User;
import com.nextcloud.client.jobs.download.FileDownloadHelper;
import com.owncloud.android.datamodel.FileDataStorageManager;
import com.owncloud.android.datamodel.OCFile;
import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1;
import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile;
import com.owncloud.android.lib.common.OwnCloudClient;
import com.owncloud.android.lib.common.operations.OperationCancelledException;
import com.owncloud.android.lib.common.operations.RemoteOperationResult;
import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode;
import com.owncloud.android.lib.common.utils.Log_OC;
import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation;
import com.owncloud.android.lib.resources.files.ReadFolderRemoteOperation;
import com.owncloud.android.lib.resources.files.model.RemoteFile;
import com.owncloud.android.lib.resources.status.E2EVersion;
import com.owncloud.android.operations.common.SyncOperation;
import com.owncloud.android.services.OperationsService;
import com.owncloud.android.utils.FileStorageUtils;
import com.owncloud.android.utils.MimeTypeUtil;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Vector;
import java.util.concurrent.atomic.AtomicBoolean;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
/**
* Remote operation performing the synchronization of the list of files contained
* in a folder identified with its remote path.
* Fetches the list and properties of the files contained in the given folder, including their
* properties, and updates the local database with them.
* Does NOT enter in the child folders to synchronize their contents also, BUT requests for a new operation instance
* doing so.
*/
public class SynchronizeFolderOperation extends SyncOperation {
private static final String TAG = SynchronizeFolderOperation.class.getSimpleName();
/** Time stamp for the synchronization process in progress */
private long mCurrentSyncTime;
/** Remote path of the folder to synchronize */
private String mRemotePath;
/** Account where the file to synchronize belongs */
private User user;
/** Android context; necessary to send requests to the download service */
private Context mContext;
/** Locally cached information about folder to synchronize */
private OCFile mLocalFolder;
/** Counter of conflicts found between local and remote files */
private int mConflictsFound;
/** Counter of failed operations in synchronization of kept-in-sync files */
private int mFailsInFileSyncsFound;
/**
* 'True' means that the remote folder changed and should be fetched
*/
private boolean mRemoteFolderChanged;
private List<OCFile> mFilesForDirectDownload;
// to avoid extra PROPFINDs when there was no change in the folder
private List<SyncOperation> mFilesToSyncContents;
// this will be used for every file when 'folder synchronization' replaces 'folder download'
private final AtomicBoolean mCancellationRequested;
/**
* Creates a new instance of {@link SynchronizeFolderOperation}.
*
* @param context Application context.
* @param remotePath Path to synchronize.
* @param user Nextcloud account where the folder is located.
* @param currentSyncTime Time stamp for the synchronization process in progress.
*/
public SynchronizeFolderOperation(Context context,
String remotePath,
User user,
long currentSyncTime,
FileDataStorageManager storageManager) {
super(storageManager);
mRemotePath = remotePath;
mCurrentSyncTime = currentSyncTime;
this.user = user;
mContext = context;
mRemoteFolderChanged = false;
mFilesForDirectDownload = new Vector<>();
mFilesToSyncContents = new Vector<>();
mCancellationRequested = new AtomicBoolean(false);
}
/**
* Performs the synchronization.
*
* {@inheritDoc}
*/
@Override
protected RemoteOperationResult run(OwnCloudClient client) {
RemoteOperationResult result;
mFailsInFileSyncsFound = 0;
mConflictsFound = 0;
try {
// get locally cached information about folder
mLocalFolder = getStorageManager().getFileByPath(mRemotePath);
result = checkForChanges(client);
if (result.isSuccess()) {
if (mRemoteFolderChanged) {
result = fetchAndSyncRemoteFolder(client);
} else {
prepareOpsFromLocalKnowledge();
}
if (result.isSuccess()) {
syncContents();
}
}
if (mCancellationRequested.get()) {
throw new OperationCancelledException();
}
} catch (OperationCancelledException e) {
result = new RemoteOperationResult(e);
}
return result;
}
private RemoteOperationResult checkForChanges(OwnCloudClient client) throws OperationCancelledException {
Log_OC.d(TAG, "Checking changes in " + user.getAccountName() + mRemotePath);
mRemoteFolderChanged = true;
if (mCancellationRequested.get()) {
throw new OperationCancelledException();
}
// remote request
ReadFileRemoteOperation operation = new ReadFileRemoteOperation(mRemotePath);
RemoteOperationResult result = operation.execute(client);
if (result.isSuccess()) {
OCFile remoteFolder = FileStorageUtils.fillOCFile((RemoteFile) result.getData().get(0));
// check if remote and local folder are different
mRemoteFolderChanged = !(remoteFolder.getEtag().equalsIgnoreCase(mLocalFolder.getEtag()));
result = new RemoteOperationResult(ResultCode.OK);
Log_OC.i(TAG, "Checked " + user.getAccountName() + mRemotePath + " : " +
(mRemoteFolderChanged ? "changed" : "not changed"));
} else {
// check failed
if (result.getCode() == ResultCode.FILE_NOT_FOUND) {
removeLocalFolder();
}
if (result.isException()) {
Log_OC.e(TAG, "Checked " + user.getAccountName() + mRemotePath + " : " +
result.getLogMessage(), result.getException());
} else {
Log_OC.e(TAG, "Checked " + user.getAccountName() + mRemotePath + " : " +
result.getLogMessage());
}
}
return result;
}
private RemoteOperationResult fetchAndSyncRemoteFolder(OwnCloudClient client) throws OperationCancelledException {
if (mCancellationRequested.get()) {
throw new OperationCancelledException();
}
ReadFolderRemoteOperation operation = new ReadFolderRemoteOperation(mRemotePath);
RemoteOperationResult result = operation.execute(client);
Log_OC.d(TAG, "Synchronizing " + user.getAccountName() + mRemotePath);
Log_OC.d(TAG, "Synchronizing remote id" + mLocalFolder.getRemoteId());
if (result.isSuccess()) {
synchronizeData(result.getData());
if (mConflictsFound > 0 || mFailsInFileSyncsFound > 0) {
result = new RemoteOperationResult(ResultCode.SYNC_CONFLICT);
// should be a different result code, but will do the job
}
} else {
if (result.getCode() == ResultCode.FILE_NOT_FOUND) {
removeLocalFolder();
}
}
return result;
}
private void removeLocalFolder() {
FileDataStorageManager storageManager = getStorageManager();
if (storageManager.fileExists(mLocalFolder.getFileId())) {
String currentSavePath = FileStorageUtils.getSavePath(user.getAccountName());
storageManager.removeFolder(
mLocalFolder,
true,
mLocalFolder.isDown() // TODO: debug, I think this is always false for folders
&& mLocalFolder.getStoragePath().startsWith(currentSavePath)
);
}
}
/**
* Synchronizes the data retrieved from the server about the contents of the target folder
* with the current data in the local database.
*
* @param folderAndFiles Remote folder and children files in Folder
*/
private void synchronizeData(List<Object> folderAndFiles) throws OperationCancelledException {
// parse data from remote folder
OCFile remoteFolder = FileStorageUtils.fillOCFile((RemoteFile) folderAndFiles.get(0));
remoteFolder.setParentId(mLocalFolder.getParentId());
remoteFolder.setFileId(mLocalFolder.getFileId());
Log_OC.d(TAG, "Remote folder " + mLocalFolder.getRemotePath() + " changed - starting update of local data ");
mFilesForDirectDownload.clear();
mFilesToSyncContents.clear();
if (mCancellationRequested.get()) {
throw new OperationCancelledException();
}
FileDataStorageManager storageManager = getStorageManager();
// if local folder is encrypted, download fresh metadata
boolean encryptedAncestor = FileStorageUtils.checkEncryptionStatus(remoteFolder, storageManager);
mLocalFolder.setEncrypted(encryptedAncestor);
// update permission
mLocalFolder.setPermissions(remoteFolder.getPermissions());
// update richWorkspace
mLocalFolder.setRichWorkspace(remoteFolder.getRichWorkspace());
Object object = RefreshFolderOperation.getDecryptedFolderMetadata(encryptedAncestor,
mLocalFolder,
getClient(),
user,
mContext);
if (mLocalFolder.isEncrypted() && object == null) {
throw new IllegalStateException("metadata is null!");
}
// get current data about local contents of the folder to synchronize
Map<String, OCFile> localFilesMap;
E2EVersion e2EVersion;
if (object instanceof DecryptedFolderMetadataFileV1) {
e2EVersion = E2EVersion.V1_2;
localFilesMap = RefreshFolderOperation.prefillLocalFilesMap((DecryptedFolderMetadataFileV1) object,
storageManager.getFolderContent(mLocalFolder, false));
} else {
e2EVersion = E2EVersion.V2_0;
localFilesMap = RefreshFolderOperation.prefillLocalFilesMap((DecryptedFolderMetadataFile) object,
storageManager.getFolderContent(mLocalFolder, false));
}
// loop to synchronize every child
List<OCFile> updatedFiles = new ArrayList<>(folderAndFiles.size() - 1);
OCFile remoteFile;
OCFile localFile;
OCFile updatedFile;
RemoteFile remote;
for (int i = 1; i < folderAndFiles.size(); i++) {
/// new OCFile instance with the data from the server
remote = (RemoteFile) folderAndFiles.get(i);
remoteFile = FileStorageUtils.fillOCFile(remote);
/// new OCFile instance to merge fresh data from server with local state
updatedFile = FileStorageUtils.fillOCFile(remote);
updatedFile.setParentId(mLocalFolder.getFileId());
/// retrieve local data for the read file
localFile = localFilesMap.remove(remoteFile.getRemotePath());
// TODO better implementation is needed
if (localFile == null) {
localFile = storageManager.getFileByPath(updatedFile.getRemotePath());
}
/// add to updatedFile data about LOCAL STATE (not existing in server)
updateLocalStateData(remoteFile, localFile, updatedFile);
/// check and fix, if needed, local storage path
FileStorageUtils.searchForLocalFileInDefaultPath(updatedFile, user.getAccountName());
// update file name for encrypted files
if (e2EVersion == E2EVersion.V1_2) {
RefreshFolderOperation.updateFileNameForEncryptedFileV1(storageManager,
(DecryptedFolderMetadataFileV1) object,
updatedFile);
} else {
RefreshFolderOperation.updateFileNameForEncryptedFile(storageManager,
(DecryptedFolderMetadataFile) object,
updatedFile);
}
// we parse content, so either the folder itself or its direct parent (which we check) must be encrypted
boolean encrypted = updatedFile.isEncrypted() || mLocalFolder.isEncrypted();
updatedFile.setEncrypted(encrypted);
/// classify file to sync/download contents later
classifyFileForLaterSyncOrDownload(remoteFile, localFile);
updatedFiles.add(updatedFile);
}
// update file name for encrypted files
if (e2EVersion == E2EVersion.V1_2) {
RefreshFolderOperation.updateFileNameForEncryptedFileV1(storageManager,
(DecryptedFolderMetadataFileV1) object,
mLocalFolder);
} else {
RefreshFolderOperation.updateFileNameForEncryptedFile(storageManager,
(DecryptedFolderMetadataFile) object,
mLocalFolder);
}
// save updated contents in local database
storageManager.saveFolder(remoteFolder, updatedFiles, localFilesMap.values());
mLocalFolder.setLastSyncDateForData(System.currentTimeMillis());
storageManager.saveFile(mLocalFolder);
}
private void updateLocalStateData(OCFile remoteFile, OCFile localFile, OCFile updatedFile) {
updatedFile.setLastSyncDateForProperties(mCurrentSyncTime);
if (localFile != null) {
updatedFile.setFileId(localFile.getFileId());
updatedFile.setLastSyncDateForData(localFile.getLastSyncDateForData());
updatedFile.setModificationTimestampAtLastSyncForData(
localFile.getModificationTimestampAtLastSyncForData()
);
updatedFile.setStoragePath(localFile.getStoragePath());
// eTag will not be updated unless file CONTENTS are synchronized
updatedFile.setEtag(localFile.getEtag());
if (updatedFile.isFolder()) {
updatedFile.setFileLength(localFile.getFileLength());
// TODO move operations about size of folders to FileContentProvider
} else if (mRemoteFolderChanged && MimeTypeUtil.isImage(remoteFile) &&
remoteFile.getModificationTimestamp() !=
localFile.getModificationTimestamp()) {
updatedFile.setUpdateThumbnailNeeded(true);
Log_OC.d(TAG, "Image " + remoteFile.getFileName() + " updated on the server");
}
updatedFile.setSharedViaLink(localFile.isSharedViaLink());
updatedFile.setSharedWithSharee(localFile.isSharedWithSharee());
updatedFile.setEtagInConflict(localFile.getEtagInConflict());
} else {
// remote eTag will not be updated unless file CONTENTS are synchronized
updatedFile.setEtag("");
}
}
private void classifyFileForLaterSyncOrDownload(OCFile remoteFile, OCFile localFile) {
if (!remoteFile.isFolder()) {
SynchronizeFileOperation operation = new SynchronizeFileOperation(
localFile,
remoteFile,
user,
true,
mContext,
getStorageManager()
);
mFilesToSyncContents.add(operation);
}
}
private void prepareOpsFromLocalKnowledge() throws OperationCancelledException {
List<OCFile> children = getStorageManager().getFolderContent(mLocalFolder, false);
for (OCFile child : children) {
if (!child.isFolder()) {
if (!child.isDown()) {
mFilesForDirectDownload.add(child);
} else {
/// this should result in direct upload of files that were locally modified
SynchronizeFileOperation operation = new SynchronizeFileOperation(
child,
child.getEtagInConflict() != null ? child : null,
user,
true,
mContext,
getStorageManager()
);
mFilesToSyncContents.add(operation);
}
}
}
}
private void syncContents() throws OperationCancelledException {
startDirectDownloads();
startContentSynchronizations(mFilesToSyncContents);
}
private void startDirectDownloads() {
FileDownloadHelper.Companion.instance().downloadFile(user, mLocalFolder);
}
/**
* Performs a list of synchronization operations, determining if a download or upload is needed
* or if exists conflict due to changes both in local and remote contents of the each file.
*
* If download or upload is needed, request the operation to the corresponding service and goes on.
*
* @param filesToSyncContents Synchronization operations to execute.
*/
private void startContentSynchronizations(List<SyncOperation> filesToSyncContents)
throws OperationCancelledException {
Log_OC.v(TAG, "Starting content synchronization... ");
RemoteOperationResult contentsResult;
for (SyncOperation op: filesToSyncContents) {
if (mCancellationRequested.get()) {
throw new OperationCancelledException();
}
contentsResult = op.execute(mContext);
if (!contentsResult.isSuccess()) {
if (contentsResult.getCode() == ResultCode.SYNC_CONFLICT) {
mConflictsFound++;
} else {
mFailsInFileSyncsFound++;
if (contentsResult.getException() != null) {
Log_OC.e(TAG, "Error while synchronizing file : "
+ contentsResult.getLogMessage(), contentsResult.getException());
} else {
Log_OC.e(TAG, "Error while synchronizing file : "
+ contentsResult.getLogMessage());
}
}
// TODO - use the errors count in notifications
} // won't let these fails break the synchronization process
}
}
/**
* Scans the default location for saving local copies of files searching for
* a 'lost' file with the same full name as the {@link com.owncloud.android.datamodel.OCFile}
* received as parameter.
*
* @param file File to associate a possible 'lost' local file.
*/
private void searchForLocalFileInDefaultPath(OCFile file) {
if (file.getStoragePath() == null && !file.isFolder()) {
File f = new File(FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), file));
if (f.exists()) {
file.setStoragePath(f.getAbsolutePath());
file.setLastSyncDateForData(f.lastModified());
}
}
}
/**
* Cancel operation
*/
public void cancel() {
mCancellationRequested.set(true);
}
public String getFolderPath() {
String path = mLocalFolder.getStoragePath();
if (!TextUtils.isEmpty(path)) {
return path;
}
return FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), mLocalFolder);
}
private void startSyncFolderOperation(String path){
Intent intent = new Intent(mContext, OperationsService.class);
intent.setAction(OperationsService.ACTION_SYNC_FOLDER);
intent.putExtra(OperationsService.EXTRA_ACCOUNT, user.toPlatformAccount());
intent.putExtra(OperationsService.EXTRA_REMOTE_PATH, path);
mContext.startService(intent);
}
public String getRemotePath() {
return mRemotePath;
}
}