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
@@ -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?)))
}
}
@@ -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))
}
}