From f60a081c2ac2bdf35490b939589d208e07fb427d Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sun, 26 Apr 2026 15:03:53 +0200 Subject: [PATCH 1/2] Add offline access and sync support --- .gitignore | 3 + gradle/libs.versions.toml | 2 + opencloudApp/build.gradle | 1 + opencloudApp/src/main/AndroidManifest.xml | 18 +- .../main/java/eu/opencloud/android/MainApp.kt | 8 - .../download/DownloadProgressDataStore.kt | 175 ++++++ .../dependecyinjection/CommonModule.kt | 2 + .../RemoteDataSourceModule.kt | 2 +- .../dependecyinjection/UseCaseModule.kt | 2 - .../dependecyinjection/ViewModelModule.kt | 2 - .../android/extensions/ActivityExt.kt | 11 +- .../conflicts/ConflictsResolveActivity.kt | 106 ---- .../ConflictsResolveDialogFragment.kt | 92 --- .../conflicts/ConflictsResolveViewModel.kt | 92 --- .../DocumentsStorageProvider.kt | 24 +- .../files/details/FileDetailsFragment.kt | 8 +- .../migration/MigrationViewModel.kt | 2 +- .../migration/StorageMigrationActivity.kt | 2 +- .../SettingsPictureUploadsFragment.kt | 6 +- .../SettingsVideoUploadsFragment.kt | 6 +- .../security/SettingsSecurityFragment.kt | 148 ++++- .../security/SettingsSecurityViewModel.kt | 21 + .../thumbnails/ThumbnailsRequester.kt | 30 +- .../android/providers/WorkManagerProvider.kt | 64 ++ .../ui/activity/FileDisplayActivity.kt | 31 +- .../CopyAndUploadContentUrisTask.java | 7 +- .../ui/helpers/FileOperationsHelper.java | 2 +- .../synchronization/SynchronizeFileUseCase.kt | 239 +++++-- .../uploads/UploadFileInConflictUseCase.kt | 27 +- .../android/utils/NotificationConstants.kt | 1 - .../android/utils/NotificationUtils.kt | 40 -- .../android/utils/StorageMigrationHelper.kt | 63 ++ .../workers/DownloadEverythingWorker.kt | 587 ++++++++++++++++++ .../android/workers/DownloadFileWorker.kt | 136 +++- .../android/workers/LocalFileSyncWorker.kt | 233 +++++++ .../workers/UploadFileFromContentUriWorker.kt | 11 +- .../workers/UploadFileFromFileSystemWorker.kt | 32 +- .../res/layout/activity_conflicts_resolve.xml | 31 - opencloudApp/src/main/res/values/setup.xml | 2 +- opencloudApp/src/main/res/values/strings.xml | 21 + .../src/main/res/xml/settings_security.xml | 28 + .../files/DownloadRemoteFileOperation.kt | 21 +- .../resources/users/services/UserService.kt | 2 +- .../services/implementation/OCUserService.kt | 2 +- opencloudData/build.gradle | 1 + .../implementation/OCRemoteFileDataSource.kt | 6 +- .../data/providers/LocalStorageProvider.kt | 52 +- .../data/providers/ScopedStorageProvider.kt | 88 ++- .../implementation/OCRemoteUserDataSource.kt | 3 +- .../providers/ScopedStorageProviderTest.kt | 102 +-- .../OCRemoteUserDataSourceTest.kt | 7 +- .../files/usecases/SaveConflictUseCase.kt | 36 -- 52 files changed, 2002 insertions(+), 636 deletions(-) create mode 100644 opencloudApp/src/main/java/eu/opencloud/android/data/download/DownloadProgressDataStore.kt delete mode 100644 opencloudApp/src/main/java/eu/opencloud/android/presentation/conflicts/ConflictsResolveActivity.kt delete mode 100644 opencloudApp/src/main/java/eu/opencloud/android/presentation/conflicts/ConflictsResolveDialogFragment.kt delete mode 100644 opencloudApp/src/main/java/eu/opencloud/android/presentation/conflicts/ConflictsResolveViewModel.kt create mode 100644 opencloudApp/src/main/java/eu/opencloud/android/utils/StorageMigrationHelper.kt create mode 100644 opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt create mode 100644 opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt delete mode 100644 opencloudApp/src/main/res/layout/activity_conflicts_resolve.xml delete mode 100644 opencloudDomain/src/main/java/eu/opencloud/android/domain/files/usecases/SaveConflictUseCase.kt diff --git a/.gitignore b/.gitignore index 973377bc02..46c589a9e0 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ lint-*ml # Prevent exidental commits of build folders opencloudApp/release + +# Log files +logcat.txt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 079d7d550a..8a9e7072e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ androidxTest = "1.6.1" androidxTestExt = "1.2.1" androidxTestMonitor = "1.7.2" androidxTestUiAutomator ="2.3.0" +androidxDataStore = "1.0.0" androidxWork = "2.8.1" coil = "2.2.2" detekt = "1.23.3" @@ -56,6 +57,7 @@ androidx-biometric = { group = "androidx.biometric", name = "biometric", version androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidxContraintLayout" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDataStore" } androidx-enterprise-feedback = { group = "androidx.enterprise", name = "enterprise-feedback", version.ref = "androidxEnterpriseFeedback" } androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidxFragment" } androidx-fragment-testing = { group = "androidx.fragment", name = "fragment-testing", version.ref = "androidxFragment" } diff --git a/opencloudApp/build.gradle b/opencloudApp/build.gradle index 235313c491..2814c32530 100644 --- a/opencloudApp/build.gradle +++ b/opencloudApp/build.gradle @@ -22,6 +22,7 @@ dependencies { implementation libs.androidx.biometric implementation libs.androidx.constraintlayout implementation libs.androidx.core.ktx + implementation libs.androidx.datastore.preferences implementation libs.androidx.fragment.ktx implementation libs.androidx.legacy.support implementation libs.androidx.lifecycle.common.java8 diff --git a/opencloudApp/src/main/AndroidManifest.xml b/opencloudApp/src/main/AndroidManifest.xml index 9e456753a0..a022d6bbcd 100644 --- a/opencloudApp/src/main/AndroidManifest.xml +++ b/opencloudApp/src/main/AndroidManifest.xml @@ -45,6 +45,22 @@ + + + + + + + + + + + + + + + + - . + */ + +package eu.opencloud.android.data.download + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import timber.log.Timber + +/** + * Data class representing the current progress of the [DownloadEverythingWorker] scan. + */ +data class DownloadProgress( + val accountIndex: Int = 0, + val spaceIndex: Int = 0, + val processedFolderIds: Set = emptySet(), + val totalFilesFound: Int = 0, + val filesDownloaded: Int = 0, + val filesAlreadyLocal: Int = 0, + val filesSkipped: Int = 0, + val foldersProcessed: Int = 0, + val isRunning: Boolean = false, + val lastUpdateTimestamp: Long = 0 +) + +/** + * DataStore-backed persistence for [DownloadEverythingWorker] progress. + * + * Stores the scan state so that the worker can resume from where it left off + * instead of restarting from scratch on every interruption (Doze, app killed, + * screen lock, network drop, etc.). + * + * Progress older than [MAX_AGE_MS] is considered stale and will be discarded. + */ +class DownloadProgressDataStore(private val context: Context) { + + private val Context.dataStore: DataStore by preferencesDataStore(name = PREFERENCES_NAME) + + private val dataStore = context.dataStore + + /** + * Persists the current progress to DataStore. + */ + suspend fun saveProgress(progress: DownloadProgress) { + try { + dataStore.edit { prefs -> + prefs[KEY_ACCOUNT_INDEX] = progress.accountIndex + prefs[KEY_SPACE_INDEX] = progress.spaceIndex + prefs[KEY_PROCESSED_FOLDER_IDS] = progress.processedFolderIds.joinToString(SEPARATOR) + prefs[KEY_TOTAL_FILES_FOUND] = progress.totalFilesFound + prefs[KEY_FILES_DOWNLOADED] = progress.filesDownloaded + prefs[KEY_FILES_ALREADY_LOCAL] = progress.filesAlreadyLocal + prefs[KEY_FILES_SKIPPED] = progress.filesSkipped + prefs[KEY_FOLDERS_PROCESSED] = progress.foldersProcessed + prefs[KEY_IS_RUNNING] = progress.isRunning + prefs[KEY_LAST_UPDATE_TIMESTAMP] = System.currentTimeMillis() + } + } catch (e: Exception) { + Timber.e(e, "Failed to save download progress") + } + } + + /** + * Loads the last saved progress if it exists and is still valid. + * + * Returns `null` when: + * - No progress was ever saved + * - The saved progress is not marked as running + * - The saved progress is older than [MAX_AGE_MS] + */ + suspend fun loadProgress(): DownloadProgress? { + return try { + val prefs = dataStore.data.map { it }.firstOrNull() ?: return null + + val isRunning = prefs[KEY_IS_RUNNING] ?: false + if (!isRunning) return null + + val timestamp = prefs[KEY_LAST_UPDATE_TIMESTAMP] ?: 0L + val age = System.currentTimeMillis() - timestamp + if (age > MAX_AGE_MS) { + Timber.w("Download progress is ${age / 1000}s old (> ${MAX_AGE_MS / 1000}s), discarding") + clearProgress() + return null + } + + val folderIdsString = prefs[KEY_PROCESSED_FOLDER_IDS] ?: "" + val processedFolderIds = if (folderIdsString.isNotBlank()) { + folderIdsString.split(SEPARATOR).mapNotNull { it.toLongOrNull() }.toSet() + } else { + emptySet() + } + + DownloadProgress( + accountIndex = prefs[KEY_ACCOUNT_INDEX] ?: 0, + spaceIndex = prefs[KEY_SPACE_INDEX] ?: 0, + processedFolderIds = processedFolderIds, + totalFilesFound = prefs[KEY_TOTAL_FILES_FOUND] ?: 0, + filesDownloaded = prefs[KEY_FILES_DOWNLOADED] ?: 0, + filesAlreadyLocal = prefs[KEY_FILES_ALREADY_LOCAL] ?: 0, + filesSkipped = prefs[KEY_FILES_SKIPPED] ?: 0, + foldersProcessed = prefs[KEY_FOLDERS_PROCESSED] ?: 0, + isRunning = true, + lastUpdateTimestamp = timestamp + ) + } catch (e: Exception) { + Timber.e(e, "Failed to load download progress") + null + } + } + + /** + * Clears all saved progress. Call this when the scan completes successfully. + */ + suspend fun clearProgress() { + try { + dataStore.edit { prefs -> + prefs.remove(KEY_ACCOUNT_INDEX) + prefs.remove(KEY_SPACE_INDEX) + prefs.remove(KEY_PROCESSED_FOLDER_IDS) + prefs.remove(KEY_TOTAL_FILES_FOUND) + prefs.remove(KEY_FILES_DOWNLOADED) + prefs.remove(KEY_FILES_ALREADY_LOCAL) + prefs.remove(KEY_FILES_SKIPPED) + prefs.remove(KEY_FOLDERS_PROCESSED) + prefs.remove(KEY_IS_RUNNING) + prefs.remove(KEY_LAST_UPDATE_TIMESTAMP) + } + } catch (e: Exception) { + Timber.e(e, "Failed to clear download progress") + } + } + + companion object { + private const val PREFERENCES_NAME = "download_progress" + private const val SEPARATOR = "," + private const val MAX_AGE_MS = 24L * 60L * 60L * 1000L // 24 hours + + private val KEY_ACCOUNT_INDEX = intPreferencesKey("account_index") + private val KEY_SPACE_INDEX = intPreferencesKey("space_index") + private val KEY_PROCESSED_FOLDER_IDS = stringPreferencesKey("processed_folder_ids") + private val KEY_TOTAL_FILES_FOUND = intPreferencesKey("total_files_found") + private val KEY_FILES_DOWNLOADED = intPreferencesKey("files_downloaded") + private val KEY_FILES_ALREADY_LOCAL = intPreferencesKey("files_already_local") + private val KEY_FILES_SKIPPED = intPreferencesKey("files_skipped") + private val KEY_FOLDERS_PROCESSED = intPreferencesKey("folders_processed") + private val KEY_IS_RUNNING = booleanPreferencesKey("is_running") + private val KEY_LAST_UPDATE_TIMESTAMP = longPreferencesKey("last_update_timestamp") + } +} diff --git a/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/CommonModule.kt b/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/CommonModule.kt index 04c978ea38..f0ad0df6a7 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/CommonModule.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/CommonModule.kt @@ -22,6 +22,7 @@ package eu.opencloud.android.dependecyinjection import androidx.work.WorkManager +import eu.opencloud.android.data.download.DownloadProgressDataStore import eu.opencloud.android.providers.AccountProvider import eu.opencloud.android.providers.ContextProvider import eu.opencloud.android.providers.CoroutinesDispatcherProvider @@ -43,4 +44,5 @@ val commonModule = module { single { WorkManagerProvider(androidContext()) } single { AccountProvider(androidContext()) } single { WorkManager.getInstance(androidApplication()) } + single { DownloadProgressDataStore(androidContext()) } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/RemoteDataSourceModule.kt b/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/RemoteDataSourceModule.kt index 322db8ba13..1c0b8a25bd 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/RemoteDataSourceModule.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/RemoteDataSourceModule.kt @@ -78,7 +78,7 @@ val remoteDataSourceModule = module { singleOf(::OCRemoteShareeDataSource) bind RemoteShareeDataSource::class singleOf(::OCRemoteSpacesDataSource) bind RemoteSpacesDataSource::class singleOf(::OCRemoteWebFingerDataSource) bind RemoteWebFingerDataSource::class - single { OCRemoteUserDataSource(get(), androidContext().resources.getDimension(R.dimen.file_avatar_size).toInt()) } + singleOf(::OCRemoteUserDataSource) bind RemoteUserDataSource::class factoryOf(::RemoteCapabilityMapper) factoryOf(::RemoteShareMapper) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/UseCaseModule.kt b/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/UseCaseModule.kt index 9b61b812ab..e988c96831 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/UseCaseModule.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/UseCaseModule.kt @@ -72,7 +72,6 @@ import eu.opencloud.android.domain.files.usecases.ManageDeepLinkUseCase import eu.opencloud.android.domain.files.usecases.MoveFileUseCase import eu.opencloud.android.domain.files.usecases.RemoveFileUseCase import eu.opencloud.android.domain.files.usecases.RenameFileUseCase -import eu.opencloud.android.domain.files.usecases.SaveConflictUseCase import eu.opencloud.android.domain.files.usecases.SaveDownloadWorkerUUIDUseCase import eu.opencloud.android.domain.files.usecases.SaveFileOrFolderUseCase import eu.opencloud.android.domain.files.usecases.SetLastUsageFileUseCase @@ -184,7 +183,6 @@ val useCaseModule = module { factoryOf(::RemoveLocalFilesForAccountUseCase) factoryOf(::RemoveLocallyFilesWithLastUsageOlderThanGivenTimeUseCase) factoryOf(::RenameFileUseCase) - factoryOf(::SaveConflictUseCase) factoryOf(::SaveDownloadWorkerUUIDUseCase) factoryOf(::SaveFileOrFolderUseCase) factoryOf(::SetLastUsageFileUseCase) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/ViewModelModule.kt b/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/ViewModelModule.kt index bc63f097c6..cb9c82a37c 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/ViewModelModule.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/ViewModelModule.kt @@ -31,7 +31,6 @@ import eu.opencloud.android.presentation.authentication.AuthenticationViewModel import eu.opencloud.android.presentation.authentication.oauth.OAuthViewModel import eu.opencloud.android.presentation.capabilities.CapabilityViewModel import eu.opencloud.android.presentation.common.DrawerViewModel -import eu.opencloud.android.presentation.conflicts.ConflictsResolveViewModel import eu.opencloud.android.presentation.files.details.FileDetailsViewModel import eu.opencloud.android.presentation.files.filelist.MainFileListViewModel import eu.opencloud.android.presentation.files.operations.FileOperationsViewModel @@ -94,7 +93,6 @@ val viewModelModule = module { MainFileListViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), initialFolderToDisplay, fileListOption) } - viewModel { (ocFile: OCFile) -> ConflictsResolveViewModel(get(), get(), get(), get(), get(), ocFile) } viewModel { AuthenticationViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { MigrationViewModel(MainApp.dataFolder, get(), get(), get(), get(), get(), get(), get()) } viewModel { TransfersViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), diff --git a/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt b/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt index cb94fd7777..7659c17bca 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt @@ -163,8 +163,8 @@ private fun getIntentForSavedMimeType(data: Uri, type: String): Intent { private fun getIntentForGuessedMimeType(storagePath: String, type: String, data: Uri): Intent? { var intentForGuessedMimeType: Intent? = null if (storagePath.lastIndexOf('.') >= 0) { - val guessedMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(storagePath.substring(storagePath.lastIndexOf('.') + 1)) - if (guessedMimeType != null && guessedMimeType != type) { + val guessedMimeType = MimetypeIconUtil.getBestMimeTypeByFilename(storagePath) + if (guessedMimeType != null && guessedMimeType != type && guessedMimeType != "application/octet-stream") { intentForGuessedMimeType = Intent(Intent.ACTION_VIEW) intentForGuessedMimeType.setDataAndType(data, guessedMimeType) intentForGuessedMimeType.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION @@ -456,8 +456,13 @@ fun FragmentActivity.sendDownloadedFilesByShareSheet(ocFiles: List) { } fun Activity.openOCFile(ocFile: OCFile) { + var finalMimeType = MimetypeIconUtil.getBestMimeTypeByFilename(ocFile.fileName) + if (finalMimeType.isNullOrEmpty() || finalMimeType == "application/octet-stream") { + finalMimeType = ocFile.mimeType + } + val intentForSavedMimeType = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(getExposedFileUriForOCFile(this@openOCFile, ocFile), ocFile.mimeType) + setDataAndType(getExposedFileUriForOCFile(this@openOCFile, ocFile), finalMimeType) flags = Intent.FLAG_GRANT_READ_URI_PERMISSION if (ocFile.hasWritePermission) { flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/conflicts/ConflictsResolveActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/conflicts/ConflictsResolveActivity.kt deleted file mode 100644 index 16bfeb4251..0000000000 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/conflicts/ConflictsResolveActivity.kt +++ /dev/null @@ -1,106 +0,0 @@ -/** - * openCloud Android client application - * - * @author Bartek Przybylski - * @author David A. Velasco - * @author Juan Carlos Garrote Gascón - * - * Copyright (C) 2012 Bartek Przybylski - * Copyright (C) 2022 ownCloud GmbH. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 2, - * as published by the Free Software Foundation. - * - * 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 eu.opencloud.android.presentation.conflicts - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.updatePadding -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import eu.opencloud.android.databinding.ActivityConflictsResolveBinding -import eu.opencloud.android.ui.activity.enableEdgeToEdgePostSetContentView -import eu.opencloud.android.ui.activity.enableEdgeToEdgePreSetContentView -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf -import timber.log.Timber - -class ConflictsResolveActivity : AppCompatActivity(), ConflictsResolveDialogFragment.OnConflictDecisionMadeListener { - - private var _binding: ActivityConflictsResolveBinding? = null - val binding get() = _binding!! - - private val conflictsResolveViewModel by viewModel { - parametersOf( - intent.getParcelableExtra( - EXTRA_FILE - ) - ) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // edge-to-edge - enableEdgeToEdgePreSetContentView(true) - - _binding = ActivityConflictsResolveBinding.inflate(layoutInflater) - setContentView(binding.root) - setSupportActionBar(binding.toolbar) - - // edge-to-edge - enableEdgeToEdgePostSetContentView { insets -> - binding.toolbar.updatePadding(top = insets.top) - binding.root.updatePadding(bottom = insets.bottom) - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - conflictsResolveViewModel.currentFile.collectLatest { updatedOCFile -> - Timber.d("File ${updatedOCFile?.remotePath} from ${updatedOCFile?.owner} needs to fix a conflict with etag" + - " in conflict ${updatedOCFile?.etagInConflict}") - // Finish if the file does not exists or if the file is not in conflict anymore. - updatedOCFile?.etagInConflict ?: finish() - } - } - } - - ConflictsResolveDialogFragment.newInstance(onConflictDecisionMadeListener = this).showDialog(this) - } - - override fun conflictDecisionMade(decision: ConflictsResolveDialogFragment.Decision) { - when (decision) { - ConflictsResolveDialogFragment.Decision.CANCEL -> {} - ConflictsResolveDialogFragment.Decision.KEEP_LOCAL -> { - conflictsResolveViewModel.uploadFileInConflict() - } - ConflictsResolveDialogFragment.Decision.KEEP_BOTH -> { - conflictsResolveViewModel.uploadFileFromSystem() - } - ConflictsResolveDialogFragment.Decision.KEEP_SERVER -> { - conflictsResolveViewModel.downloadFile() - } - } - - Timber.d("Decision to fix conflict on file ${conflictsResolveViewModel.currentFile.value?.remotePath} is ${decision.name}") - - finish() - } - - companion object { - const val EXTRA_FILE = "EXTRA_FILE" - } -} diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/conflicts/ConflictsResolveDialogFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/conflicts/ConflictsResolveDialogFragment.kt deleted file mode 100644 index 4b85e85380..0000000000 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/conflicts/ConflictsResolveDialogFragment.kt +++ /dev/null @@ -1,92 +0,0 @@ -/** - * openCloud Android client application - * - * @author Bartek Przybylski - * @author Christian Schabesberger - * @author Juan Carlos Garrote Gascón - * - * Copyright (C) 2012 Bartek Przybylski - * Copyright (C) 2022 ownCloud GmbH. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 2, - * as published by the Free Software Foundation. - * - * 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 eu.opencloud.android.presentation.conflicts - -import android.app.AlertDialog -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.DialogFragment -import eu.opencloud.android.R -import eu.opencloud.android.extensions.avoidScreenshotsIfNeeded - -class ConflictsResolveDialogFragment : DialogFragment() { - - private lateinit var listener: OnConflictDecisionMadeListener - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = AlertDialog.Builder(requireActivity()) - .setIcon(R.drawable.ic_warning) - .setTitle(R.string.conflict_title) - .setMessage(R.string.conflict_message) - .setPositiveButton(R.string.conflict_use_local_version) { _, _ -> - listener.conflictDecisionMade(Decision.KEEP_LOCAL) - } - .setNeutralButton(R.string.conflict_keep_both) { _, _ -> - listener.conflictDecisionMade(Decision.KEEP_BOTH) - } - .setNegativeButton(R.string.conflict_use_server_version) { _, _ -> - listener.conflictDecisionMade(Decision.KEEP_SERVER) - } - .create() - - dialog.avoidScreenshotsIfNeeded() - - return dialog - } - - override fun onCancel(dialog: DialogInterface) { - listener.conflictDecisionMade(Decision.CANCEL) - } - - fun showDialog(activity: AppCompatActivity) { - val previousFragment = activity.supportFragmentManager.findFragmentByTag("dialog") - val fragmentTransaction = activity.supportFragmentManager.beginTransaction() - if (previousFragment != null) { - fragmentTransaction.remove(previousFragment) - } - fragmentTransaction.addToBackStack(null) - - this.show(fragmentTransaction, "dialog") - } - - interface OnConflictDecisionMadeListener { - fun conflictDecisionMade(decision: Decision) - } - - enum class Decision { - CANCEL, - KEEP_BOTH, - KEEP_LOCAL, - KEEP_SERVER - } - - companion object { - fun newInstance(onConflictDecisionMadeListener: OnConflictDecisionMadeListener): ConflictsResolveDialogFragment = - ConflictsResolveDialogFragment().apply { - listener = onConflictDecisionMadeListener - } - } -} diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/conflicts/ConflictsResolveViewModel.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/conflicts/ConflictsResolveViewModel.kt deleted file mode 100644 index 05b614480e..0000000000 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/conflicts/ConflictsResolveViewModel.kt +++ /dev/null @@ -1,92 +0,0 @@ -/** - * openCloud Android client application - * - * @author Juan Carlos Garrote Gascón - * - * Copyright (C) 2023 ownCloud GmbH. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 2, - * as published by the Free Software Foundation. - * - * 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 eu.opencloud.android.presentation.conflicts - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import eu.opencloud.android.domain.files.model.OCFile -import eu.opencloud.android.domain.files.usecases.GetFileByIdAsStreamUseCase -import eu.opencloud.android.providers.CoroutinesDispatcherProvider -import eu.opencloud.android.usecases.transfers.downloads.DownloadFileUseCase -import eu.opencloud.android.usecases.transfers.uploads.UploadFileInConflictUseCase -import eu.opencloud.android.usecases.transfers.uploads.UploadFilesFromSystemUseCase -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -class ConflictsResolveViewModel( - private val downloadFileUseCase: DownloadFileUseCase, - private val uploadFileInConflictUseCase: UploadFileInConflictUseCase, - private val uploadFilesFromSystemUseCase: UploadFilesFromSystemUseCase, - getFileByIdAsStreamUseCase: GetFileByIdAsStreamUseCase, - private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider, - ocFile: OCFile, -) : ViewModel() { - - val currentFile: StateFlow = - getFileByIdAsStreamUseCase(GetFileByIdAsStreamUseCase.Params(ocFile.id!!)) - .stateIn( - viewModelScope, - started = SharingStarted.WhileSubscribed(), - initialValue = ocFile - ) - - fun downloadFile() { - val fileToDownload = currentFile.value ?: return - viewModelScope.launch(coroutinesDispatcherProvider.io) { - downloadFileUseCase( - DownloadFileUseCase.Params( - accountName = fileToDownload.owner, - file = fileToDownload - ) - ) - } - } - - fun uploadFileInConflict() { - val fileToUpload = currentFile.value ?: return - viewModelScope.launch(coroutinesDispatcherProvider.io) { - uploadFileInConflictUseCase( - UploadFileInConflictUseCase.Params( - accountName = fileToUpload.owner, - localPath = fileToUpload.storagePath!!, - uploadFolderPath = fileToUpload.getParentRemotePath(), - spaceId = fileToUpload.spaceId, - ) - ) - } - } - - fun uploadFileFromSystem() { - val fileToUpload = currentFile.value ?: return - viewModelScope.launch(coroutinesDispatcherProvider.io) { - uploadFilesFromSystemUseCase( - UploadFilesFromSystemUseCase.Params( - accountName = fileToUpload.owner, - listOfLocalPaths = listOf(fileToUpload.storagePath!!), - uploadFolderPath = fileToUpload.getParentRemotePath(), - spaceId = fileToUpload.spaceId, - ) - ) - } - } -} diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt index 13877b9638..3e5c4fdd9a 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt @@ -65,8 +65,6 @@ import eu.opencloud.android.usecases.synchronization.SynchronizeFileUseCase import eu.opencloud.android.usecases.transfers.downloads.DownloadFileUseCase import eu.opencloud.android.usecases.synchronization.SynchronizeFolderUseCase import eu.opencloud.android.usecases.transfers.uploads.UploadFilesFromSystemUseCase -import eu.opencloud.android.utils.FileStorageUtils -import eu.opencloud.android.utils.NotificationUtils import androidx.work.WorkInfo import androidx.work.WorkManager import kotlinx.coroutines.CoroutineScope @@ -168,11 +166,10 @@ class DocumentsStorageProvider : DocumentsProvider() { return null } } - is SynchronizeFileUseCase.SyncType.ConflictDetected -> { - // File changed both locally and remotely. Notify the user and - // serve the local version (same behavior as before). - context?.let { - NotificationUtils.notifyConflict(fileInConflict = ocFile, context = it) + is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy -> { + // Conflict resolved by keeping a local copy and downloading the remote version. + if (!waitForDownload(syncResult.workerId, documentId.toInt(), signal)) { + return null } } is SynchronizeFileUseCase.SyncType.FileNotFound -> { @@ -532,7 +529,11 @@ class DocumentsStorageProvider : DocumentsProvider() { ): String { // We just need to return a Document ID, so we'll return an empty one. File does not exist in our db yet. // File will be created at [openDocument] method. - val tempDir = File(FileStorageUtils.getTemporalPath(parentDocument.owner, parentDocument.spaceId)) + val cacheBase = context?.externalCacheDir ?: context?.cacheDir + val baseTmpDir = File(cacheBase, "upload_tmp") + val accountSanitized = Uri.encode(parentDocument.owner, "@") + val accountDir = File(baseTmpDir, accountSanitized) + val tempDir = if (parentDocument.spaceId != null) File(accountDir, parentDocument.spaceId!!) else accountDir val newFile = File(tempDir, displayName) newFile.parentFile?.mkdirs() fileToUpload = OCFile( @@ -656,10 +657,9 @@ class DocumentsStorageProvider : DocumentsProvider() { ) Timber.d("${fileToSync.remotePath} from ${fileToSync.owner} synced with result: $useCaseResult") - if (useCaseResult.getDataOrNull() is SynchronizeFileUseCase.SyncType.ConflictDetected) { - context?.let { - NotificationUtils.notifyConflict(fileInConflict = fileToSync, context = it) - } + val syncResult = useCaseResult.getDataOrNull() + if (syncResult is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy) { + Timber.i("File sync conflict auto-resolved. Conflicted copy at: ${syncResult.conflictedCopyPath}") } } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt index 85c7fd91f9..60ea1c4b8d 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt @@ -63,7 +63,7 @@ import eu.opencloud.android.presentation.authentication.EXTRA_ACCOUNT import eu.opencloud.android.presentation.authentication.EXTRA_ACTION import eu.opencloud.android.presentation.authentication.LoginActivity import eu.opencloud.android.presentation.common.UIResult -import eu.opencloud.android.presentation.conflicts.ConflictsResolveActivity + import eu.opencloud.android.presentation.files.details.FileDetailsViewModel.ActionsInDetailsView.NONE import eu.opencloud.android.presentation.files.details.FileDetailsViewModel.ActionsInDetailsView.SYNC import eu.opencloud.android.presentation.files.details.FileDetailsViewModel.ActionsInDetailsView.SYNC_AND_OPEN @@ -192,10 +192,8 @@ class FileDetailsFragment : FileFragment() { SynchronizeFileUseCase.SyncType.AlreadySynchronized -> { showMessageInSnackbar(getString(R.string.sync_file_nothing_to_do_msg)) } - is SynchronizeFileUseCase.SyncType.ConflictDetected -> { - val showConflictActivityIntent = Intent(requireActivity(), ConflictsResolveActivity::class.java) - showConflictActivityIntent.putExtra(ConflictsResolveActivity.EXTRA_FILE, file) - startActivity(showConflictActivityIntent) + is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy -> { + showMessageInSnackbar(getString(R.string.sync_conflict_resolved_with_copy)) } is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> { diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/migration/MigrationViewModel.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/migration/MigrationViewModel.kt index 089fd3175c..781a26b311 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/migration/MigrationViewModel.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/migration/MigrationViewModel.kt @@ -58,7 +58,7 @@ class MigrationViewModel( private val _migrationState = MediatorLiveData>() val migrationState: LiveData> = _migrationState - private val legacyStorageDirectoryPath = LegacyStorageProvider(rootFolder).getRootFolderPath() + private val legacyStorageDirectoryPath = LegacyStorageProvider(rootFolder.lowercase()).getRootFolderPath() init { _migrationState.postValue(Event(MigrationState.MigrationIntroState)) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/migration/StorageMigrationActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/migration/StorageMigrationActivity.kt index 52538c0784..e2e22a0a22 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/migration/StorageMigrationActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/migration/StorageMigrationActivity.kt @@ -86,7 +86,7 @@ class StorageMigrationActivity : AppCompatActivity() { companion object { - private val legacyStorageFolder = File(LegacyStorageProvider(MainApp.dataFolder).getRootFolderPath()) + private val legacyStorageFolder = File(LegacyStorageProvider(MainApp.dataFolder.lowercase()).getRootFolderPath()) const val PREFERENCE_ALREADY_MIGRATED_TO_SCOPED_STORAGE = "MIGRATED_TO_SCOPED_STORAGE" diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/automaticuploads/SettingsPictureUploadsFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/automaticuploads/SettingsPictureUploadsFragment.kt index e1b8a7d17a..c2cdf6a08a 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/automaticuploads/SettingsPictureUploadsFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/automaticuploads/SettingsPictureUploadsFragment.kt @@ -183,8 +183,12 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() { positiveButtonText = getString(R.string.common_yes), positiveButtonListener = { _: DialogInterface?, _: Int -> picturesViewModel.disablePictureUploads() + prefEnablePictureUploads?.isChecked = false }, - negativeButtonText = getString(R.string.common_no) + negativeButtonText = getString(R.string.common_no), + negativeButtonListener = { _, _ -> + prefEnablePictureUploads?.isChecked = true + } ) false } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/automaticuploads/SettingsVideoUploadsFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/automaticuploads/SettingsVideoUploadsFragment.kt index 98b6dc0cfd..e2529819b3 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/automaticuploads/SettingsVideoUploadsFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/automaticuploads/SettingsVideoUploadsFragment.kt @@ -181,8 +181,12 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() { positiveButtonText = getString(R.string.common_yes), positiveButtonListener = { _: DialogInterface?, _: Int -> videosViewModel.disableVideoUploads() + prefEnableVideoUploads?.isChecked = false }, - negativeButtonText = getString(R.string.common_no) + negativeButtonText = getString(R.string.common_no), + negativeButtonListener = { _, _ -> + prefEnableVideoUploads?.isChecked = true + } ) false } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt index 5f28b8f7b2..5246b81327 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt @@ -42,12 +42,22 @@ import eu.opencloud.android.presentation.security.biometric.BiometricManager import eu.opencloud.android.presentation.security.passcode.PassCodeActivity import eu.opencloud.android.presentation.security.pattern.PatternActivity import eu.opencloud.android.presentation.settings.SettingsFragment.Companion.removePreferenceFromScreen +import eu.opencloud.android.providers.WorkManagerProvider +import eu.opencloud.android.data.providers.LocalStorageProvider +import eu.opencloud.android.domain.files.FileRepository +import eu.opencloud.android.utils.StorageMigrationHelper +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel class SettingsSecurityFragment : PreferenceFragmentCompat() { // ViewModel private val securityViewModel by viewModel() + private val workManagerProvider: WorkManagerProvider by inject() + private val localStorageProvider: LocalStorageProvider by inject() + private val fileRepository: FileRepository by inject() private var screenSecurity: PreferenceScreen? = null private var prefPasscode: CheckBoxPreference? = null @@ -56,6 +66,10 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { private var prefLockApplication: ListPreference? = null private var prefLockAccessDocumentProvider: CheckBoxPreference? = null private var prefTouchesWithOtherVisibleWindows: CheckBoxPreference? = null + private var prefDownloadEverything: CheckBoxPreference? = null + private var prefAutoSync: CheckBoxPreference? = null + private var prefPreferLocalOnConflict: CheckBoxPreference? = null + private var prefFileManagerAccess: CheckBoxPreference? = null private val enablePasscodeLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> @@ -111,6 +125,16 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.settings_security, rootKey) + initializePreferences(rootKey) + configureLockPreferences() + configureBiometricPreference() + configureSecurityPreferences() + configureDownloadAndSyncPreferences() + } + + + @Suppress("UnusedParameter") + private fun initializePreferences(rootKey: String?) { screenSecurity = findPreference(SCREEN_SECURITY) prefPasscode = findPreference(PassCodeActivity.PREFERENCE_SET_PASSCODE) prefPattern = findPreference(PatternActivity.PREFERENCE_SET_PATTERN) @@ -132,10 +156,16 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { } prefLockAccessDocumentProvider = findPreference(PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER) prefTouchesWithOtherVisibleWindows = findPreference(PREFERENCE_TOUCHES_WITH_OTHER_VISIBLE_WINDOWS) + prefDownloadEverything = findPreference(PREFERENCE_DOWNLOAD_EVERYTHING) + prefAutoSync = findPreference(PREFERENCE_AUTO_SYNC) + prefPreferLocalOnConflict = findPreference(PREFERENCE_PREFER_LOCAL_ON_CONFLICT) + prefFileManagerAccess = findPreference(PREFERENCE_ENABLE_FILE_MANAGER_ACCESS) prefPasscode?.isVisible = !securityViewModel.isSecurityEnforcedEnabled() prefPattern?.isVisible = !securityViewModel.isSecurityEnforcedEnabled() + } + private fun configureLockPreferences() { // Passcode lock prefPasscode?.setOnPreferenceChangeListener { _: Preference?, newValue: Any -> if (securityViewModel.isPatternSet()) { @@ -169,8 +199,9 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { } false } + } - // Biometric lock + private fun configureBiometricPreference() { if (prefBiometric != null) { if (!BiometricManager.isHardwareDetected()) { // Biometric not supported screenSecurity?.removePreferenceFromScreen(prefBiometric) @@ -192,8 +223,12 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { } // Lock application - if (prefPasscode?.isChecked == false && prefPattern?.isChecked == false) { prefLockApplication?.isEnabled = false } + if (prefPasscode?.isChecked == false && prefPattern?.isChecked == false) { + prefLockApplication?.isEnabled = false + } + } + private fun configureSecurityPreferences() { // Lock access from document provider prefLockAccessDocumentProvider?.setOnPreferenceChangeListener { _: Preference?, newValue: Any -> securityViewModel.setPrefLockAccessDocumentProvider(true) @@ -208,7 +243,9 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { AlertDialog.Builder(it) .setTitle(getString(R.string.confirmation_touches_with_other_windows_title)) .setMessage(getString(R.string.confirmation_touches_with_other_windows_message)) - .setNegativeButton(getString(R.string.common_no), null) + .setNegativeButton(getString(R.string.common_no)) { _, _ -> + prefTouchesWithOtherVisibleWindows?.isChecked = false + } .setPositiveButton( getString(R.string.common_yes) ) { _: DialogInterface?, _: Int -> @@ -224,6 +261,107 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { } } + private fun configureDownloadAndSyncPreferences() { + // File Manager Access (Android/media) + prefFileManagerAccess?.setOnPreferenceChangeListener { _: Preference?, newValue: Any -> + val isEnabled = newValue as Boolean + activity?.let { + val message = if (isEnabled) { + "Enabling this will move existing offline files to the visible Android/media folder. This may take a moment." + } else { + "Disabling this will move existing offline files back to the hidden internal storage. This may take a moment." + } + + AlertDialog.Builder(it) + .setTitle(getString(R.string.prefs_enable_file_manager_access)) + .setMessage(message) + .setNegativeButton(getString(R.string.common_no)) { _, _ -> + prefFileManagerAccess?.isChecked = !isEnabled + localStorageProvider.invalidateCache() + } + .setPositiveButton(getString(R.string.common_yes)) { _, _ -> + val oldPath = localStorageProvider.getRootFolderPath() + + // Update UI state + prefFileManagerAccess?.isChecked = isEnabled + localStorageProvider.invalidateCache() + + val newPath = localStorageProvider.getRootFolderPath() + + // Perform migration + lifecycleScope.launch { + StorageMigrationHelper.migrateStorageDirectory( + oldRootPath = oldPath, + newRootPath = newPath, + fileRepository = fileRepository + ) + } + } + .show() + .avoidScreenshotsIfNeeded() + } + return@setOnPreferenceChangeListener false + } + + // Download Everything Feature + prefDownloadEverything?.setOnPreferenceChangeListener { _: Preference?, newValue: Any -> + if (newValue as Boolean) { + activity?.let { + AlertDialog.Builder(it) + .setTitle(getString(R.string.download_everything_warning_title)) + .setMessage(getString(R.string.download_everything_warning_message)) + .setNegativeButton(getString(R.string.common_no)) { _, _ -> + prefDownloadEverything?.isChecked = false + } + .setPositiveButton(getString(R.string.common_yes)) { _, _ -> + securityViewModel.setDownloadEverything(true) + prefDownloadEverything?.isChecked = true + workManagerProvider.enqueueDownloadEverythingWorker() + } + .show() + .avoidScreenshotsIfNeeded() + } + return@setOnPreferenceChangeListener false + } else { + securityViewModel.setDownloadEverything(false) + workManagerProvider.cancelDownloadEverythingWorker() + true + } + } + + // Auto-Sync Feature + prefAutoSync?.setOnPreferenceChangeListener { _: Preference?, newValue: Any -> + if (newValue as Boolean) { + activity?.let { + AlertDialog.Builder(it) + .setTitle(getString(R.string.auto_sync_warning_title)) + .setMessage(getString(R.string.auto_sync_warning_message)) + .setNegativeButton(getString(R.string.common_no)) { _, _ -> + prefAutoSync?.isChecked = false + } + .setPositiveButton(getString(R.string.common_yes)) { _, _ -> + securityViewModel.setAutoSync(true) + prefAutoSync?.isChecked = true + workManagerProvider.enqueueLocalFileSyncWorker() + } + .show() + .avoidScreenshotsIfNeeded() + } + return@setOnPreferenceChangeListener false + } else { + securityViewModel.setAutoSync(false) + workManagerProvider.cancelLocalFileSyncWorker() + true + } + } + + // Conflict Resolution Strategy + prefPreferLocalOnConflict?.setOnPreferenceChangeListener { _: Preference?, newValue: Any -> + securityViewModel.setPreferLocalOnConflict(newValue as Boolean) + true + } + } + private fun enableBiometricAndLockApplication() { prefBiometric?.apply { isEnabled = true @@ -246,5 +384,9 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { const val PREFERENCE_TOUCHES_WITH_OTHER_VISIBLE_WINDOWS = "touches_with_other_visible_windows" const val EXTRAS_LOCK_ENFORCED = "EXTRAS_LOCK_ENFORCED" const val PREFERENCE_LOCK_ATTEMPTS = "PrefLockAttempts" + const val PREFERENCE_DOWNLOAD_EVERYTHING = "download_everything" + const val PREFERENCE_AUTO_SYNC = "auto_sync_local_changes" + const val PREFERENCE_PREFER_LOCAL_ON_CONFLICT = "prefer_local_on_conflict" + const val PREFERENCE_ENABLE_FILE_MANAGER_ACCESS = "enable_file_manager_access" } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityViewModel.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityViewModel.kt index 103cd4e5ca..c179eb94cc 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityViewModel.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityViewModel.kt @@ -63,4 +63,25 @@ class SettingsSecurityViewModel( integerKey = R.integer.lock_delay_enforced ) ) != LockTimeout.DISABLED + + // Download Everything Feature + fun isDownloadEverythingEnabled(): Boolean = + preferencesProvider.getBoolean(SettingsSecurityFragment.PREFERENCE_DOWNLOAD_EVERYTHING, false) + + fun setDownloadEverything(enabled: Boolean) = + preferencesProvider.putBoolean(SettingsSecurityFragment.PREFERENCE_DOWNLOAD_EVERYTHING, enabled) + + // Auto-Sync Feature + fun isAutoSyncEnabled(): Boolean = + preferencesProvider.getBoolean(SettingsSecurityFragment.PREFERENCE_AUTO_SYNC, false) + + fun setAutoSync(enabled: Boolean) = + preferencesProvider.putBoolean(SettingsSecurityFragment.PREFERENCE_AUTO_SYNC, enabled) + + // Conflict Resolution Strategy + fun isPreferLocalOnConflictEnabled(): Boolean = + preferencesProvider.getBoolean(SettingsSecurityFragment.PREFERENCE_PREFER_LOCAL_ON_CONFLICT, false) + + fun setPreferLocalOnConflict(enabled: Boolean) = + preferencesProvider.putBoolean(SettingsSecurityFragment.PREFERENCE_PREFER_LOCAL_ON_CONFLICT, enabled) } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt index af4ecc5035..300dbe41e7 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt @@ -64,6 +64,7 @@ object ThumbnailsRequester : KoinComponent { private val thumbnailImageLoaders = ConcurrentHashMap() private val avatarImageLoaders = ConcurrentHashMap() + private val accountBaseUrls = ConcurrentHashMap() private val sharedDiskCache: DiskCache by lazy { DiskCache.Builder() @@ -86,11 +87,12 @@ object ThumbnailsRequester : KoinComponent { } fun getAvatarUri(account: Account): String { - val accountManager = AccountManager.get(appContext) - val baseUrl = + val baseUrl = accountBaseUrls.getOrPut(account.name) { + val accountManager = AccountManager.get(appContext) accountManager.getUserData(account, eu.opencloud.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) ?.trimEnd('/') .orEmpty() + } // ?u= disambiguates the Coil cache key per account; without it two accounts // on the same server share the same URL and collide in the shared disk/memory cache. return "$baseUrl/graph/v1.0/me/photo/\$value?u=${account.name.hashCode().toString(16)}" @@ -106,10 +108,12 @@ object ThumbnailsRequester : KoinComponent { String.format(Locale.US, SPACE_SPECIAL_PREVIEW_URI, spaceSpecial.webDavUrl, 1024, 1024, spaceSpecial.eTag) private fun getPreviewUri(remotePath: String?, etag: String?, account: Account, width: Int, height: Int): String { - val accountManager = AccountManager.get(appContext) - val baseUrl = accountManager.getUserData(account, eu.opencloud.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) - ?.trimEnd('/') - .orEmpty() + val baseUrl = accountBaseUrls.getOrPut(account.name) { + val accountManager = AccountManager.get(appContext) + accountManager.getUserData(account, eu.opencloud.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) + ?.trimEnd('/') + .orEmpty() + } val path = if (remotePath?.startsWith("/") == true) remotePath else "/$remotePath" val encodedPath = Uri.encode(path, "/") @@ -156,7 +160,9 @@ object ThumbnailsRequester : KoinComponent { // must not run on the main thread. clientManager.getClientForCoilThumbnails(account.name) .okHttpClient.newBuilder() - .addInterceptor(interceptor).build() + .addInterceptor(interceptor) + .addNetworkInterceptor(CoilCacheResponseInterceptor()) + .build() } .apply { if (preferencesProvider.getBoolean("enable_logging", false)) logger(DebugLogger()) } .memoryCache { sharedMemoryCache } @@ -172,6 +178,7 @@ object ThumbnailsRequester : KoinComponent { clientManager.getClientForCoilThumbnails(account.name) .okHttpClient.newBuilder() .addInterceptor(interceptor) + .addNetworkInterceptor(CoilCacheResponseInterceptor()) .cache(avatarHttpCache) .build() } @@ -221,7 +228,13 @@ object ThumbnailsRequester : KoinComponent { requestHeaders.toHeaders().forEach { requestBuilder.addHeader(it.first, it.second) } val requestWithHeaders = requestBuilder.build() - var response = chain.proceed(requestWithHeaders) + return chain.proceed(requestWithHeaders) + } + } + + private class CoilCacheResponseInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) var builder = response.newBuilder() var changed = false @@ -252,6 +265,5 @@ object ThumbnailsRequester : KoinComponent { } return response } - } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/providers/WorkManagerProvider.kt b/opencloudApp/src/main/java/eu/opencloud/android/providers/WorkManagerProvider.kt index e6f586572e..7cc26c1d49 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/providers/WorkManagerProvider.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/providers/WorkManagerProvider.kt @@ -23,6 +23,7 @@ package eu.opencloud.android.providers import android.content.Context import androidx.lifecycle.LiveData +import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy @@ -37,6 +38,8 @@ import eu.opencloud.android.workers.AccountDiscoveryWorker import eu.opencloud.android.workers.AvailableOfflinePeriodicWorker import eu.opencloud.android.workers.AvailableOfflinePeriodicWorker.Companion.AVAILABLE_OFFLINE_PERIODIC_WORKER import eu.opencloud.android.workers.AutomaticUploadsWorker +import eu.opencloud.android.workers.DownloadEverythingWorker +import eu.opencloud.android.workers.LocalFileSyncWorker import eu.opencloud.android.workers.OldLogsCollectorWorker import eu.opencloud.android.workers.RemoveLocallyFilesWithLastUsageOlderThanGivenTimeWorker import eu.opencloud.android.workers.UploadFileFromContentUriWorker @@ -161,4 +164,65 @@ class WorkManagerProvider( fun cancelAllWorkByTag(tag: String) = WorkManager.getInstance(context).cancelAllWorkByTag(tag) + // Download Everything Feature + fun enqueueDownloadEverythingWorker() { + val constraintsRequired = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .setRequiresStorageNotLow(true) + .build() + + val downloadEverythingWorker = PeriodicWorkRequestBuilder( + repeatInterval = DownloadEverythingWorker.repeatInterval, + repeatIntervalTimeUnit = DownloadEverythingWorker.repeatIntervalTimeUnit + ) + .addTag(DownloadEverythingWorker.DOWNLOAD_EVERYTHING_WORKER) + .setConstraints(constraintsRequired) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 10, + java.util.concurrent.TimeUnit.MINUTES + ) + .build() + + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + DownloadEverythingWorker.DOWNLOAD_EVERYTHING_WORKER, + ExistingPeriodicWorkPolicy.KEEP, + downloadEverythingWorker + ) + } + + fun cancelDownloadEverythingWorker() { + WorkManager.getInstance(context) + .cancelUniqueWork(DownloadEverythingWorker.DOWNLOAD_EVERYTHING_WORKER) + } + + // Local File Sync (Auto-Sync) Feature + fun enqueueLocalFileSyncWorker() { + val constraintsRequired = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val localFileSyncWorker = PeriodicWorkRequestBuilder( + repeatInterval = LocalFileSyncWorker.repeatInterval, + repeatIntervalTimeUnit = LocalFileSyncWorker.repeatIntervalTimeUnit + ) + .addTag(LocalFileSyncWorker.LOCAL_FILE_SYNC_WORKER) + .setConstraints(constraintsRequired) + .build() + + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + LocalFileSyncWorker.LOCAL_FILE_SYNC_WORKER, + ExistingPeriodicWorkPolicy.KEEP, + localFileSyncWorker + ) + } + + fun cancelLocalFileSyncWorker() { + WorkManager.getInstance(context) + .cancelUniqueWork(LocalFileSyncWorker.LOCAL_FILE_SYNC_WORKER) + } + } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt index 9013ac5382..eb4e828c85 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt @@ -58,7 +58,6 @@ import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.work.WorkManager import eu.opencloud.android.AppRater import eu.opencloud.android.BuildConfig import eu.opencloud.android.MainApp @@ -98,7 +97,6 @@ import eu.opencloud.android.presentation.accounts.ManageAccountsViewModel import eu.opencloud.android.presentation.authentication.AccountUtils.getCurrentOpenCloudAccount import eu.opencloud.android.presentation.capabilities.CapabilityViewModel import eu.opencloud.android.presentation.common.UIResult -import eu.opencloud.android.presentation.conflicts.ConflictsResolveActivity import eu.opencloud.android.presentation.files.details.FileDetailsFragment import eu.opencloud.android.presentation.files.filelist.MainEmptyListFragment import eu.opencloud.android.presentation.files.filelist.MainFileListFragment @@ -113,17 +111,19 @@ import eu.opencloud.android.presentation.spaces.SpacesListFragment.Companion.BUN import eu.opencloud.android.presentation.spaces.SpacesListFragment.Companion.REQUEST_KEY_CLICK_SPACE import eu.opencloud.android.presentation.spaces.SpacesListViewModel import eu.opencloud.android.presentation.transfers.TransfersViewModel +import eu.opencloud.android.presentation.settings.security.SettingsSecurityFragment import eu.opencloud.android.providers.WorkManagerProvider import eu.opencloud.android.syncadapter.FileSyncAdapter -import eu.opencloud.android.ui.dialog.FileAlreadyExistsDialog import eu.opencloud.android.ui.fragment.FileFragment import eu.opencloud.android.ui.fragment.TaskRetainerFragment import eu.opencloud.android.ui.helpers.FilesUploadHelper +import eu.opencloud.android.ui.dialog.FileAlreadyExistsDialog import eu.opencloud.android.ui.preview.PreviewAudioFragment import eu.opencloud.android.ui.preview.PreviewImageActivity import eu.opencloud.android.ui.preview.PreviewImageFragment import eu.opencloud.android.ui.preview.PreviewTextFragment import eu.opencloud.android.ui.preview.PreviewVideoActivity +import androidx.work.WorkManager import eu.opencloud.android.usecases.synchronization.SynchronizeFileUseCase import eu.opencloud.android.usecases.transfers.downloads.DownloadFileUseCase import eu.opencloud.android.utils.PreferenceUtils @@ -288,7 +288,6 @@ class FileDisplayActivity : FileActivity(), AppRater.appLaunched(this, packageName) } - checkNotificationPermission() // edge-to-edge @@ -309,6 +308,8 @@ class FileDisplayActivity : FileActivity(), Timber.v("onCreate() end") } + + private fun checkNotificationPermission() { // Ask for permission only in case it's api >= 33 and notifications are not granted. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || @@ -348,7 +349,6 @@ class FileDisplayActivity : FileActivity(), isLightUser = manageAccountsViewModel.checkUserLight(account.name) isMultiPersonal = capabilitiesViewModel.checkMultiPersonal() navigateTo(fileListOption, initialState = true) - } startListeningToOperations() @@ -398,6 +398,16 @@ class FileDisplayActivity : FileActivity(), syncProfileOperation.syncUserProfile() val workManagerProvider = WorkManagerProvider(context = baseContext) workManagerProvider.enqueueAvailableOfflinePeriodicWorker() + + // Enqueue Download Everything worker if enabled + if (sharedPreferences.getBoolean(SettingsSecurityFragment.PREFERENCE_DOWNLOAD_EVERYTHING, false)) { + workManagerProvider.enqueueDownloadEverythingWorker() + } + + // Enqueue Local File Sync worker if enabled + if (sharedPreferences.getBoolean(SettingsSecurityFragment.PREFERENCE_AUTO_SYNC, false)) { + workManagerProvider.enqueueLocalFileSyncWorker() + } } else { file?.isFolder?.let { isFolder -> updateFragmentsVisibility(!isFolder) @@ -755,10 +765,7 @@ class FileDisplayActivity : FileActivity(), * 2. close FAB if open (only if drawer isn't open) * 3. navigate up (only if drawer and FAB aren't open) */ - if (isDrawerOpen() && isFabOpen) { - // close drawer first - super.onBackPressed() - } else if (isDrawerOpen() && !isFabOpen) { + if (isDrawerOpen()) { // close drawer super.onBackPressed() } else if (!isDrawerOpen() && isFabOpen) { @@ -1376,10 +1383,8 @@ class FileDisplayActivity : FileActivity(), } } - is SynchronizeFileUseCase.SyncType.ConflictDetected -> { - val showConflictActivityIntent = Intent(this, ConflictsResolveActivity::class.java) - showConflictActivityIntent.putExtra(ConflictsResolveActivity.EXTRA_FILE, file) - startActivity(showConflictActivityIntent) + is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy -> { + showSnackMessage(getString(R.string.sync_conflict_resolved_with_copy)) } is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> { diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.java b/opencloudApp/src/main/java/eu/opencloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.java index aee0a70d60..f71ed5ce64 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.java +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.java @@ -147,7 +147,12 @@ protected ResultCode doInBackground(Object[] params) { currentUri = uris[i]; currentRemotePath = uploadPath + UriUtils.getDisplayNameForUri(currentUri, mAppContext); - fullTempPath = FileStorageUtils.getTemporalPath(account.name, spaceId) + currentRemotePath; + File cacheBase = mAppContext.getExternalCacheDir() != null ? mAppContext.getExternalCacheDir() : mAppContext.getCacheDir(); + File baseTmpDir = new File(cacheBase, "upload_tmp"); + String accountSanitized = Uri.encode(account.name, "@"); + File accountDir = new File(baseTmpDir, accountSanitized); + File spaceDir = spaceId != null ? new File(accountDir, spaceId) : accountDir; + fullTempPath = spaceDir.getAbsolutePath() + currentRemotePath; inputStream = leakedContentResolver.openInputStream(currentUri); File cacheFile = new File(fullTempPath); File tempDir = cacheFile.getParentFile(); diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/helpers/FileOperationsHelper.java b/opencloudApp/src/main/java/eu/opencloud/android/ui/helpers/FileOperationsHelper.java index dbaaf9cb09..6def7361b9 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/helpers/FileOperationsHelper.java +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/helpers/FileOperationsHelper.java @@ -78,7 +78,7 @@ private Intent getIntentForGuessedMimeType(String storagePath, String type, Uri Intent intentForGuessedMimeType = null; if (storagePath != null && storagePath.lastIndexOf('.') >= 0) { - String guessedMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(storagePath.substring(storagePath.lastIndexOf('.') + 1)); + String guessedMimeType = eu.opencloud.android.utils.MimetypeIconUtil.getBestMimeTypeByFilename(storagePath); if (guessedMimeType != null && !guessedMimeType.equals(type)) { intentForGuessedMimeType = new Intent(Intent.ACTION_VIEW); diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt index f75824b29e..02fb5b466e 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt @@ -21,96 +21,163 @@ package eu.opencloud.android.usecases.synchronization +import eu.opencloud.android.data.providers.SharedPreferencesProvider import eu.opencloud.android.domain.BaseUseCaseWithResult import eu.opencloud.android.domain.exceptions.FileNotFoundException import eu.opencloud.android.domain.files.FileRepository import eu.opencloud.android.domain.files.model.OCFile -import eu.opencloud.android.domain.files.usecases.SaveConflictUseCase + +import eu.opencloud.android.presentation.settings.security.SettingsSecurityFragment import eu.opencloud.android.usecases.transfers.downloads.DownloadFileUseCase import eu.opencloud.android.usecases.transfers.uploads.UploadFileInConflictUseCase -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import timber.log.Timber +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import java.util.UUID class SynchronizeFileUseCase( private val downloadFileUseCase: DownloadFileUseCase, private val uploadFileInConflictUseCase: UploadFileInConflictUseCase, - private val saveConflictUseCase: SaveConflictUseCase, private val fileRepository: FileRepository, + private val preferencesProvider: SharedPreferencesProvider, ) : BaseUseCaseWithResult() { override fun run(params: Params): SyncType { val fileToSynchronize = params.fileToSynchronize val accountName: String = fileToSynchronize.owner - CoroutineScope(Dispatchers.IO).run { - // 1. Perform a propfind to check if the file still exists in remote - val serverFile = try { - fileRepository.readFile( - remotePath = fileToSynchronize.remotePath, - accountName = fileToSynchronize.owner, - spaceId = fileToSynchronize.spaceId - ) - } catch (exception: FileNotFoundException) { - Timber.i(exception, "File does not exist anymore in remote") - // 1.1 File does not exist anymore in remote - val localFile = fileToSynchronize.id?.let { fileRepository.getFileById(it) } - // If it still exists locally, but file has different path, another operation could have been done simultaneously - // Do not remove the file in that case, it may be synced later - // Remove locally (storage) in any other case - if (localFile != null && (localFile.remotePath == fileToSynchronize.remotePath && localFile.spaceId == fileToSynchronize.spaceId)) { - fileRepository.deleteFiles(listOf(fileToSynchronize), true) + // 1. Check local state first to avoid network calls if possible (optimization) + // Check if file has changed locally by reading ACTUAL file timestamp from filesystem + val storagePath = fileToSynchronize.storagePath + val localFile = storagePath?.let { File(it) } + val fileExistsLocally = localFile?.exists() == true + + var changedLocally = false + if (fileExistsLocally) { + val actualFileModificationTime = localFile!!.lastModified() + changedLocally = actualFileModificationTime > (fileToSynchronize.lastSyncDateForData ?: 0) + + Timber.d( + "File ${fileToSynchronize.fileName}: localTimestamp=$actualFileModificationTime, " + + "lastSync=${fileToSynchronize.lastSyncDateForData}, changedLocally=$changedLocally" + ) + } + + // 2. Perform propfind to check remote state + val serverFile = try { + fileRepository.readFile( + remotePath = fileToSynchronize.remotePath, + accountName = fileToSynchronize.owner, + spaceId = fileToSynchronize.spaceId + ) + } catch (exception: FileNotFoundException) { + Timber.i(exception, "File does not exist anymore in remote") + + if (changedLocally) { + Timber.w("File deleted remotely but changed locally. Uploading local version instead of deleting.") + val uuid = requestForUpload(accountName, fileToSynchronize, currentRemoteEtag = "") + return SyncType.UploadEnqueued(uuid) + } else { + val localDbFile = fileToSynchronize.id?.let { fileRepository.getFileById(it) } + + val sameFile = localDbFile != null && + localDbFile.remotePath == fileToSynchronize.remotePath && + localDbFile.spaceId == fileToSynchronize.spaceId + if (sameFile) { + fileRepository.deleteFiles(listOf(fileToSynchronize), true) } return SyncType.FileNotFound } + } - // 2. File not downloaded -> Download it - return if (!fileToSynchronize.isAvailableLocally) { - Timber.i("File ${fileToSynchronize.fileName} is not downloaded. Let's download it") + // 3. File not downloaded -> Download it or update state + if (!fileToSynchronize.isAvailableLocally) { + return if (fileToSynchronize.isAvailableOffline) { + Timber.i("File ${fileToSynchronize.fileName} is marked for offline access but missing locally. Downloading.") val uuid = requestForDownload(accountName = accountName, ocFile = fileToSynchronize) SyncType.DownloadEnqueued(uuid) } else { - // 3. Check if file has changed locally - val changedLocally = fileToSynchronize.localModificationTimestamp > fileToSynchronize.lastSyncDateForData!! - Timber.i("Local file modification timestamp :${fileToSynchronize.localModificationTimestamp}" + - " and last sync date for data :${fileToSynchronize.lastSyncDateForData}") - Timber.i("So it has changed locally: $changedLocally") - - // 4. Check if file has changed remotely - val changedRemotely = serverFile.etag != fileToSynchronize.etag - Timber.i("Local etag :${fileToSynchronize.etag} and remote etag :${serverFile.etag}") - Timber.i("So it has changed remotely: $changedRemotely") - - if (changedLocally && changedRemotely) { - // 5.1 File has changed locally and remotely. We got a conflict, save the conflict. - Timber.i("File ${fileToSynchronize.fileName} has changed locally and remotely. We got a conflict with etag: ${serverFile.etag}") - if (fileToSynchronize.etagInConflict == null) { - saveConflictUseCase( - SaveConflictUseCase.Params( - fileId = fileToSynchronize.id!!, - eTagInConflict = serverFile.etag!! - ) - ) - } - SyncType.ConflictDetected(serverFile.etag!!) - } else if (changedRemotely) { - // 5.2 File has changed ONLY remotely -> download new version - Timber.i("File ${fileToSynchronize.fileName} has changed remotely. Let's download the new version") - val uuid = requestForDownload(accountName, fileToSynchronize) - SyncType.DownloadEnqueued(uuid) - } else if (changedLocally) { - // 5.3 File has change ONLY locally -> upload new version - Timber.i("File ${fileToSynchronize.fileName} has changed locally. Let's upload the new version") - val uuid = requestForUpload(accountName, fileToSynchronize) - SyncType.UploadEnqueued(uuid) - } else { - // 5.4 File has not change locally not remotely -> do nothing - Timber.i("File ${fileToSynchronize.fileName} is already synchronized. Nothing to do here") - SyncType.AlreadySynchronized + Timber.i("File ${fileToSynchronize.fileName} is not downloaded and not marked for offline access. Updating database.") + if (fileToSynchronize.storagePath != null) { + val updatedFile = fileToSynchronize.copy(storagePath = null) + fileRepository.saveFile(updatedFile) } + SyncType.AlreadySynchronized + } + } + + // 4. Check if file has changed remotely + val changedRemotely = serverFile.etag != fileToSynchronize.etag + Timber.i("Local etag :${fileToSynchronize.etag} and remote etag :${serverFile.etag}") + Timber.i("So it has changed remotely: $changedRemotely") + + if (changedLocally && changedRemotely) { + return handleConflict(fileToSynchronize, accountName, serverFile.etag) + } else if (changedRemotely) { + // 5.2 File has changed ONLY remotely -> download new version + Timber.i("File ${fileToSynchronize.fileName} has changed remotely. Let's download the new version") + val uuid = requestForDownload(accountName, fileToSynchronize) + return SyncType.DownloadEnqueued(uuid) + } else if (changedLocally) { + // 5.3 File has change ONLY locally -> upload new version + Timber.i("File ${fileToSynchronize.fileName} has changed locally. Let's upload the new version") + val uuid = requestForUpload(accountName, fileToSynchronize, serverFile.etag) + return SyncType.UploadEnqueued(uuid) + } else { + // 5.4 File has not change locally not remotely -> do nothing + if (!fileExistsLocally && fileToSynchronize.storagePath != null) { + val updatedFile = fileToSynchronize.copy(storagePath = null) + fileRepository.saveFile(updatedFile) + Timber.i("File ${fileToSynchronize.fileName} no longer exists locally, updating database") } + Timber.i("File ${fileToSynchronize.fileName} is already synchronized. Nothing to do here") + return SyncType.AlreadySynchronized + } + } + + private fun handleConflict(fileToSynchronize: OCFile, accountName: String, currentRemoteEtag: String?): SyncType { + val preferLocal = preferencesProvider.getBoolean( + SettingsSecurityFragment.PREFERENCE_PREFER_LOCAL_ON_CONFLICT, false + ) + + if (preferLocal) { + Timber.i("File ${fileToSynchronize.fileName} has conflict. User prefers local version, uploading.") + val uuid = requestForUpload(accountName, fileToSynchronize, currentRemoteEtag) + return SyncType.UploadEnqueued(uuid) + } + + Timber.i("File ${fileToSynchronize.fileName} has changed locally and remotely. Creating conflicted copy.") + val localPath = fileToSynchronize.storagePath + if (localPath.isNullOrEmpty()) { + Timber.e("File ${fileToSynchronize.fileName} has no local storage path. Cannot create conflicted copy.") + return SyncType.AlreadySynchronized + } + val conflictedCopyPath = createConflictedCopyPath(localPath) + val renamed = renameLocalFile(localPath, conflictedCopyPath) + + if (!renamed) { + Timber.e("Failed to rename local file to conflicted copy. ABORTING DOWNLOAD to prevent data loss.") + return SyncType.AlreadySynchronized } + + Timber.i("Local file renamed to conflicted copy: $conflictedCopyPath") + + val conflictedFile = OCFile( + owner = fileToSynchronize.owner, + parentId = fileToSynchronize.parentId, + length = File(conflictedCopyPath).length(), + modificationTimestamp = File(conflictedCopyPath).lastModified(), + remotePath = fileToSynchronize.getParentRemotePath() + File(conflictedCopyPath).name, + mimeType = fileToSynchronize.mimeType, + storagePath = conflictedCopyPath, + spaceId = fileToSynchronize.spaceId, + ) + fileRepository.saveFile(conflictedFile) + + val uuid = requestForDownload(accountName, fileToSynchronize) + return SyncType.ConflictResolvedWithCopy(uuid, conflictedCopyPath) } private fun requestForDownload(accountName: String, ocFile: OCFile): UUID? = @@ -121,15 +188,57 @@ class SynchronizeFileUseCase( ) ) - private fun requestForUpload(accountName: String, ocFile: OCFile): UUID? = - uploadFileInConflictUseCase( + private fun requestForUpload(accountName: String, ocFile: OCFile, currentRemoteEtag: String?): UUID? { + val localPath = ocFile.storagePath + if (localPath.isNullOrEmpty()) { + Timber.e("Cannot upload file ${ocFile.fileName} because storagePath is null or empty.") + return null + } + return uploadFileInConflictUseCase( UploadFileInConflictUseCase.Params( accountName = accountName, - localPath = ocFile.storagePath!!, + localPath = localPath, uploadFolderPath = ocFile.getParentRemotePath(), spaceId = ocFile.spaceId, + currentRemoteEtag = currentRemoteEtag, ) ) + } + + private fun createConflictedCopyPath(originalPath: String): String { + val file = File(originalPath) + val nameWithoutExt = file.nameWithoutExtension + val extension = file.extension + val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US).format(Date()) + val conflictedName = if (extension.isNotEmpty()) { + "${nameWithoutExt}_conflicted_copy_$timestamp.$extension" + } else { + "${nameWithoutExt}_conflicted_copy_$timestamp" + } + return File(file.parent, conflictedName).absolutePath + } + + private fun renameLocalFile(oldPath: String, newPath: String): Boolean = try { + val oldFile = File(oldPath) + val newFile = File(newPath) + if (oldFile.renameTo(newFile)) { + true + } else { + Timber.w("Failed to renameTo, falling back to copyTo: $oldPath to $newPath") + oldFile.copyTo(newFile, overwrite = true) + if (!oldFile.delete()) { + Timber.w("Failed to delete original file after copy: $oldPath. Removing conflicted copy.") + newFile.delete() + false + } else { + true + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to rename local file from $oldPath to $newPath") + File(newPath).delete() + false + } data class Params( val fileToSynchronize: OCFile, @@ -137,7 +246,7 @@ class SynchronizeFileUseCase( sealed interface SyncType { object FileNotFound : SyncType - data class ConflictDetected(val etagInConflict: String) : SyncType + data class ConflictResolvedWithCopy(val workerId: UUID?, val conflictedCopyPath: String) : SyncType data class DownloadEnqueued(val workerId: UUID?) : SyncType data class UploadEnqueued(val workerId: UUID?) : SyncType object AlreadySynchronized : SyncType diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileInConflictUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileInConflictUseCase.kt index d1de775e5a..2d42226dad 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileInConflictUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileInConflictUseCase.kt @@ -23,11 +23,11 @@ package eu.opencloud.android.usecases.transfers.uploads import androidx.work.Constraints +import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager -import androidx.work.workDataOf import eu.opencloud.android.domain.BaseUseCase import eu.opencloud.android.domain.automaticuploads.model.UploadBehavior import eu.opencloud.android.domain.transfers.TransferRepository @@ -73,6 +73,7 @@ class UploadFileInConflictUseCase( lastModifiedInSeconds = localFile.lastModified().div(1_000).toString(), accountName = params.accountName, uploadIdInStorageManager = uploadId, + currentRemoteEtag = params.currentRemoteEtag, ) } @@ -105,16 +106,21 @@ class UploadFileInConflictUseCase( lastModifiedInSeconds: String, uploadIdInStorageManager: Long, uploadPath: String, + currentRemoteEtag: String?, ): UUID { - val inputData = workDataOf( - UploadFileFromFileSystemWorker.KEY_PARAM_ACCOUNT_NAME to accountName, - UploadFileFromFileSystemWorker.KEY_PARAM_BEHAVIOR to UploadBehavior.COPY.name, - UploadFileFromFileSystemWorker.KEY_PARAM_LOCAL_PATH to localPath, - UploadFileFromFileSystemWorker.KEY_PARAM_LAST_MODIFIED to lastModifiedInSeconds, - UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_PATH to uploadPath, - UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_ID to uploadIdInStorageManager, - UploadFileFromFileSystemWorker.KEY_PARAM_REMOVE_LOCAL to false - ) + val inputDataBuilder = Data.Builder() + .putString(UploadFileFromFileSystemWorker.KEY_PARAM_ACCOUNT_NAME, accountName) + .putString(UploadFileFromFileSystemWorker.KEY_PARAM_BEHAVIOR, UploadBehavior.COPY.name) + .putString(UploadFileFromFileSystemWorker.KEY_PARAM_LOCAL_PATH, localPath) + .putString(UploadFileFromFileSystemWorker.KEY_PARAM_LAST_MODIFIED, lastModifiedInSeconds) + .putString(UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_PATH, uploadPath) + .putLong(UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_ID, uploadIdInStorageManager) + .putBoolean(UploadFileFromFileSystemWorker.KEY_PARAM_REMOVE_LOCAL, false) + + currentRemoteEtag?.let { + inputDataBuilder.putString(UploadFileFromFileSystemWorker.KEY_PARAM_OVERWRITE_ETAG, it) + } + val inputData = inputDataBuilder.build() val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) @@ -144,5 +150,6 @@ class UploadFileInConflictUseCase( val localPath: String, val uploadFolderPath: String, val spaceId: String?, + val currentRemoteEtag: String? = null, ) } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/utils/NotificationConstants.kt b/opencloudApp/src/main/java/eu/opencloud/android/utils/NotificationConstants.kt index b4b5921ff8..a6858abef3 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/utils/NotificationConstants.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/utils/NotificationConstants.kt @@ -21,7 +21,6 @@ package eu.opencloud.android.utils const val DOWNLOAD_NOTIFICATION_CHANNEL_ID = "DOWNLOAD_NOTIFICATION_CHANNEL" const val UPLOAD_NOTIFICATION_CHANNEL_ID = "UPLOAD_NOTIFICATION_CHANNEL" const val MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID = "MEDIA_SERVICE_NOTIFICATION_CHANNEL" -const val FILE_SYNC_CONFLICT_NOTIFICATION_CHANNEL_ID = "FILE_SYNC_CONFLICT_CHANNEL_ID" const val FILE_SYNC_NOTIFICATION_CHANNEL_ID = "FILE_SYNC_NOTIFICATION_CHANNEL" const val DOWNLOAD_NOTIFICATION_ID_DEFAULT = 123 diff --git a/opencloudApp/src/main/java/eu/opencloud/android/utils/NotificationUtils.kt b/opencloudApp/src/main/java/eu/opencloud/android/utils/NotificationUtils.kt index 7b524f9112..d267976bd0 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/utils/NotificationUtils.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/utils/NotificationUtils.kt @@ -33,12 +33,10 @@ import android.os.Process import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import eu.opencloud.android.R -import eu.opencloud.android.domain.files.model.OCFile import eu.opencloud.android.presentation.authentication.ACTION_UPDATE_EXPIRED_TOKEN import eu.opencloud.android.presentation.authentication.EXTRA_ACCOUNT import eu.opencloud.android.presentation.authentication.EXTRA_ACTION import eu.opencloud.android.presentation.authentication.LoginActivity -import eu.opencloud.android.presentation.conflicts.ConflictsResolveActivity import eu.opencloud.android.presentation.settings.SettingsActivity import eu.opencloud.android.presentation.settings.SettingsActivity.Companion.KEY_NOTIFICATION_INTENT import eu.opencloud.android.ui.activity.UploadListActivity @@ -149,42 +147,4 @@ object NotificationUtils { }, delayInMillis) } - /** - * Show a notification with file conflict information, that will open a dialog to solve it when tapping it - * - * @param fileInConflict file in conflict - * @param account account which the file in conflict belongs to - */ - @JvmStatic - fun notifyConflict(fileInConflict: OCFile, context: Context) { - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val notificationBuilder = newNotificationBuilder(context, FILE_SYNC_CONFLICT_NOTIFICATION_CHANNEL_ID) - notificationBuilder - .setTicker(context.getString(R.string.conflict_title)) - .setContentTitle(context.getString(R.string.conflict_title)) - .setContentText( - String.format( - context.getString(R.string.conflict_description), - fileInConflict.remotePath - ) - ) - .setAutoCancel(true) - val showConflictActivityIntent = Intent(context, ConflictsResolveActivity::class.java) - showConflictActivityIntent.flags = showConflictActivityIntent.flags or Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_FROM_BACKGROUND - showConflictActivityIntent.putExtra(ConflictsResolveActivity.EXTRA_FILE, fileInConflict) - notificationBuilder.setContentIntent( - PendingIntent.getActivity( - context, System.currentTimeMillis().toInt(), - showConflictActivityIntent, pendingIntentFlags - ) - ) - var notificationId = 0 - - // We need a notification id for each file in conflict, let's use the file id but in a safe way - if (fileInConflict.id!!.toInt() >= Int.MIN_VALUE && fileInConflict.id!!.toInt() <= Int.MAX_VALUE) { - notificationId = fileInConflict.id!!.toInt() - } - notificationManager.notify(notificationId, notificationBuilder.build()) - } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/utils/StorageMigrationHelper.kt b/opencloudApp/src/main/java/eu/opencloud/android/utils/StorageMigrationHelper.kt new file mode 100644 index 0000000000..05ae76d967 --- /dev/null +++ b/opencloudApp/src/main/java/eu/opencloud/android/utils/StorageMigrationHelper.kt @@ -0,0 +1,63 @@ +package eu.opencloud.android.utils + +import eu.opencloud.android.data.extensions.moveRecursively +import eu.opencloud.android.domain.files.FileRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File + +object StorageMigrationHelper { + + suspend fun migrateStorageDirectory( + oldRootPath: String, + newRootPath: String, + fileRepository: FileRepository + ): Boolean = withContext(Dispatchers.IO) { + if (oldRootPath == newRootPath) { + Timber.i("Old and new paths are the same, nothing to migrate.") + return@withContext true + } + + val oldDir = File(oldRootPath) + val newDir = File(newRootPath) + + if (!oldDir.exists() || !oldDir.isDirectory) { + Timber.i("Old directory does not exist or is not a directory, nothing to migrate.") + return@withContext true + } + + try { + newDir.parentFile?.mkdirs() + + // Fast path: Try to rename the root directory + if (oldDir.renameTo(newDir)) { + Timber.i("Successfully renamed root directory from $oldRootPath to $newRootPath") + fileRepository.updateDownloadedFilesStorageDirectoryInStoragePath(oldRootPath, newRootPath) + return@withContext true + } + + // Fallback: move recursively if rename fails (e.g. across mount points) + Timber.w("renameTo failed, falling back to moveRecursively") + oldDir.listFiles()?.forEach { file -> + val targetFile = File(newDir, file.name) + if (file.isDirectory) { + file.moveRecursively(targetFile, overwrite = true) + } else { + file.copyTo(targetFile, overwrite = true) + file.delete() + } + } + + // Clean up old root + oldDir.deleteRecursively() + Timber.i("Successfully moved files to $newRootPath") + fileRepository.updateDownloadedFilesStorageDirectoryInStoragePath(oldRootPath, newRootPath) + return@withContext true + + } catch (e: Exception) { + Timber.e(e, "Error migrating storage directory from $oldRootPath to $newRootPath") + return@withContext false + } + } +} diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt new file mode 100644 index 0000000000..343d2aa668 --- /dev/null +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt @@ -0,0 +1,587 @@ +/** + * openCloud Android client application + * + * @author OpenCloud Development Team + * + * Copyright (C) 2026 OpenCloud. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * 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 eu.opencloud.android.workers + +import android.accounts.AccountManager +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import eu.opencloud.android.MainApp +import eu.opencloud.android.R +import eu.opencloud.android.data.download.DownloadProgress +import eu.opencloud.android.data.download.DownloadProgressDataStore +import eu.opencloud.android.domain.UseCaseResult +import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase +import eu.opencloud.android.domain.files.FileRepository +import eu.opencloud.android.domain.files.model.OCFile +import eu.opencloud.android.domain.files.model.OCFile.Companion.ROOT_PATH +import eu.opencloud.android.domain.files.usecases.GetFileByRemotePathUseCase +import eu.opencloud.android.domain.spaces.usecases.GetPersonalAndProjectSpacesForAccountUseCase +import eu.opencloud.android.domain.spaces.usecases.RefreshSpacesFromServerAsyncUseCase +import eu.opencloud.android.presentation.authentication.AccountUtils +import eu.opencloud.android.usecases.synchronization.SynchronizeFileUseCase +import eu.opencloud.android.usecases.transfers.downloads.DownloadFileUseCase +import eu.opencloud.android.utils.FileStorageUtils +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import timber.log.Timber +import java.io.IOException +import java.util.ArrayDeque +import java.util.concurrent.TimeUnit + +/** + * Worker that downloads ALL files from all accounts for offline access. + * This is an opt-in feature that can be enabled in Security Settings. + * + * This worker: + * 1. Iterates through all connected accounts + * 2. Discovers all spaces (personal + project) for each account + * 3. Iteratively scans all folders to find all files + * 4. Enqueues a download for each file that is not yet available locally + * 5. Shows a notification with progress information + * + * **Resumability**: Progress is persisted to [DownloadProgressDataStore] after every + * account, space, and folder. If the worker is interrupted (Doze, app killed, screen lock, + * network drop, etc.), the next run will resume from where it left off instead of + * restarting from scratch. + */ +class DownloadEverythingWorker( + private val appContext: Context, + workerParameters: WorkerParameters +) : CoroutineWorker( + appContext, + workerParameters +), KoinComponent { + + private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject() + private val refreshSpacesFromServerAsyncUseCase: RefreshSpacesFromServerAsyncUseCase by inject() + private val getPersonalAndProjectSpacesForAccountUseCase: GetPersonalAndProjectSpacesForAccountUseCase by inject() + private val getFileByRemotePathUseCase: GetFileByRemotePathUseCase by inject() + private val fileRepository: FileRepository by inject() + private val downloadFileUseCase: DownloadFileUseCase by inject() + private val synchronizeFileUseCase: SynchronizeFileUseCase by inject() + private val progressDataStore: DownloadProgressDataStore by inject() + + private var totalFilesFound = 0 + private var filesDownloaded = 0 + private var filesAlreadyLocal = 0 + private var filesSkipped = 0 + private var foldersProcessed = 0 + private var wasInterrupted = false + private var processedFolderIds = mutableSetOf() + private var bytesEnqueuedInThisRun = 0L + private var filesEnqueuedInThisRun = 0 + + override suspend fun doWork(): Result { + Timber.i("DownloadEverythingWorker started") + + // Create notification channel and show initial foreground notification + createNotificationChannel() + + val usableSpace = FileStorageUtils.getUsableSpace() + if (usableSpace < MIN_FREE_SPACE_BYTES) { + val msg = "Insufficient storage: ${usableSpace / MB} MB available, need at least ${MIN_FREE_SPACE_BYTES / MB} MB" + Timber.w("DownloadEverythingWorker aborted: $msg") + updateNotification(msg) + return Result.failure() + } + + // Try to resume from previous progress + val savedProgress = progressDataStore.loadProgress() + if (savedProgress != null) { + restoreCounters(savedProgress) + savedProgress.processedFolderIds.let { processedFolderIds.addAll(it) } + Timber.i( + "Resuming download scan from account ${savedProgress.accountIndex + 1}, " + + "space ${savedProgress.spaceIndex}, ${savedProgress.foldersProcessed} folders already processed" + ) + updateNotification( + "Resuming: ${savedProgress.foldersProcessed} folders already processed" + ) + } else { + Timber.i("Starting fresh download scan") + updateNotification("Starting download of all files...") + } + + return try { + processAllAccounts(savedProgress) + + if (wasInterrupted || isStopped) { + Timber.w( + "Worker was interrupted (wasInterrupted=$wasInterrupted, isStopped=$isStopped). " + + "Progress saved. Requesting retry." + ) + return Result.retry() + } + + val summary = "Done! Files: $totalFilesFound, Downloaded: $filesDownloaded, " + + "Already local: $filesAlreadyLocal, Skipped: $filesSkipped, Folders: $foldersProcessed" + Timber.i("DownloadEverythingWorker completed: $summary") + updateNotification(summary) + + // Success — clear progress so next run starts fresh + progressDataStore.clearProgress() + Timber.i("Cleared download progress state") + + Result.success() + } catch (exception: Exception) { + Timber.e(exception, "DownloadEverythingWorker failed with fatal error") + updateNotification("Failed: ${exception.message}") + progressDataStore.clearProgress() + Result.failure() + } + } + + private suspend fun processAllAccounts(savedProgress: DownloadProgress?) { + val accountManager = AccountManager.get(appContext) + val accounts = accountManager.getAccountsByType(MainApp.accountType) + + Timber.i("Found ${accounts.size} accounts to process") + + for (accountIndex in accounts.indices) { + val account = accounts[accountIndex] + if (isStopped || wasInterrupted) { + Timber.w("Worker stopped by system or interrupted during account loop. Requesting retry.") + wasInterrupted = true + return + } + + // Skip already processed accounts when resuming + if (savedProgress != null && accountIndex < savedProgress.accountIndex) { + Timber.d("Skipping already processed account $accountIndex") + continue + } + + val accountName = account.name + Timber.i("Processing account ${accountIndex + 1}/${accounts.size}: $accountName") + updateNotification("Account ${accountIndex + 1}/${accounts.size}: $accountName") + + try { + processAccount(accountName, account, accountIndex, savedProgress) + } catch (e: IOException) { + Timber.e(e, "Network error processing account $accountName — requesting retry") + wasInterrupted = true + return + } catch (e: Exception) { + Timber.e(e, "Error processing account $accountName — continuing with next account") + } + } + } + + private suspend fun processAccount( + accountName: String, + account: android.accounts.Account, + accountIndex: Int, + savedProgress: DownloadProgress? + ) { + val capabilities = getStoredCapabilitiesUseCase(GetStoredCapabilitiesUseCase.Params(accountName)) + val spacesAvailableForAccount = AccountUtils.isSpacesFeatureAllowedForAccount( + appContext, + account, + capabilities + ) + + if (!spacesAvailableForAccount) { + // Account does not support spaces - process legacy root + Timber.i("Account $accountName uses legacy mode (no spaces)") + processSpaceRoot(accountName, ROOT_PATH, null, savedProgress) + } else { + // Account supports spaces - process all spaces + refreshSpacesFromServerAsyncUseCase(RefreshSpacesFromServerAsyncUseCase.Params(accountName)) + val spaces = getPersonalAndProjectSpacesForAccountUseCase( + GetPersonalAndProjectSpacesForAccountUseCase.Params(accountName) + ) + + Timber.i("Account $accountName has ${spaces.size} spaces") + + spaces.forEachIndexed { spaceIndex, space -> + if (isStopped || wasInterrupted) { + Timber.w("Worker stopped or interrupted during space loop. Requesting retry.") + wasInterrupted = true + return@forEachIndexed + } + + // When resuming the same account, skip already processed spaces + if (savedProgress != null && + accountIndex == savedProgress.accountIndex && + spaceIndex < savedProgress.spaceIndex + ) { + Timber.d("Skipping already processed space $spaceIndex") + return@forEachIndexed + } + + Timber.i("Processing space ${spaceIndex + 1}/${spaces.size}: ${space.name}") + updateNotification("Space ${spaceIndex + 1}/${spaces.size}: ${space.name}") + + processSpaceRoot(accountName, ROOT_PATH, space.id, savedProgress) + + if (wasInterrupted) return@forEachIndexed + + // Save progress after each space + saveCurrentProgress(accountIndex = accountIndex, spaceIndex = spaceIndex + 1) + } + } + + if (wasInterrupted) return + + // Save progress after each account (reset space index) + saveCurrentProgress(accountIndex = accountIndex + 1, spaceIndex = 0) + } + + /** + * Restores counters from a previously saved [DownloadProgress]. + */ + private fun restoreCounters(progress: DownloadProgress) { + totalFilesFound = progress.totalFilesFound + filesDownloaded = progress.filesDownloaded + filesAlreadyLocal = progress.filesAlreadyLocal + filesSkipped = progress.filesSkipped + foldersProcessed = progress.foldersProcessed + } + + /** + * Persists the current progress to [DownloadProgressDataStore]. + */ + private suspend fun saveCurrentProgress(accountIndex: Int, spaceIndex: Int) { + progressDataStore.saveProgress( + DownloadProgress( + accountIndex = accountIndex, + spaceIndex = spaceIndex, + processedFolderIds = processedFolderIds, + totalFilesFound = totalFilesFound, + filesDownloaded = filesDownloaded, + filesAlreadyLocal = filesAlreadyLocal, + filesSkipped = filesSkipped, + foldersProcessed = foldersProcessed, + isRunning = true, + lastUpdateTimestamp = System.currentTimeMillis() + ) + ) + } + + /** + * Processes the root of a space by refreshing it and then iteratively processing all content. + */ + private suspend fun processSpaceRoot( + accountName: String, + remotePath: String, + spaceId: String?, + savedProgress: DownloadProgress? + ) { + try { + Timber.i("Processing space root: remotePath=$remotePath, spaceId=$spaceId") + + // First refresh the root folder from server to ensure DB has latest data + fileRepository.refreshFolder( + remotePath = remotePath, + accountName = accountName, + spaceId = spaceId, + isActionSetFolderAvailableOfflineOrSynchronize = false + ) + + // Now get the root folder from local database + val rootFolder = getFileByRemotePathUseCase( + GetFileByRemotePathUseCase.Params(accountName, remotePath, spaceId) + ).getDataOrNull() + + if (rootFolder == null) { + Timber.w("Root folder not found after refresh for spaceId=$spaceId") + return + } + + Timber.i("Got root folder with id=${rootFolder.id}, remotePath=${rootFolder.remotePath}") + + // Process the root folder iteratively to avoid stack overflow on deep hierarchies + processFolderIteratively(accountName, rootFolder, spaceId, savedProgress) + + } catch (e: IOException) { + Timber.e(e, "Network error processing space root: spaceId=$spaceId") + throw e // Re-throw so caller can trigger retry + } catch (e: Exception) { + Timber.e(e, "Error processing space root: spaceId=$spaceId") + } + } + + /** + * Iteratively processes a folder using an explicit stack instead of recursion. + * This avoids StackOverflowError on deeply nested folder hierarchies. + * + * When resuming, folders whose IDs are already in [savedProgress.processedFolderIds] + * are skipped. + */ + private suspend fun processFolderIteratively( + accountName: String, + rootFolder: OCFile, + spaceId: String?, + savedProgress: DownloadProgress? + ) { + val folderStack = ArrayDeque() + folderStack.addLast(rootFolder) + + // Collect processed folder IDs from saved progress for quick lookup + val processedIds = savedProgress?.processedFolderIds ?: emptySet() + + while (folderStack.isNotEmpty()) { + if (isStopped || wasInterrupted) { + Timber.w("Worker stopped or interrupted during folder processing. Requesting retry.") + wasInterrupted = true + break + } + + val folder = folderStack.removeLast() + val folderId = folder.id + if (folderId == null) { + Timber.w("Folder ${folder.remotePath} has no id, skipping") + continue + } + + // Skip refreshing and file processing for folders already processed in a previous run, + // BUT we must still fetch their content from local DB to add subfolders to the stack. + val alreadyProcessed = folderId in processedIds + if (!alreadyProcessed) { + foldersProcessed++ + processedFolderIds.add(folderId) + Timber.d("Processing folder: ${folder.remotePath} (id=$folderId)") + + // First refresh this folder from server + try { + fileRepository.refreshFolder( + remotePath = folder.remotePath, + accountName = accountName, + spaceId = spaceId, + isActionSetFolderAvailableOfflineOrSynchronize = false + ) + } catch (e: IOException) { + Timber.e(e, "Network error refreshing folder ${folder.remotePath}") + wasInterrupted = true + processedFolderIds.remove(folderId) + break + } catch (e: Exception) { + Timber.e(e, "Error refreshing folder ${folder.remotePath}") + } + } else { + Timber.d("Folder ${folder.remotePath} (id=$folderId) already processed in previous run, fetching subfolders only") + } + + // Now get ALL content from local database + val folderContent = try { + fileRepository.getFolderContent(folderId) + } catch (e: Exception) { + Timber.e(e, "Error getting folder content for ${folder.remotePath}") + emptyList() + } + + Timber.d("Folder ${folder.remotePath} contains ${folderContent.size} items") + + for (item in folderContent) { + if (isStopped || wasInterrupted) { + Timber.w("Worker stopped or interrupted during item processing. Requesting retry.") + wasInterrupted = true + if (!alreadyProcessed) processedFolderIds.remove(folderId) + break + } + if (item.isFolder) { + folderStack.addLast(item) + } else if (!alreadyProcessed) { + processFile(accountName, item) + } + } + + // Update notification periodically + if (foldersProcessed % 5 == 0) { + updateNotification("Scanning: $foldersProcessed folders, $totalFilesFound files found") + } + } + } + + /** + * Processes a single file: checks if it's already local, + * and if not, enqueues a download. + */ + private suspend fun processFile(accountName: String, file: OCFile) { + totalFilesFound++ + + try { + if (file.isAvailableLocally) { + if (file.isDownloadedRemoteVersionCurrent()) { + filesAlreadyLocal++ + Timber.d("File already local and current: ${file.fileName}") + } else { + synchronizeStaleLocalFile(file) + } + } else { + enqueueDownloadIfPossible(accountName, file) + } + + // Update notification periodically (every 20 files) + if (totalFilesFound % 20 == 0) { + updateNotification("Found: $totalFilesFound files, $filesDownloaded queued for download") + } + } catch (e: Exception) { + filesSkipped++ + Timber.e(e, "Error processing file ${file.fileName}") + } + } + + private fun OCFile.isDownloadedRemoteVersionCurrent(): Boolean { + val currentRemoteEtag = remoteEtag + return currentRemoteEtag.isNullOrBlank() || etag == currentRemoteEtag + } + + private fun synchronizeStaleLocalFile(file: OCFile) { + Timber.i( + "File ${file.fileName} is local but stale. Synced etag=${file.etag}, remote etag=${file.remoteEtag}" + ) + + when (val useCaseResult = synchronizeFileUseCase(SynchronizeFileUseCase.Params(file))) { + is UseCaseResult.Success -> handleStaleLocalSyncResult(file, useCaseResult.data) + is UseCaseResult.Error -> { + filesSkipped++ + Timber.e(useCaseResult.throwable, "Error synchronizing stale local file ${file.fileName}") + } + } + } + + private fun handleStaleLocalSyncResult(file: OCFile, syncType: SynchronizeFileUseCase.SyncType) { + when (syncType) { + is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> { + if (syncType.workerId != null) { + filesDownloaded++ + markDownloadEnqueued(file) + Timber.i("Enqueued download for stale local file: ${file.fileName}") + } else { + filesSkipped++ + Timber.d("Download already enqueued or skipped for stale local file: ${file.fileName}") + } + } + is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy -> { + if (syncType.workerId != null) { + filesDownloaded++ + markDownloadEnqueued(file) + Timber.i("Resolved conflict and enqueued download for stale local file: ${file.fileName}") + } else { + filesSkipped++ + Timber.d("Conflict was handled but download was not enqueued for stale local file: ${file.fileName}") + } + } + is SynchronizeFileUseCase.SyncType.UploadEnqueued -> { + filesAlreadyLocal++ + Timber.i("Local changes for ${file.fileName} were queued for upload") + } + SynchronizeFileUseCase.SyncType.AlreadySynchronized -> { + filesAlreadyLocal++ + Timber.d("File already synchronized after stale check: ${file.fileName}") + } + SynchronizeFileUseCase.SyncType.FileNotFound -> { + filesSkipped++ + Timber.w("File no longer exists on server: ${file.fileName}") + } + } + } + + private fun enqueueDownloadIfPossible(accountName: String, file: OCFile) { + val usableSpace = FileStorageUtils.getUsableSpace() - bytesEnqueuedInThisRun + if (file.length > 0 && file.length > usableSpace) { + filesSkipped++ + Timber.w("Skipping ${file.fileName} (${file.length} bytes) — not enough free space") + return + } + + val downloadId = downloadFileUseCase( + DownloadFileUseCase.Params(accountName, file) + ) + if (downloadId != null) { + filesDownloaded++ + markDownloadEnqueued(file) + Timber.i("Enqueued download for: ${file.fileName}") + } else { + filesSkipped++ + Timber.d("Download already enqueued or skipped: ${file.fileName}") + } + } + + private fun markDownloadEnqueued(file: OCFile) { + filesEnqueuedInThisRun++ + bytesEnqueuedInThisRun += maxOf(0L, file.length) + if (filesEnqueuedInThisRun >= MAX_ENQUEUED_FILES_PER_RUN) { + Timber.i("Reached max enqueued files for this run ($MAX_ENQUEUED_FILES_PER_RUN). Requesting retry.") + wasInterrupted = true + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "Download Everything", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Shows progress when downloading all files" + } + val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + private suspend fun updateNotification(contentText: String) { + try { + setForeground(createForegroundInfo(contentText)) + } catch (e: Exception) { + Timber.e(e, "Error updating foreground notification") + } + } + + private fun createForegroundInfo(contentText: String): ForegroundInfo = + ForegroundInfo( + NOTIFICATION_ID, + buildNotification(contentText), + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + + private fun buildNotification(contentText: String): Notification = + NotificationCompat.Builder(appContext, NOTIFICATION_CHANNEL_ID) + .setContentTitle("Download Everything") + .setContentText(contentText) + .setStyle(NotificationCompat.BigTextStyle().bigText(contentText)) + .setSmallIcon(R.drawable.notification_icon) + .setOngoing(true) + .setProgress(0, 0, true) + .build() + + companion object { + const val DOWNLOAD_EVERYTHING_WORKER = "DOWNLOAD_EVERYTHING_WORKER" + const val repeatInterval: Long = 6L + val repeatIntervalTimeUnit: TimeUnit = TimeUnit.HOURS + + private const val NOTIFICATION_CHANNEL_ID = "download_everything_channel" + private const val NOTIFICATION_ID = 9001 + private const val MB = 1024L * 1024L + private const val MIN_FREE_SPACE_BYTES = 100L * MB + private const val MAX_ENQUEUED_FILES_PER_RUN = 500 + } +} diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadFileWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadFileWorker.kt index e588f16b38..90128f8e9a 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadFileWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadFileWorker.kt @@ -22,10 +22,18 @@ package eu.opencloud.android.workers import android.accounts.Account +import android.app.Notification import android.app.PendingIntent +import android.content.ContentValues import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import androidx.work.workDataOf import at.bitfire.dav4jvm.exception.UnauthorizedException @@ -42,6 +50,7 @@ import eu.opencloud.android.domain.files.usecases.GetFileByIdUseCase import eu.opencloud.android.domain.files.usecases.GetWebDavUrlForSpaceUseCase import eu.opencloud.android.domain.files.usecases.SaveDownloadWorkerUUIDUseCase import eu.opencloud.android.domain.files.usecases.SaveFileOrFolderUseCase +import eu.opencloud.android.domain.spaces.usecases.GetSpaceByIdForAccountUseCase import eu.opencloud.android.lib.common.OpenCloudAccount import eu.opencloud.android.lib.common.OpenCloudClient import eu.opencloud.android.lib.common.SingleSessionManager @@ -58,6 +67,7 @@ import eu.opencloud.android.utils.DOWNLOAD_NOTIFICATION_CHANNEL_ID import eu.opencloud.android.utils.DOWNLOAD_NOTIFICATION_ID_DEFAULT import eu.opencloud.android.utils.FileStorageUtils import eu.opencloud.android.utils.NOTIFICATION_TIMEOUT_STANDARD +import eu.opencloud.android.utils.NotificationUtils import eu.opencloud.android.utils.NotificationUtils.createBasicNotification import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -82,13 +92,19 @@ class DownloadFileWorker( private val saveDownloadWorkerUuidUseCase: SaveDownloadWorkerUUIDUseCase by inject() private val cleanWorkersUuidUseCase: CleanWorkersUUIDUseCase by inject() private val localStorageProvider: LocalStorageProvider by inject() + private val getSpaceByIdForAccountUseCase: GetSpaceByIdForAccountUseCase by inject() lateinit var account: Account lateinit var ocFile: OCFile + private var spaceName: String? = null private lateinit var downloadRemoteFileOperation: DownloadRemoteFileOperation private var lastPercent = 0 + private var foregroundInitialized = false + private var currentForegroundProgress = -1 + private val foregroundScope = CoroutineScope(Dispatchers.Main) + /** * Temporal path for this file to be downloaded. */ @@ -97,9 +113,19 @@ class DownloadFileWorker( /** * Temporal path where every file of this account will be downloaded. + * Uses the app's external cache directory which is always writable without special permissions + * AND is on the same filesystem as /storage/emulated/0/ so that File.renameTo() works + * when moving the downloaded file to its final location under OpenCloud/. */ private val temporalFolderPath - get() = FileStorageUtils.getTemporalPath(account.name, ocFile.spaceId) + get(): String { + val cacheBase = appContext.externalCacheDir ?: appContext.cacheDir + requireNotNull(cacheBase) { "Both externalCacheDir and cacheDir are null" } + val baseTmpDir = File(cacheBase, "download_tmp") + val accountDir = File(baseTmpDir, Uri.encode(account.name, "@")) + val spaceDir = if (ocFile.spaceId != null) File(accountDir, ocFile.spaceId!!) else accountDir + return spaceDir.absolutePath + } /** * Final path where this file should be stored. @@ -109,12 +135,18 @@ class DownloadFileWorker( */ private val finalLocationForFile: String get() = ocFile.storagePath.takeUnless { it.isNullOrBlank() } - ?: localStorageProvider.getDefaultSavePathFor(accountName = account.name, remotePath = ocFile.remotePath, spaceId = ocFile.spaceId) + ?: localStorageProvider.getDefaultSavePathFor( + accountName = account.name, + remotePath = ocFile.remotePath, + spaceId = ocFile.spaceId, + spaceName = spaceName + ) override suspend fun doWork(): Result { if (!areParametersValid()) return Result.failure() return try { + startForeground() downloadFileToTemporalFile() moveTemporalFileToFinalLocation() updateDatabaseWithLatestInfoForThisFile() @@ -140,6 +172,13 @@ class DownloadFileWorker( account = AccountUtils.getOpenCloudAccountByName(appContext, accountName) ?: return false ocFile = getFileByIdUseCase(GetFileByIdUseCase.Params(fileId)).getDataOrNull() ?: return false + if (ocFile.spaceId != null) { + val space = getSpaceByIdForAccountUseCase(GetSpaceByIdForAccountUseCase.Params(account.name, ocFile.spaceId)) + if (space != null) { + spaceName = space.name + } + } + return !ocFile.isFolder } @@ -190,9 +229,19 @@ class DownloadFileWorker( val finalLocation = File(finalLocationForFile) finalLocation.parentFile?.mkdirs() - val movedToTheFinalLocation = temporalLocation.renameTo(finalLocation) - if (!movedToTheFinalLocation) { + if (temporalLocation.renameTo(finalLocation)) { + return + } + + Timber.w("renameTo failed from %s to %s, falling back to copy+delete", temporalLocation.absolutePath, finalLocation.absolutePath) + try { + temporalLocation.copyTo(finalLocation, overwrite = true) + if (!temporalLocation.delete()) { + Timber.w("Failed to delete temporal file after copy: %s", temporalLocation.absolutePath) + } + } catch (e: Exception) { + Timber.e(e, "Copy+delete fallback also failed from %s to %s", temporalLocation.absolutePath, finalLocation.absolutePath) throw LocalStorageNotMovedException() } } @@ -211,6 +260,7 @@ class DownloadFileWorker( needsToUpdateThumbnail = true modificationTimestamp = downloadRemoteFileOperation.modificationTimestamp etag = downloadRemoteFileOperation.etag + remoteEtag = downloadRemoteFileOperation.etag storagePath = finalLocationForFile length = finalFile.length() // Use the file's actual mtime, not the current time. SynchronizeFileUseCase @@ -233,8 +283,6 @@ class DownloadFileWorker( ) ) - // To be done. Probably we will move it out from here. - //mStorageManager.triggerMediaScan(file.getStoragePath()) } /** @@ -316,7 +364,10 @@ class DownloadFileWorker( } private fun getClientForThisDownload(): OpenCloudClient = SingleSessionManager.getDefaultSingleton() - .getClientFor(OpenCloudAccount(AccountUtils.getOpenCloudAccountByName(appContext, account.name), appContext), appContext) + .getClientFor( + OpenCloudAccount(AccountUtils.getOpenCloudAccountByName(appContext, account.name), appContext), + appContext + ) override fun onTransferProgress( progressRate: Long, @@ -330,7 +381,8 @@ class DownloadFileWorker( downloadRemoteFileOperation.removeDatatransferProgressListener(this) } - val percent: Int = if (totalToTransfer == -1L) -1 else (100.0 * totalTransferredSoFar.toDouble() / totalToTransfer.toDouble()).toInt() + val percent: Int = if (totalToTransfer == -1L) -1 else (100.0 * totalTransferredSoFar.toDouble() / + totalToTransfer.toDouble()).toInt() if (percent == lastPercent) return // Set current progress. Observers will listen. @@ -339,9 +391,77 @@ class DownloadFileWorker( setProgress(progress) } + scheduleForegroundUpdate(percent) lastPercent = percent } + private suspend fun startForeground() { + if (foregroundInitialized) return + foregroundInitialized = true + currentForegroundProgress = Int.MIN_VALUE + try { + setForeground(createForegroundInfo(-1)) + } catch (e: Exception) { + Timber.w(e, "Failed to set foreground for download worker") + } + currentForegroundProgress = -1 + } + + private fun scheduleForegroundUpdate(progress: Int) { + if (!foregroundInitialized) return + if (progress == currentForegroundProgress) return + currentForegroundProgress = progress + foregroundScope.launch { + try { + setForeground(createForegroundInfo(progress)) + } catch (e: Exception) { + Timber.w(e, "Failed to update foreground notification") + } + } + } + + private fun createForegroundInfo(progress: Int): ForegroundInfo = + ForegroundInfo( + computeNotificationId(), + buildForegroundNotification(progress), + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + + private fun buildForegroundNotification(progress: Int): Notification { + val fileName = ocFile.fileName + val builder = NotificationUtils + .newNotificationBuilder(appContext, DOWNLOAD_NOTIFICATION_CHANNEL_ID) + .setContentTitle(appContext.getString(R.string.downloader_download_in_progress_ticker)) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setSubText(fileName) + + if (progress in 0..100) { + builder.setContentText( + appContext.getString( + R.string.downloader_download_in_progress_content, + progress, + fileName + ) + ) + builder.setProgress(100, progress, false) + } else { + builder.setContentText(appContext.getString(R.string.downloader_download_in_progress_ticker)) + builder.setProgress(0, 0, true) + } + + return builder.build() + } + + private fun computeNotificationId(): Int { + val id = ocFile.id ?: 0L + return if (id in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) { + id.toInt() + } else { + id.hashCode() + } + } + companion object { const val KEY_PARAM_ACCOUNT = "KEY_PARAM_ACCOUNT" const val KEY_PARAM_FILE_ID = "KEY_PARAM_FILE_ID" diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt new file mode 100644 index 0000000000..3c0c6b1f31 --- /dev/null +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt @@ -0,0 +1,233 @@ +/** + * openCloud Android client application + * + * @author OpenCloud Development Team + * + * Copyright (C) 2026 OpenCloud. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * 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 eu.opencloud.android.workers + +import android.accounts.AccountManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import eu.opencloud.android.MainApp +import eu.opencloud.android.R +import eu.opencloud.android.domain.UseCaseResult +import eu.opencloud.android.domain.files.FileRepository +import eu.opencloud.android.usecases.synchronization.SynchronizeFileUseCase +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import timber.log.Timber +import java.util.concurrent.TimeUnit + +/** + * Worker that periodically syncs locally modified files to the cloud. + * This is an opt-in feature that can be enabled in Security Settings. + * + * It monitors all downloaded files and checks if they have been modified locally. + * If a file has been modified, it uploads the new version to the server. + * + * Shows a notification with sync progress and results. + */ +class LocalFileSyncWorker( + private val appContext: Context, + workerParameters: WorkerParameters +) : CoroutineWorker( + appContext, + workerParameters +), KoinComponent { + + private val fileRepository: FileRepository by inject() + private val synchronizeFileUseCase: SynchronizeFileUseCase by inject() + + override suspend fun doWork(): Result { + Timber.i("LocalFileSyncWorker started") + + createNotificationChannel() + + return try { + val accountManager = AccountManager.get(appContext) + val accounts = accountManager.getAccountsByType(MainApp.accountType) + + Timber.i("Checking ${accounts.size} accounts for local file changes") + + var totalFilesChecked = 0 + var filesUploaded = 0 + var filesDownloaded = 0 + var filesWithConflicts = 0 + var filesAlreadySynced = 0 + var filesNotFound = 0 + var errors = 0 + + for (account in accounts) { + val accountName = account.name + Timber.d("Checking locally downloaded files for account: $accountName") + + val downloadedFiles = fileRepository.getDownloadedFilesForAccount(accountName) + Timber.d("Found ${downloadedFiles.size} downloaded files for account $accountName") + + for (file in downloadedFiles) { + if (isStopped) { + Timber.i("Worker stopped by system. Halting sync.") + return Result.success() + } + if (file.isFolder) continue + + totalFilesChecked++ + val result = syncSingleFile(file) + when (result) { + is FileSyncResult.Uploaded -> filesUploaded++ + is FileSyncResult.Downloaded -> filesDownloaded++ + is FileSyncResult.Conflict -> filesWithConflicts++ + is FileSyncResult.AlreadySynced -> filesAlreadySynced++ + is FileSyncResult.NotFound -> filesNotFound++ + is FileSyncResult.Error -> errors++ + } + } + } + + val summary = buildString { + append("Checked: $totalFilesChecked") + if (filesUploaded > 0) append(" | Uploaded: $filesUploaded") + if (filesDownloaded > 0) append(" | Downloaded: $filesDownloaded") + if (filesWithConflicts > 0) append(" | Conflicts: $filesWithConflicts") + if (errors > 0) append(" | Errors: $errors") + } + + Timber.i("LocalFileSyncWorker completed: $summary") + + if (filesUploaded > 0 || filesDownloaded > 0 || filesWithConflicts > 0) { + showCompletionNotification(summary) + } + + Result.success() + } catch (exception: Exception) { + Timber.e(exception, "LocalFileSyncWorker failed") + Result.failure() + } + } + + private suspend fun syncSingleFile(file: eu.opencloud.android.domain.files.model.OCFile): FileSyncResult { + try { + val storagePath = file.storagePath + val shouldSync = if (!storagePath.isNullOrBlank()) { + val localFile = java.io.File(storagePath) + if (localFile.exists()) { + val lastModified = localFile.lastModified() + val lastSync = file.lastSyncDateForData ?: 0 + lastModified > lastSync + } else { + true + } + } else { + true + } + + if (!shouldSync) return FileSyncResult.AlreadySynced + + val useCaseResult = synchronizeFileUseCase(SynchronizeFileUseCase.Params(file)) + return when (useCaseResult) { + is UseCaseResult.Success -> { + when (val syncResult = useCaseResult.data) { + is SynchronizeFileUseCase.SyncType.UploadEnqueued -> { + Timber.i("File ${file.fileName} has local changes, upload enqueued") + FileSyncResult.Uploaded + } + is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> { + Timber.i("File ${file.fileName} has remote changes, download enqueued") + FileSyncResult.Downloaded + } + is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy -> { + Timber.i( + "File ${file.fileName} had a conflict. " + + "Conflicted copy created at: ${syncResult.conflictedCopyPath}" + ) + FileSyncResult.Conflict + } + is SynchronizeFileUseCase.SyncType.AlreadySynchronized -> { + Timber.d("File ${file.fileName} is already synchronized") + FileSyncResult.AlreadySynced + } + is SynchronizeFileUseCase.SyncType.FileNotFound -> { + Timber.w("File ${file.fileName} was not found on server") + FileSyncResult.NotFound + } + } + } + is UseCaseResult.Error -> { + Timber.e(useCaseResult.throwable, "Error syncing file ${file.fileName}") + FileSyncResult.Error + } + } + } catch (e: Exception) { + Timber.e(e, "Error syncing file ${file.fileName}") + return FileSyncResult.Error + } + } + + private sealed interface FileSyncResult { + object Uploaded : FileSyncResult + object Downloaded : FileSyncResult + object Conflict : FileSyncResult + object AlreadySynced : FileSyncResult + object NotFound : FileSyncResult + object Error : FileSyncResult + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "Auto-Sync", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Shows when local file changes are synced" + } + val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + private fun showCompletionNotification(summary: String) { + try { + val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notification = NotificationCompat.Builder(appContext, NOTIFICATION_CHANNEL_ID) + .setContentTitle("Auto-Sync Complete") + .setContentText(summary) + .setSmallIcon(R.drawable.notification_icon) + .setAutoCancel(true) + .build() + + notificationManager.notify(NOTIFICATION_ID, notification) + } catch (e: Exception) { + Timber.e(e, "Error showing notification") + } + } + + companion object { + const val LOCAL_FILE_SYNC_WORKER = "LOCAL_FILE_SYNC_WORKER" + const val repeatInterval: Long = 2L + val repeatIntervalTimeUnit: TimeUnit = TimeUnit.HOURS + + private const val NOTIFICATION_CHANNEL_ID = "auto_sync_channel" + private const val NOTIFICATION_ID = 9002 + } +} diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt index bee2f350dd..44b499c4bd 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt @@ -35,7 +35,6 @@ import androidx.work.WorkerParameters import androidx.work.workDataOf import eu.opencloud.android.R import eu.opencloud.android.data.executeRemoteOperation -import eu.opencloud.android.data.providers.LocalStorageProvider import eu.opencloud.android.domain.automaticuploads.model.UploadBehavior import eu.opencloud.android.domain.exceptions.LocalFileNotFoundException import eu.opencloud.android.domain.exceptions.NetworkErrorException @@ -139,8 +138,12 @@ class UploadFileFromContentUriWorker( spaceWebDavUrl = getWebdavUrlForSpaceUseCase(GetWebDavUrlForSpaceUseCase.Params(accountName = account.name, spaceId = ocTransfer.spaceId)) - val localStorageProvider: LocalStorageProvider by inject() - cachePath = localStorageProvider.getTemporalPath(account.name, ocTransfer.spaceId) + uploadPath + val cacheBase = appContext.externalCacheDir ?: appContext.cacheDir + requireNotNull(cacheBase) { "Both externalCacheDir and cacheDir are null" } + val baseTmpDir = File(cacheBase, "upload_tmp") + val accountDir = File(baseTmpDir, Uri.encode(account.name, "@")) + val spaceDir = if (ocTransfer.spaceId != null) File(accountDir, ocTransfer.spaceId!!) else accountDir + cachePath = spaceDir.absolutePath + uploadPath if (ocTransfer.isContentUri(appContext)) { checkDocumentFileExists() @@ -302,9 +305,7 @@ class UploadFileFromContentUriWorker( val hasPendingTusSession = !ocTransfer.tusUploadUrl.isNullOrBlank() val shouldTryTus = hasPendingTusSession || (supportsTus && fileSize >= TusUploadHelper.DEFAULT_CHUNK_SIZE) - var attemptedTus = false if (shouldTryTus) { - attemptedTus = true Timber.d( "Attempting TUS upload (size=%d, threshold=%d, resume=%s)", fileSize, diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt index 91296f8427..212c131a3e 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt @@ -94,7 +94,8 @@ class UploadFileFromFileSystemWorker( private val getWebdavUrlForSpaceUseCase: GetWebDavUrlForSpaceUseCase by inject() // Etag in conflict required to overwrite files in server. Otherwise, the upload will be rejected. - private var eTagInConflict: String = "" + private var overwriteEtag: String = "" + private var overwriteEtagFromInput: String? = null private var lastPercent = -1 @@ -151,6 +152,7 @@ class UploadFileFromFileSystemWorker( val paramFileSystemUri = workerParameters.inputData.getString(KEY_PARAM_LOCAL_PATH) val paramUploadId = workerParameters.inputData.getLong(KEY_PARAM_UPLOAD_ID, -1) val paramRemoveLocal = workerParameters.inputData.getBoolean(KEY_PARAM_REMOVE_LOCAL, false) + overwriteEtagFromInput = workerParameters.inputData.getString(KEY_PARAM_OVERWRITE_ETAG) account = AccountUtils.getOpenCloudAccountByName(appContext, paramAccountName) ?: return false fileSystemPath = paramFileSystemUri.takeUnless { it.isNullOrBlank() } ?: return false @@ -223,18 +225,19 @@ class UploadFileFromFileSystemWorker( private fun checkNameCollisionAndGetAnAvailableOneInCase(client: OpenCloudClient) { if (ocTransfer.forceOverwrite) { - val getFileByRemotePathUseCase: GetFileByRemotePathUseCase by inject() - val useCaseResult = getFileByRemotePathUseCase( - GetFileByRemotePathUseCase.Params( - ocTransfer.accountName, - ocTransfer.remotePath, - ocTransfer.spaceId + overwriteEtag = overwriteEtagFromInput ?: run { + val getFileByRemotePathUseCase: GetFileByRemotePathUseCase by inject() + val useCaseResult = getFileByRemotePathUseCase( + GetFileByRemotePathUseCase.Params( + ocTransfer.accountName, + ocTransfer.remotePath, + ocTransfer.spaceId + ) ) - ) - - eTagInConflict = useCaseResult.getDataOrNull()?.etagInConflict.orEmpty() + useCaseResult.getDataOrNull()?.etag.orEmpty() + } - Timber.d("Upload will overwrite current server file with the following etag in conflict: $eTagInConflict") + Timber.d("Upload will overwrite current server file with required etag: $overwriteEtag") } else { Timber.d("Checking name collision in server") @@ -323,7 +326,7 @@ class UploadFileFromFileSystemWorker( remotePath = uploadPath, mimeType = mimetype, lastModifiedTimestamp = lastModified, - requiredEtag = eTagInConflict, + requiredEtag = overwriteEtag, spaceWebDavUrl = spaceWebDavUrl, ).apply { addDataTransferProgressListener(this@UploadFileFromFileSystemWorker) @@ -415,7 +418,8 @@ class UploadFileFromFileSystemWorker( if (ocTransfer.forceOverwrite) { ocFile.copy( needsToUpdateThumbnail = true, - etag = finalEtag, + etag = finalEtag.ifBlank { ocFile.etag }, + remoteEtag = finalEtag.ifBlank { ocFile.remoteEtag }, length = fileSize, lastSyncDateForData = currentTime, modifiedAtLastSyncForData = currentTime, @@ -426,6 +430,7 @@ class UploadFileFromFileSystemWorker( storagePath = null, needsToUpdateThumbnail = true, etag = finalEtag.ifBlank { ocFile.etag }, + remoteEtag = finalEtag.ifBlank { ocFile.remoteEtag }, length = fileSize, lastSyncDateForData = currentTime, modifiedAtLastSyncForData = currentTime, @@ -569,5 +574,6 @@ class UploadFileFromFileSystemWorker( const val KEY_PARAM_UPLOAD_PATH: String = "KEY_PARAM_UPLOAD_PATH" const val KEY_PARAM_UPLOAD_ID: String = "KEY_PARAM_UPLOAD_ID" const val KEY_PARAM_REMOVE_LOCAL: String = "KEY_REMOVE_LOCAL" + const val KEY_PARAM_OVERWRITE_ETAG: String = "KEY_PARAM_OVERWRITE_ETAG" } } diff --git a/opencloudApp/src/main/res/layout/activity_conflicts_resolve.xml b/opencloudApp/src/main/res/layout/activity_conflicts_resolve.xml deleted file mode 100644 index 87919826f9..0000000000 --- a/opencloudApp/src/main/res/layout/activity_conflicts_resolve.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/opencloudApp/src/main/res/values/setup.xml b/opencloudApp/src/main/res/values/setup.xml index 1b0b7dfdcd..6068c9c6da 100644 --- a/opencloudApp/src/main/res/values/setup.xml +++ b/opencloudApp/src/main/res/values/setup.xml @@ -11,7 +11,7 @@ eu.opencloud.search.users_and_groups.action.SHARE_WITH opencloud.db OpenCloud - opencloud + OpenCloud OpenCloud_ OpenCloud Mozilla/5.0 (Android) OpenCloud-android/%1$s diff --git a/opencloudApp/src/main/res/values/strings.xml b/opencloudApp/src/main/res/values/strings.xml index 35e5873b9b..57862db1b6 100644 --- a/opencloudApp/src/main/res/values/strings.xml +++ b/opencloudApp/src/main/res/values/strings.xml @@ -403,6 +403,7 @@ A new version was found in server. Downloading… Download enqueued Upload enqueued + Conflict resolved. Your local changes were saved as a separate copy. Folder could not be created File could not be created Forbidden characters: / \\ @@ -853,4 +854,24 @@ Added text labels on bottom bar Text labels were added and default active indicator is used to show which section is selected on the bottom bar + + Make all files available offline + Keep a local copy of all your files in the app for offline access (requires significant storage) + Download Everything + This will keep a local copy of ALL files from your cloud. This may use significant storage space and bandwidth. Continue? + + + Auto-sync local changes + Automatically upload changes to locally modified files + Auto-Sync + Local file changes will be automatically synced to the cloud. This requires a stable network connection. Continue? + + + Prefer local version on conflict + When a file is modified both locally and on server, upload local version instead of creating a conflicted copy + + + Enable File Manager Access + Saves offline files to a visible Android/media folder so other apps can access them + diff --git a/opencloudApp/src/main/res/xml/settings_security.xml b/opencloudApp/src/main/res/xml/settings_security.xml index 91c72bb0dd..7856e6f72e 100644 --- a/opencloudApp/src/main/res/xml/settings_security.xml +++ b/opencloudApp/src/main/res/xml/settings_security.xml @@ -49,4 +49,32 @@ app:summary="@string/prefs_touches_with_other_visible_windows_summary" app:title="@string/prefs_touches_with_other_visible_windows" /> + + + + + + + + + + + + \ No newline at end of file diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/DownloadRemoteFileOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/DownloadRemoteFileOperation.kt index 005aa600a3..e58575d737 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/DownloadRemoteFileOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/DownloadRemoteFileOperation.kt @@ -49,7 +49,7 @@ import java.util.concurrent.atomic.AtomicBoolean */ class DownloadRemoteFileOperation( private val remotePath: String, - localFolderPath: String, + private val localFolderPath: String, private val spaceWebDavUrl: String? = null, ) : RemoteOperation() { @@ -77,7 +77,24 @@ class DownloadRemoteFileOperation( // perform the download return try { - tmpFile.parentFile?.mkdirs() + val parent = tmpFile.parentFile + if (parent != null && !parent.mkdirs() && !parent.exists()) { + Timber.w("Failed to mkdirs for %s, checking for blocking files", parent.absolutePath) + val safeRoot = File(localFolderPath) + var current = parent + while (current != null && !current.exists() && current != safeRoot && current.parentFile != null) { + current = current.parentFile + } + if (current != null && current != safeRoot && current.isFile) { + throw java.io.IOException( + "Cannot create directory ${parent.absolutePath}: a file exists at ${current.absolutePath} " + + "blocking the path. Please remove the file manually." + ) + } + if (!parent.mkdirs() && !parent.exists()) { + throw java.io.IOException("Could not create parent directory: " + parent.absolutePath) + } + } downloadFile(client, tmpFile).also { Timber.i("Download of $remotePath to $tmpPath - HTTP status code: $status") } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/services/UserService.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/services/UserService.kt index ff206a535c..91bbf6ac07 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/services/UserService.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/services/UserService.kt @@ -33,5 +33,5 @@ import eu.opencloud.android.lib.resources.users.RemoteUserInfo interface UserService : Service { fun getUserInfo(): RemoteOperationResult fun getUserQuota(): RemoteOperationResult - fun getUserAvatar(avatarDimension: Int): RemoteOperationResult + fun getUserAvatar(): RemoteOperationResult } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/services/implementation/OCUserService.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/services/implementation/OCUserService.kt index 8481f91670..edf2b258f1 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/services/implementation/OCUserService.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/services/implementation/OCUserService.kt @@ -42,7 +42,7 @@ class OCUserService(override val client: OpenCloudClient) : UserService { override fun getUserQuota(): RemoteOperationResult = GetRemoteUserQuotaOperation().execute(client) - override fun getUserAvatar(avatarDimension: Int): RemoteOperationResult = + override fun getUserAvatar(): RemoteOperationResult = GetRemoteUserAvatarOperation().execute(client) } diff --git a/opencloudData/build.gradle b/opencloudData/build.gradle index 7a57eb9710..f77e8df34c 100644 --- a/opencloudData/build.gradle +++ b/opencloudData/build.gradle @@ -61,6 +61,7 @@ dependencies { // Androidx implementation libs.androidx.core.ktx + implementation libs.androidx.datastore.preferences implementation libs.androidx.lifecycle.livedata.ktx // Room diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/implementation/OCRemoteFileDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/implementation/OCRemoteFileDataSource.kt index 333bf4cd69..2404aca9f9 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/implementation/OCRemoteFileDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/implementation/OCRemoteFileDataSource.kt @@ -218,7 +218,11 @@ class OCRemoteFileDataSource( OCFile( owner = owner, remoteId = remoteId, - remotePath = remotePath, + remotePath = if (isFolder && !remotePath.endsWith(OCFile.PATH_SEPARATOR)) { + "$remotePath${OCFile.PATH_SEPARATOR}" + } else { + remotePath + }, length = if (isFolder) { size } else { diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/providers/LocalStorageProvider.kt b/opencloudData/src/main/java/eu/opencloud/android/data/providers/LocalStorageProvider.kt index c41846fa6e..33424b841f 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/providers/LocalStorageProvider.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/providers/LocalStorageProvider.kt @@ -36,20 +36,22 @@ import java.io.File import java.util.concurrent.TimeUnit import kotlin.system.measureTimeMillis -sealed class LocalStorageProvider(private val rootFolderName: String) { +sealed class LocalStorageProvider(protected val rootFolderName: String) { abstract fun getPrimaryStorageDirectory(): File /** * Return the root path of primary shared/external storage directory for this application. - * For example: /storage/emulated/0/opencloud + * For example: /storage/emulated/0/OpenCloud */ - fun getRootFolderPath(): String = getPrimaryStorageDirectory().absolutePath + File.separator + rootFolderName + open fun getRootFolderPath(): String = getPrimaryStorageDirectory().absolutePath + File.separator + rootFolderName + + open fun invalidateCache() {} /** * Get local storage path for accountName. */ - private fun getAccountDirectoryPath( + protected open fun getAccountDirectoryPath( accountName: String ): String = getRootFolderPath() + File.separator + getEncodedAccountName(accountName) @@ -62,9 +64,16 @@ sealed class LocalStorageProvider(private val rootFolderName: String) { accountName: String, remotePath: String, spaceId: String?, + spaceName: String? = null, ): String = if (spaceId != null) { - getAccountDirectoryPath(accountName) + File.separator + spaceId + File.separator + remotePath + val spaceFolder = if (!spaceName.isNullOrBlank()) { + val sanitized = sanitizeAccountName(spaceName) + "$sanitized ($spaceId)" + } else { + spaceId + } + getAccountDirectoryPath(accountName) + File.separator + spaceFolder + File.separator + remotePath } else { getAccountDirectoryPath(accountName) + remotePath } @@ -149,6 +158,13 @@ sealed class LocalStorageProvider(private val rootFolderName: String) { */ private fun getEncodedAccountName(accountName: String?): String = Uri.encode(accountName, "@") + /** + * Sanitize account name for use as a directory name. + * Uses URI encoding to avoid collisions between different account names. + * Keeps '@' unencoded so email-based accounts remain readable. + */ + protected fun sanitizeAccountName(accountName: String): String = Uri.encode(accountName, "@") + fun moveLegacyToScopedStorage() { val timeInMillis = measureTimeMillis { moveFileOrFolderToScopedStorage(retrieveRootLegacyStorage()) @@ -230,10 +246,30 @@ sealed class LocalStorageProvider(private val rootFolderName: String) { } val targetFile = File(finalStoragePath) val targetFolder = targetFile.parentFile - if (targetFolder != null && !targetFolder.exists()) { - targetFolder.mkdirs() + if (targetFolder != null && !targetFolder.mkdirs() && !targetFolder.exists()) { + val safeRoot = File(getRootFolderPath()) + var current = targetFolder + while (current != null && !current.exists() && current != safeRoot && current.parentFile != null) { + current = current.parentFile + } + if (current != null && current != safeRoot && current.isFile) { + Timber.e("Cannot create directory ${targetFolder.absolutePath}: a file exists at ${current.absolutePath} blocking the path") + return + } + } + if (!fileToMove.renameTo(targetFile)) { + Timber.w("renameTo failed for ${fileToMove.absolutePath} -> ${targetFile.absolutePath}. Falling back to copy+delete.") + try { + fileToMove.copyTo(targetFile, overwrite = true) + if (!fileToMove.delete()) { + Timber.e("Fallback copy succeeded but delete failed for ${fileToMove.absolutePath}. Removing copy to avoid duplicates.") + targetFile.delete() + } + } catch (e: Exception) { + Timber.e(e, "Fallback copy+delete also failed for ${fileToMove.absolutePath}") + targetFile.delete() + } } - fileToMove.renameTo(targetFile) } fun clearUnrelatedTemporalFiles(uploads: List, accountsNames: List) { diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/providers/ScopedStorageProvider.kt b/opencloudData/src/main/java/eu/opencloud/android/data/providers/ScopedStorageProvider.kt index a9a4c996c8..0c13ffb72a 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/providers/ScopedStorageProvider.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/providers/ScopedStorageProvider.kt @@ -20,6 +20,8 @@ package eu.opencloud.android.data.providers import android.content.Context +import eu.opencloud.android.data.providers.implementation.OCSharedPreferencesProvider +import timber.log.Timber import java.io.File class ScopedStorageProvider( @@ -27,5 +29,89 @@ class ScopedStorageProvider( private val context: Context ) : LocalStorageProvider(rootFolderName) { - override fun getPrimaryStorageDirectory(): File = context.filesDir + private var rootFolderPath: String? = null + + override fun getPrimaryStorageDirectory(): File { + val prefs = OCSharedPreferencesProvider(context) + val fileManagerAccessEnabled = prefs.getBoolean("enable_file_manager_access", false) + + if (fileManagerAccessEnabled) { + val mediaDir = context.externalMediaDirs.firstOrNull() + if (mediaDir != null) { + if (!mediaDir.exists()) { + mediaDir.mkdirs() + } + return mediaDir + } + Timber.w("File manager access enabled but external media dir is unavailable") + } + + val externalDir = context.getExternalFilesDir(null) + val internalDir = context.filesDir + val internalRoot = File(internalDir, rootFolderName) + val internalRootLegacy = File(internalDir, rootFolderName.lowercase()) + + // If there's existing data in internal storage, keep using it to avoid a split storage layout. + // Check both current rootFolderName and legacy lowercase variant for existing installs. + // New installs without prior data will use external storage if available. + return if ( + (internalRoot.exists() && internalRoot.isDirectory && internalRoot.listFiles()?.isNotEmpty() == true) || + (internalRootLegacy.exists() && internalRootLegacy.isDirectory) + ) { + internalDir + } else { + externalDir ?: internalDir + } + } + + override fun getRootFolderPath(): String { + rootFolderPath?.let { return it } + + val newPath = super.getRootFolderPath() + val newDir = File(newPath) + + if (newDir.exists()) { + rootFolderPath = newPath + return newPath + } + + val primaryStorage = getPrimaryStorageDirectory().absolutePath + val oldFolderName = rootFolderName.lowercase() + val oldPath = primaryStorage + File.separator + oldFolderName + val oldDir = File(oldPath) + + if (oldDir.exists() && oldDir.isDirectory) { + try { + newDir.parentFile?.mkdirs() + if (oldDir.renameTo(newDir)) { + Timber.i("Migrated root folder from '$oldFolderName' to '$rootFolderName'") + rootFolderPath = newPath + return newPath + } else if (newDir.exists() && oldDir.canonicalPath == newDir.canonicalPath) { + // Case-insensitive filesystem: renameTo may fail but both paths refer to the same directory + Timber.i("Root folder already accessible at new path (case-insensitive filesystem)") + rootFolderPath = newPath + return newPath + } else { + Timber.w("Failed to rename root folder from '$oldFolderName' to '$rootFolderName', using old path") + rootFolderPath = oldPath + return oldPath + } + } catch (e: Exception) { + Timber.e(e, "Failed to migrate root folder name") + rootFolderPath = oldPath + return oldPath + } + } + + rootFolderPath = newPath + return newPath + } + + override fun getAccountDirectoryPath(accountName: String): String = + getRootFolderPath() + File.separator + sanitizeAccountName(accountName) + + override fun invalidateCache() { + rootFolderPath = null + } } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/user/datasources/implementation/OCRemoteUserDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/user/datasources/implementation/OCRemoteUserDataSource.kt index d5d074550c..c65b4865be 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/user/datasources/implementation/OCRemoteUserDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/user/datasources/implementation/OCRemoteUserDataSource.kt @@ -32,7 +32,6 @@ import eu.opencloud.android.lib.resources.users.RemoteUserInfo class OCRemoteUserDataSource( private val clientManager: ClientManager, - private val avatarDimension: Int ) : RemoteUserDataSource { override fun getUserInfo(accountName: String): UserInfo = @@ -47,7 +46,7 @@ class OCRemoteUserDataSource( override fun getUserAvatar(accountName: String): UserAvatar = executeRemoteOperation { - clientManager.getUserService(accountName = accountName).getUserAvatar(avatarDimension) + clientManager.getUserService(accountName = accountName).getUserAvatar() }.toDomain() } diff --git a/opencloudData/src/test/java/eu/opencloud/android/data/providers/ScopedStorageProviderTest.kt b/opencloudData/src/test/java/eu/opencloud/android/data/providers/ScopedStorageProviderTest.kt index d01ab0ae09..6427eda2f2 100644 --- a/opencloudData/src/test/java/eu/opencloud/android/data/providers/ScopedStorageProviderTest.kt +++ b/opencloudData/src/test/java/eu/opencloud/android/data/providers/ScopedStorageProviderTest.kt @@ -8,7 +8,10 @@ import eu.opencloud.android.testutil.OC_SPACE_PROJECT_WITH_IMAGE import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.spyk import io.mockk.verify +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before @@ -43,46 +46,71 @@ class ScopedStorageProviderTest { File(this, "child.bin").writeBytes(ByteArray(expectedSizeOfDirectoryValue.toInt())) } - scopedStorageProvider = ScopedStorageProvider(rootFolderName, context) + scopedStorageProvider = spyk(ScopedStorageProvider(rootFolderName, context)) + every { context.getExternalFilesDir(null) } returns filesDir every { context.filesDir } returns filesDir } + @After + fun tearDown() { + unmockkAll() + } + @Test - fun `getPrimaryStorageDirectory returns filesDir`() { + fun `getPrimaryStorageDirectory returns external files dir when no internal data exists`() { val result = scopedStorageProvider.getPrimaryStorageDirectory() assertEquals(filesDir, result) verify(exactly = 1) { - context.filesDir + context.getExternalFilesDir(null) } } + @Test + fun `getPrimaryStorageDirectory returns internal files dir when internal data exists`() { + // Simulate existing data in internal storage + val internalFilesDir = Files.createTempDirectory("internal-storage").toFile().apply { deleteOnExit() } + val internalRoot = File(internalFilesDir, rootFolderName) + internalRoot.mkdirs() + File(internalRoot, "existing_file.txt").writeBytes(ByteArray(10)) + + every { context.filesDir } returns internalFilesDir + + val result = scopedStorageProvider.getPrimaryStorageDirectory() + assertEquals(internalFilesDir, result) + } + + @Test + fun `getPrimaryStorageDirectory returns internal files dir when legacy lowercase internal data exists`() { + // Simulate existing data in internal storage under old lowercase folder name + val internalFilesDir = Files.createTempDirectory("internal-storage-legacy").toFile().apply { deleteOnExit() } + val internalRootLegacy = File(internalFilesDir, rootFolderName.lowercase()) + internalRootLegacy.mkdirs() + File(internalRootLegacy, "existing_file.txt").writeBytes(ByteArray(10)) + + every { context.filesDir } returns internalFilesDir + + val result = scopedStorageProvider.getPrimaryStorageDirectory() + assertEquals(internalFilesDir, result) + } + @Test fun `getRootFolderPath returns the root folder path String`() { - val rootFolderPath = filesDir.absolutePath + File.separator + rootFolderName val actualPath = scopedStorageProvider.getRootFolderPath() assertEquals(rootFolderPath, actualPath) - - verify(exactly = 1) { - scopedStorageProvider.getPrimaryStorageDirectory() - } - } @Test fun `getDefaultSavePathFor returns the path with spaces when there is a space`() { mockkStatic(Uri::class) - every { Uri.encode(accountName, "@") } returns uriEncoded + every { Uri.encode(accountName, "@") } returns accountName + every { scopedStorageProvider.getRootFolderPath() } returns rootFolderPath - val accountDirectoryPath = filesDir.absolutePath + File.separator + rootFolderName + File.separator + uriEncoded + val accountDirectoryPath = rootFolderPath + File.separator + accountName val expectedPath = accountDirectoryPath + File.separator + spaceId + File.separator + remotePath val actualPath = scopedStorageProvider.getDefaultSavePathFor(accountName, remotePath, spaceId) assertEquals(expectedPath, actualPath) - - verify(exactly = 1) { - scopedStorageProvider.getPrimaryStorageDirectory() - } } @Test @@ -90,17 +118,14 @@ class ScopedStorageProviderTest { val spaceId = null mockkStatic(Uri::class) - every { Uri.encode(accountName, "@") } returns uriEncoded + every { Uri.encode(accountName, "@") } returns accountName + every { scopedStorageProvider.getRootFolderPath() } returns rootFolderPath - val accountDirectoryPath = filesDir.absolutePath + File.separator + rootFolderName + File.separator + uriEncoded + val accountDirectoryPath = rootFolderPath + File.separator + accountName val expectedPath = accountDirectoryPath + remotePath val actualPath = scopedStorageProvider.getDefaultSavePathFor(accountName, remotePath, spaceId) assertEquals(expectedPath, actualPath) - - verify(exactly = 1) { - scopedStorageProvider.getPrimaryStorageDirectory() - } } @Test @@ -154,16 +179,13 @@ class ScopedStorageProviderTest { fun `getTemporalPath returns expected temporal path with separator and space when there is a space`() { mockkStatic(Uri::class) every { Uri.encode(accountName, "@") } returns uriEncoded + every { scopedStorageProvider.getRootFolderPath() } returns rootFolderPath val temporalPathWithoutSpace = rootFolderPath + File.separator + "tmp" + File.separator + uriEncoded val expectedValue = temporalPathWithoutSpace + File.separator + spaceId val actualValue = scopedStorageProvider.getTemporalPath(accountName, spaceId) assertEquals(expectedValue, actualValue) - - verify(exactly = 1) { - scopedStorageProvider.getPrimaryStorageDirectory() - } } @Test @@ -172,36 +194,27 @@ class ScopedStorageProviderTest { mockkStatic(Uri::class) every { Uri.encode(accountName, "@") } returns uriEncoded + every { scopedStorageProvider.getRootFolderPath() } returns rootFolderPath val expectedValue = rootFolderPath + File.separator + TEMPORAL_FOLDER_NAME + File.separator + uriEncoded val actualValue = scopedStorageProvider.getTemporalPath(accountName, spaceId) assertEquals(expectedValue, actualValue) - - verify(exactly = 1) { - scopedStorageProvider.getPrimaryStorageDirectory() - } } @Test fun `getLogsPath returns logs path`() { + every { scopedStorageProvider.getRootFolderPath() } returns rootFolderPath + val expectedValue = rootFolderPath + File.separator + LOGS_FOLDER_NAME + File.separator val actualValue = scopedStorageProvider.getLogsPath() assertEquals(expectedValue, actualValue) - - verify(exactly = 1) { - scopedStorageProvider.getPrimaryStorageDirectory() - } } @Test fun `getUsableSpace returns usable space from the storage directory`() { val actualUsableSpace = scopedStorageProvider.getUsableSpace() assertTrue(actualUsableSpace > 0) - - verify(exactly = 1) { - scopedStorageProvider.getPrimaryStorageDirectory() - } } @Test @@ -237,11 +250,8 @@ class ScopedStorageProviderTest { fun `deleteLocalFile calls getPrimaryStorageDirectory()`() { mockkStatic(Uri::class) every { Uri.encode(any(), any()) } returns uriEncoded + every { scopedStorageProvider.getRootFolderPath() } returns rootFolderPath scopedStorageProvider.deleteLocalFile(OC_FILE) - - verify(exactly = 1) { - scopedStorageProvider.getPrimaryStorageDirectory() - } } @Test @@ -250,11 +260,8 @@ class ScopedStorageProviderTest { mockkStatic(Uri::class) every { Uri.encode(any(), any()) } returns uriEncoded + every { scopedStorageProvider.getRootFolderPath() } returns rootFolderPath scopedStorageProvider.moveLocalFile(OC_FILE, finalStoragePath) - - verify(exactly = 1) { - scopedStorageProvider.getPrimaryStorageDirectory() - } } @Test @@ -265,14 +272,11 @@ class ScopedStorageProviderTest { mockkStatic(Uri::class) every { Uri.encode(any(), any()) } returns uriEncoded + every { scopedStorageProvider.getRootFolderPath() } returns rootFolderPath every { transfer.accountName } returns accountName every { transfer.localPath } returns localPath scopedStorageProvider.deleteCacheIfNeeded(transfer) - - verify(exactly = 1) { - scopedStorageProvider.getPrimaryStorageDirectory() - } } private fun expectedRemotePath(current: String, newName: String, isFolder: Boolean): String { diff --git a/opencloudData/src/test/java/eu/opencloud/android/data/user/datasources/implementation/OCRemoteUserDataSourceTest.kt b/opencloudData/src/test/java/eu/opencloud/android/data/user/datasources/implementation/OCRemoteUserDataSourceTest.kt index 3abb55562e..9db5ab7c04 100644 --- a/opencloudData/src/test/java/eu/opencloud/android/data/user/datasources/implementation/OCRemoteUserDataSourceTest.kt +++ b/opencloudData/src/test/java/eu/opencloud/android/data/user/datasources/implementation/OCRemoteUserDataSourceTest.kt @@ -44,8 +44,6 @@ class OCRemoteUserDataSourceTest { private val clientManager: ClientManager = mockk(relaxed = true) private val ocUserService: OCUserService = mockk() - private val avatarDimension = 128 - private val remoteUserInfo = RemoteUserInfo( id = OC_USER_INFO.id, displayName = OC_USER_INFO.displayName, @@ -69,7 +67,6 @@ class OCRemoteUserDataSourceTest { ocRemoteUserDataSource = OCRemoteUserDataSource( clientManager, - avatarDimension ) } @@ -113,7 +110,7 @@ class OCRemoteUserDataSourceTest { createRemoteOperationResultMock(data = remoteAvatar, isSuccess = true) every { - ocUserService.getUserAvatar(avatarDimension) + ocUserService.getUserAvatar() } returns getUserAvatarResult val userAvatar = ocRemoteUserDataSource.getUserAvatar(OC_ACCOUNT_NAME) @@ -121,7 +118,7 @@ class OCRemoteUserDataSourceTest { assertNotNull(userAvatar) assertEquals(OC_USER_AVATAR, userAvatar) - verify(exactly = 1) { ocUserService.getUserAvatar(avatarDimension) } + verify(exactly = 1) { ocUserService.getUserAvatar() } } } diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/files/usecases/SaveConflictUseCase.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/files/usecases/SaveConflictUseCase.kt deleted file mode 100644 index 09d545ffea..0000000000 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/files/usecases/SaveConflictUseCase.kt +++ /dev/null @@ -1,36 +0,0 @@ -/** - * openCloud Android client application - * - * @author Juan Carlos Garrote Gascón - * - * Copyright (C) 2022 ownCloud GmbH. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 2, - * as published by the Free Software Foundation. - * - * 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 eu.opencloud.android.domain.files.usecases - -import eu.opencloud.android.domain.BaseUseCaseWithResult -import eu.opencloud.android.domain.files.FileRepository - -class SaveConflictUseCase( - private val fileRepository: FileRepository -) : BaseUseCaseWithResult() { - override fun run(params: Params) = - fileRepository.saveConflict(params.fileId, params.eTagInConflict) - - data class Params( - val fileId: Long, - val eTagInConflict: String - ) -} From 6c293604315fbd294e06d312fd4811b0409e2d48 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sun, 26 Apr 2026 15:25:53 +0200 Subject: [PATCH 2/2] Fix detekt and unit test failures --- .../android/extensions/ActivityExt.kt | 1 - .../security/SettingsSecurityFragment.kt | 8 ++++---- .../android/utils/StorageMigrationHelper.kt | 6 +++--- .../workers/DownloadEverythingWorker.kt | 4 +++- .../android/workers/DownloadFileWorker.kt | 4 ---- .../viewmodels/DrawerViewModelTest.kt | 2 -- .../presentation/viewmodels/ViewModelTest.kt | 7 +++---- .../AuthenticationViewModelTest.kt | 2 -- .../authentication/OAuthViewModelTest.kt | 2 -- .../viewmodels/sharing/ShareViewModelTest.kt | 19 ++++++++----------- .../data/providers/ScopedStorageProvider.kt | 12 +++++++++--- .../providers/ScopedStorageProviderTest.kt | 5 ++++- 12 files changed, 34 insertions(+), 38 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt b/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt index 7659c17bca..069f3e5e66 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt @@ -33,7 +33,6 @@ import android.net.Uri import android.text.method.LinkMovementMethod import android.util.TypedValue import android.view.inputmethod.InputMethodManager -import android.webkit.MimeTypeMap import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt index 5246b81327..c1ef7f3573 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt @@ -271,7 +271,7 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { } else { "Disabling this will move existing offline files back to the hidden internal storage. This may take a moment." } - + AlertDialog.Builder(it) .setTitle(getString(R.string.prefs_enable_file_manager_access)) .setMessage(message) @@ -281,13 +281,13 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { } .setPositiveButton(getString(R.string.common_yes)) { _, _ -> val oldPath = localStorageProvider.getRootFolderPath() - + // Update UI state prefFileManagerAccess?.isChecked = isEnabled localStorageProvider.invalidateCache() - + val newPath = localStorageProvider.getRootFolderPath() - + // Perform migration lifecycleScope.launch { StorageMigrationHelper.migrateStorageDirectory( diff --git a/opencloudApp/src/main/java/eu/opencloud/android/utils/StorageMigrationHelper.kt b/opencloudApp/src/main/java/eu/opencloud/android/utils/StorageMigrationHelper.kt index 05ae76d967..f94d1a5778 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/utils/StorageMigrationHelper.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/utils/StorageMigrationHelper.kt @@ -29,7 +29,7 @@ object StorageMigrationHelper { try { newDir.parentFile?.mkdirs() - + // Fast path: Try to rename the root directory if (oldDir.renameTo(newDir)) { Timber.i("Successfully renamed root directory from $oldRootPath to $newRootPath") @@ -48,13 +48,13 @@ object StorageMigrationHelper { file.delete() } } - + // Clean up old root oldDir.deleteRecursively() Timber.i("Successfully moved files to $newRootPath") fileRepository.updateDownloadedFilesStorageDirectoryInStoragePath(oldRootPath, newRootPath) return@withContext true - + } catch (e: Exception) { Timber.e(e, "Error migrating storage directory from $oldRootPath to $newRootPath") return@withContext false diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt index 343d2aa668..f06136db1e 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt @@ -459,7 +459,9 @@ class DownloadEverythingWorker( ) when (val useCaseResult = synchronizeFileUseCase(SynchronizeFileUseCase.Params(file))) { - is UseCaseResult.Success -> handleStaleLocalSyncResult(file, useCaseResult.data) + is UseCaseResult.Success -> { + handleStaleLocalSyncResult(file, useCaseResult.data) + } is UseCaseResult.Error -> { filesSkipped++ Timber.e(useCaseResult.throwable, "Error synchronizing stale local file ${file.fileName}") diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadFileWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadFileWorker.kt index 90128f8e9a..835cb2cd90 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadFileWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadFileWorker.kt @@ -24,14 +24,10 @@ package eu.opencloud.android.workers import android.accounts.Account import android.app.Notification import android.app.PendingIntent -import android.content.ContentValues import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.MediaStore import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.WorkerParameters diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/DrawerViewModelTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/DrawerViewModelTest.kt index fdcd50e2e8..a3a212e120 100644 --- a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/DrawerViewModelTest.kt +++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/DrawerViewModelTest.kt @@ -69,8 +69,6 @@ class DrawerViewModelTest : ViewModelTest() { getUserQuotasUseCase = mockk() localStorageProvider = mockk() - testCoroutineDispatcher.pauseDispatcher() - drawerViewModel = DrawerViewModel( getStoredQuotaAsStreamUseCase = getStoredQuotaAsStreamUseCase, removeAccountUseCase = removeAccountUseCase, diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/ViewModelTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/ViewModelTest.kt index fe323d997f..3bc17faab2 100644 --- a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/ViewModelTest.kt +++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/ViewModelTest.kt @@ -28,7 +28,7 @@ import eu.opencloud.android.testutil.livedata.getEmittedValues import io.mockk.unmockkAll import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import org.junit.After import org.junit.Assert.assertEquals @@ -41,7 +41,7 @@ open class ViewModelTest { @JvmField val instantExecutorRule = InstantTaskExecutorRule() - val testCoroutineDispatcher = TestCoroutineDispatcher() + val testCoroutineDispatcher = StandardTestDispatcher() val coroutineDispatcherProvider: CoroutinesDispatcherProvider = CoroutinesDispatcherProvider( io = testCoroutineDispatcher, main = testCoroutineDispatcher, @@ -51,7 +51,6 @@ open class ViewModelTest { @After open fun tearDown() { Dispatchers.resetMain() - testCoroutineDispatcher.cleanupTestCoroutines() unmockkAll() } @@ -61,7 +60,7 @@ open class ViewModelTest { liveData: LiveData>> ) { val emittedValues = liveData.getEmittedValues(expectedValues.size) { - testCoroutineDispatcher.resumeDispatcher() + testCoroutineDispatcher.scheduler.advanceUntilIdle() } assertEquals(expectedValues, emittedValues) } diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/authentication/AuthenticationViewModelTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/authentication/AuthenticationViewModelTest.kt index c6f7f4c307..4ce734c701 100644 --- a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/authentication/AuthenticationViewModelTest.kt +++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/authentication/AuthenticationViewModelTest.kt @@ -129,8 +129,6 @@ class AuthenticationViewModelTest : ViewModelTest() { every { contextProvider.getBoolean(R.bool.enforce_secure_connection) } returns false every { contextProvider.getBoolean(R.bool.enforce_oidc) } returns false - testCoroutineDispatcher.pauseDispatcher() - authenticationViewModel = AuthenticationViewModel( loginBasicAsyncUseCase = loginBasicAsyncUseCase, loginOAuthAsyncUseCase = loginOAuthAsyncUseCase, diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/authentication/OAuthViewModelTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/authentication/OAuthViewModelTest.kt index 23f19e219c..ee736131a0 100644 --- a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/authentication/OAuthViewModelTest.kt +++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/authentication/OAuthViewModelTest.kt @@ -87,8 +87,6 @@ class OAuthViewModelTest : ViewModelTest() { requestTokenUseCase = mockk() registerClientUseCase = mockk() - testCoroutineDispatcher.pauseDispatcher() - oAuthViewModel = OAuthViewModel( getOIDCDiscoveryUseCase = getOIDCDiscoveryUseCase, requestTokenUseCase = requestTokenUseCase, diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/sharing/ShareViewModelTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/sharing/ShareViewModelTest.kt index a8953b6519..10c6acff5c 100644 --- a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/sharing/ShareViewModelTest.kt +++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/sharing/ShareViewModelTest.kt @@ -51,7 +51,7 @@ import io.mockk.spyk import io.mockk.unmockkAll import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.After @@ -84,7 +84,7 @@ class ShareViewModelTest { private val sharesLiveData = MutableLiveData>() private val privateShareLiveData = MutableLiveData() - private val testCoroutineDispatcher = TestCoroutineDispatcher() + private val testCoroutineDispatcher = StandardTestDispatcher() private val coroutineDispatcherProvider: CoroutinesDispatcherProvider = CoroutinesDispatcherProvider( io = testCoroutineDispatcher, main = testCoroutineDispatcher, @@ -117,7 +117,6 @@ class ShareViewModelTest { @After fun tearDown() { Dispatchers.resetMain() - testCoroutineDispatcher.cleanupTestCoroutines() stopKoin() unmockkAll() @@ -137,8 +136,6 @@ class ShareViewModelTest { every { getSharesAsLiveDataUseCase(any()) } returns sharesLiveData every { getShareAsLiveDataUseCase(any()) } returns privateShareLiveData - testCoroutineDispatcher.pauseDispatcher() - shareViewModel = ShareViewModel( filePath, testAccountName, @@ -193,7 +190,7 @@ class ShareViewModelTest { ) val emittedValues = shareViewModel.privateShareCreationStatus.getEmittedValues(expectedValues.size) { - testCoroutineDispatcher.resumeDispatcher() + testCoroutineDispatcher.scheduler.advanceUntilIdle() } assertEquals(expectedValues, emittedValues) @@ -209,7 +206,7 @@ class ShareViewModelTest { shareViewModel.refreshPrivateShare(OC_SHARE.remoteId) val emittedValues = shareViewModel.privateShare.getLastEmittedValue { - testCoroutineDispatcher.resumeDispatcher() + testCoroutineDispatcher.scheduler.advanceUntilIdle() } assertEquals(Event(UIResult.Success(OC_SHARE)), emittedValues) @@ -248,7 +245,7 @@ class ShareViewModelTest { ) val emittedValues = shareViewModel.privateShareEditionStatus.getEmittedValues(expectedValues.size) { - testCoroutineDispatcher.resumeDispatcher() + testCoroutineDispatcher.scheduler.advanceUntilIdle() } assertEquals(expectedValues, emittedValues) @@ -295,7 +292,7 @@ class ShareViewModelTest { ) val emittedValues = shareViewModel.publicShareCreationStatus.getEmittedValues(expectedValues.size) { - testCoroutineDispatcher.resumeDispatcher() + testCoroutineDispatcher.scheduler.advanceUntilIdle() } assertEquals(expectedValues, emittedValues) @@ -338,7 +335,7 @@ class ShareViewModelTest { ) val emittedValues = shareViewModel.publicShareEditionStatus.getEmittedValues(expectedValues.size) { - testCoroutineDispatcher.resumeDispatcher() + testCoroutineDispatcher.scheduler.advanceUntilIdle() } assertEquals(expectedValues, emittedValues) @@ -378,7 +375,7 @@ class ShareViewModelTest { shareViewModel.deleteShare(remoteId = OC_SHARE.remoteId) val emittedValues = shareViewModel.shareDeletionStatus.getEmittedValues(expectedValues.size) { - testCoroutineDispatcher.resumeDispatcher() + testCoroutineDispatcher.scheduler.advanceUntilIdle() } assertEquals(expectedValues, emittedValues) diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/providers/ScopedStorageProvider.kt b/opencloudData/src/main/java/eu/opencloud/android/data/providers/ScopedStorageProvider.kt index 0c13ffb72a..2258aaa992 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/providers/ScopedStorageProvider.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/providers/ScopedStorageProvider.kt @@ -26,14 +26,20 @@ import java.io.File class ScopedStorageProvider( rootFolderName: String, - private val context: Context + private val context: Context, + private val preferencesProvider: SharedPreferencesProvider ) : LocalStorageProvider(rootFolderName) { private var rootFolderPath: String? = null + constructor(rootFolderName: String, context: Context) : this( + rootFolderName = rootFolderName, + context = context, + preferencesProvider = OCSharedPreferencesProvider(context) + ) + override fun getPrimaryStorageDirectory(): File { - val prefs = OCSharedPreferencesProvider(context) - val fileManagerAccessEnabled = prefs.getBoolean("enable_file_manager_access", false) + val fileManagerAccessEnabled = preferencesProvider.getBoolean("enable_file_manager_access", false) if (fileManagerAccessEnabled) { val mediaDir = context.externalMediaDirs.firstOrNull() diff --git a/opencloudData/src/test/java/eu/opencloud/android/data/providers/ScopedStorageProviderTest.kt b/opencloudData/src/test/java/eu/opencloud/android/data/providers/ScopedStorageProviderTest.kt index 6427eda2f2..344e3971ca 100644 --- a/opencloudData/src/test/java/eu/opencloud/android/data/providers/ScopedStorageProviderTest.kt +++ b/opencloudData/src/test/java/eu/opencloud/android/data/providers/ScopedStorageProviderTest.kt @@ -23,6 +23,7 @@ class ScopedStorageProviderTest { private lateinit var scopedStorageProvider: ScopedStorageProvider private lateinit var context: Context + private lateinit var preferencesProvider: SharedPreferencesProvider private lateinit var filesDir: File private val spaceId = OC_SPACE_PROJECT_WITH_IMAGE.id @@ -40,13 +41,15 @@ class ScopedStorageProviderTest { @Before fun setUp() { context = mockk() + preferencesProvider = mockk() filesDir = Files.createTempDirectory("scoped-storage-provider").toFile().apply { deleteOnExit() } directory = File(filesDir, "dir").apply { mkdirs() File(this, "child.bin").writeBytes(ByteArray(expectedSizeOfDirectoryValue.toInt())) } - scopedStorageProvider = spyk(ScopedStorageProvider(rootFolderName, context)) + scopedStorageProvider = spyk(ScopedStorageProvider(rootFolderName, context, preferencesProvider)) + every { preferencesProvider.getBoolean("enable_file_manager_access", false) } returns false every { context.getExternalFilesDir(null) } returns filesDir every { context.filesDir } returns filesDir }