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
@@ -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,