From 019ba930d30ab411c47f109ec01b142cf780f251 Mon Sep 17 00:00:00 2001 From: Amir Date: Sun, 7 Jun 2026 13:49:59 +0000 Subject: [PATCH] v1.0.76: multi-folder backup (many folders -> one remote base, each its own subfolder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in 'Back up multiple folders' mode to Add Sync: pick N folders + one remote base, and the app creates one normal pair per folder at base/. Each source keeps its own subfolder so same-named files across folders never collide/overwrite — the safe many-to-one. - AddPairViewModel: multiFolder + localPaths state, batch save via buildEntity; folderLeafName() + uniqueSubName() derive collision-free remote subfolders - AddPairScreen: mode toggle (new pairs only), folder list w/ add/remove, remote-base labelling - SyncEngine: first-sync against a not-yet-created remote folder no longer fails — treat 404 as empty remote ONLY when no prior state exists (no DELETE_* can fire), and MKCOL the base dir so first uploads have a parent. An established pair still fails loudly on 404 (never mass-deletes a vanished remote). - Tests: MultiFolderTest, isRemoteNotFound cases in ExcludePathTest --- .../com/syncflow/domain/sync/SyncEngine.kt | 43 +++++- .../com/syncflow/ui/addpair/AddPairScreen.kt | 64 +++++++-- .../syncflow/ui/addpair/AddPairViewModel.kt | 133 +++++++++++++----- .../syncflow/domain/sync/ExcludePathTest.kt | 12 ++ .../syncflow/ui/addpair/MultiFolderTest.kt | 41 ++++++ version.properties | 4 +- 6 files changed, 241 insertions(+), 56 deletions(-) create mode 100644 app/src/test/kotlin/com/syncflow/ui/addpair/MultiFolderTest.kt diff --git a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt index 0cf036b..5fdf327 100644 --- a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt +++ b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt @@ -116,9 +116,22 @@ class SyncEngine @Inject constructor( // got DELETE_REMOTE'd every cycle — endless churn as Android regenerates the cache. // Filtering remote + the merged path set makes excluded paths invisible to the engine: // never uploaded, downloaded, or deleted on either side. - val remoteFiles = listRemoteFilesRecursive(provider, pair.remotePath) - .associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') } - .filterKeys { !isExcludedPath(it, pair) } + val remoteFiles = try { + listRemoteFilesRecursive(provider, pair.remotePath) + .associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') } + .filterKeys { !isExcludedPath(it, pair) } + } catch (e: Exception) { + // A brand-new pair whose remote folder doesn't exist yet 404s on the first listing — + // that simply means "empty remote", so everything uploads. This is ONLY safe with no + // prior sync state: on a first sync every known==null, so no DELETE_* branch can fire + // (verified in syncDecide). Once state exists, a 404 means the remote folder vanished + // or is unreachable, and treating it as empty would mirror-delete every local file — + // so we rethrow and let the sync fail loudly instead of destroying data. + if (knownStates.isEmpty() && isRemoteNotFound(e)) { + runCatching { ensureRemoteBaseDir(provider, pair.remotePath) } + emptyMap() + } else throw e + } val localFiles = accessor.walkFiles(pair) // Self-healing: if every known-state path is absent from the current local scan but @@ -298,6 +311,20 @@ class SyncEngine @Inject constructor( ) } + /** + * Create every path component of the pair's remote root (e.g. /Backup/DCIM → MKCOL /Backup + * then /Backup/DCIM) so the first uploads of a brand-new pair have a parent to land in. + * Only called on a first sync where the root listing 404'd; existing-dir MKCOLs fail harmlessly. + */ + private suspend fun ensureRemoteBaseDir(provider: CloudProvider, remotePath: String) { + val parts = remotePath.replace('\\', '/').split('/').filter { it.isNotEmpty() } + var current = "" + for (part in parts) { + current = "$current/$part" + provider.createDirectory(current).onFailure { e -> Timber.w("MKCOL base $current: ${e.message}") } + } + } + private suspend fun ensureRemoteDirs(provider: CloudProvider, remotePairPath: String, rel: String) { val parts = rel.replace('\\', '/').split('/') var currentPath = remotePairPath @@ -421,6 +448,16 @@ internal fun syncDecide( enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP } +/** + * Heuristic: did a provider call fail because the remote path doesn't exist (HTTP 404)? Providers + * surface errors as exceptions carrying the HTTP status in the message, so we match on that. Used + * only to let a first-ever sync proceed against a not-yet-created remote folder (see performSync). + */ +internal fun isRemoteNotFound(e: Throwable): Boolean { + val m = (e.message ?: "").lowercase() + return "404" in m || "not found" in m || "notfound" in m +} + /** * OS-generated, volatile paths that must NEVER sync on any pair, regardless of user exclude * config. These are matched against every path SEGMENT (directory names included), so an entire diff --git a/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairScreen.kt b/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairScreen.kt index 4583c0f..365c108 100644 --- a/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairScreen.kt +++ b/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairScreen.kt @@ -52,7 +52,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) { LocalBrowserDialog( initialPath = s.localPath.ifBlank { "" }, onSelect = { path -> - vm.update { copy(localPath = path) } + if (s.multiFolder) vm.addLocalFolder(path) else vm.update { copy(localPath = path) } showLocalBrowser = false }, onDismiss = { showLocalBrowser = false }, @@ -94,7 +94,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) { Section(title = null) { OutlinedTextField( value = s.name, onValueChange = { vm.update { copy(name = it) } }, - label = { Text("Sync pair name") }, + label = { Text(if (s.multiFolder) "Group name prefix (optional)" else "Sync pair name") }, leadingIcon = { Icon(Icons.Default.DriveFileRenameOutline, null) }, singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), @@ -122,23 +122,57 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) { Spacer(Modifier.height(4.dp)) - // Local folder - Box(modifier = Modifier.fillMaxWidth()) { - OutlinedTextField( - value = uriToDisplay(s.localPath), onValueChange = {}, - label = { Text("Local folder") }, - leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) }, - trailingIcon = { Icon(Icons.Default.FolderOpen, null) }, - readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Tap to choose folder…") }, + // Multi-folder mode toggle (new pairs only — editing stays single-folder) + if (!s.isEditing) { + ToggleRow( + label = "Back up multiple folders", + description = "Pick several folders; each is saved as its own subfolder under the remote base (no overwrites)", + checked = s.multiFolder, + onToggle = { vm.setMultiFolder(it) }, ) - Box(modifier = Modifier.matchParentSize().clickable { showLocalBrowser = true }) + Spacer(Modifier.height(4.dp)) } - // Remote folder + if (s.multiFolder) { + // Chosen folders list + s.localPaths.forEach { p -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Folder, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(8.dp)) + Text(uriToDisplay(p), style = MaterialTheme.typography.bodyMedium, + maxLines = 1, modifier = Modifier.weight(1f)) + IconButton(onClick = { vm.removeLocalFolder(p) }) { + Icon(Icons.Default.Close, "Remove folder") + } + } + } + OutlinedButton(onClick = { showLocalBrowser = true }, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Default.Add, null) + Spacer(Modifier.width(6.dp)) + Text("Add folder") + } + } else { + // Single local folder + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = uriToDisplay(s.localPath), onValueChange = {}, + label = { Text("Local folder") }, + leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) }, + trailingIcon = { Icon(Icons.Default.FolderOpen, null) }, + readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Tap to choose folder…") }, + ) + Box(modifier = Modifier.matchParentSize().clickable { showLocalBrowser = true }) + } + } + + // Remote folder (base, in multi-folder mode) OutlinedTextField( value = s.remotePath, onValueChange = { vm.update { copy(remotePath = it) } }, - label = { Text("Remote folder") }, + label = { Text(if (s.multiFolder) "Remote base folder" else "Remote folder") }, leadingIcon = { Icon(Icons.Default.Cloud, null) }, trailingIcon = { IconButton( @@ -147,7 +181,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) { ) { Icon(Icons.Default.Folder, "Browse remote") } }, singleLine = true, modifier = Modifier.fillMaxWidth(), - placeholder = { Text("/ or /Documents/Photos") }, + placeholder = { Text(if (s.multiFolder) "/Backup — each folder becomes a subfolder" else "/ or /Documents/Photos") }, ) // Recursive diff --git a/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairViewModel.kt index 9ffaf7b..f70718a 100644 --- a/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairViewModel.kt +++ b/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairViewModel.kt @@ -25,6 +25,10 @@ data class AddPairUiState( val name: String = "", // ── Folders ────────────────────────────────────────────────────────────── val localPath: String = "", + // Multi-folder mode: pick several local folders that each back up to their own subfolder + // under one remote base. Empty unless multiFolder is on. + val multiFolder: Boolean = false, + val localPaths: List = emptyList(), val remotePath: String = "", val selectedAccountId: Long = -1L, val accounts: List = emptyList(), @@ -57,6 +61,7 @@ data class AddPairUiState( val notifyOnComplete: Boolean = false, val notifyOnError: Boolean = true, // ── Form state ─────────────────────────────────────────────────────────── + val isEditing: Boolean = false, val isSaving: Boolean = false, val error: String? = null, val done: Boolean = false, @@ -72,6 +77,31 @@ internal fun recommendedDeleteBehavior(direction: SyncDirection): DeleteBehavior SyncDirection.TWO_WAY -> DeleteBehavior.MIRROR } +/** + * Last path component of a local folder path or SAF content:// tree URI, used as the remote + * subfolder name in multi-folder mode. Handles URL-encoded tree URIs + * (content://…/tree/primary%3ADCIM%2FCamera → "Camera") and plain filesystem paths. + */ +internal fun folderLeafName(path: String): String { + val decoded = try { java.net.URLDecoder.decode(path, "UTF-8") } catch (e: Exception) { path } + val afterColon = decoded.substringAfterLast(':') // strip the "primary:" storage-volume prefix + val leaf = afterColon.trimEnd('/').substringAfterLast('/') + return leaf.ifBlank { "folder" } +} + +/** + * Make a remote subfolder name unique within one multi-folder batch so two source folders that + * share a leaf name (e.g. DCIM/Camera and Movies/Camera) don't collide into one remote dir. + * Slashes are flattened to underscores; collisions get a numeric suffix. + */ +internal fun uniqueSubName(base: String, used: MutableSet): String { + val clean = base.replace('/', '_').replace('\\', '_').ifBlank { "folder" } + if (used.add(clean)) return clean + var n = 2 + while (!used.add("${clean}_$n")) n++ + return "${clean}_$n" +} + @HiltViewModel class AddPairViewModel @Inject constructor( private val syncPairDao: SyncPairDao, @@ -83,7 +113,7 @@ class AddPairViewModel @Inject constructor( private val editPairId = savedState.get("pairId").takeIf { it != -1L } - private val _state = MutableStateFlow(AddPairUiState()) + private val _state = MutableStateFlow(AddPairUiState(isEditing = editPairId != null)) val state = _state.asStateFlow() init { @@ -102,6 +132,7 @@ class AddPairViewModel @Inject constructor( syncPairDao.getById(id)?.let { pair -> _state.update { _ -> AddPairUiState( + isEditing = true, name = pair.name, localPath = pair.localPath, remotePath = pair.remotePath, @@ -148,12 +179,25 @@ class AddPairViewModel @Inject constructor( it.copy(deleteBehavior = behavior, deleteBehaviorTouched = true) } + fun setMultiFolder(enabled: Boolean) = _state.update { it.copy(multiFolder = enabled) } + + fun addLocalFolder(path: String) = _state.update { + if (path.isBlank() || path in it.localPaths) it else it.copy(localPaths = it.localPaths + path) + } + + fun removeLocalFolder(path: String) = _state.update { it.copy(localPaths = it.localPaths - path) } + fun save() { val s = _state.value val errors = buildList { - if (s.name.isBlank()) add("Name is required") - if (s.localPath.isBlank()) add("Local folder is required") - if (s.remotePath.isBlank()) add("Remote folder is required") + if (s.multiFolder) { + if (s.localPaths.isEmpty()) add("Add at least one folder") + if (s.remotePath.isBlank()) add("Remote base folder is required") + } else { + if (s.name.isBlank()) add("Name is required") + if (s.localPath.isBlank()) add("Local folder is required") + if (s.remotePath.isBlank()) add("Remote folder is required") + } if (s.selectedAccountId == -1L) add("Select a cloud account") if (s.scheduleType == ScheduleType.INTERVAL && s.intervalMinutes < 15) add("Minimum interval is 15 minutes") } @@ -162,48 +206,65 @@ class AddPairViewModel @Inject constructor( viewModelScope.launch { _state.update { it.copy(isSaving = true, error = null) } runCatching { - val entity = SyncPairEntity( - id = editPairId ?: 0L, - name = s.name, localPath = s.localPath, remotePath = s.remotePath, - accountId = s.selectedAccountId, - syncDirection = s.syncDirection, conflictStrategy = s.conflictStrategy, - deleteBehavior = s.deleteBehavior, recursive = s.recursive, - scheduleType = s.scheduleType, scheduleIntervalMinutes = s.intervalMinutes, - scheduleDailyTime = if (s.scheduleType == ScheduleType.DAILY || s.scheduleType == ScheduleType.WEEKLY) s.dailyTime else null, - scheduleWeekdays = s.weekdays, - wifiOnly = s.wifiOnly, wifiSsid = s.wifiSsid, - chargingOnly = s.chargingOnly, minBatteryPct = s.minBatteryPct, - excludePatterns = s.excludePatterns, includeExtensions = s.includeExtensions, - excludeExtensions = s.excludeExtensions, skipHiddenFiles = s.skipHiddenFiles, - minFileSizeKb = s.minFileSizeKb, maxFileSizeKb = s.maxFileSizeKb, - notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError, - isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0, - ) - val pairId = if (editPairId == null) { - syncPairDao.insert(entity) - } else { - val existing = syncPairDao.getById(editPairId) - syncPairDao.update(entity) - // If local or remote folder changed, old file-state records no longer - // correspond to any real path — wipe them so the next sync starts fresh - // instead of trying to delete/re-upload stale paths. - if (existing != null && - (existing.localPath != entity.localPath || existing.remotePath != entity.remotePath) - ) { - fileStateDao.deleteForPair(editPairId) + if (s.multiFolder) { + // One normal pair per folder, each into its OWN subfolder under the remote base. + // Keeping each source in a distinct subfolder is what makes many-to-one safe — + // flattening would let same-named files from different folders overwrite each other. + val base = s.remotePath.trimEnd('/') + val used = mutableSetOf() + s.localPaths.map { folder -> + val sub = uniqueSubName(folderLeafName(folder), used) + val pairName = if (s.name.isBlank()) sub else "${s.name} — $sub" + val entity = buildEntity(s, name = pairName, localPath = folder, remotePath = "$base/$sub", id = 0L) + entity.copy(id = syncPairDao.insert(entity)) } - editPairId + } else { + val entity = buildEntity(s, name = s.name, localPath = s.localPath, remotePath = s.remotePath, id = editPairId ?: 0L) + val pairId = if (editPairId == null) { + syncPairDao.insert(entity) + } else { + val existing = syncPairDao.getById(editPairId) + syncPairDao.update(entity) + // If local or remote folder changed, old file-state records no longer + // correspond to any real path — wipe them so the next sync starts fresh + // instead of trying to delete/re-upload stale paths. + if (existing != null && + (existing.localPath != entity.localPath || existing.remotePath != entity.remotePath) + ) { + fileStateDao.deleteForPair(editPairId) + } + editPairId + } + listOf(entity.copy(id = pairId)) } - entity.copy(id = pairId) } .onSuccess { saved -> - applySchedule(saved) + saved.forEach { applySchedule(it) } _state.update { it.copy(done = true) } } .onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } } } } + private fun buildEntity(s: AddPairUiState, name: String, localPath: String, remotePath: String, id: Long) = + SyncPairEntity( + id = id, + name = name, localPath = localPath, remotePath = remotePath, + accountId = s.selectedAccountId, + syncDirection = s.syncDirection, conflictStrategy = s.conflictStrategy, + deleteBehavior = s.deleteBehavior, recursive = s.recursive, + scheduleType = s.scheduleType, scheduleIntervalMinutes = s.intervalMinutes, + scheduleDailyTime = if (s.scheduleType == ScheduleType.DAILY || s.scheduleType == ScheduleType.WEEKLY) s.dailyTime else null, + scheduleWeekdays = s.weekdays, + wifiOnly = s.wifiOnly, wifiSsid = s.wifiSsid, + chargingOnly = s.chargingOnly, minBatteryPct = s.minBatteryPct, + excludePatterns = s.excludePatterns, includeExtensions = s.includeExtensions, + excludeExtensions = s.excludeExtensions, skipHiddenFiles = s.skipHiddenFiles, + minFileSizeKb = s.minFileSizeKb, maxFileSizeKb = s.maxFileSizeKb, + notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError, + isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0, + ) + /** * Register the pair's background work the moment it's saved. Previously this only happened on * the enable-toggle or a reboot, so a freshly-created scheduled pair never actually ran in the diff --git a/app/src/test/kotlin/com/syncflow/domain/sync/ExcludePathTest.kt b/app/src/test/kotlin/com/syncflow/domain/sync/ExcludePathTest.kt index 93dc661..8bcba35 100644 --- a/app/src/test/kotlin/com/syncflow/domain/sync/ExcludePathTest.kt +++ b/app/src/test/kotlin/com/syncflow/domain/sync/ExcludePathTest.kt @@ -91,4 +91,16 @@ class ExcludePathTest { assertTrue(isExcludedPath("a/.hidden", pair(skipHiddenFiles = true))) assertFalse(isExcludedPath("a/.hidden", pair(skipHiddenFiles = false))) } + + @Test fun `remote-not-found detected from http status messages`() { + assertTrue(isRemoteNotFound(Exception("HTTP 404"))) + assertTrue(isRemoteNotFound(Exception("Not Found"))) + assertTrue(isRemoteNotFound(RuntimeException("PROPFIND failed: notfound"))) + } + + @Test fun `remote-not-found is false for other errors`() { + assertFalse(isRemoteNotFound(Exception("HTTP 500"))) + assertFalse(isRemoteNotFound(Exception("timeout"))) + assertFalse(isRemoteNotFound(Exception(null as String?))) + } } diff --git a/app/src/test/kotlin/com/syncflow/ui/addpair/MultiFolderTest.kt b/app/src/test/kotlin/com/syncflow/ui/addpair/MultiFolderTest.kt new file mode 100644 index 0000000..84e8808 --- /dev/null +++ b/app/src/test/kotlin/com/syncflow/ui/addpair/MultiFolderTest.kt @@ -0,0 +1,41 @@ +package com.syncflow.ui.addpair + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Multi-folder backup: each selected folder maps to its OWN remote subfolder. The leaf-name and + * uniqueness helpers are what keep that safe — same-named folders from different parents must not + * collapse into one remote dir and overwrite each other. + */ +class MultiFolderTest { + + @Test fun `leaf name from SAF tree uri`() { + assertEquals("Camera", folderLeafName("content://com.android.externalstorage.documents/tree/primary%3ADCIM%2FCamera")) + assertEquals("DCIM", folderLeafName("content://com.android.externalstorage.documents/tree/primary%3ADCIM")) + } + + @Test fun `leaf name from plain filesystem path`() { + assertEquals("Camera", folderLeafName("/storage/emulated/0/DCIM/Camera")) + assertEquals("Pictures", folderLeafName("/storage/emulated/0/Pictures/")) + } + + @Test fun `blank-ish paths fall back to folder`() { + assertEquals("folder", folderLeafName("")) + assertEquals("folder", folderLeafName("/")) + } + + @Test fun `unique sub names disambiguate collisions`() { + val used = mutableSetOf() + assertEquals("Camera", uniqueSubName("Camera", used)) + assertEquals("Camera_2", uniqueSubName("Camera", used)) + assertEquals("Camera_3", uniqueSubName("Camera", used)) + assertEquals("Pictures", uniqueSubName("Pictures", used)) + } + + @Test fun `unique sub name flattens slashes`() { + val used = mutableSetOf() + assertTrue('/' !in uniqueSubName("a/b/c", used)) + } +} diff --git a/version.properties b/version.properties index b6ee0b7..97833f2 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.75 -VERSION_CODE=75 +VERSION_NAME=1.0.76 +VERSION_CODE=76