v1.0.76: multi-folder backup (many folders -> one remote base, each its own subfolder)
Build & Release APK / build (push) Successful in 12m53s

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/<folderName>. 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
This commit is contained in:
2026-06-07 13:49:59 +00:00
parent ddb558263f
commit 019ba930d3
6 changed files with 241 additions and 56 deletions
@@ -116,9 +116,22 @@ class SyncEngine @Inject constructor(
// got DELETE_REMOTE'd every cycle — endless churn as Android regenerates the cache. // 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: // Filtering remote + the merged path set makes excluded paths invisible to the engine:
// never uploaded, downloaded, or deleted on either side. // never uploaded, downloaded, or deleted on either side.
val remoteFiles = listRemoteFilesRecursive(provider, pair.remotePath) val remoteFiles = try {
listRemoteFilesRecursive(provider, pair.remotePath)
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') } .associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
.filterKeys { !isExcludedPath(it, pair) } .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) val localFiles = accessor.walkFiles(pair)
// Self-healing: if every known-state path is absent from the current local scan but // 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) { private suspend fun ensureRemoteDirs(provider: CloudProvider, remotePairPath: String, rel: String) {
val parts = rel.replace('\\', '/').split('/') val parts = rel.replace('\\', '/').split('/')
var currentPath = remotePairPath var currentPath = remotePairPath
@@ -421,6 +448,16 @@ internal fun syncDecide(
enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP } 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 * 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 * config. These are matched against every path SEGMENT (directory names included), so an entire
@@ -52,7 +52,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
LocalBrowserDialog( LocalBrowserDialog(
initialPath = s.localPath.ifBlank { "" }, initialPath = s.localPath.ifBlank { "" },
onSelect = { path -> onSelect = { path ->
vm.update { copy(localPath = path) } if (s.multiFolder) vm.addLocalFolder(path) else vm.update { copy(localPath = path) }
showLocalBrowser = false showLocalBrowser = false
}, },
onDismiss = { showLocalBrowser = false }, onDismiss = { showLocalBrowser = false },
@@ -94,7 +94,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
Section(title = null) { Section(title = null) {
OutlinedTextField( OutlinedTextField(
value = s.name, onValueChange = { vm.update { copy(name = it) } }, 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) }, leadingIcon = { Icon(Icons.Default.DriveFileRenameOutline, null) },
singleLine = true, modifier = Modifier.fillMaxWidth(), singleLine = true, modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
@@ -122,7 +122,40 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
// Local 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) },
)
Spacer(Modifier.height(4.dp))
}
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()) { Box(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField( OutlinedTextField(
value = uriToDisplay(s.localPath), onValueChange = {}, value = uriToDisplay(s.localPath), onValueChange = {},
@@ -134,11 +167,12 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
) )
Box(modifier = Modifier.matchParentSize().clickable { showLocalBrowser = true }) Box(modifier = Modifier.matchParentSize().clickable { showLocalBrowser = true })
} }
}
// Remote folder // Remote folder (base, in multi-folder mode)
OutlinedTextField( OutlinedTextField(
value = s.remotePath, onValueChange = { vm.update { copy(remotePath = it) } }, 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) }, leadingIcon = { Icon(Icons.Default.Cloud, null) },
trailingIcon = { trailingIcon = {
IconButton( IconButton(
@@ -147,7 +181,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
) { Icon(Icons.Default.Folder, "Browse remote") } ) { Icon(Icons.Default.Folder, "Browse remote") }
}, },
singleLine = true, modifier = Modifier.fillMaxWidth(), 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 // Recursive
@@ -25,6 +25,10 @@ data class AddPairUiState(
val name: String = "", val name: String = "",
// ── Folders ────────────────────────────────────────────────────────────── // ── Folders ──────────────────────────────────────────────────────────────
val localPath: String = "", 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<String> = emptyList(),
val remotePath: String = "", val remotePath: String = "",
val selectedAccountId: Long = -1L, val selectedAccountId: Long = -1L,
val accounts: List<CloudAccountEntity> = emptyList(), val accounts: List<CloudAccountEntity> = emptyList(),
@@ -57,6 +61,7 @@ data class AddPairUiState(
val notifyOnComplete: Boolean = false, val notifyOnComplete: Boolean = false,
val notifyOnError: Boolean = true, val notifyOnError: Boolean = true,
// ── Form state ─────────────────────────────────────────────────────────── // ── Form state ───────────────────────────────────────────────────────────
val isEditing: Boolean = false,
val isSaving: Boolean = false, val isSaving: Boolean = false,
val error: String? = null, val error: String? = null,
val done: Boolean = false, val done: Boolean = false,
@@ -72,6 +77,31 @@ internal fun recommendedDeleteBehavior(direction: SyncDirection): DeleteBehavior
SyncDirection.TWO_WAY -> DeleteBehavior.MIRROR 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>): 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 @HiltViewModel
class AddPairViewModel @Inject constructor( class AddPairViewModel @Inject constructor(
private val syncPairDao: SyncPairDao, private val syncPairDao: SyncPairDao,
@@ -83,7 +113,7 @@ class AddPairViewModel @Inject constructor(
private val editPairId = savedState.get<Long>("pairId").takeIf { it != -1L } private val editPairId = savedState.get<Long>("pairId").takeIf { it != -1L }
private val _state = MutableStateFlow(AddPairUiState()) private val _state = MutableStateFlow(AddPairUiState(isEditing = editPairId != null))
val state = _state.asStateFlow() val state = _state.asStateFlow()
init { init {
@@ -102,6 +132,7 @@ class AddPairViewModel @Inject constructor(
syncPairDao.getById(id)?.let { pair -> syncPairDao.getById(id)?.let { pair ->
_state.update { _ -> _state.update { _ ->
AddPairUiState( AddPairUiState(
isEditing = true,
name = pair.name, name = pair.name,
localPath = pair.localPath, localPath = pair.localPath,
remotePath = pair.remotePath, remotePath = pair.remotePath,
@@ -148,12 +179,25 @@ class AddPairViewModel @Inject constructor(
it.copy(deleteBehavior = behavior, deleteBehaviorTouched = true) 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() { fun save() {
val s = _state.value val s = _state.value
val errors = buildList { val errors = buildList {
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.name.isBlank()) add("Name is required")
if (s.localPath.isBlank()) add("Local folder is required") if (s.localPath.isBlank()) add("Local folder is required")
if (s.remotePath.isBlank()) add("Remote folder is required") if (s.remotePath.isBlank()) add("Remote folder is required")
}
if (s.selectedAccountId == -1L) add("Select a cloud account") if (s.selectedAccountId == -1L) add("Select a cloud account")
if (s.scheduleType == ScheduleType.INTERVAL && s.intervalMinutes < 15) add("Minimum interval is 15 minutes") if (s.scheduleType == ScheduleType.INTERVAL && s.intervalMinutes < 15) add("Minimum interval is 15 minutes")
} }
@@ -162,23 +206,20 @@ class AddPairViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isSaving = true, error = null) } _state.update { it.copy(isSaving = true, error = null) }
runCatching { runCatching {
val entity = SyncPairEntity( if (s.multiFolder) {
id = editPairId ?: 0L, // One normal pair per folder, each into its OWN subfolder under the remote base.
name = s.name, localPath = s.localPath, remotePath = s.remotePath, // Keeping each source in a distinct subfolder is what makes many-to-one safe —
accountId = s.selectedAccountId, // flattening would let same-named files from different folders overwrite each other.
syncDirection = s.syncDirection, conflictStrategy = s.conflictStrategy, val base = s.remotePath.trimEnd('/')
deleteBehavior = s.deleteBehavior, recursive = s.recursive, val used = mutableSetOf<String>()
scheduleType = s.scheduleType, scheduleIntervalMinutes = s.intervalMinutes, s.localPaths.map { folder ->
scheduleDailyTime = if (s.scheduleType == ScheduleType.DAILY || s.scheduleType == ScheduleType.WEEKLY) s.dailyTime else null, val sub = uniqueSubName(folderLeafName(folder), used)
scheduleWeekdays = s.weekdays, val pairName = if (s.name.isBlank()) sub else "${s.name}$sub"
wifiOnly = s.wifiOnly, wifiSsid = s.wifiSsid, val entity = buildEntity(s, name = pairName, localPath = folder, remotePath = "$base/$sub", id = 0L)
chargingOnly = s.chargingOnly, minBatteryPct = s.minBatteryPct, entity.copy(id = syncPairDao.insert(entity))
excludePatterns = s.excludePatterns, includeExtensions = s.includeExtensions, }
excludeExtensions = s.excludeExtensions, skipHiddenFiles = s.skipHiddenFiles, } else {
minFileSizeKb = s.minFileSizeKb, maxFileSizeKb = s.maxFileSizeKb, val entity = buildEntity(s, name = s.name, localPath = s.localPath, remotePath = s.remotePath, id = editPairId ?: 0L)
notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError,
isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0,
)
val pairId = if (editPairId == null) { val pairId = if (editPairId == null) {
syncPairDao.insert(entity) syncPairDao.insert(entity)
} else { } else {
@@ -194,16 +235,36 @@ class AddPairViewModel @Inject constructor(
} }
editPairId editPairId
} }
entity.copy(id = pairId) listOf(entity.copy(id = pairId))
}
} }
.onSuccess { saved -> .onSuccess { saved ->
applySchedule(saved) saved.forEach { applySchedule(it) }
_state.update { it.copy(done = true) } _state.update { it.copy(done = true) }
} }
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } } .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 * 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 * the enable-toggle or a reboot, so a freshly-created scheduled pair never actually ran in the
@@ -91,4 +91,16 @@ class ExcludePathTest {
assertTrue(isExcludedPath("a/.hidden", pair(skipHiddenFiles = true))) assertTrue(isExcludedPath("a/.hidden", pair(skipHiddenFiles = true)))
assertFalse(isExcludedPath("a/.hidden", pair(skipHiddenFiles = false))) 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?)))
}
} }
@@ -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<String>()
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<String>()
assertTrue('/' !in uniqueSubName("a/b/c", used))
}
}
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.75 VERSION_NAME=1.0.76
VERSION_CODE=75 VERSION_CODE=76