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..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
@@ -163,8 +162,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 +455,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..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
@@ -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..f94d1a5778
--- /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..f06136db1e
--- /dev/null
+++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt
@@ -0,0 +1,589 @@
+/**
+ * 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..835cb2cd90 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,14 @@
package eu.opencloud.android.workers
import android.accounts.Account
+import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.net.Uri
import androidx.work.CoroutineWorker
+import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import at.bitfire.dav4jvm.exception.UnauthorizedException
@@ -42,6 +46,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 +63,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 +88,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 +109,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 +131,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 +168,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 +225,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 +256,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 +279,6 @@ class DownloadFileWorker(
)
)
- // To be done. Probably we will move it out from here.
- //mStorageManager.triggerMediaScan(file.getStoragePath())
}
/**
@@ -316,7 +360,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 +377,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 +387,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/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/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..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
@@ -20,12 +20,104 @@
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(
rootFolderName: String,
- private val context: Context
+ private val context: Context,
+ private val preferencesProvider: SharedPreferencesProvider
) : LocalStorageProvider(rootFolderName) {
- override fun getPrimaryStorageDirectory(): File = context.filesDir
+ private var rootFolderPath: String? = null
+
+ constructor(rootFolderName: String, context: Context) : this(
+ rootFolderName = rootFolderName,
+ context = context,
+ preferencesProvider = OCSharedPreferencesProvider(context)
+ )
+
+ override fun getPrimaryStorageDirectory(): File {
+ val fileManagerAccessEnabled = preferencesProvider.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..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
@@ -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
@@ -20,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
@@ -37,52 +41,79 @@ 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 = 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
}
+ @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 +121,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 +182,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 +197,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 +253,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 +263,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 +275,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
- )
-}