diff --git a/app/src/main/java/cn/airnan/a2fair/A2FairApplication.kt b/app/src/main/java/cn/airnan/a2fair/A2FairApplication.kt index f0d16cf..1a08e25 100644 --- a/app/src/main/java/cn/airnan/a2fair/A2FairApplication.kt +++ b/app/src/main/java/cn/airnan/a2fair/A2FairApplication.kt @@ -65,7 +65,7 @@ class A2FairApplication : Application() { tokenRepository = TokenRepository(database.tokenDao(), database.groupDao()) settingsRepository = SettingsRepository(this, cryptoManager) - appLockManager = AppLockManager(settingsRepository) + appLockManager = AppLockManager(this, settingsRepository) ProcessLifecycleOwner.get().lifecycle.addObserver(appLockManager) backupManager = BackupManager(this, tokenRepository, cryptoManager) diff --git a/app/src/main/java/cn/airnan/a2fair/MainActivity.kt b/app/src/main/java/cn/airnan/a2fair/MainActivity.kt index 0d09c72..f05480f 100644 --- a/app/src/main/java/cn/airnan/a2fair/MainActivity.kt +++ b/app/src/main/java/cn/airnan/a2fair/MainActivity.kt @@ -5,7 +5,6 @@ import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.view.WindowManager -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize @@ -13,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.datastore.preferences.core.intPreferencesKey +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import cn.airnan.a2fair.data.repository.dataStore import cn.airnan.a2fair.ui.navigation.AppNavigation @@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import java.util.Locale -class MainActivity : ComponentActivity() { +class MainActivity : FragmentActivity() { override fun attachBaseContext(newBase: Context) { val languageCode = try { @@ -91,4 +91,4 @@ class MainActivity : ComponentActivity() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/cn/airnan/a2fair/core/security/AppLockManager.kt b/app/src/main/java/cn/airnan/a2fair/core/security/AppLockManager.kt index 1f6ce40..8b15c3c 100644 --- a/app/src/main/java/cn/airnan/a2fair/core/security/AppLockManager.kt +++ b/app/src/main/java/cn/airnan/a2fair/core/security/AppLockManager.kt @@ -1,37 +1,144 @@ package cn.airnan.a2fair.core.security +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import cn.airnan.a2fair.data.repository.SettingsRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -class AppLockManager(private val settingsRepository: SettingsRepository) : DefaultLifecycleObserver { +class AppLockManager( + context: Context, + private val settingsRepository: SettingsRepository +) : DefaultLifecycleObserver { + private val appContext = context.applicationContext + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val _isLocked = MutableStateFlow(false) val isLocked: StateFlow = _isLocked.asStateFlow() - private val scope = CoroutineScope(Dispatchers.Main) + private var pendingLockJob: Job? = null + private var lockDeadlineAtMillis: Long? = null - fun lock() { - scope.launch { - val isPinEnabled = settingsRepository.isPinEnabled.first() - if (isPinEnabled) { - _isLocked.value = true + private val screenOffReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == Intent.ACTION_SCREEN_OFF) { + scheduleLock() } } } + init { + appContext.registerReceiver(screenOffReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF)) + observeSettings() + } + + fun lock() { + pendingLockJob?.cancel() + pendingLockJob = null + lockDeadlineAtMillis = null + scope.launch { + val appLockSettings = settingsRepository.appLockSettings.first() + _isLocked.value = appLockSettings.isAppLockEnabled + } + } + fun unlock() { + pendingLockJob?.cancel() + pendingLockJob = null + lockDeadlineAtMillis = null _isLocked.value = false } + override fun onStart(owner: LifecycleOwner) { + super.onStart(owner) + scope.launch { + refreshLockStateWhenForegrounded() + } + } + override fun onStop(owner: LifecycleOwner) { super.onStop(owner) - lock() + scheduleLock() + } + + private fun observeSettings() { + scope.launch { + settingsRepository.appLockSettings.collect { settings -> + if (!settings.isAppLockEnabled) { + pendingLockJob?.cancel() + pendingLockJob = null + lockDeadlineAtMillis = null + _isLocked.value = false + } + } + } + } + + private fun scheduleLock() { + pendingLockJob?.cancel() + pendingLockJob = null + + scope.launch { + val appLockSettings = settingsRepository.appLockSettings.first() + if (!appLockSettings.isAppLockEnabled) { + lockDeadlineAtMillis = null + _isLocked.value = false + return@launch + } + + val delayMillis = appLockSettings.lockTrigger.delayMillis + if (delayMillis <= 0L) { + lockDeadlineAtMillis = null + _isLocked.value = true + return@launch + } + + val deadline = System.currentTimeMillis() + delayMillis + lockDeadlineAtMillis = deadline + pendingLockJob = scope.launch { + delay(delayMillis) + val latestSettings = settingsRepository.appLockSettings.first() + if (latestSettings.isAppLockEnabled && lockDeadlineAtMillis == deadline) { + _isLocked.value = true + } + } + } + } + + private suspend fun refreshLockStateWhenForegrounded() { + val appLockSettings = settingsRepository.appLockSettings.first() + if (!appLockSettings.isAppLockEnabled) { + unlock() + return + } + + val deadline = lockDeadlineAtMillis + if (deadline == null) { + return + } + + if (System.currentTimeMillis() >= deadline) { + pendingLockJob?.cancel() + pendingLockJob = null + lockDeadlineAtMillis = null + _isLocked.value = true + } else { + pendingLockJob?.cancel() + pendingLockJob = null + lockDeadlineAtMillis = null + } } } diff --git a/app/src/main/java/cn/airnan/a2fair/core/security/PinPolicy.kt b/app/src/main/java/cn/airnan/a2fair/core/security/PinPolicy.kt new file mode 100644 index 0000000..61a4726 --- /dev/null +++ b/app/src/main/java/cn/airnan/a2fair/core/security/PinPolicy.kt @@ -0,0 +1,7 @@ +package cn.airnan.a2fair.core.security + +const val PIN_LENGTH = 6 + +private val PinRegex = Regex("^\\d{$PIN_LENGTH}$") + +fun String.isValidPin(): Boolean = PinRegex.matches(this) diff --git a/app/src/main/java/cn/airnan/a2fair/data/repository/SettingsRepository.kt b/app/src/main/java/cn/airnan/a2fair/data/repository/SettingsRepository.kt index 99b20a6..8042071 100644 --- a/app/src/main/java/cn/airnan/a2fair/data/repository/SettingsRepository.kt +++ b/app/src/main/java/cn/airnan/a2fair/data/repository/SettingsRepository.kt @@ -9,26 +9,60 @@ import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import cn.airnan.a2fair.core.security.isValidPin import cn.airnan.a2fair.crypto.CryptoManager import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map val Context.dataStore: DataStore by preferencesDataStore(name = "settings") +enum class AppLockTrigger(val preferenceValue: Int, val delayMillis: Long) { + IMMEDIATELY(0, 0L), + AFTER_1_MINUTE(1, 60_000L), + AFTER_3_MINUTES(2, 3 * 60_000L), + AFTER_5_MINUTES(3, 5 * 60_000L); + + companion object { + val DEFAULT = IMMEDIATELY + + fun fromPreferenceValue(value: Int): AppLockTrigger { + return entries.firstOrNull { it.preferenceValue == value } ?: DEFAULT + } + } +} + +data class AppLockSettings( + val isPinEnabled: Boolean = false, + val hasPinCode: Boolean = false, + val isBiometricEnabled: Boolean = false, + val lockTrigger: AppLockTrigger = AppLockTrigger.DEFAULT +) { + val isAppLockEnabled: Boolean + get() = isPinEnabled && hasPinCode + + val canUseBiometric: Boolean + get() = isAppLockEnabled && isBiometricEnabled +} + class SettingsRepository(private val context: Context, private val cryptoManager: CryptoManager) { private val dataStore = context.dataStore - val isPinEnabled: Flow = dataStore.data.map { preferences -> + private val storedPinEnabled: Flow = dataStore.data.map { preferences -> preferences[PreferencesKeys.PIN_ENABLED] ?: false } - suspend fun setPinEnabled(enabled: Boolean) { - dataStore.edit { preferences -> - preferences[PreferencesKeys.PIN_ENABLED] = enabled - } + private val storedBiometricEnabled: Flow = dataStore.data.map { preferences -> + preferences[PreferencesKeys.BIOMETRIC_ENABLED] ?: false + } + + private val storedLockTrigger: Flow = dataStore.data.map { preferences -> + AppLockTrigger.fromPreferenceValue( + preferences[PreferencesKeys.APP_LOCK_TRIGGER] ?: AppLockTrigger.DEFAULT.preferenceValue + ) } val pinCode: Flow = dataStore.data.map { preferences -> @@ -44,23 +78,87 @@ class SettingsRepository(private val context: Context, private val cryptoManager } } + val appLockSettings: Flow = combine( + storedPinEnabled, + pinCode, + storedBiometricEnabled, + storedLockTrigger + ) { pinEnabled, decryptedPinCode, biometricEnabled, lockTrigger -> + val hasPinCode = decryptedPinCode?.isValidPin() == true + val isAppLockEnabled = pinEnabled && hasPinCode + AppLockSettings( + isPinEnabled = isAppLockEnabled, + hasPinCode = hasPinCode, + isBiometricEnabled = isAppLockEnabled && biometricEnabled, + lockTrigger = lockTrigger + ) + } + + val isPinEnabled: Flow = appLockSettings.map { settings -> + settings.isPinEnabled + } + + suspend fun setPinEnabled(enabled: Boolean) { + if (!enabled) { + disablePin() + return + } + + dataStore.edit { preferences -> + preferences[PreferencesKeys.PIN_ENABLED] = true + } + } + suspend fun setPinCode(pin: String?) { + require(pin == null || pin.isValidPin()) { "PIN must be exactly 6 digits." } dataStore.edit { preferences -> if (pin == null) { preferences.remove(PreferencesKeys.PIN_CODE) + preferences[PreferencesKeys.PIN_ENABLED] = false + preferences[PreferencesKeys.BIOMETRIC_ENABLED] = false } else { preferences[PreferencesKeys.PIN_CODE] = cryptoManager.encrypt(pin) } } } - val isBiometricEnabled: Flow = dataStore.data.map { preferences -> - preferences[PreferencesKeys.BIOMETRIC_ENABLED] ?: false + suspend fun enablePin(pin: String) { + require(pin.isValidPin()) { "PIN must be exactly 6 digits." } + dataStore.edit { preferences -> + preferences[PreferencesKeys.PIN_CODE] = cryptoManager.encrypt(pin) + preferences[PreferencesKeys.PIN_ENABLED] = true + } + } + + suspend fun disablePin() { + dataStore.edit { preferences -> + preferences[PreferencesKeys.PIN_ENABLED] = false + preferences[PreferencesKeys.BIOMETRIC_ENABLED] = false + preferences.remove(PreferencesKeys.PIN_CODE) + preferences[PreferencesKeys.FAILED_ATTEMPTS] = 0 + preferences[PreferencesKeys.LOCKOUT_END_TIME] = 0L + } + } + + val isBiometricEnabled: Flow = appLockSettings.map { settings -> + settings.isBiometricEnabled } suspend fun setBiometricEnabled(enabled: Boolean) { dataStore.edit { preferences -> - preferences[PreferencesKeys.BIOMETRIC_ENABLED] = enabled + val hasPinCode = preferences[PreferencesKeys.PIN_CODE] != null + val isPinEnabled = preferences[PreferencesKeys.PIN_ENABLED] ?: false + preferences[PreferencesKeys.BIOMETRIC_ENABLED] = enabled && hasPinCode && isPinEnabled + } + } + + val appLockTrigger: Flow = appLockSettings.map { settings -> + settings.lockTrigger + } + + suspend fun setAppLockTrigger(trigger: AppLockTrigger) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.APP_LOCK_TRIGGER] = trigger.preferenceValue } } @@ -145,6 +243,7 @@ class SettingsRepository(private val context: Context, private val cryptoManager val FLAG_SECURE_ENABLED = booleanPreferencesKey("flag_secure_enabled") val FAILED_ATTEMPTS = intPreferencesKey("failed_attempts") val LOCKOUT_END_TIME = longPreferencesKey("lockout_end_time") + val APP_LOCK_TRIGGER = intPreferencesKey("app_lock_trigger") val THEME_MODE = intPreferencesKey("theme_mode") val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") val SEED_COLOR = intPreferencesKey("seed_color") diff --git a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/BiometricAuth.kt b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/BiometricAuth.kt new file mode 100644 index 0000000..6681dff --- /dev/null +++ b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/BiometricAuth.kt @@ -0,0 +1,135 @@ +package cn.airnan.a2fair.ui.settings.security + +import android.content.Context +import android.content.ContextWrapper +import androidx.annotation.StringRes +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import cn.airnan.a2fair.R + +enum class BiometricAuthMode(val authenticators: Int) { + BiometricOnly(BiometricManager.Authenticators.BIOMETRIC_STRONG), + AppConfirmation( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + ) +} + +sealed interface BiometricAuthResult { + object Success : BiometricAuthResult + object Failed : BiometricAuthResult + object Cancelled : BiometricAuthResult + data class Unavailable(@StringRes val messageRes: Int) : BiometricAuthResult +} + +class BiometricAuthenticator(private val context: Context) { + + fun getAvailability(mode: BiometricAuthMode): BiometricAuthResult? { + val activity = context.findFragmentActivity() + ?: return BiometricAuthResult.Unavailable(R.string.biometric_not_available_on_this_screen) + + return when (BiometricManager.from(activity).canAuthenticate(mode.authenticators)) { + BiometricManager.BIOMETRIC_SUCCESS -> null + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> { + BiometricAuthResult.Unavailable(R.string.biometric_error_no_hardware) + } + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> { + BiometricAuthResult.Unavailable(R.string.biometric_error_hw_unavailable) + } + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> { + BiometricAuthResult.Unavailable( + if (mode == BiometricAuthMode.BiometricOnly) { + R.string.biometric_error_none_enrolled + } else { + R.string.security_verification_unavailable + } + ) + } + else -> BiometricAuthResult.Unavailable(R.string.biometric_error_unavailable) + } + } + + fun authenticate( + mode: BiometricAuthMode, + @StringRes titleRes: Int, + @StringRes subtitleRes: Int, + onResult: (BiometricAuthResult) -> Unit + ) { + val activity = context.findFragmentActivity() + if (activity == null) { + onResult(BiometricAuthResult.Unavailable(R.string.biometric_not_available_on_this_screen)) + return + } + + val availability = getAvailability(mode) + if (availability != null) { + onResult(availability) + return + } + + val executor = ContextCompat.getMainExecutor(activity) + val prompt = BiometricPrompt( + activity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + if (errorCode in cancellationErrorCodes) { + onResult(BiometricAuthResult.Cancelled) + } else { + onResult(BiometricAuthResult.Failed) + } + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + onResult(BiometricAuthResult.Success) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + onResult(BiometricAuthResult.Failed) + } + } + ) + + val builder = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(titleRes)) + .setSubtitle(activity.getString(subtitleRes)) + .setAllowedAuthenticators(mode.authenticators) + + // BiometricOnly (without DEVICE_CREDENTIAL) requires a negative button text + if (mode == BiometricAuthMode.BiometricOnly) { + builder.setNegativeButtonText(activity.getString(R.string.cancel)) + } + + val promptInfo = builder.build() + + prompt.authenticate(promptInfo) + } + + private companion object { + val cancellationErrorCodes = setOf( + BiometricPrompt.ERROR_NEGATIVE_BUTTON, + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_CANCELED, + BiometricPrompt.ERROR_TIMEOUT + ) + } +} + +@Composable +fun rememberBiometricAuthenticator(): BiometricAuthenticator { + val context = androidx.compose.ui.platform.LocalContext.current + return remember(context) { BiometricAuthenticator(context) } +} + +private tailrec fun Context.findFragmentActivity(): FragmentActivity? = when (this) { + is FragmentActivity -> this + is ContextWrapper -> baseContext.findFragmentActivity() + else -> null +} diff --git a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/LockScreen.kt b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/LockScreen.kt index 452f30f..ddc300c 100644 --- a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/LockScreen.kt +++ b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/LockScreen.kt @@ -1,8 +1,5 @@ package cn.airnan.a2fair.ui.settings.security -import android.widget.Toast -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Fingerprint @@ -11,16 +8,12 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle -import cn.airnan.a2fair.A2FairApplication import cn.airnan.a2fair.R -import kotlinx.coroutines.flow.first +import cn.airnan.a2fair.A2FairApplication +import cn.airnan.a2fair.core.security.PIN_LENGTH import kotlinx.coroutines.launch @Composable @@ -28,24 +21,29 @@ fun LockScreen( onUnlock: () -> Unit, modifier: Modifier = Modifier ) { - val context = LocalContext.current - val app = context.applicationContext as A2FairApplication + val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as A2FairApplication val settingsRepo = app.settingsRepository - - val isBiometricEnabled by settingsRepo.isBiometricEnabled.collectAsStateWithLifecycle(initialValue = false) + val biometricAuthenticator = rememberBiometricAuthenticator() + + val appLockSettings by settingsRepo.appLockSettings.collectAsStateWithLifecycle( + initialValue = cn.airnan.a2fair.data.repository.AppLockSettings() + ) val pinCode by settingsRepo.pinCode.collectAsStateWithLifecycle(initialValue = null) - + + val isBiometricEnabled = appLockSettings.canUseBiometric + val failedAttempts by settingsRepo.failedAttempts.collectAsStateWithLifecycle(initialValue = 0) val lockoutEndTime by settingsRepo.lockoutEndTime.collectAsStateWithLifecycle(initialValue = 0L) - + var enteredPin by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf(null) } val incorrectPinMsg = stringResource(R.string.incorrect_pin) + val pinHintMsg = stringResource(R.string.pin_digits_hint) var remainingLockOutTime by remember { mutableStateOf(0L) } - + val scope = rememberCoroutineScope() val biometricFailedMsg = stringResource(R.string.biometric_failed) - + LaunchedEffect(lockoutEndTime) { while (true) { val now = System.currentTimeMillis() @@ -62,12 +60,40 @@ fun LockScreen( } } - // Auto prompt biometric if enabled LaunchedEffect(isBiometricEnabled) { if (isBiometricEnabled) { - showBiometricPrompt(context as FragmentActivity, onUnlock, onFailed = { - errorMessage = biometricFailedMsg - }) + biometricAuthenticator.authenticate( + mode = BiometricAuthMode.AppConfirmation, + titleRes = R.string.unlock_2fair, + subtitleRes = R.string.biometric_prompt_subtitle + ) { result -> + when (result) { + BiometricAuthResult.Success -> onUnlock() + BiometricAuthResult.Failed -> errorMessage = biometricFailedMsg + else -> Unit + } + } + } + } + + fun submitPin(candidate: String) { + if (pinCode != null && candidate == pinCode) { + scope.launch { + settingsRepo.setFailedAttempts(0) + settingsRepo.setLockoutEndTime(0L) + } + onUnlock() + } else { + val newAttempts = failedAttempts + 1 + errorMessage = incorrectPinMsg + enteredPin = "" + scope.launch { + settingsRepo.setFailedAttempts(newAttempts) + if (newAttempts >= 5) { + val lockoutTime = System.currentTimeMillis() + 30000L + settingsRepo.setLockoutEndTime(lockoutTime) + } + } } } @@ -92,125 +118,88 @@ fun LockScreen( style = MaterialTheme.typography.headlineSmall ) Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = enteredPin, - onValueChange = { - enteredPin = it - errorMessage = null - }, - visualTransformation = PasswordVisualTransformation(), - singleLine = true, - isError = errorMessage != null, + + PinIndicatorRow( + filledCount = enteredPin.length, + isError = errorMessage != null || remainingLockOutTime > 0L, modifier = Modifier.fillMaxWidth(0.8f) ) - - if (errorMessage != null) { - Text( - text = errorMessage!!, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 4.dp) - ) - } - - if (remainingLockOutTime > 0) { + Spacer(modifier = Modifier.height(12.dp)) - Text( - text = stringResource(R.string.try_again_in, remainingLockOutTime), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 8.dp) - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - Row( - modifier = Modifier.fillMaxWidth(0.8f), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - if (isBiometricEnabled && remainingLockOutTime == 0L) { - IconButton(onClick = { - showBiometricPrompt(context as FragmentActivity, onUnlock, onFailed = { - errorMessage = biometricFailedMsg - }) - }) { - Icon( - imageVector = Icons.Default.Fingerprint, - contentDescription = stringResource(R.string.use_biometric), - modifier = Modifier.size(48.dp) - ) - } + Text( + text = when { + remainingLockOutTime > 0L -> stringResource(R.string.try_again_in, remainingLockOutTime) + errorMessage != null -> errorMessage!! + enteredPin.isEmpty() -> pinHintMsg + else -> stringResource(R.string.pin_digits_remaining, PIN_LENGTH - enteredPin.length) + }, + color = if (errorMessage != null || remainingLockOutTime > 0L) { + MaterialTheme.colorScheme.error } else { - Spacer(modifier = Modifier.width(48.dp)) - } - - Button( + MaterialTheme.colorScheme.onSurfaceVariant + }, + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(24.dp)) + + PinNumberPad( + resetLabel = stringResource(R.string.clear_pin_input), + deleteLabel = stringResource(R.string.delete_digit), + onDigit = { digit -> + if (remainingLockOutTime > 0L || enteredPin.length >= PIN_LENGTH) { + return@PinNumberPad + } + val updatedPin = enteredPin + digit + enteredPin = updatedPin + errorMessage = null + if (updatedPin.length == PIN_LENGTH) { + submitPin(updatedPin) + } + }, + onDelete = { + if (remainingLockOutTime == 0L && enteredPin.isNotEmpty()) { + enteredPin = enteredPin.dropLast(1) + errorMessage = null + } + }, + onReset = { + if (remainingLockOutTime == 0L && enteredPin.isNotEmpty()) { + enteredPin = "" + errorMessage = null + } + }, + modifier = Modifier.fillMaxWidth(0.9f), + isEnabled = remainingLockOutTime == 0L, + isDeleteEnabled = enteredPin.isNotEmpty(), + isResetEnabled = enteredPin.isNotEmpty() + ) + + if (isBiometricEnabled) { + Spacer(modifier = Modifier.height(16.dp)) + FilledTonalButton( onClick = { - if (pinCode != null && enteredPin == pinCode) { - scope.launch { - settingsRepo.setFailedAttempts(0) - settingsRepo.setLockoutEndTime(0L) - } - onUnlock() - } else { - val newAttempts = failedAttempts + 1 - errorMessage = incorrectPinMsg - enteredPin = "" - scope.launch { - settingsRepo.setFailedAttempts(newAttempts) - if (newAttempts >= 5) { - val lockoutTime = System.currentTimeMillis() + 30000L // 30s lockout - settingsRepo.setLockoutEndTime(lockoutTime) - } + biometricAuthenticator.authenticate( + mode = BiometricAuthMode.AppConfirmation, + titleRes = R.string.unlock_2fair, + subtitleRes = R.string.biometric_prompt_subtitle + ) { result -> + when (result) { + BiometricAuthResult.Success -> onUnlock() + BiometricAuthResult.Failed -> errorMessage = biometricFailedMsg + else -> Unit } } }, - enabled = remainingLockOutTime == 0L && enteredPin.isNotEmpty() + enabled = remainingLockOutTime == 0L ) { - Text(stringResource(R.string.unlock)) + Icon( + imageVector = Icons.Default.Fingerprint, + contentDescription = stringResource(R.string.use_biometric) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.use_biometric)) } } } } } - -private fun showBiometricPrompt( - activity: FragmentActivity, - onSuccess: () -> Unit, - onFailed: () -> Unit -) { - val biometricManager = BiometricManager.from(activity) - if (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL) != BiometricManager.BIOMETRIC_SUCCESS) { - return - } - - val executor = ContextCompat.getMainExecutor(activity) - val biometricPrompt = BiometricPrompt(activity, executor, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - onFailed() - } - - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - onSuccess() - } - - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - onFailed() - } - }) - - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(activity.getString(R.string.unlock_2fair)) - .setSubtitle(activity.getString(R.string.biometric_prompt_subtitle)) - .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL) - .build() - - biometricPrompt.authenticate(promptInfo) -} diff --git a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/PinInputComponents.kt b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/PinInputComponents.kt new file mode 100644 index 0000000..ee77bd8 --- /dev/null +++ b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/PinInputComponents.kt @@ -0,0 +1,137 @@ +package cn.airnan.a2fair.ui.settings.security + +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import cn.airnan.a2fair.core.security.PIN_LENGTH + +@Composable +fun PinIndicatorRow( + filledCount: Int, + modifier: Modifier = Modifier, + isError: Boolean = false +) { + val activeColor = if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + val inactiveColor = if (isError) { + MaterialTheme.colorScheme.error.copy(alpha = 0.24f) + } else { + MaterialTheme.colorScheme.surfaceVariant + } + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center + ) { + repeat(PIN_LENGTH) { index -> + Box( + modifier = Modifier + .padding(horizontal = 6.dp) + .size(14.dp) + .clip(CircleShape) + .background(if (index < filledCount) activeColor else inactiveColor) + ) + } + } +} + +@Composable +fun PinNumberPad( + resetLabel: String, + deleteLabel: String, + onDigit: (String) -> Unit, + onDelete: () -> Unit, + onReset: () -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, + isDeleteEnabled: Boolean = true, + isResetEnabled: Boolean = true +) { + val rows = listOf( + listOf("1", "2", "3"), + listOf("4", "5", "6"), + listOf("7", "8", "9") + ) + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + rows.forEach { rowDigits -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + rowDigits.forEach { digit -> + FilledTonalButton( + onClick = { onDigit(digit) }, + modifier = Modifier + .weight(1f) + .height(56.dp), + enabled = isEnabled + ) { + Text(text = digit, style = MaterialTheme.typography.titleLarge) + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = onReset, + modifier = Modifier + .weight(1f) + .height(56.dp), + enabled = isEnabled && isResetEnabled + ) { + Text(text = resetLabel) + } + + FilledTonalButton( + onClick = { onDigit("0") }, + modifier = Modifier + .weight(1f) + .height(56.dp), + enabled = isEnabled + ) { + Text(text = "0", style = MaterialTheme.typography.titleLarge) + } + + OutlinedButton( + onClick = onDelete, + modifier = Modifier + .weight(1f) + .height(56.dp) + .semantics { contentDescription = deleteLabel }, + enabled = isEnabled && isDeleteEnabled + ) { + Text(text = deleteLabel) + } + } + } +} diff --git a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/PinSetupScreen.kt b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/PinSetupScreen.kt index 3649d5d..e7c4db7 100644 --- a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/PinSetupScreen.kt +++ b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/PinSetupScreen.kt @@ -8,10 +8,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import cn.airnan.a2fair.R +import cn.airnan.a2fair.core.security.PIN_LENGTH import cn.airnan.a2fair.data.repository.SettingsRepository import kotlinx.coroutines.launch @@ -26,12 +25,22 @@ fun PinSetupScreen( var pin by remember { mutableStateOf("") } var confirmPin by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf(null) } - + val pinTooShortMsg = stringResource(R.string.pin_too_short) val pinsDoNotMatchMsg = stringResource(R.string.pins_do_not_match) - + val pinRequirementMsg = stringResource(R.string.pin_requirement) + val scope = rememberCoroutineScope() - + val currentPin = if (step == 1) pin else confirmPin + val helperMessage = errorMessage ?: when { + currentPin.isEmpty() -> pinRequirementMsg + currentPin.length < PIN_LENGTH -> stringResource( + R.string.pin_digits_remaining, + PIN_LENGTH - currentPin.length + ) + else -> pinRequirementMsg + } + Scaffold( topBar = { TopAppBar( @@ -58,47 +67,86 @@ fun PinSetupScreen( style = MaterialTheme.typography.titleLarge ) Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = if (step == 1) pin else confirmPin, - onValueChange = { + + Text( + text = pinRequirementMsg, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(24.dp)) + + PinIndicatorRow( + filledCount = currentPin.length, + isError = errorMessage != null + ) + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = helperMessage, + style = MaterialTheme.typography.bodyMedium, + color = if (errorMessage != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + Spacer(modifier = Modifier.height(24.dp)) + + PinNumberPad( + resetLabel = if (step == 1) { + stringResource(R.string.clear_pin_input) + } else { + stringResource(R.string.reset_pin_input) + }, + deleteLabel = stringResource(R.string.delete_digit), + onDigit = { digit -> + if (currentPin.length >= PIN_LENGTH) return@PinNumberPad if (step == 1) { - pin = it + pin += digit } else { - confirmPin = it + confirmPin += digit } errorMessage = null }, - visualTransformation = PasswordVisualTransformation(), - singleLine = true, - isError = errorMessage != null, - modifier = Modifier.fillMaxWidth() + onDelete = { + if (step == 1 && pin.isNotEmpty()) { + pin = pin.dropLast(1) + } else if (step == 2 && confirmPin.isNotEmpty()) { + confirmPin = confirmPin.dropLast(1) + } + errorMessage = null + }, + onReset = { + if (step == 1) { + pin = "" + } else { + step = 1 + pin = "" + confirmPin = "" + } + errorMessage = null + }, + isDeleteEnabled = currentPin.isNotEmpty(), + isResetEnabled = currentPin.isNotEmpty() || step == 2 ) - - if (errorMessage != null) { - Text( - text = errorMessage!!, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 4.dp) - ) - } - Spacer(modifier = Modifier.height(24.dp)) - + Button( onClick = { if (step == 1) { - if (pin.length < 4) { + if (pin.length != PIN_LENGTH) { errorMessage = pinTooShortMsg } else { + confirmPin = "" + errorMessage = null step = 2 } } else { - if (pin == confirmPin) { + if (confirmPin.length != PIN_LENGTH) { + errorMessage = pinTooShortMsg + } else if (pin == confirmPin) { scope.launch { - settingsRepository.setPinCode(pin) - settingsRepository.setPinEnabled(true) + settingsRepository.enablePin(pin) onNavigateBack() } } else { diff --git a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/SecuritySettingsScreen.kt b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/SecuritySettingsScreen.kt index 6ab1cd3..b384ba0 100644 --- a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/SecuritySettingsScreen.kt +++ b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/SecuritySettingsScreen.kt @@ -1,8 +1,9 @@ package cn.airnan.a2fair.ui.settings.security import androidx.compose.foundation.layout.* +import androidx.compose.foundation.selection.selectable import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -11,6 +12,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import cn.airnan.a2fair.R +import cn.airnan.a2fair.data.repository.AppLockTrigger import cn.airnan.a2fair.data.repository.SettingsRepository import kotlinx.coroutines.launch @@ -22,11 +24,46 @@ fun SecuritySettingsScreen( onNavigateToPinSetup: () -> Unit, modifier: Modifier = Modifier ) { - val isPinEnabled by settingsRepository.isPinEnabled.collectAsStateWithLifecycle(initialValue = false) - val isBiometricEnabled by settingsRepository.isBiometricEnabled.collectAsStateWithLifecycle(initialValue = false) + val appLockSettings by settingsRepository.appLockSettings.collectAsStateWithLifecycle( + initialValue = cn.airnan.a2fair.data.repository.AppLockSettings() + ) + val pinCode by settingsRepository.pinCode.collectAsStateWithLifecycle(initialValue = null) val isFlagSecureEnabled by settingsRepository.isFlagSecureEnabled.collectAsStateWithLifecycle(initialValue = false) - + + val isPinEnabled = appLockSettings.isPinEnabled + val isBiometricEnabled = appLockSettings.isBiometricEnabled + val lockTrigger = appLockSettings.lockTrigger + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val biometricAuthenticator = rememberBiometricAuthenticator() + var showLockTriggerDialog by remember { mutableStateOf(false) } + var verificationAction by remember { mutableStateOf(null) } + + val enableBiometricTitle = stringResource(R.string.enable_biometric_verification_title) + val enableBiometricSubtitle = stringResource(R.string.enable_biometric_verification_desc) + val disableBiometricTitle = stringResource(R.string.disable_biometric_verification_title) + val disableBiometricDesc = stringResource(R.string.disable_biometric_verification_desc) + val disablePinTitle = stringResource(R.string.disable_pin_verification_title) + val disablePinDesc = stringResource(R.string.disable_pin_verification_desc) + val biometricFailedMessage = stringResource(R.string.biometric_failed) + val biometricNoHardwareMessage = stringResource(R.string.biometric_error_no_hardware) + val biometricHardwareUnavailableMessage = stringResource(R.string.biometric_error_hw_unavailable) + val biometricNotEnrolledMessage = stringResource(R.string.biometric_error_none_enrolled) + val biometricUnavailableMessage = stringResource(R.string.biometric_error_unavailable) + val biometricNotAvailableOnScreenMessage = stringResource(R.string.biometric_not_available_on_this_screen) + val securityVerificationUnavailableMessage = stringResource(R.string.security_verification_unavailable) + + val lockTriggerOptions = remember { + AppLockTrigger.entries.map { trigger -> + trigger to when (trigger) { + AppLockTrigger.IMMEDIATELY -> R.string.lock_trigger_immediately + AppLockTrigger.AFTER_1_MINUTE -> R.string.lock_trigger_after_1_minute + AppLockTrigger.AFTER_3_MINUTES -> R.string.lock_trigger_after_3_minutes + AppLockTrigger.AFTER_5_MINUTES -> R.string.lock_trigger_after_5_minutes + } + } + } Scaffold( topBar = { @@ -34,11 +71,12 @@ fun SecuritySettingsScreen( title = { Text(stringResource(R.string.security)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { - Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back)) + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } } ) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, modifier = modifier ) { padding -> Column( @@ -66,11 +104,7 @@ fun SecuritySettingsScreen( if (checked) { onNavigateToPinSetup() } else { - scope.launch { - settingsRepository.setPinEnabled(false) - settingsRepository.setPinCode(null) - settingsRepository.setBiometricEnabled(false) - } + verificationAction = SecurityVerificationAction.DisablePin } } ) @@ -95,14 +129,77 @@ fun SecuritySettingsScreen( Switch( checked = isBiometricEnabled, onCheckedChange = { checked -> - scope.launch { - settingsRepository.setBiometricEnabled(checked) + if (checked) { + biometricAuthenticator.authenticate( + mode = BiometricAuthMode.BiometricOnly, + titleRes = R.string.enable_biometric_verification_title, + subtitleRes = R.string.enable_biometric_verification_desc + ) { result -> + when (result) { + BiometricAuthResult.Success -> { + scope.launch { + settingsRepository.setBiometricEnabled(true) + } + } + is BiometricAuthResult.Unavailable -> { + scope.launch { + snackbarHostState.showSnackbar( + message = when (result.messageRes) { + R.string.biometric_error_no_hardware -> biometricNoHardwareMessage + R.string.biometric_error_hw_unavailable -> biometricHardwareUnavailableMessage + R.string.biometric_error_none_enrolled -> biometricNotEnrolledMessage + R.string.biometric_not_available_on_this_screen -> biometricNotAvailableOnScreenMessage + R.string.security_verification_unavailable -> securityVerificationUnavailableMessage + else -> biometricUnavailableMessage + } + ) + } + } + BiometricAuthResult.Failed -> { + scope.launch { + snackbarHostState.showSnackbar( + message = biometricFailedMessage + ) + } + } + BiometricAuthResult.Cancelled -> Unit + } + } + } else { + verificationAction = SecurityVerificationAction.DisableBiometric } } ) } Spacer(modifier = Modifier.height(16.dp)) + + Card( + onClick = { showLockTriggerDialog = true }, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + stringResource(R.string.lock_trigger_title), + style = MaterialTheme.typography.titleMedium + ) + Text( + stringResource(R.string.lock_trigger_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(lockTrigger.toLabelRes()), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) } Row( @@ -129,4 +226,84 @@ fun SecuritySettingsScreen( } } } + + verificationAction?.let { action -> + SecurityVerificationDialog( + title = when (action) { + SecurityVerificationAction.DisableBiometric -> disableBiometricTitle + SecurityVerificationAction.DisablePin -> disablePinTitle + }, + description = when (action) { + SecurityVerificationAction.DisableBiometric -> disableBiometricDesc + SecurityVerificationAction.DisablePin -> disablePinDesc + }, + expectedPin = pinCode, + onDismissRequest = { verificationAction = null }, + onVerified = { + scope.launch { + when (action) { + SecurityVerificationAction.DisableBiometric -> { + settingsRepository.setBiometricEnabled(false) + } + SecurityVerificationAction.DisablePin -> { + settingsRepository.disablePin() + } + } + verificationAction = null + } + } + ) + } + + if (showLockTriggerDialog) { + AlertDialog( + onDismissRequest = { showLockTriggerDialog = false }, + title = { Text(stringResource(R.string.lock_trigger_title)) }, + text = { + Column { + lockTriggerOptions.forEach { (trigger, labelRes) -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = lockTrigger == trigger, + onClick = { + scope.launch { + settingsRepository.setAppLockTrigger(trigger) + } + showLockTriggerDialog = false + } + ) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = lockTrigger == trigger, + onClick = null + ) + Spacer(modifier = Modifier.width(12.dp)) + Text(text = stringResource(labelRes)) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showLockTriggerDialog = false }) { + Text(stringResource(R.string.close)) + } + } + ) + } +} + +private enum class SecurityVerificationAction { + DisableBiometric, + DisablePin +} + +private fun AppLockTrigger.toLabelRes(): Int = when (this) { + AppLockTrigger.IMMEDIATELY -> R.string.lock_trigger_immediately + AppLockTrigger.AFTER_1_MINUTE -> R.string.lock_trigger_after_1_minute + AppLockTrigger.AFTER_3_MINUTES -> R.string.lock_trigger_after_3_minutes + AppLockTrigger.AFTER_5_MINUTES -> R.string.lock_trigger_after_5_minutes } diff --git a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/SecurityVerificationDialog.kt b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/SecurityVerificationDialog.kt new file mode 100644 index 0000000..96fe5da --- /dev/null +++ b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/SecurityVerificationDialog.kt @@ -0,0 +1,116 @@ +package cn.airnan.a2fair.ui.settings.security + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cn.airnan.a2fair.R +import cn.airnan.a2fair.core.security.PIN_LENGTH + +@Composable +fun SecurityVerificationDialog( + title: String, + description: String, + expectedPin: String?, + onDismissRequest: () -> Unit, + onVerified: () -> Unit +) { + var enteredPin by remember { mutableStateOf("") } + var errorMessage by remember { mutableStateOf(null) } + val incorrectPinMessage = stringResource(R.string.incorrect_pin) + val pinHintMessage = stringResource(R.string.confirm_current_pin) + + fun submitPin(candidate: String) { + if (!expectedPin.isNullOrEmpty() && candidate == expectedPin) { + onVerified() + } else { + enteredPin = "" + errorMessage = incorrectPinMessage + } + } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = title) }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + PinIndicatorRow( + filledCount = enteredPin.length, + isError = errorMessage != null, + modifier = Modifier.fillMaxWidth() + ) + + Text( + text = errorMessage ?: pinHintMessage, + style = MaterialTheme.typography.bodyMedium, + color = if (errorMessage != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + + Spacer(modifier = Modifier.height(4.dp)) + + PinNumberPad( + resetLabel = stringResource(R.string.clear_pin_input), + deleteLabel = stringResource(R.string.delete_digit), + onDigit = { digit -> + if (enteredPin.length >= PIN_LENGTH) return@PinNumberPad + val updatedPin = enteredPin + digit + enteredPin = updatedPin + errorMessage = null + if (updatedPin.length == PIN_LENGTH) { + submitPin(updatedPin) + } + }, + onDelete = { + if (enteredPin.isNotEmpty()) { + enteredPin = enteredPin.dropLast(1) + errorMessage = null + } + }, + onReset = { + if (enteredPin.isNotEmpty()) { + enteredPin = "" + errorMessage = null + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + isDeleteEnabled = enteredPin.isNotEmpty(), + isResetEnabled = enteredPin.isNotEmpty() + ) + } + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.cancel)) + } + } + ) +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 00d6396..4467b16 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -69,6 +69,12 @@ 打开应用时需要输入 PIN 码 生物识别解锁 使用指纹/面部解锁应用 + 锁定时机 + 选择离开应用后何时自动锁定 + 立即 + 1 分钟 + 3 分钟 + 5 分钟 禁止截屏 防止对应用内容进行截屏或录屏 @@ -76,8 +82,14 @@ 设置 PIN 码 输入新 PIN 码 确认 PIN 码 - PIN 码至少需要 4 位 + PIN 码固定为 6 位数字 + 请输入 6 位数字 PIN 码 + 还需输入 %d 位 + PIN 码必须为 6 位数字 两次输入的 PIN 码不一致 + 删除 + 重输 + 清空 下一步 确认 @@ -91,6 +103,19 @@ 请等待 %d 秒后重试 解锁 2FAir 使用您的生物识别凭证登录 + 请输入当前 PIN 码以继续 + 验证生物识别 + 启用生物识别解锁前,请先完成一次生物识别验证 + 确认关闭生物识别 + 关闭生物识别解锁前,请先输入当前 PIN 码。关闭后会保留 PIN 码。 + 确认关闭应用锁 PIN 码 + 关闭应用锁前,请先输入当前 PIN 码。关闭后会同时关闭生物识别并清除已保存的 PIN 码。 + 此设备不支持生物识别 + 生物识别当前暂时不可用 + 请先在系统设置中录入生物识别信息 + 当前无法使用生物识别 + 当前页面无法发起生物识别 + 当前没有可用的系统认证方式 导出成功 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index bf9cecb..d9b5fe1 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -69,6 +69,12 @@ 開啟應用程式時需要輸入 PIN 碼 生物辨識解鎖 使用指紋/臉部解鎖應用程式 + 鎖定時機 + 選擇離開應用程式後何時自動鎖定 + 立即 + 1 分鐘 + 3 分鐘 + 5 分鐘 禁止截圖 防止對應用程式內容進行截圖或錄影 @@ -76,8 +82,14 @@ 設定 PIN 碼 輸入新 PIN 碼 確認 PIN 碼 - PIN 碼至少需要 4 位 + PIN 碼固定為 6 位數字 + 請輸入 6 位數字 PIN 碼 + 還需輸入 %d 位 + PIN 碼必須為 6 位數字 兩次輸入的 PIN 碼不一致 + 刪除 + 重輸 + 清空 下一步 確認 @@ -91,6 +103,19 @@ 請等待 %d 秒後重試 解鎖 2FAir 使用您的生物辨識憑證登入 + 請輸入目前的 PIN 碼以繼續 + 驗證生物辨識 + 啟用生物辨識解鎖前,請先完成一次生物辨識驗證 + 確認關閉生物辨識 + 關閉生物辨識解鎖前,請先輸入目前的 PIN 碼。關閉後會保留 PIN 碼。 + 確認關閉應用程式 PIN 碼鎖定 + 關閉應用程式鎖定前,請先輸入目前的 PIN 碼。關閉後會同時關閉生物辨識並清除已儲存的 PIN 碼。 + 此裝置不支援生物辨識 + 生物辨識目前暫時無法使用 + 請先在系統設定中錄入生物辨識資訊 + 目前無法使用生物辨識 + 目前頁面無法啟動生物辨識 + 目前沒有可用的系統驗證方式 匯出成功 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2a65b31..c4946bb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -69,6 +69,12 @@ Require PIN to open the app Biometric Unlock Use fingerprint/face to unlock + Lock Timing + Choose when the app should lock after leaving it + Immediately + After 1 minute + After 3 minutes + After 5 minutes Block Screenshots Prevent taking screenshots of the app @@ -76,8 +82,14 @@ Set PIN Enter a new PIN Confirm your PIN - PIN must be at least 4 characters + PIN must be exactly 6 digits + Enter your 6-digit PIN + %d digits remaining + PIN must be exactly 6 digits PINs do not match + Delete + Start over + Clear Next Confirm @@ -91,6 +103,19 @@ Try again in %ds Unlock 2FAir Log in using your biometric credential + Enter your current PIN to continue + Verify biometric unlock + Authenticate once with biometrics before enabling biometric unlock + Confirm to turn off biometric unlock + Enter your current PIN before turning off biometric unlock. Your PIN will remain enabled. + Confirm to turn off app lock PIN + Enter your current PIN before turning off app lock. This will also turn off biometric unlock and clear the saved PIN. + This device does not support biometric authentication + Biometric authentication is temporarily unavailable + Set up biometrics in system settings before enabling biometric unlock + Biometric authentication is currently unavailable + Biometric authentication is not available on this screen + No system verification method is available right now Export successful