Compare commits

..

1 Commits

Author SHA1 Message Date
amir 019ba930d3 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
2026-06-07 13:49:59 +00:00
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 {
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') } listRemoteFilesRecursive(provider, pair.remotePath)
.filterKeys { !isExcludedPath(it, pair) } .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) 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,23 +122,57 @@ 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)
Box(modifier = Modifier.fillMaxWidth()) { if (!s.isEditing) {
OutlinedTextField( ToggleRow(
value = uriToDisplay(s.localPath), onValueChange = {}, label = "Back up multiple folders",
label = { Text("Local folder") }, description = "Pick several folders; each is saved as its own subfolder under the remote base (no overwrites)",
leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) }, checked = s.multiFolder,
trailingIcon = { Icon(Icons.Default.FolderOpen, null) }, onToggle = { vm.setMultiFolder(it) },
readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Tap to choose folder…") },
) )
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( 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.name.isBlank()) add("Name is required") if (s.multiFolder) {
if (s.localPath.isBlank()) add("Local folder is required") if (s.localPaths.isEmpty()) add("Add at least one folder")
if (s.remotePath.isBlank()) add("Remote folder is required") 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.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,48 +206,65 @@ 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,
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)
} }
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 -> .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