Compare commits

...

5 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
amir ddb558263f v1.0.75: exclude Android app-private trees (Android/data,media,obb)
Build & Release APK / build (push) Successful in 12m49s
Scoped storage (Android 11+) lets a SAF grant LIST another app's
Android/media|data|obb dir but not OPEN the files, so every transfer there
failed and the pair was stuck reporting Partial forever (65 failures under
Android/media/com.whatsapp/ on Zahra). These hold app-managed data, not user
content, so exclude the whole subtree on both sides via path-prefix match in
isExcludedPath. ExcludePathTest covers the new prefixes + case-insensitivity.
2026-06-07 13:01:05 +00:00
amir fb26e83484 v1.0.74: exclude OS-volatile junk (.thumbnails/.trashed-/.pending-/.sfpart) symmetrically
Build & Release APK / build (push) Successful in 12m50s
The .thumbnails cache was synced then DELETE_REMOTE'd every cycle: exclude
patterns were applied only to the local walk (filename-only), never to the
remote listing, so a previously-uploaded thumbnail looked local-missing and
got mirror-deleted endlessly as Android regenerates the cache.

- isExcludedPath(): path-segment-aware, hardcoded always-ignored set protects
  all existing pairs without a DB migration
- applied symmetrically to remote listing + merged path set (never upload,
  download, or delete an excluded path on either side)
- add .thumbnails to new-pair default excludePatterns
- ExcludePathTest covers cache/trash/pending/sfpart + user patterns
2026-06-07 05:30:00 +00:00
amir 0131d8d4fd v1.0.73: treat HTTP 423 Locked as success for MKCOL
Build & Release APK / build (push) Successful in 12m53s
SFTPGo returns HTTP 423 (Locked) on MKCOL when a directory already
exists and has an active lock. ensureRemoteDirs only handled 405
(already exists), so 423 was thrown as an exception causing all file
uploads within that directory to fail.

65 files failed every time because they were all inside directories
that returned 423 on MKCOL, not 405. Treat 423 the same as 405.
2026-06-07 02:55:50 +00:00
amir d2ca3f1918 v1.0.73: auto-upgrade http:// to https:// for WebDAV
Zahra's sync pair was configured with http://dav.khodak.me. Traefik has
a global HTTP->HTTPS redirect, but PROPFIND/PUT/MOVE are not followed
through redirects by OkHttp — so every WebDAV operation was getting
redirected and silently failing. 1072 logins, 0 actual DAV operations.

