diff --git a/.kotlin/sessions/kotlin-compiler-6167349745264583245.salive b/.kotlin/sessions/kotlin-compiler-16347658167541255052.salive similarity index 100% rename from .kotlin/sessions/kotlin-compiler-6167349745264583245.salive rename to .kotlin/sessions/kotlin-compiler-16347658167541255052.salive diff --git a/app/src/main/kotlin/com/syncflow/MainActivity.kt b/app/src/main/kotlin/com/syncflow/MainActivity.kt index 59b8cbf..974f69b 100644 --- a/app/src/main/kotlin/com/syncflow/MainActivity.kt +++ b/app/src/main/kotlin/com/syncflow/MainActivity.kt @@ -7,6 +7,7 @@ import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL import androidx.biometric.BiometricPrompt import androidx.compose.foundation.background @@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Lock import androidx.compose.material3.Button @@ -45,6 +47,7 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var appPreferences: AppPreferences private var isLocked by mutableStateOf(false) + private var showRetry by mutableStateOf(false) override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() @@ -56,81 +59,81 @@ class MainActivity : AppCompatActivity() { SyncFlowNavGraph(rememberNavController()) } if (isLocked) { - var showRetry by remember { mutableStateOf(false) } LockOverlay( showRetry = showRetry, - onRetry = { - showRetry = false - showBiometricPrompt( - onSuccess = { isLocked = false }, - onFailed = { showRetry = true }, - ) - }, + onRetry = { triggerBiometric() }, ) - LaunchedEffect(Unit) { - showBiometricPrompt( - onSuccess = { isLocked = false }, - onFailed = { showRetry = true }, - ) - } } } } } + override fun onResume() { + super.onResume() + if (isLocked) triggerBiometric() + } + override fun onStop() { super.onStop() if (isChangingConfigurations) return lifecycleScope.launch { if (appPreferences.biometricLockEnabled.first() && canAuthenticate()) { isLocked = true + showRetry = false } } } - private fun canAuthenticate(): Boolean { - val authenticators = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) - BIOMETRIC_STRONG or DEVICE_CREDENTIAL - else - BIOMETRIC_STRONG - return BiometricManager.from(this).canAuthenticate(authenticators) == - BiometricManager.BIOMETRIC_SUCCESS - } - - private fun showBiometricPrompt(onSuccess: () -> Unit, onFailed: () -> Unit) { + private fun triggerBiometric() { + showRetry = false + val authenticators = bestAuthenticators() val executor = ContextCompat.getMainExecutor(this) val prompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - onSuccess() + isLocked = false + showRetry = false } 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() - } + // Show the Unlock button so the user can tap to retry manually + showRetry = true } override fun onAuthenticationFailed() { - // Wrong finger/face — BiometricPrompt handles retries internally, no action needed + // Wrong biometric — BiometricPrompt retries automatically } }) val promptInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { BiometricPrompt.PromptInfo.Builder() .setTitle("Unlock SyncFlow") - .setSubtitle("Use fingerprint or device PIN") - .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) + .setSubtitle("Use fingerprint or PIN") + .setAllowedAuthenticators(authenticators) .build() } else { BiometricPrompt.PromptInfo.Builder() .setTitle("Unlock SyncFlow") - .setSubtitle("Use fingerprint to continue") - .setNegativeButtonText("Use PIN") + .setSubtitle("Use fingerprint") + .setNegativeButtonText("Cancel") .build() } prompt.authenticate(promptInfo) } + + private fun bestAuthenticators(): Int { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return BIOMETRIC_STRONG + val bm = BiometricManager.from(this) + // Prefer strong+credential; fall back to weak+credential so side-sensor phones work + return if (bm.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS) + BIOMETRIC_STRONG or DEVICE_CREDENTIAL + else + BIOMETRIC_WEAK or DEVICE_CREDENTIAL + } + + private fun canAuthenticate(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) + return BiometricManager.from(this).canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS + val bm = BiometricManager.from(this) + return bm.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS || + bm.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS + } } @Composable @@ -143,18 +146,18 @@ private fun LockOverlay(showRetry: Boolean, onRetry: () -> Unit) { ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Icon(Icons.Default.Lock, null, modifier = Modifier.height(48.dp), tint = MaterialTheme.colorScheme.primary) - Spacer(Modifier.height(16.dp)) + Icon(Icons.Default.Lock, null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.primary) 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) + Text( + "Use fingerprint or PIN to unlock", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } diff --git a/app/src/main/kotlin/com/syncflow/data/db/DbConverters.kt b/app/src/main/kotlin/com/syncflow/data/db/DbConverters.kt index 09b86cb..b2ce962 100644 --- a/app/src/main/kotlin/com/syncflow/data/db/DbConverters.kt +++ b/app/src/main/kotlin/com/syncflow/data/db/DbConverters.kt @@ -4,6 +4,6 @@ import androidx.room.TypeConverter import java.time.Instant class DbConverters { - @TypeConverter fun fromInstant(v: Instant?): Long? = v?.epochSecond - @TypeConverter fun toInstant(v: Long?): Instant? = v?.let { Instant.ofEpochSecond(it) } + @TypeConverter fun fromInstant(v: Instant?): Long? = v?.toEpochMilli() + @TypeConverter fun toInstant(v: Long?): Instant? = v?.let { Instant.ofEpochMilli(it) } } diff --git a/app/src/main/kotlin/com/syncflow/data/db/SyncDatabase.kt b/app/src/main/kotlin/com/syncflow/data/db/SyncDatabase.kt index 66d3c62..0f40a2c 100644 --- a/app/src/main/kotlin/com/syncflow/data/db/SyncDatabase.kt +++ b/app/src/main/kotlin/com/syncflow/data/db/SyncDatabase.kt @@ -3,6 +3,8 @@ package com.syncflow.data.db import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import com.syncflow.data.db.entities.* @Database( @@ -13,11 +15,21 @@ import com.syncflow.data.db.entities.* SyncConflictEntity::class, SyncEventEntity::class, ], - version = 2, + version = 3, exportSchema = true, ) @TypeConverters(DbConverters::class) abstract class SyncDatabase : RoomDatabase() { + + companion object { + // Wipe file states: timestamps were stored as epoch-seconds, now epoch-millis. + // All previously saved states are wrong so we drop and re-learn on next sync. + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DELETE FROM sync_file_states") + } + } + } abstract fun cloudAccountDao(): CloudAccountDao abstract fun syncPairDao(): SyncPairDao abstract fun syncFileStateDao(): SyncFileStateDao diff --git a/app/src/main/kotlin/com/syncflow/di/AppModule.kt b/app/src/main/kotlin/com/syncflow/di/AppModule.kt index 70c5524..919516f 100644 --- a/app/src/main/kotlin/com/syncflow/di/AppModule.kt +++ b/app/src/main/kotlin/com/syncflow/di/AppModule.kt @@ -21,9 +21,8 @@ object AppModule { @Provides @Singleton fun provideDatabase(@ApplicationContext ctx: Context): SyncDatabase = Room.databaseBuilder(ctx, SyncDatabase::class.java, "syncflow.db") - // Only fall back to destructive migration for very old dev builds (v1). - // All future version bumps must include a proper Migration object. .fallbackToDestructiveMigrationFrom(1) + .addMigrations(SyncDatabase.MIGRATION_2_3) .build() @Provides fun provideCloudAccountDao(db: SyncDatabase): CloudAccountDao = db.cloudAccountDao() diff --git a/version.properties b/version.properties index a6c5b8a..623e0b3 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.4 -VERSION_CODE=5 +VERSION_NAME=1.0.5 +VERSION_CODE=6