Skip to content

Commit 644300d

Browse files
committed
feat: Improve download synchronization and conflict handling
This commit consolidates several improvements to the download and synchronization flow: - Implemented human-readable folder names on external storage for better accessibility. - Added 'Download all files' and 'Auto-Sync' capabilities. - Enhanced conflict resolution: - Automatically create local conflicted copies and download remote versions when both are modified (matching desktop behavior). - Added a security setting to toggle between 'Prefer local version' and 'Conflicted copy' strategies. - Improved conflict detection in both sync and direct upload paths. - Updated user avatars to use Graph API endpoints. - UI/UX improvements: - Translated storage permission dialogs to English for consistency. - Automated folder refresh after conflict resolution. - Technical debt and stability: - Fixed DownloadEverythingWorker logic. - Fixed ScopedStorageProviderTest by properly mocking Environment and updating expectations for non-URI-encoded paths.
1 parent cc6a3c6 commit 644300d

19 files changed

Lines changed: 900 additions & 49 deletions

File tree

opencloudApp/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
API >= 23; the app needs to handle this
2424
-->
2525
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
26+
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
2627
<!--
2728
Notifications are off by default since API 33;
2829
See note in https://developer.android.com/develop/ui/views/notifications/notification-permission

opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,9 @@ class DocumentsStorageProvider : DocumentsProvider() {
158158
)
159159
)
160160
Timber.d("Synced ${ocFile.remotePath} from ${ocFile.owner} with result: $result")
161-
if (result.getDataOrNull() is SynchronizeFileUseCase.SyncType.ConflictDetected) {
162-
context?.let {
163-
NotificationUtils.notifyConflict(
164-
fileInConflict = ocFile,
165-
context = it
166-
)
167-
}
161+
if (result.getDataOrNull() is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy) {
162+
val conflictResult = result.getDataOrNull() as SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy
163+
Timber.i("File sync conflict auto-resolved. Conflicted copy at: ${conflictResult.conflictedCopyPath}")
168164
}
169165
}.start()
170166
}

opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,8 @@ class FileDetailsFragment : FileFragment() {
190190
SynchronizeFileUseCase.SyncType.AlreadySynchronized -> {
191191
showMessageInSnackbar(getString(R.string.sync_file_nothing_to_do_msg))
192192
}
193-
is SynchronizeFileUseCase.SyncType.ConflictDetected -> {
194-
val showConflictActivityIntent = Intent(requireActivity(), ConflictsResolveActivity::class.java)
195-
showConflictActivityIntent.putExtra(ConflictsResolveActivity.EXTRA_FILE, file)
196-
startActivity(showConflictActivityIntent)
193+
is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy -> {
194+
showMessageInSnackbar(getString(R.string.sync_conflict_resolved_with_copy))
197195
}
198196

199197
is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> {

opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,15 @@ import eu.opencloud.android.presentation.security.biometric.BiometricManager
4242
import eu.opencloud.android.presentation.security.passcode.PassCodeActivity
4343
import eu.opencloud.android.presentation.security.pattern.PatternActivity
4444
import eu.opencloud.android.presentation.settings.SettingsFragment.Companion.removePreferenceFromScreen
45+
import eu.opencloud.android.providers.WorkManagerProvider
46+
import org.koin.android.ext.android.inject
4547
import org.koin.androidx.viewmodel.ext.android.viewModel
4648

4749
class SettingsSecurityFragment : PreferenceFragmentCompat() {
4850

4951
// ViewModel
5052
private val securityViewModel by viewModel<SettingsSecurityViewModel>()
53+
private val workManagerProvider: WorkManagerProvider by inject()
5154

5255
private var screenSecurity: PreferenceScreen? = null
5356
private var prefPasscode: CheckBoxPreference? = null
@@ -56,6 +59,9 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() {
5659
private var prefLockApplication: ListPreference? = null
5760
private var prefLockAccessDocumentProvider: CheckBoxPreference? = null
5861
private var prefTouchesWithOtherVisibleWindows: CheckBoxPreference? = null
62+
private var prefDownloadEverything: CheckBoxPreference? = null
63+
private var prefAutoSync: CheckBoxPreference? = null
64+
private var prefPreferLocalOnConflict: CheckBoxPreference? = null
5965

6066
private val enablePasscodeLauncher =
6167
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
@@ -132,6 +138,9 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() {
132138
}
133139
prefLockAccessDocumentProvider = findPreference(PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER)
134140
prefTouchesWithOtherVisibleWindows = findPreference(PREFERENCE_TOUCHES_WITH_OTHER_VISIBLE_WINDOWS)
141+
prefDownloadEverything = findPreference(PREFERENCE_DOWNLOAD_EVERYTHING)
142+
prefAutoSync = findPreference(PREFERENCE_AUTO_SYNC)
143+
prefPreferLocalOnConflict = findPreference(PREFERENCE_PREFER_LOCAL_ON_CONFLICT)
135144

136145
prefPasscode?.isVisible = !securityViewModel.isSecurityEnforcedEnabled()
137146
prefPattern?.isVisible = !securityViewModel.isSecurityEnforcedEnabled()
@@ -222,6 +231,60 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() {
222231
}
223232
true
224233
}
234+
235+
// Download Everything Feature
236+
prefDownloadEverything?.setOnPreferenceChangeListener { _: Preference?, newValue: Any ->
237+
if (newValue as Boolean) {
238+
activity?.let {
239+
AlertDialog.Builder(it)
240+
.setTitle(getString(R.string.download_everything_warning_title))
241+
.setMessage(getString(R.string.download_everything_warning_message))
242+
.setNegativeButton(getString(R.string.common_no), null)
243+
.setPositiveButton(getString(R.string.common_yes)) { _, _ ->
244+
securityViewModel.setDownloadEverything(true)
245+
prefDownloadEverything?.isChecked = true
246+
workManagerProvider.enqueueDownloadEverythingWorker()
247+
}
248+
.show()
249+
.avoidScreenshotsIfNeeded()
250+
}
251+
return@setOnPreferenceChangeListener false
252+
} else {
253+
securityViewModel.setDownloadEverything(false)
254+
workManagerProvider.cancelDownloadEverythingWorker()
255+
true
256+
}
257+
}
258+
259+
// Auto-Sync Feature
260+
prefAutoSync?.setOnPreferenceChangeListener { _: Preference?, newValue: Any ->
261+
if (newValue as Boolean) {
262+
activity?.let {
263+
AlertDialog.Builder(it)
264+
.setTitle(getString(R.string.auto_sync_warning_title))
265+
.setMessage(getString(R.string.auto_sync_warning_message))
266+
.setNegativeButton(getString(R.string.common_no), null)
267+
.setPositiveButton(getString(R.string.common_yes)) { _, _ ->
268+
securityViewModel.setAutoSync(true)
269+
prefAutoSync?.isChecked = true
270+
workManagerProvider.enqueueLocalFileSyncWorker()
271+
}
272+
.show()
273+
.avoidScreenshotsIfNeeded()
274+
}
275+
return@setOnPreferenceChangeListener false
276+
} else {
277+
securityViewModel.setAutoSync(false)
278+
workManagerProvider.cancelLocalFileSyncWorker()
279+
true
280+
}
281+
}
282+
283+
// Conflict Resolution Strategy
284+
prefPreferLocalOnConflict?.setOnPreferenceChangeListener { _: Preference?, newValue: Any ->
285+
securityViewModel.setPreferLocalOnConflict(newValue as Boolean)
286+
true
287+
}
225288
}
226289

227290
private fun enableBiometricAndLockApplication() {
@@ -246,5 +309,8 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() {
246309
const val PREFERENCE_TOUCHES_WITH_OTHER_VISIBLE_WINDOWS = "touches_with_other_visible_windows"
247310
const val EXTRAS_LOCK_ENFORCED = "EXTRAS_LOCK_ENFORCED"
248311
const val PREFERENCE_LOCK_ATTEMPTS = "PrefLockAttempts"
312+
const val PREFERENCE_DOWNLOAD_EVERYTHING = "download_everything"
313+
const val PREFERENCE_AUTO_SYNC = "auto_sync_local_changes"
314+
const val PREFERENCE_PREFER_LOCAL_ON_CONFLICT = "prefer_local_on_conflict"
249315
}
250316
}

opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityViewModel.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,25 @@ class SettingsSecurityViewModel(
6363
integerKey = R.integer.lock_delay_enforced
6464
)
6565
) != LockTimeout.DISABLED
66+
67+
// Download Everything Feature
68+
fun isDownloadEverythingEnabled(): Boolean =
69+
preferencesProvider.getBoolean(SettingsSecurityFragment.PREFERENCE_DOWNLOAD_EVERYTHING, false)
70+
71+
fun setDownloadEverything(enabled: Boolean) =
72+
preferencesProvider.putBoolean(SettingsSecurityFragment.PREFERENCE_DOWNLOAD_EVERYTHING, enabled)
73+
74+
// Auto-Sync Feature
75+
fun isAutoSyncEnabled(): Boolean =
76+
preferencesProvider.getBoolean(SettingsSecurityFragment.PREFERENCE_AUTO_SYNC, false)
77+
78+
fun setAutoSync(enabled: Boolean) =
79+
preferencesProvider.putBoolean(SettingsSecurityFragment.PREFERENCE_AUTO_SYNC, enabled)
80+
81+
// Conflict Resolution Strategy
82+
fun isPreferLocalOnConflictEnabled(): Boolean =
83+
preferencesProvider.getBoolean(SettingsSecurityFragment.PREFERENCE_PREFER_LOCAL_ON_CONFLICT, false)
84+
85+
fun setPreferLocalOnConflict(enabled: Boolean) =
86+
preferencesProvider.putBoolean(SettingsSecurityFragment.PREFERENCE_PREFER_LOCAL_ON_CONFLICT, enabled)
6687
}

opencloudApp/src/main/java/eu/opencloud/android/providers/WorkManagerProvider.kt

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import eu.opencloud.android.workers.AccountDiscoveryWorker
3636
import eu.opencloud.android.workers.AvailableOfflinePeriodicWorker
3737
import eu.opencloud.android.workers.AvailableOfflinePeriodicWorker.Companion.AVAILABLE_OFFLINE_PERIODIC_WORKER
3838
import eu.opencloud.android.workers.AutomaticUploadsWorker
39+
import eu.opencloud.android.workers.DownloadEverythingWorker
40+
import eu.opencloud.android.workers.LocalFileSyncWorker
3941
import eu.opencloud.android.workers.OldLogsCollectorWorker
4042
import eu.opencloud.android.workers.RemoveLocallyFilesWithLastUsageOlderThanGivenTimeWorker
4143
import eu.opencloud.android.workers.UploadFileFromContentUriWorker
@@ -129,4 +131,60 @@ class WorkManagerProvider(
129131

130132
fun cancelAllWorkByTag(tag: String) = WorkManager.getInstance(context).cancelAllWorkByTag(tag)
131133

134+
// Download Everything Feature
135+
fun enqueueDownloadEverythingWorker() {
136+
val constraintsRequired = Constraints.Builder()
137+
.setRequiredNetworkType(NetworkType.CONNECTED)
138+
.setRequiresBatteryNotLow(true)
139+
.setRequiresStorageNotLow(true)
140+
.build()
141+
142+
val downloadEverythingWorker = PeriodicWorkRequestBuilder<DownloadEverythingWorker>(
143+
repeatInterval = DownloadEverythingWorker.repeatInterval,
144+
repeatIntervalTimeUnit = DownloadEverythingWorker.repeatIntervalTimeUnit
145+
)
146+
.addTag(DownloadEverythingWorker.DOWNLOAD_EVERYTHING_WORKER)
147+
.setConstraints(constraintsRequired)
148+
.build()
149+
150+
WorkManager.getInstance(context)
151+
.enqueueUniquePeriodicWork(
152+
DownloadEverythingWorker.DOWNLOAD_EVERYTHING_WORKER,
153+
ExistingPeriodicWorkPolicy.KEEP,
154+
downloadEverythingWorker
155+
)
156+
}
157+
158+
fun cancelDownloadEverythingWorker() {
159+
WorkManager.getInstance(context)
160+
.cancelUniqueWork(DownloadEverythingWorker.DOWNLOAD_EVERYTHING_WORKER)
161+
}
162+
163+
// Local File Sync (Auto-Sync) Feature
164+
fun enqueueLocalFileSyncWorker() {
165+
val constraintsRequired = Constraints.Builder()
166+
.setRequiredNetworkType(NetworkType.CONNECTED)
167+
.build()
168+
169+
val localFileSyncWorker = PeriodicWorkRequestBuilder<LocalFileSyncWorker>(
170+
repeatInterval = LocalFileSyncWorker.repeatInterval,
171+
repeatIntervalTimeUnit = LocalFileSyncWorker.repeatIntervalTimeUnit
172+
)
173+
.addTag(LocalFileSyncWorker.LOCAL_FILE_SYNC_WORKER)
174+
.setConstraints(constraintsRequired)
175+
.build()
176+
177+
WorkManager.getInstance(context)
178+
.enqueueUniquePeriodicWork(
179+
LocalFileSyncWorker.LOCAL_FILE_SYNC_WORKER,
180+
ExistingPeriodicWorkPolicy.KEEP,
181+
localFileSyncWorker
182+
)
183+
}
184+
185+
fun cancelLocalFileSyncWorker() {
186+
WorkManager.getInstance(context)
187+
.cancelUniqueWork(LocalFileSyncWorker.LOCAL_FILE_SYNC_WORKER)
188+
}
189+
132190
}

opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import eu.opencloud.android.presentation.spaces.SpacesListFragment.Companion.BUN
110110
import eu.opencloud.android.presentation.spaces.SpacesListFragment.Companion.REQUEST_KEY_CLICK_SPACE
111111
import eu.opencloud.android.presentation.spaces.SpacesListViewModel
112112
import eu.opencloud.android.presentation.transfers.TransfersViewModel
113+
import eu.opencloud.android.presentation.settings.security.SettingsSecurityFragment
113114
import eu.opencloud.android.providers.WorkManagerProvider
114115
import eu.opencloud.android.syncadapter.FileSyncAdapter
115116
import eu.opencloud.android.ui.dialog.FileAlreadyExistsDialog
@@ -284,9 +285,28 @@ class FileDisplayActivity : FileActivity(),
284285

285286

286287
checkNotificationPermission()
288+
checkManageExternalStoragePermission()
287289
Timber.v("onCreate() end")
288290
}
289291

