From 147da702a18e3b8a47bec5f1b4330e7271267210 Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Sun, 7 Jun 2026 00:43:16 +0000 Subject: [PATCH] =?UTF-8?q?v1.0.68:=20fix=20two-way=20DATA=20LOSS=20?= =?UTF-8?q?=E2=80=94=20list=20remote=20recursively?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remote was listed Depth:1 (top level only) while the local folder is walked recursively. Files inside remote subfolders looked 'missing from remote', so TWO_WAY + mirror-delete ran DELETE_LOCAL and wiped them off the device. Now walk the remote tree (Depth:1 per dir) so subfolder files are matched and never falsely deleted. --- .../com/syncflow/domain/sync/SyncEngine.kt | 37 ++++++++++++++++++- version.properties | 4 +- 2 files changed, 37 insertions(+), 4 deletions(-) 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 5379bb6..3f90d99 100644 --- a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt +++ b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt @@ -68,6 +68,36 @@ class SyncEngine @Inject constructor( else LocalAccessor.JavaFile(File(localPath)) + /** + * Recursively collect every FILE under [basePath] on the remote, descending into each + * subdirectory (one Depth:1 PROPFIND per directory). Directories are never returned as + * files — only their contents. The provider already drops the parent entry from each + * listing, so children-only is returned; the explicit self-path guard prevents any + * pathological infinite recursion. This MUST mirror the recursive local walk: otherwise + * files in remote subfolders appear absent and a TWO_WAY/MIRROR sync deletes them locally. + */ + private suspend fun listRemoteFilesRecursive( + provider: CloudProvider, + basePath: String, + depth: Int = 0, + ): List { + if (depth > 64) { + Timber.w("SyncEngine: remote recursion depth limit hit at $basePath") + return emptyList() + } + val out = mutableListOf() + for (entry in provider.listFiles(basePath).getOrThrow()) { + if (entry.isDirectory) { + if (entry.path.trimEnd('/') != basePath.trimEnd('/')) { + out += listRemoteFilesRecursive(provider, entry.path, depth + 1) + } + } else { + out += entry + } + } + return out + } + private suspend fun performSync( pair: SyncPair, provider: CloudProvider, @@ -76,8 +106,11 @@ class SyncEngine @Inject constructor( ): SyncResult { val accessor = makeAccessor(pair.localPath) var knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath } - val remoteFiles = provider.listFiles(pair.remotePath).getOrThrow() - .filter { !it.isDirectory } // skip remote directories — they are not sync targets + // The local walk is RECURSIVE, so the remote listing must be too. Listing only the top + // 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) .associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') } val localFiles = accessor.walkFiles(pair) diff --git a/version.properties b/version.properties index 919ad90..e0c9d89 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.67 -VERSION_CODE=68 +VERSION_NAME=1.0.68 +VERSION_CODE=69