diff --git a/.kotlin/sessions/kotlin-compiler-16648647692068590643.salive b/.kotlin/sessions/kotlin-compiler-6167349745264583245.salive similarity index 100% rename from .kotlin/sessions/kotlin-compiler-16648647692068590643.salive rename to .kotlin/sessions/kotlin-compiler-6167349745264583245.salive diff --git a/app/src/main/kotlin/com/syncflow/MainActivity.kt b/app/src/main/kotlin/com/syncflow/MainActivity.kt index 6abfbf3..59b8cbf 100644 --- a/app/src/main/kotlin/com/syncflow/MainActivity.kt +++ b/app/src/main/kotlin/com/syncflow/MainActivity.kt @@ -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) + } + } } } diff --git a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt index 94601da..7bdd232 100644 --- a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt +++ b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt @@ -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() - 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 = 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 diff --git a/version.properties b/version.properties index 44116a2..a6c5b8a 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.3 -VERSION_CODE=4 +VERSION_NAME=1.0.4 +VERSION_CODE=5