292+
private fun checkManageExternalStoragePermission() {
293+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
294+
if (!android.os.Environment.isExternalStorageManager()) {
295+
val builder = AlertDialog.Builder(this)
296+
builder.setTitle(getString(R.string.app_name))
297+
builder.setMessage("To save offline files, the app needs access to all files.")
298+
builder.setPositiveButton("Settings") { _, _ ->
299+
val intent = Intent(android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
300+
intent.addCategory("android.intent.category.DEFAULT")
301+
intent.data = Uri.parse("package:$packageName")
302+
startActivity(intent)
303+
}
304+
builder.setNegativeButton("Cancel", null)
305+
builder.show()
306+
}
307+
}
308+
}
309+
290310
private fun checkNotificationPermission() {
291311
// Ask for permission only in case it's api >= 33 and notifications are not granted.
292312
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
@@ -376,6 +396,16 @@ class FileDisplayActivity : FileActivity(),
376396
syncProfileOperation.syncUserProfile()
377397
val workManagerProvider = WorkManagerProvider(context = baseContext)
378398
workManagerProvider.enqueueAvailableOfflinePeriodicWorker()
399+
400+
// Enqueue Download Everything worker if enabled
401+
if (sharedPreferences.getBoolean(SettingsSecurityFragment.PREFERENCE_DOWNLOAD_EVERYTHING, false)) {
402+
workManagerProvider.enqueueDownloadEverythingWorker()
403+
}
404+
405+
// Enqueue Local File Sync worker if enabled
406+
if (sharedPreferences.getBoolean(SettingsSecurityFragment.PREFERENCE_AUTO_SYNC, false)) {
407+
workManagerProvider.enqueueLocalFileSyncWorker()
408+
}
379409
} else {
380410
file?.isFolder?.let { isFolder ->
381411
updateFragmentsVisibility(!isFolder)
@@ -1354,10 +1384,8 @@ class FileDisplayActivity : FileActivity(),
13541384
}
13551385
}
13561386

1357-
is SynchronizeFileUseCase.SyncType.ConflictDetected -> {
1358-
val showConflictActivityIntent = Intent(this, ConflictsResolveActivity::class.java)
1359-
showConflictActivityIntent.putExtra(ConflictsResolveActivity.EXTRA_FILE, file)
1360-
startActivity(showConflictActivityIntent)
1387+
is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy -> {
1388+
showSnackMessage(getString(R.string.sync_conflict_resolved_with_copy))
13611389
}
13621390

13631391
is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> {

0 commit comments

Comments
 (0)