fix: biometric retry + sync change detection race condition
Biometric: - Handle onAuthenticationError with auto-retry (except user cancel) - Show lock screen with proper UI and an Unlock button as fallback - Add subtitle clarifying fingerprint/PIN options Sync engine: - Fix data race: async coroutines now return FileOutcome instead of mutating shared vars/list concurrently (was causing file states to not be saved, so every sync re-transferred all files) - Fix remoteChanged: use || instead of && so either etag or modifiedAt change is enough to detect a remote modification Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -68,14 +68,18 @@ class SyncEngine @Inject constructor(
|
||||
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
|
||||
val localFiles = accessor.walkFiles(pair)
|
||||
|
||||
var uploaded = 0; var downloaded = 0; var deleted = 0; var skipped = 0; var failed = 0; var conflicts = 0
|
||||
var bytesTransferred = 0L
|
||||
val newStates = mutableListOf<com.syncflow.data.db.entities.SyncFileStateEntity>()
|
||||
|
||||
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
|
||||
val semaphore = Semaphore(4)
|
||||
|
||||
coroutineScope {
|
||||
// Each async block returns its outcome; no shared mutable state across coroutines.
|
||||
data class FileOutcome(
|
||||
val uploaded: Int = 0, val downloaded: Int = 0, val deleted: Int = 0,
|
||||
val skipped: Int = 0, val failed: Int = 0, val conflicts: Int = 0,
|
||||
val bytesTransferred: Long = 0L,
|
||||
val newState: com.syncflow.data.db.entities.SyncFileStateEntity? = null,
|
||||
)
|
||||
|
||||
val outcomes: List<FileOutcome> = coroutineScope {
|
||||
allPaths.map { rel ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
@@ -93,14 +97,11 @@ class SyncEngine @Inject constructor(
|
||||
local!!.sizeBytes
|
||||
}.getOrElse { e ->
|
||||
Timber.e(e, "Upload failed: $rel")
|
||||
failed++
|
||||
logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, e.message, 0)
|
||||
return@withPermit
|
||||
return@withPermit FileOutcome(failed = 1)
|
||||
}
|
||||
uploaded++
|
||||
bytesTransferred += bytes
|
||||
newStates += buildState(pair.id, rel, local!!, remote)
|
||||
logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes)
|
||||
FileOutcome(uploaded = 1, bytesTransferred = bytes, newState = buildState(pair.id, rel, local!!, remote))
|
||||
}
|
||||
SyncDecision.DOWNLOAD -> {
|
||||
val bytes = runCatching {
|
||||
@@ -110,29 +111,25 @@ class SyncEngine @Inject constructor(
|
||||
remote!!.sizeBytes
|
||||
}.getOrElse { e ->
|
||||
Timber.e(e, "Download failed: $rel")
|
||||
failed++
|
||||
logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, e.message, 0)
|
||||
return@withPermit
|
||||
return@withPermit FileOutcome(failed = 1)
|
||||
}
|
||||
downloaded++
|
||||
bytesTransferred += bytes
|
||||
newStates += buildState(pair.id, rel, null, remote)
|
||||
logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes)
|
||||
FileOutcome(downloaded = 1, bytesTransferred = bytes, newState = buildState(pair.id, rel, null, remote))
|
||||
}
|
||||
SyncDecision.DELETE_LOCAL -> {
|
||||
accessor.delete(rel)
|
||||
fileStateDao.delete(pair.id, rel)
|
||||
deleted++
|
||||
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0)
|
||||
FileOutcome(deleted = 1)
|
||||
}
|
||||
SyncDecision.DELETE_REMOTE -> {
|
||||
provider.deleteFile("${pair.remotePath}/$rel")
|
||||
fileStateDao.delete(pair.id, rel)
|
||||
deleted++
|
||||
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0)
|
||||
FileOutcome(deleted = 1)
|
||||
}
|
||||
SyncDecision.CONFLICT -> {
|
||||
conflicts++
|
||||
conflictDao.insert(SyncConflictEntity(
|
||||
syncPairId = pair.id,
|
||||
relativePath = rel,
|
||||
@@ -144,16 +141,25 @@ class SyncEngine @Inject constructor(
|
||||
detectedAt = Instant.now(),
|
||||
))
|
||||
logEvent(pair.id, SyncEventType.CONFLICT_DETECTED, rel, null, 0)
|
||||
FileOutcome(conflicts = 1)
|
||||
}
|
||||
SyncDecision.SKIP -> skipped++
|
||||
SyncDecision.SKIP -> FileOutcome(skipped = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
fileStateDao.upsertAll(newStates)
|
||||
return SyncResult(uploaded, downloaded, deleted, skipped, failed, conflicts, bytesTransferred)
|
||||
fileStateDao.upsertAll(outcomes.mapNotNull { it.newState })
|
||||
return SyncResult(
|
||||
uploaded = outcomes.sumOf { it.uploaded },
|
||||
downloaded = outcomes.sumOf { it.downloaded },
|
||||
deleted = outcomes.sumOf { it.deleted },
|
||||
skipped = outcomes.sumOf { it.skipped },
|
||||
failedFiles = outcomes.sumOf { it.failed },
|
||||
conflicts = outcomes.sumOf { it.conflicts },
|
||||
bytesTransferred = outcomes.sumOf { it.bytesTransferred },
|
||||
)
|
||||
}
|
||||
|
||||
private fun decide(
|
||||
@@ -168,7 +174,7 @@ class SyncEngine @Inject constructor(
|
||||
val remoteExists = remote != null
|
||||
|
||||
val localChanged = known == null || (localExists && local!!.lastModifiedMs != known.localModifiedAt?.toEpochMilli())
|
||||
val remoteChanged = known == null || (remoteExists && remote!!.etag != known.remoteEtag && remote.modifiedAt != known.remoteModifiedAt)
|
||||
val remoteChanged = known == null || (remoteExists && (remote!!.etag != known.remoteEtag || remote.modifiedAt != known.remoteModifiedAt))
|
||||
|
||||
return when {
|
||||
!localExists && !remoteExists -> SyncDecision.SKIP
|
||||
|
||||
Reference in New Issue
Block a user