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
@@ -10,14 +10,23 @@ import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
@@ -47,9 +56,22 @@ class MainActivity : AppCompatActivity() {
SyncFlowNavGraph(rememberNavController())
}
if (isLocked) {
LockOverlay()
var showRetry by remember { mutableStateOf(false) }
LockOverlay(
showRetry = showRetry,
onRetry = {
showRetry = false
showBiometricPrompt(
onSuccess = { isLocked = false },
onFailed = { showRetry = true },
)
},
)
LaunchedEffect(Unit) {
showBiometricPrompt(onSuccess = { isLocked = false })
showBiometricPrompt(
onSuccess = { isLocked = false },
onFailed = { showRetry = true },
)
}
}
}
@@ -75,24 +97,36 @@ class MainActivity : AppCompatActivity() {
BiometricManager.BIOMETRIC_SUCCESS
}
private fun showBiometricPrompt(onSuccess: () -> Unit) {
private fun showBiometricPrompt(onSuccess: () -> Unit, onFailed: () -> Unit) {
val executor = ContextCompat.getMainExecutor(this)
val prompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
// User cancelled or lockout — re-show the prompt so they can retry
if (errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
showBiometricPrompt(onSuccess, onFailed)
} else {
onFailed()
}
}
override fun onAuthenticationFailed() {
// Wrong finger/face — BiometricPrompt handles retries internally, no action needed
}
})
val promptInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock SyncFlow")
.setSubtitle("Confirm your identity to continue")
.setSubtitle("Use fingerprint or device PIN")
.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
.build()
} else {
BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock SyncFlow")
.setSubtitle("Confirm your identity to continue")
.setNegativeButtonText("Cancel")
.setSubtitle("Use fingerprint to continue")
.setNegativeButtonText("Use PIN")
.build()
}
prompt.authenticate(promptInfo)
@@ -100,13 +134,28 @@ class MainActivity : AppCompatActivity() {
}
@Composable
private fun LockOverlay() {
private fun LockOverlay(showRetry: Boolean, onRetry: () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(Icons.Default.Lock, null, modifier = Modifier.height(48.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(16.dp))
Text("SyncFlow is locked", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
if (showRetry) {
Spacer(Modifier.height(8.dp))
Button(onClick = onRetry) { Text("Unlock") }
} else {
Text("Use fingerprint or PIN to unlock", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@@ -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
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.3
VERSION_CODE=4
VERSION_NAME=1.0.4
VERSION_CODE=5