diff --git a/app/src/main/kotlin/com/syncflow/data/providers/webdav/WebDavProvider.kt b/app/src/main/kotlin/com/syncflow/data/providers/webdav/WebDavProvider.kt index de19788..f918292 100644 --- a/app/src/main/kotlin/com/syncflow/data/providers/webdav/WebDavProvider.kt +++ b/app/src/main/kotlin/com/syncflow/data/providers/webdav/WebDavProvider.kt @@ -132,7 +132,10 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider { val req = Request.Builder().url(url(remotePath)).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build() client.newCall(req).execute().use { resp -> if (resp.code != 207) throw Exception("HTTP ${resp.code}") - parsePropfind(resp.body?.string() ?: "", remotePath.substringBeforeLast('/')) + // Depth:0 returns exactly the requested resource as the single response entry. + // parsePropfind normally drops the first entry (the parent dir) for Depth:1 + // directory listings, so pass dropFirst=false here. + parsePropfind(resp.body?.string() ?: "", remotePath.substringBeforeLast('/'), dropFirst = false) .firstOrNull() ?: throw Exception("File not found") } } @@ -153,7 +156,7 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider { protected fun url(path: String) = "$baseUrl/${path.trimStart('/')}" - private fun parsePropfind(xml: String, parentPath: String): List { + private fun parsePropfind(xml: String, parentPath: String, dropFirst: Boolean = true): List { val results = mutableListOf() try { val factory = XmlPullParserFactory.newInstance() @@ -192,7 +195,7 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider { eventType = parser.next() } } catch (_: Exception) {} - return results.drop(1) // drop the parent folder itself + return if (dropFirst) results.drop(1) else results } private fun parseHttpDate(value: String): Instant = try { diff --git a/app/src/main/kotlin/com/syncflow/domain/sync/LocalAccessor.kt b/app/src/main/kotlin/com/syncflow/domain/sync/LocalAccessor.kt index 0fcdccf..1146922 100644 --- a/app/src/main/kotlin/com/syncflow/domain/sync/LocalAccessor.kt +++ b/app/src/main/kotlin/com/syncflow/domain/sync/LocalAccessor.kt @@ -63,7 +63,13 @@ sealed class LocalAccessor { class Saf(private val treeUri: Uri, private val resolver: ContentResolver) : LocalAccessor() { + // Populated by walkFiles so openInputStream can skip the re-query for files that + // already exist locally (uploads). Root-level files are the common failure case + // when findDocUri re-queries: the cache sidesteps the issue entirely. + private val docIdCache = mutableMapOf() + override fun walkFiles(pair: SyncPair): Map { + docIdCache.clear() val rootDocId = DocumentsContract.getTreeDocumentId(treeUri) val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, rootDocId) return cursorWalk(childrenUri, "", pair) @@ -110,6 +116,7 @@ sealed class LocalAccessor { if (ext in excludeExts) continue if (size !in minBytes..maxBytes) continue result[rel] = LocalFileInfo(rel, size, modified) + docIdCache[rel] = docId } } } @@ -117,7 +124,10 @@ sealed class LocalAccessor { } override fun openInputStream(relativePath: String): InputStream? { - val docUri = findDocUri(relativePath) ?: return null + val docUri = docIdCache[relativePath] + ?.let { DocumentsContract.buildDocumentUriUsingTree(treeUri, it) } + ?: findDocUri(relativePath) + ?: return null return resolver.openInputStream(docUri) } @@ -155,8 +165,15 @@ sealed class LocalAccessor { } override fun delete(relativePath: String): Boolean { - val docUri = findDocUri(relativePath) ?: return false - return DocumentsContract.deleteDocument(resolver, docUri) + val docUri = docIdCache[relativePath] + ?.let { DocumentsContract.buildDocumentUriUsingTree(treeUri, it) } + ?: findDocUri(relativePath) + ?: return false + return try { + DocumentsContract.deleteDocument(resolver, docUri) + } catch (e: Exception) { + false + } } override fun lastModifiedMs(relativePath: String): Long { 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 f3aaafa..7fdddef 100644 --- a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt +++ b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt @@ -91,9 +91,11 @@ class SyncEngine @Inject constructor( when (decision) { SyncDecision.UPLOAD -> { + var uploadedRemoteFile: RemoteFile? = null val bytes = runCatching { + ensureRemoteDirs(provider, pair.remotePath, rel) accessor.openInputStream(rel)?.use { stream -> - provider.uploadFile(stream, "${pair.remotePath}/$rel", local!!.sizeBytes) { } + uploadedRemoteFile = provider.uploadFile(stream, "${pair.remotePath}/$rel", local!!.sizeBytes) { }.getOrThrow() } local!!.sizeBytes }.getOrElse { e -> @@ -102,10 +104,8 @@ class SyncEngine @Inject constructor( return@withPermit FileOutcome(failed = 1) } logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes) - // Remote metadata is unknown until the next listing; save null so - // decide() treats it as "not changed" and reconciles on next SKIP. FileOutcome(uploaded = 1, bytesTransferred = bytes, - newState = buildState(pair.id, rel, local!!, remoteAfterTransfer = null)) + newState = buildState(pair.id, rel, local!!, remoteAfterTransfer = uploadedRemoteFile)) } SyncDecision.DOWNLOAD -> { val bytes = runCatching { @@ -154,13 +154,15 @@ class SyncEngine @Inject constructor( FileOutcome(conflicts = 1) } SyncDecision.SKIP -> { - // Reconcile: if the known state is missing remote or local metadata - // (saved right after an upload before we had the server's response), - // fill it in now that we have the full listing. - val needsReconcile = known != null && - (known.remoteModifiedAt == null || known.localModifiedAt == null) && - local != null && remote != null - if (needsReconcile) { + // Save state whenever both sides are present and state is absent or + // incomplete (post-upload null metadata). Without a baseline record, + // a subsequent local deletion would look like an unseen remote file + // and be re-downloaded instead of triggering DELETE_REMOTE. + val saveState = local != null && remote != null && ( + known == null || + known.remoteModifiedAt == null || known.localModifiedAt == null + ) + if (saveState) { FileOutcome(skipped = 1, newState = buildState(pair.id, rel, local, remoteAfterTransfer = remote)) } else { FileOutcome(skipped = 1) @@ -184,6 +186,17 @@ class SyncEngine @Inject constructor( ) } + private suspend fun ensureRemoteDirs(provider: CloudProvider, remotePairPath: String, rel: String) { + val parts = rel.replace('\\', '/').split('/') + var currentPath = remotePairPath + for (part in parts.dropLast(1)) { + currentPath = "$currentPath/$part" + provider.createDirectory(currentPath).onFailure { e -> + Timber.w("MKCOL $currentPath: ${e.message}") + } + } + } + private fun buildState( pairId: Long, rel: String, diff --git a/app/src/test/kotlin/com/syncflow/domain/sync/SyncDecideTest.kt b/app/src/test/kotlin/com/syncflow/domain/sync/SyncDecideTest.kt index fa612ab..93e02af 100644 --- a/app/src/test/kotlin/com/syncflow/domain/sync/SyncDecideTest.kt +++ b/app/src/test/kotlin/com/syncflow/domain/sync/SyncDecideTest.kt @@ -116,6 +116,29 @@ class SyncDecideTest { assertEquals(SyncDecision.SKIP, decide(local(), null, state(), dir = SyncDirection.UPLOAD_ONLY)) + @Test fun `local deleted TWO_WAY MIRROR deletes remote`() = + assertEquals(SyncDecision.DELETE_REMOTE, decide(null, remote(), state(), delete = DeleteBehavior.MIRROR)) + + @Test fun `local deleted TWO_WAY KEEP skips`() = + assertEquals(SyncDecision.SKIP, decide(null, remote(), state(), delete = DeleteBehavior.KEEP)) + + @Test fun `local deleted DOWNLOAD_ONLY skips remote deletion`() = + assertEquals(SyncDecision.SKIP, + decide(null, remote(), state(), dir = SyncDirection.DOWNLOAD_ONLY)) + + // ── first-seen SKIP saves baseline so later deletions are detected ──────── + + @Test fun `first sync both exist same mtime uploads local wins tie`() = + assertEquals(SyncDecision.UPLOAD, decide(local(MS), remote(MS, etag = "abc"))) + + @Test fun `after first-seen skip local deleted deletes remote`() { + // Simulate: first sync saw both sides identical → SKIP (state saved by engine). + // Then local file deleted → known is now present → DELETE_REMOTE. + val known = state(localMs = MS, remoteMs = MS, etag = "abc") + assertEquals(SyncDecision.DELETE_REMOTE, + decide(null, remote(MS, etag = "abc"), known, delete = DeleteBehavior.MIRROR)) + } + // ── directions ──────────────────────────────────────────────────────────── @Test fun `UPLOAD_ONLY ignores remote changes`() = diff --git a/version.properties b/version.properties index c5b3ce6..c6bf469 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.6 -VERSION_CODE=7 +VERSION_NAME=1.0.11 +VERSION_CODE=12