diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt index b9e8012aa969..2551263f1329 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt @@ -19,7 +19,6 @@ import com.owncloud.android.datamodel.OCFileDepth.Root import com.owncloud.android.ui.events.EncryptionEvent import com.owncloud.android.utils.FileStorageUtils import java.util.Calendar -import java.util.Date fun List.filterFilenames(): List = distinctBy { it.fileName } @@ -85,6 +84,19 @@ private fun transformToRows(list: List, layout: GalleryRowLayout): List< .map { chunk -> GalleryRow(chunk, layout.measure(chunk)) } } +fun OCFile?.isDownloadedFileChanged(serverFile: OCFile): Boolean { + if (this == null || !isDown || serverFile.isFolder) { + return false + } + + return etag.eTagChanged(serverFile.etag) +} + +fun OCFile.isLocalETagOutdated(): Boolean { + val serverETag = etagInConflict + return !serverETag.isNullOrEmpty() && etag.eTagChanged(serverETag) +} + fun OCFile.toEncryptionEvent(encrypt: Boolean): EncryptionEvent = EncryptionEvent( localId, remoteId, diff --git a/app/src/main/java/com/owncloud/android/operations/FolderRefreshScheduler.kt b/app/src/main/java/com/owncloud/android/operations/FolderRefreshScheduler.kt index c6106a98dbb5..7b1ee0ec86f7 100644 --- a/app/src/main/java/com/owncloud/android/operations/FolderRefreshScheduler.kt +++ b/app/src/main/java/com/owncloud/android/operations/FolderRefreshScheduler.kt @@ -20,11 +20,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds class FolderRefreshScheduler(private val activity: FileDisplayActivity) { companion object { - private const val ETAG_POLL_INTERVAL_MS = 30_000L + private val ETAG_POLL_INTERVAL = 10.seconds private const val TAG = "FolderRefreshScheduler" } @@ -35,12 +35,12 @@ class FolderRefreshScheduler(private val activity: FileDisplayActivity) { job = activity.lifecycleScope.launch { while (isActive) { - delay(ETAG_POLL_INTERVAL_MS.milliseconds) + delay(ETAG_POLL_INTERVAL) checkAndRefreshIfETagChanged() } } - Log_OC.d(TAG, "eTag polling started interval 30 seconds") + Log_OC.d(TAG, "eTag polling started interval $ETAG_POLL_INTERVAL") } fun stop() { diff --git a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java index dc27a5f236a3..eecd9468489c 100644 --- a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java @@ -19,6 +19,7 @@ import com.nextcloud.utils.ResultParser; import com.nextcloud.utils.e2ee.E2EVersionHelper; import com.nextcloud.utils.share.UnifiedShareSharees; +import com.nextcloud.utils.extensions.OCFileExtensionsKt; import com.nextcloud.utils.extensions.StringExtensionsKt; import com.owncloud.android.datamodel.ArbitraryDataProvider; import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; @@ -55,6 +56,7 @@ import java.util.List; import java.util.Map; import java.util.Vector; +import java.util.concurrent.atomic.AtomicInteger; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -113,12 +115,12 @@ public class RefreshFolderOperation extends RemoteOperation { /** * Counter of conflicts found between local and remote files */ - private int mConflictsFound; + private final AtomicInteger mConflictsFound = new AtomicInteger(); /** * Counter of failed operations in synchronization of kept-in-sync files */ - private int mFailsInKeptInSyncFound; + private final AtomicInteger mFailsInKeptInSyncFound = new AtomicInteger(); /** * Map of remote and local paths to files that where locally stored in a location out of the ownCloud folder and @@ -134,7 +136,7 @@ public class RefreshFolderOperation extends RemoteOperation { /** * 'True' means that the remote folder changed and should be fetched */ - private boolean mRemoteFolderChanged; + private volatile boolean mRemoteFolderChanged; /** * 'True' means that the sharees of at least one child of the folder changed @@ -154,6 +156,8 @@ public class RefreshFolderOperation extends RemoteOperation { private final List mFilesToSyncContents; // this will be used for every file when 'folder synchronization' replaces 'folder download' + private final boolean syncChangedDownloadedFiles; + /** * Creates a new instance of {@link RefreshFolderOperation}. @@ -174,6 +178,17 @@ public RefreshFolderOperation(OCFile folder, FileDataStorageManager dataStorageManager, User user, Context context) { + this(folder, currentSyncTime, syncFullAccount, ignoreETag, dataStorageManager, user, context, false); + } + + public RefreshFolderOperation(OCFile folder, + long currentSyncTime, + boolean syncFullAccount, + boolean ignoreETag, + FileDataStorageManager dataStorageManager, + User user, + Context context, + boolean syncChangedDownloadedFiles) { mLocalFolder = folder; mCurrentSyncTime = currentSyncTime; mSyncFullAccount = syncFullAccount; @@ -185,6 +200,7 @@ public RefreshFolderOperation(OCFile folder, mIgnoreETag = ignoreETag; mOnlyFileMetadata = false; mFilesToSyncContents = new Vector<>(); + this.syncChangedDownloadedFiles = syncChangedDownloadedFiles; } /** @@ -205,6 +221,7 @@ public RefreshFolderOperation(OCFile folder, mIgnoreETag = false; mOnlyFileMetadata = true; mFilesToSyncContents = new Vector<>(); + syncChangedDownloadedFiles = false; // since metadata worker working in background for sub-folders no need send folder refresh event isMetadataSyncWorkerRunning = true; @@ -229,14 +246,15 @@ public RefreshFolderOperation(OCFile folder, mIgnoreETag = ignoreETag; mOnlyFileMetadata = onlyFileMetadata; mFilesToSyncContents = new Vector<>(); + syncChangedDownloadedFiles = false; } public int getConflictsFound() { - return mConflictsFound; + return mConflictsFound.get(); } public int getFailsInKeptInSyncFound() { - return mFailsInKeptInSyncFound; + return mFailsInKeptInSyncFound.get(); } public Map getForgottenLocalFiles() { @@ -261,8 +279,8 @@ public List getChildren() { @Override protected RemoteOperationResult run(OwnCloudClient client) { RemoteOperationResult result; - mFailsInKeptInSyncFound = 0; - mConflictsFound = 0; + mFailsInKeptInSyncFound.set(0); + mConflictsFound.set(0); mForgottenLocalFiles.clear(); if (mLocalFolder == null) { @@ -470,7 +488,7 @@ private RemoteOperationResult fetchAndSyncRemoteFolder(OwnCloudClient client) { if (result.isSuccess()) { synchronizeData(result.getData()); - if (mConflictsFound > 0 || mFailsInKeptInSyncFound > 0) { + if (mConflictsFound.get() > 0 || mFailsInKeptInSyncFound.get() > 0) { result = new RemoteOperationResult(ResultCode.SYNC_CONFLICT); // should be a different result code, but will do the job } @@ -619,6 +637,12 @@ private void synchronizeData(List folderAndFiles) { updatedFile.setEncrypted(encrypted); updatedFile.setReadOnly(localFile != null && localFile.isReadOnly()); + if (syncChangedDownloadedFiles && !encrypted && + OCFileExtensionsKt.isDownloadedFileChanged(localFile, remoteFile)) { + mFilesToSyncContents.add(new SynchronizeFileOperation(updatedFile, remoteFile, user, true, mContext, + fileDataStorageManager, true)); + } + updatedFiles.add(updatedFile); } @@ -817,26 +841,37 @@ public static Map prefillLocalFilesMap(Object metadata, List filesToSyncContents) { - RemoteOperationResult contentsResult; - for (SynchronizeFileOperation op : filesToSyncContents) { - contentsResult = op.execute(mContext); // async - if (!contentsResult.isSuccess()) { - if (contentsResult.getCode() == ResultCode.SYNC_CONFLICT) { - mConflictsFound++; - } else { - mFailsInKeptInSyncFound++; - if (contentsResult.getException() != null) { - Log_OC.e(TAG, "Error while synchronizing favourites : " - + contentsResult.getLogMessage(), contentsResult.getException()); - } else { - Log_OC.e(TAG, "Error while synchronizing favourites : " - + contentsResult.getLogMessage()); - } - } - } // won't let these fails break the synchronization process + // won't let these fails break the synchronization process + for (SynchronizeFileOperation operation : filesToSyncContents) { + final RemoteOperationResult result = operation.execute(mContext); + if (result.isSuccess()) { + operation.printSuccess(); + continue; + } + + if (result.getCode() == ResultCode.SYNC_CONFLICT) { + operation.printConflict(); + mConflictsFound.incrementAndGet(); + continue; + } + + operation.printFailure(); + mFailsInKeptInSyncFound.incrementAndGet(); + logContentSynchronizationFailure(result); } } + private void logContentSynchronizationFailure(RemoteOperationResult result) { + final String message = "Error while synchronizing favourites : " + result.getLogMessage(mContext); + final Exception exception = result.getException(); + if (exception == null) { + Log_OC.e(TAG, message); + return; + } + + Log_OC.e(TAG, message, exception); + } + /** * Sends a message to any application component interested in the progress of the synchronization. * diff --git a/app/src/main/java/com/owncloud/android/operations/SynchronizeFileOperation.kt b/app/src/main/java/com/owncloud/android/operations/SynchronizeFileOperation.kt index 345c56dd882b..a6db2efbbdef 100644 --- a/app/src/main/java/com/owncloud/android/operations/SynchronizeFileOperation.kt +++ b/app/src/main/java/com/owncloud/android/operations/SynchronizeFileOperation.kt @@ -257,6 +257,18 @@ class SynchronizeFileOperation : SyncOperation { } } + fun printSuccess() { + Log_OC.i(TAG, "content synchronized, remote path: $remotePath") + } + + fun printConflict() { + Log_OC.w(TAG, "content has conflict, remote path: $remotePath") + } + + fun printFailure() { + Log_OC.e(TAG, "content not synchronized, remote path: $remotePath") + } + companion object { private val TAG: String = SynchronizeFileOperation::class.java.simpleName } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 27d5ebeea7b7..80e1b43d4683 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -2014,7 +2014,7 @@ class FileDisplayActivity : override fun onBrowsedDownTo(directory: OCFile?) { file = directory resetScrollingAndUpdateActionBar() - startSyncFolderOperation(directory, false) + startSyncFolderOperation(directory, ignoreETag = false, syncChangedDownloadedFiles = true) startMetadataSyncForCurrentDir() } @@ -2598,14 +2598,19 @@ class FileDisplayActivity : * @param ignoreFocus reloads file list even without focus, e.g. on tablet mode, focus can still be in detail view */ @JvmOverloads - fun startSyncFolderOperation(folder: OCFile?, ignoreETag: Boolean, ignoreFocus: Boolean = false) { + fun startSyncFolderOperation( + folder: OCFile?, + ignoreETag: Boolean, + ignoreFocus: Boolean = false, + syncChangedDownloadedFiles: Boolean = false + ) { Log_OC.d(TAG, "startSyncFolderOperation called, ignoreEtag: $ignoreETag, ignoreFocus: $ignoreFocus") if (!searchQuery.isNullOrEmpty() || !user.isPresent) { return } - val syncFolder = Runnable { executeSyncFolderOperation(folder, ignoreETag) } + val syncFolder = Runnable { executeSyncFolderOperation(folder, ignoreETag, syncChangedDownloadedFiles) } // The refresh must not run while another window floats over the activity, e.g. a dialog that is being // dismissed or a rotation. Rather than waiting a fixed delay run right away when it already has focus @@ -2631,7 +2636,7 @@ class FileDisplayActivity : } } - private fun executeSyncFolderOperation(folder: OCFile?, ignoreETag: Boolean) { + private fun executeSyncFolderOperation(folder: OCFile?, ignoreETag: Boolean, syncChangedDownloadedFiles: Boolean) { val folder = folder ?: return user.ifPresent { user -> @@ -2644,7 +2649,8 @@ class FileDisplayActivity : ignoreETag, storageManager, user, - applicationContext + applicationContext, + syncChangedDownloadedFiles ).execute( account, this, diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java index 74284f878558..9adcf301898b 100755 --- a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java +++ b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java @@ -295,68 +295,85 @@ public void openFile(OCFile file) { // first always try to use available apps if (availableApps.isEmpty()) { - Optional optionalUser = fileActivity.getUser(); + openFileWithoutAvailableApps(file); + return; + } - if (optionalUser.isPresent() && editorUtils.isEditorAvailable(optionalUser.get(), file.getMimeType())) { - TextEditorWebView.Companion.startTextEditor(file, fileActivity); - return; - } + new Thread(() -> syncAndOpenFile(file, openFileWithIntent)).start(); + } - openRichDocumentFileWithoutAvailableApps(file); + private void openFileWithoutAvailableApps(OCFile file) { + Optional optionalUser = fileActivity.getUser(); + if (optionalUser.isPresent() && editorUtils.isEditorAvailable(optionalUser.get(), file.getMimeType())) { + TextEditorWebView.Companion.startTextEditor(file, fileActivity); return; } - fileActivity.showLoadingDialog(fileActivity.getResources().getString(R.string.sync_in_progress)); + openRichDocumentFileWithoutAvailableApps(file); + } - new Thread(() -> { - User user = currentAccount.getUser(); - final var storageManager = new FileDataStorageManager(user, fileActivity.getContentResolver()); - // a fresh object is needed; many things could have occurred to the file - // since it was registered to observe again, assuming that local files - // are linked to a remote file AT MOST, SOMETHING TO BE DONE; - final var sfo = new SynchronizeFileOperation(file,null, user, true, fileActivity, storageManager, false); - final var result = sfo.execute(fileActivity); + private void syncAndOpenFile(OCFile file, Intent openFileWithIntent) { + User user = currentAccount.getUser(); + final var storageManager = new FileDataStorageManager(user, fileActivity.getContentResolver()); + if (isDownloadedAndUpToDate(storageManager.getFileById(file.getFileId()))) { + startOpenFileIntent(openFileWithIntent); + return; + } - fileActivity.dismissLoadingDialog(); - if (result.getCode() == RemoteOperationResult.ResultCode.SYNC_CONFLICT) { - // ISSUE 5: if the user is not running the app (this is a service!), - // this can be very intrusive; a notification should be preferred - Intent intent = ConflictsResolveActivity.createIntent(file, - user, - -1, - Intent.FLAG_ACTIVITY_NEW_TASK, - fileActivity); - fileActivity.startActivity(intent); - return; - } + // a fresh object is needed; many things could have occurred to the file + // since it was registered to observe again, assuming that local files + // are linked to a remote file AT MOST, SOMETHING TO BE DONE; + final var sfo = new SynchronizeFileOperation(file,null, user, true, fileActivity, storageManager, false); + final var result = sfo.execute(fileActivity); - if (availableApps.isEmpty()) { - fileActivity.runOnUiThread(() -> SnackbarUtil.show(fileActivity, R.string.file_list_no_app_for_file_type)); + if (result.getCode() == RemoteOperationResult.ResultCode.SYNC_CONFLICT) { + startConflictsResolveActivity(file, user); + return; + } - return; - } + if (!result.isSuccess()) { + showFileNotSyncedMessage(); + } - if (!result.isSuccess()) { - fileActivity.runOnUiThread(() -> SnackbarUtil.show(fileActivity, R.string.file_not_synced)); + startOpenFileIntent(openFileWithIntent); + } - // Sleep to show snackbar message - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - Log_OC.e(TAG, "Failed to sleep"); - } - } + private boolean isDownloadedAndUpToDate(@Nullable OCFile storedFile) { + return storedFile != null && storedFile.isDown() && !OCFileExtensionsKt.isLocalETagOutdated(storedFile); + } - fileActivity.runOnUiThread(() -> { - try { - openFileWithIntent.setFlags(openFileWithIntent.getFlags() | Intent.FLAG_ACTIVITY_NEW_TASK); - fileActivity.startActivity(openFileWithIntent); - } catch (ActivityNotFoundException exception) { - SnackbarUtil.show(fileActivity, R.string.file_list_no_app_for_file_type); - } - }); - }).start(); + private void startConflictsResolveActivity(OCFile file, User user) { + // ISSUE 5: if the user is not running the app (this is a service!), + // this can be very intrusive; a notification should be preferred + Intent intent = ConflictsResolveActivity.createIntent(file, + user, + -1, + Intent.FLAG_ACTIVITY_NEW_TASK, + fileActivity); + fileActivity.startActivity(intent); + } + + private void showFileNotSyncedMessage() { + fileActivity.runOnUiThread(() -> SnackbarUtil.show(fileActivity, R.string.file_not_synced)); + + // Sleep to show snackbar message + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Log_OC.e(TAG, "Failed to sleep"); + } + } + + private void startOpenFileIntent(Intent openFileWithIntent) { + fileActivity.runOnUiThread(() -> { + try { + openFileWithIntent.setFlags(openFileWithIntent.getFlags() | Intent.FLAG_ACTIVITY_NEW_TASK); + fileActivity.startActivity(openFileWithIntent); + } catch (ActivityNotFoundException exception) { + SnackbarUtil.show(fileActivity, R.string.file_list_no_app_for_file_type); + } + }); } public void openFileAsRichDocument(OCFile file, Context context) {