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:
2026-05-22 23:51:24 +00:00
parent d6220b7bd7
commit e237555222
4 changed files with 88 additions and 33 deletions
@@ -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