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.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.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.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -47,9 +56,22 @@ class MainActivity : AppCompatActivity() {
SyncFlowNavGraph(rememberNavController()) SyncFlowNavGraph(rememberNavController())
} }
if (isLocked) { if (isLocked) {
LockOverlay() var showRetry by remember { mutableStateOf(false) }
LockOverlay(
showRetry = showRetry,
onRetry = {
showRetry = false
showBiometricPrompt(
onSuccess = { isLocked = false },
onFailed = { showRetry = true },
)
},
)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
showBiometricPrompt(onSuccess = { isLocked = false }) showBiometricPrompt(
onSuccess = { isLocked = false },
onFailed = { showRetry = true },
)
} }
} }
} }
@@ -75,24 +97,36 @@ class MainActivity : AppCompatActivity() {
BiometricManager.BIOMETRIC_SUCCESS BiometricManager.BIOMETRIC_SUCCESS
} }
private fun showBiometricPrompt(onSuccess: () -> Unit) { private fun showBiometricPrompt(onSuccess: () -> Unit, onFailed: () -> Unit) {
val executor = ContextCompat.getMainExecutor(this) val executor = ContextCompat.getMainExecutor(this)
val prompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() { val prompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess() 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) { val promptInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
BiometricPrompt.PromptInfo.Builder() BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock SyncFlow") .setTitle("Unlock SyncFlow")
.setSubtitle("Confirm your identity to continue") .setSubtitle("Use fingerprint or device PIN")
.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
.build() .build()
} else { } else {
BiometricPrompt.PromptInfo.Builder() BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock SyncFlow") .setTitle("Unlock SyncFlow")
.setSubtitle("Confirm your identity to continue") .setSubtitle("Use fingerprint to continue")
.setNegativeButtonText("Cancel") .setNegativeButtonText("Use PIN")
.build() .build()
} }
prompt.authenticate(promptInfo) prompt.authenticate(promptInfo)
@@ -100,13 +134,28 @@ class MainActivity : AppCompatActivity() {
} }
@Composable @Composable
private fun LockOverlay() { private fun LockOverlay(showRetry: Boolean, onRetry: () -> Unit) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center, 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('/') } .associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
val localFiles = accessor.walkFiles(pair) 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 allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
val semaphore = Semaphore(4) 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 -> allPaths.map { rel ->
async { async {
semaphore.withPermit { semaphore.withPermit {
@@ -93,14 +97,11 @@ class SyncEngine @Inject constructor(
local!!.sizeBytes local!!.sizeBytes
}.getOrElse { e -> }.getOrElse { e ->
Timber.e(e, "Upload failed: $rel") Timber.e(e, "Upload failed: $rel")
failed++
logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, e.message, 0) 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) logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes)
FileOutcome(uploaded = 1, bytesTransferred = bytes, newState = buildState(pair.id, rel, local!!, remote))
} }
SyncDecision.DOWNLOAD -> { SyncDecision.DOWNLOAD -> {
val bytes = runCatching { val bytes = runCatching {
@@ -110,29 +111,25 @@ class SyncEngine @Inject constructor(
remote!!.sizeBytes remote!!.sizeBytes
}.getOrElse { e -> }.getOrElse { e ->
Timber.e(e, "Download failed: $rel") Timber.e(e, "Download failed: $rel")
failed++
logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, e.message, 0) 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) logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes)
FileOutcome(downloaded = 1, bytesTransferred = bytes, newState = buildState(pair.id, rel, null, remote))
} }
SyncDecision.DELETE_LOCAL -> { SyncDecision.DELETE_LOCAL -> {
accessor.delete(rel) accessor.delete(rel)
fileStateDao.delete(pair.id, rel) fileStateDao.delete(pair.id, rel)
deleted++
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0) logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0)
FileOutcome(deleted = 1)
} }
SyncDecision.DELETE_REMOTE -> { SyncDecision.DELETE_REMOTE -> {
provider.deleteFile("${pair.remotePath}/$rel") provider.deleteFile("${pair.remotePath}/$rel")
fileStateDao.delete(pair.id, rel) fileStateDao.delete(pair.id, rel)
deleted++
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0) logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0)
FileOutcome(deleted = 1)
} }
SyncDecision.CONFLICT -> { SyncDecision.CONFLICT -> {
conflicts++
conflictDao.insert(SyncConflictEntity( conflictDao.insert(SyncConflictEntity(
syncPairId = pair.id, syncPairId = pair.id,
relativePath = rel, relativePath = rel,
@@ -144,16 +141,25 @@ class SyncEngine @Inject constructor(
detectedAt = Instant.now(), detectedAt = Instant.now(),
)) ))
logEvent(pair.id, SyncEventType.CONFLICT_DETECTED, rel, null, 0) logEvent(pair.id, SyncEventType.CONFLICT_DETECTED, rel, null, 0)
FileOutcome(conflicts = 1)
} }
SyncDecision.SKIP -> skipped++ SyncDecision.SKIP -> FileOutcome(skipped = 1)
} }
} }
} }
}.awaitAll() }.awaitAll()
} }
fileStateDao.upsertAll(newStates) fileStateDao.upsertAll(outcomes.mapNotNull { it.newState })
return SyncResult(uploaded, downloaded, deleted, skipped, failed, conflicts, bytesTransferred) 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( private fun decide(
@@ -168,7 +174,7 @@ class SyncEngine @Inject constructor(
val remoteExists = remote != null val remoteExists = remote != null
val localChanged = known == null || (localExists && local!!.lastModifiedMs != known.localModifiedAt?.toEpochMilli()) 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 { return when {
!localExists && !remoteExists -> SyncDecision.SKIP !localExists && !remoteExists -> SyncDecision.SKIP
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.3 VERSION_NAME=1.0.4
VERSION_CODE=4 VERSION_CODE=5