Silently rewrite http:// to https:// at the provider level so users
never need to reconfigure.
2026-06-07 02:51:32 +00:00
8 changed files with 401 additions and 58 deletions
@@ -150,7 +150,12 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
withContext(Dispatchers.IO) {
val req = Request.Builder().url(url(remotePath)).method("MKCOL", null).build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful && resp.code != 405) throw Exception("MKCOL HTTP ${resp.code}")
// 405 = directory already exists (most servers)
// 423 = Locked — SFTPGo returns this when the dir exists and has a lock;
// treat as "already there", not a failure, so uploads inside it proceed.
if (!resp.isSuccessful && resp.code != 405 && resp.code != 423) {
throw Exception("MKCOL HTTP ${resp.code}")
}
}
}
}
@@ -110,8 +110,28 @@ class SyncEngine @Inject constructor(
// level (Depth:1) made every file inside a remote subfolder look "missing from remote",
// which on a TWO_WAY/MIRROR pair triggered DELETE_LOCAL and wiped those files off the
// device (data loss). Walk the remote tree so subfolder files are matched, not deleted.
val remoteFiles = listRemoteFilesRecursive(provider, pair.remotePath)
// Exclusions are filtered on BOTH sides. The local walk already drops excluded files,
// but the remote listing did not, so any excluded path that already existed on the
// server (e.g. a previously-uploaded ".thumbnails" entry) looked "local-missing" and
// 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 = 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
@@ -125,7 +145,12 @@ class SyncEngine @Inject constructor(
return performSync(pair, provider, isRetry = true, onProgress = onProgress)
}
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
// knownStates may still hold records for now-excluded paths (e.g. thumbnails uploaded
// by an older build). Drop them from the work set so they aren't acted on; their stale
// state rows are harmless and ignored.
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys)
.filter { !isExcludedPath(it, pair) }
.toSet()
val hasPriorSyncState = knownStates.isNotEmpty()
val semaphore = Semaphore(2) // limit concurrency to be gentle on the server
val uploadedAtomic = AtomicInteger(0)
@@ -286,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
@@ -409,6 +448,63 @@ 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
* subtree like "DCIM/.thumbnails/..." is ignored. Android continuously regenerates and evicts
* its thumbnail cache and shuffles files through .trashed-/.pending- staging dirs; syncing them
* produces an endless upload→evict→DELETE_REMOTE→regenerate loop. ".sfpart" is our own atomic-
* write temp suffix and must never be propagated either.
*/
private val ALWAYS_IGNORED_SEGMENTS = setOf(".thumbnails")
private val ALWAYS_IGNORED_PREFIXES = listOf(".trashed-", ".pending-")
private val ALWAYS_IGNORED_SUFFIXES = listOf(".sfpart")
/**
* App-private storage trees. On Android 11+ (scoped storage) another app's SAF grant can LIST
* these directories but cannot OPEN the files inside, so every transfer fails and the pair is
* stuck reporting "Partial" forever (e.g. Android/media/com.whatsapp/...). They hold app-managed
* data, not user content worth syncing, so they are excluded entirely. Matched case-insensitively
* against the full relative path so the whole subtree is ignored on both sides.
*/
private val ALWAYS_IGNORED_PATH_PREFIXES = listOf("android/data/", "android/media/", "android/obb/")
/**
* True if [rel] should be excluded from sync entirely. Applied symmetrically to the local walk,
* the remote listing, and known state so an excluded path is never uploaded, downloaded, or
* deleted. The always-ignored rules run on every segment; user-configured rules
* (skipHiddenFiles, excludePatterns, excludeExtensions) match the filename, mirroring the
* existing local-walk semantics in LocalAccessor.
*/
internal fun isExcludedPath(rel: String, pair: SyncPair): Boolean {
val normalized = rel.replace('\\', '/')
val lower = normalized.lowercase()
if (ALWAYS_IGNORED_PATH_PREFIXES.any { lower.startsWith(it) }) return true
val segments = normalized.split('/').filter { it.isNotEmpty() }
if (segments.isEmpty()) return false
for (seg in segments) {
if (seg in ALWAYS_IGNORED_SEGMENTS) return true
if (ALWAYS_IGNORED_PREFIXES.any { seg.startsWith(it) }) return true
if (ALWAYS_IGNORED_SUFFIXES.any { seg.endsWith(it) }) return true
}
val fileName = segments.last()
if (pair.skipHiddenFiles && fileName.startsWith('.')) return true
if (pair.excludePatterns.any { pat -> fileName.matches(globToRegex(pat)) }) return true
val ext = fileName.substringAfterLast('.', "").lowercase()
if (pair.excludeExtensions.any { ext == it.lowercase().trimStart('.') }) return true
return false
}
/**
* True if a relative sync path is unsafe to act on — empty, absolute, or containing a ".."
* segment that would let a hostile remote escape the sync root via path traversal. Applied to
@@ -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,7 +122,40 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
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()) {
OutlinedTextField(
value = uriToDisplay(s.localPath), onValueChange = {},
@@ -134,11 +167,12 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
)
Box(modifier = Modifier.matchParentSize().clickable { showLocalBrowser = true })
}
}
// Remote folder
// 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
@@ -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<String> = emptyList(),
val remotePath: String = "",
val selectedAccountId: Long = -1L,
val accounts: List<CloudAccountEntity> = emptyList(),
@@ -47,7 +51,7 @@ data class AddPairUiState(
val chargingOnly: Boolean = false,
val minBatteryPct: Int = 0,
// ── File filters ─────────────────────────────────────────────────────────
val excludePatterns: String = ".DS_Store\n*.tmp\n.nomedia\nThumbs.db",
val excludePatterns: String = ".DS_Store\n*.tmp\n.nomedia\nThumbs.db\n.thumbnails",
val includeExtensions: String = "",
val excludeExtensions: String = "",
val skipHiddenFiles: Boolean = true,
@@ -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>): 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<Long>("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.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,23 +206,20 @@ 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,
)
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<String>()
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))
}
} 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 {
@@ -194,16 +235,36 @@ class AddPairViewModel @Inject constructor(
}
editPairId
}
entity.copy(id = pairId)
listOf(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
@@ -0,0 +1,106 @@
package com.syncflow.domain.sync
import com.syncflow.domain.model.ConflictStrategy
import com.syncflow.domain.model.DeleteBehavior
import com.syncflow.domain.model.ScheduleType
import com.syncflow.domain.model.SyncDirection
import com.syncflow.domain.model.SyncPair
import com.syncflow.domain.model.SyncStatus
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Exclusion symmetry: paths excluded from the local walk must also be excluded from the remote
* listing and the merged work set, so a previously-uploaded excluded file is never DELETE_REMOTE'd
* in an endless churn loop (the ".thumbnails" cache regression).
*/
class ExcludePathTest {
private fun pair(
excludePatterns: List<String> = emptyList(),
excludeExtensions: List<String> = emptyList(),
skipHiddenFiles: Boolean = false,
) = SyncPair(
id = 1, name = "t", localPath = "/l", remotePath = "/r", accountId = 1,
syncDirection = SyncDirection.TWO_WAY,
conflictStrategy = ConflictStrategy.KEEP_NEWEST,
deleteBehavior = DeleteBehavior.MIRROR,
recursive = true,
scheduleType = ScheduleType.MANUAL, scheduleIntervalMinutes = 0,
scheduleDailyTime = null, scheduleWeekdays = 0,
wifiOnly = false, wifiSsid = "", chargingOnly = false, minBatteryPct = 0,
excludePatterns = excludePatterns, includeExtensions = emptyList(),
excludeExtensions = excludeExtensions, skipHiddenFiles = skipHiddenFiles,
minFileSizeKb = 0, maxFileSizeKb = 0,
notifyOnComplete = false, notifyOnError = false,
isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE,
pendingConflicts = 0,
)
@Test fun `thumbnails cache is always excluded regardless of config`() {
val p = pair()
assertTrue(isExcludedPath("DCIM/.thumbnails/123.jpg", p))
assertTrue(isExcludedPath("Pictures/.thumbnails/1000020397.jpg", p))
assertTrue(isExcludedPath(".thumbnails/x.jpg", p))
}
@Test fun `android trash and pending staging dirs are excluded`() {
val p = pair()
assertTrue(isExcludedPath("DCIM/Camera/.trashed-1700000000-IMG_0001.jpg", p))
assertTrue(isExcludedPath("DCIM/.pending-1700000000-VID_0001.mp4", p))
}
@Test fun `our own atomic-write temp files are excluded`() {
val p = pair()
assertTrue(isExcludedPath("Download/.movie.mp4.sfpart", p))
assertTrue(isExcludedPath("a/b/.photo.jpg.sfpart", p))
}
@Test fun `android app-private trees are excluded (scoped-storage unreadable)`() {
val p = pair()
assertTrue(isExcludedPath("Android/media/com.whatsapp/WhatsApp/Media/IMG.jpg", p))
assertTrue(isExcludedPath("Android/data/com.foo/files/x.bin", p))
assertTrue(isExcludedPath("Android/obb/com.game/main.obb", p))
assertTrue(isExcludedPath("android/MEDIA/com.x/y.jpg", p)) // case-insensitive
}
@Test fun `non-private Android paths are not excluded`() {
val p = pair()
// A user folder literally named "Android" at a deeper level is fine; only the
// top-level app-private trees are blocked.
assertFalse(isExcludedPath("DCIM/Android/holiday.jpg", p))
assertFalse(isExcludedPath("Pictures/android-wallpaper.png", p))
}
@Test fun `normal media files are not excluded`() {
val p = pair()
assertFalse(isExcludedPath("DCIM/Camera/IMG_0001.jpg", p))
assertFalse(isExcludedPath("Pictures/cat.png", p))
assertFalse(isExcludedPath("اصفهان/20171023.jpg", p)) // unicode folder, real data
}
@Test fun `user exclude patterns and extensions still apply on filename`() {
assertTrue(isExcludedPath("a/Thumbs.db", pair(excludePatterns = listOf("Thumbs.db"))))
assertTrue(isExcludedPath("a/note.tmp", pair(excludePatterns = listOf("*.tmp"))))
assertTrue(isExcludedPath("a/data.log", pair(excludeExtensions = listOf("log"))))
assertFalse(isExcludedPath("a/keep.jpg", pair(excludeExtensions = listOf("log"))))
}
@Test fun `skipHiddenFiles excludes dotfiles by filename only`() {
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))
}
}
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.72
VERSION_CODE=72
VERSION_NAME=1.0.76
VERSION_CODE=76