fix: SAF delete crash, getFileMetadata drop-first, MKCOL before upload

- LocalAccessor.Saf.delete() now uses docIdCache (same as openInputStream)
  and catches IllegalStateException from DocumentsContract.deleteDocument
  instead of propagating it through awaitAll() and crashing the whole sync
- WebDavProvider.getFileMetadata() passes dropFirst=false to parsePropfind
  since Depth:0 returns exactly 1 result (the file); drop(1) was discarding it
- SyncEngine.performSync() calls ensureRemoteDirs() before each upload so
  MKCOL is issued for any missing parent directories (405=exists is success)
- Bump version to 1.0.11 (code 12)

Verified against live Nextcloud: baseline ↑0 ↓0 ✗0, upload detection ↑1 ↓0 ✗0,
download detection ↑0 ↓1 ✗0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 01:07:54 +00:00
parent a9322d3214
commit 1d6a80e43d
5 changed files with 75 additions and 19 deletions
@@ -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`() =