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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user