完善生物识别功能

This commit is contained in:
2026-06-17 00:48:31 +08:00
parent fce997c3d6
commit 4ebd1babe3
14 changed files with 1084 additions and 194 deletions
@@ -65,7 +65,7 @@ class A2FairApplication : Application() {
tokenRepository = TokenRepository(database.tokenDao(), database.groupDao()) tokenRepository = TokenRepository(database.tokenDao(), database.groupDao())
settingsRepository = SettingsRepository(this, cryptoManager) settingsRepository = SettingsRepository(this, cryptoManager)
appLockManager = AppLockManager(settingsRepository) appLockManager = AppLockManager(this, settingsRepository)
ProcessLifecycleOwner.get().lifecycle.addObserver(appLockManager) ProcessLifecycleOwner.get().lifecycle.addObserver(appLockManager)
backupManager = BackupManager(this, tokenRepository, cryptoManager) backupManager = BackupManager(this, tokenRepository, cryptoManager)
@@ -5,7 +5,6 @@ import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -13,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cn.airnan.a2fair.data.repository.dataStore import cn.airnan.a2fair.data.repository.dataStore
import cn.airnan.a2fair.ui.navigation.AppNavigation import cn.airnan.a2fair.ui.navigation.AppNavigation
@@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.util.Locale import java.util.Locale
class MainActivity : ComponentActivity() { class MainActivity : FragmentActivity() {
override fun attachBaseContext(newBase: Context) { override fun attachBaseContext(newBase: Context) {
val languageCode = try { val languageCode = try {
@@ -91,4 +91,4 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
} }
@@ -1,37 +1,144 @@
package cn.airnan.a2fair.core.security 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.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import cn.airnan.a2fair.data.repository.SettingsRepository import cn.airnan.a2fair.data.repository.SettingsRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch 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) private val _isLocked = MutableStateFlow(false)
val isLocked: StateFlow<Boolean> = _isLocked.asStateFlow() val isLocked: StateFlow<Boolean> = _isLocked.asStateFlow()
private val scope = CoroutineScope(Dispatchers.Main) private var pendingLockJob: Job? = null
private var lockDeadlineAtMillis: Long? = null
fun lock() { private val screenOffReceiver = object : BroadcastReceiver() {
scope.launch { override fun onReceive(context: Context?, intent: Intent?) {
val isPinEnabled = settingsRepository.isPinEnabled.first() if (intent?.action == Intent.ACTION_SCREEN_OFF) {
if (isPinEnabled) { scheduleLock()
_isLocked.value = true
} }
} }
} }
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() { fun unlock() {
pendingLockJob?.cancel()
pendingLockJob = null
lockDeadlineAtMillis = null
_isLocked.value = false _isLocked.value = false
} }
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
scope.launch {
refreshLockStateWhenForegrounded()
}
}
override fun onStop(owner: LifecycleOwner) { override fun onStop(owner: LifecycleOwner) {
super.onStop(owner) 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
}
} }
} }
@@ -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)
@@ -9,26 +9,60 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import cn.airnan.a2fair.core.security.isValidPin
import cn.airnan.a2fair.crypto.CryptoManager import cn.airnan.a2fair.crypto.CryptoManager
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") val Context.dataStore: DataStore<Preferences> 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) { class SettingsRepository(private val context: Context, private val cryptoManager: CryptoManager) {
private val dataStore = context.dataStore private val dataStore = context.dataStore
val isPinEnabled: Flow<Boolean> = dataStore.data.map { preferences -> private val storedPinEnabled: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[PreferencesKeys.PIN_ENABLED] ?: false preferences[PreferencesKeys.PIN_ENABLED] ?: false
} }
suspend fun setPinEnabled(enabled: Boolean) { private val storedBiometricEnabled: Flow<Boolean> = dataStore.data.map { preferences ->
dataStore.edit { preferences -> preferences[PreferencesKeys.BIOMETRIC_ENABLED] ?: false
preferences[PreferencesKeys.PIN_ENABLED] = enabled }
}
private val storedLockTrigger: Flow<AppLockTrigger> = dataStore.data.map { preferences ->
AppLockTrigger.fromPreferenceValue(
preferences[PreferencesKeys.APP_LOCK_TRIGGER] ?: AppLockTrigger.DEFAULT.preferenceValue
)
} }
val pinCode: Flow<String?> = dataStore.data.map { preferences -> val pinCode: Flow<String?> = dataStore.data.map { preferences ->
@@ -44,23 +78,87 @@ class SettingsRepository(private val context: Context, private val cryptoManager
} }
} }
val appLockSettings: Flow<AppLockSettings> = 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<Boolean> = 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?) { suspend fun setPinCode(pin: String?) {
require(pin == null || pin.isValidPin()) { "PIN must be exactly 6 digits." }
dataStore.edit { preferences -> dataStore.edit { preferences ->
if (pin == null) { if (pin == null) {
preferences.remove(PreferencesKeys.PIN_CODE) preferences.remove(PreferencesKeys.PIN_CODE)
preferences[PreferencesKeys.PIN_ENABLED] = false
preferences[PreferencesKeys.BIOMETRIC_ENABLED] = false
} else { } else {
preferences[PreferencesKeys.PIN_CODE] = cryptoManager.encrypt(pin) preferences[PreferencesKeys.PIN_CODE] = cryptoManager.encrypt(pin)
} }
} }
} }
val isBiometricEnabled: Flow<Boolean> = dataStore.data.map { preferences -> suspend fun enablePin(pin: String) {
preferences[PreferencesKeys.BIOMETRIC_ENABLED] ?: false 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<Boolean> = appLockSettings.map { settings ->
settings.isBiometricEnabled
} }
suspend fun setBiometricEnabled(enabled: Boolean) { suspend fun setBiometricEnabled(enabled: Boolean) {
dataStore.edit { preferences -> 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<AppLockTrigger> = 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 FLAG_SECURE_ENABLED = booleanPreferencesKey("flag_secure_enabled")
val FAILED_ATTEMPTS = intPreferencesKey("failed_attempts") val FAILED_ATTEMPTS = intPreferencesKey("failed_attempts")
val LOCKOUT_END_TIME = longPreferencesKey("lockout_end_time") val LOCKOUT_END_TIME = longPreferencesKey("lockout_end_time")
val APP_LOCK_TRIGGER = intPreferencesKey("app_lock_trigger")
val THEME_MODE = intPreferencesKey("theme_mode") val THEME_MODE = intPreferencesKey("theme_mode")
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val SEED_COLOR = intPreferencesKey("seed_color") val SEED_COLOR = intPreferencesKey("seed_color")
@@ -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
}
@@ -1,8 +1,5 @@
package cn.airnan.a2fair.ui.settings.security 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.foundation.layout.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.Fingerprint
@@ -11,16 +8,12 @@ import androidx.compose.material3.*
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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cn.airnan.a2fair.A2FairApplication
import cn.airnan.a2fair.R 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 import kotlinx.coroutines.launch
@Composable @Composable
@@ -28,24 +21,29 @@ fun LockScreen(
onUnlock: () -> Unit, onUnlock: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as A2FairApplication
val app = context.applicationContext as A2FairApplication
val settingsRepo = app.settingsRepository val settingsRepo = app.settingsRepository
val biometricAuthenticator = rememberBiometricAuthenticator()
val isBiometricEnabled by settingsRepo.isBiometricEnabled.collectAsStateWithLifecycle(initialValue = false)
val appLockSettings by settingsRepo.appLockSettings.collectAsStateWithLifecycle(
initialValue = cn.airnan.a2fair.data.repository.AppLockSettings()
)
val pinCode by settingsRepo.pinCode.collectAsStateWithLifecycle(initialValue = null) val pinCode by settingsRepo.pinCode.collectAsStateWithLifecycle(initialValue = null)
val isBiometricEnabled = appLockSettings.canUseBiometric
val failedAttempts by settingsRepo.failedAttempts.collectAsStateWithLifecycle(initialValue = 0) val failedAttempts by settingsRepo.failedAttempts.collectAsStateWithLifecycle(initialValue = 0)
val lockoutEndTime by settingsRepo.lockoutEndTime.collectAsStateWithLifecycle(initialValue = 0L) val lockoutEndTime by settingsRepo.lockoutEndTime.collectAsStateWithLifecycle(initialValue = 0L)
var enteredPin by remember { mutableStateOf("") } var enteredPin by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) } var errorMessage by remember { mutableStateOf<String?>(null) }
val incorrectPinMsg = stringResource(R.string.incorrect_pin) val incorrectPinMsg = stringResource(R.string.incorrect_pin)
val pinHintMsg = stringResource(R.string.pin_digits_hint)
var remainingLockOutTime by remember { mutableStateOf(0L) } var remainingLockOutTime by remember { mutableStateOf(0L) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val biometricFailedMsg = stringResource(R.string.biometric_failed) val biometricFailedMsg = stringResource(R.string.biometric_failed)
LaunchedEffect(lockoutEndTime) { LaunchedEffect(lockoutEndTime) {
while (true) { while (true) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
@@ -62,12 +60,40 @@ fun LockScreen(
} }
} }
// Auto prompt biometric if enabled
LaunchedEffect(isBiometricEnabled) { LaunchedEffect(isBiometricEnabled) {
if (isBiometricEnabled) { if (isBiometricEnabled) {
showBiometricPrompt(context as FragmentActivity, onUnlock, onFailed = { biometricAuthenticator.authenticate(
errorMessage = biometricFailedMsg 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 style = MaterialTheme.typography.headlineSmall
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField( PinIndicatorRow(
value = enteredPin, filledCount = enteredPin.length,
onValueChange = { isError = errorMessage != null || remainingLockOutTime > 0L,
enteredPin = it
errorMessage = null
},
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
isError = errorMessage != null,
modifier = Modifier.fillMaxWidth(0.8f) modifier = Modifier.fillMaxWidth(0.8f)
) )
Spacer(modifier = Modifier.height(12.dp))
if (errorMessage != null) {
Text(
text = errorMessage!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp)
)
}
if (remainingLockOutTime > 0) {
Text( Text(
text = stringResource(R.string.try_again_in, remainingLockOutTime), text = when {
color = MaterialTheme.colorScheme.error, remainingLockOutTime > 0L -> stringResource(R.string.try_again_in, remainingLockOutTime)
style = MaterialTheme.typography.bodyMedium, errorMessage != null -> errorMessage!!
modifier = Modifier.padding(top = 8.dp) enteredPin.isEmpty() -> pinHintMsg
) else -> stringResource(R.string.pin_digits_remaining, PIN_LENGTH - enteredPin.length)
} },
color = if (errorMessage != null || remainingLockOutTime > 0L) {
Spacer(modifier = Modifier.height(24.dp)) MaterialTheme.colorScheme.error
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)
)
}
} else { } else {
Spacer(modifier = Modifier.width(48.dp)) MaterialTheme.colorScheme.onSurfaceVariant
} },
style = MaterialTheme.typography.bodyMedium
Button( )
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 = { onClick = {
if (pinCode != null && enteredPin == pinCode) { biometricAuthenticator.authenticate(
scope.launch { mode = BiometricAuthMode.AppConfirmation,
settingsRepo.setFailedAttempts(0) titleRes = R.string.unlock_2fair,
settingsRepo.setLockoutEndTime(0L) subtitleRes = R.string.biometric_prompt_subtitle
} ) { result ->
onUnlock() when (result) {
} else { BiometricAuthResult.Success -> onUnlock()
val newAttempts = failedAttempts + 1 BiometricAuthResult.Failed -> errorMessage = biometricFailedMsg
errorMessage = incorrectPinMsg else -> Unit
enteredPin = ""
scope.launch {
settingsRepo.setFailedAttempts(newAttempts)
if (newAttempts >= 5) {
val lockoutTime = System.currentTimeMillis() + 30000L // 30s lockout
settingsRepo.setLockoutEndTime(lockoutTime)
}
} }
} }
}, },
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)
}
@@ -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)
}
}
}
}
@@ -8,10 +8,9 @@ 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.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cn.airnan.a2fair.R import cn.airnan.a2fair.R
import cn.airnan.a2fair.core.security.PIN_LENGTH
import cn.airnan.a2fair.data.repository.SettingsRepository import cn.airnan.a2fair.data.repository.SettingsRepository
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -26,12 +25,22 @@ fun PinSetupScreen(
var pin by remember { mutableStateOf("") } var pin by remember { mutableStateOf("") }
var confirmPin by remember { mutableStateOf("") } var confirmPin by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) } var errorMessage by remember { mutableStateOf<String?>(null) }
val pinTooShortMsg = stringResource(R.string.pin_too_short) val pinTooShortMsg = stringResource(R.string.pin_too_short)
val pinsDoNotMatchMsg = stringResource(R.string.pins_do_not_match) val pinsDoNotMatchMsg = stringResource(R.string.pins_do_not_match)
val pinRequirementMsg = stringResource(R.string.pin_requirement)
val scope = rememberCoroutineScope() 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -58,47 +67,86 @@ fun PinSetupScreen(
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField( Text(
value = if (step == 1) pin else confirmPin, text = pinRequirementMsg,
onValueChange = { 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) { if (step == 1) {
pin = it pin += digit
} else { } else {
confirmPin = it confirmPin += digit
} }
errorMessage = null errorMessage = null
}, },
visualTransformation = PasswordVisualTransformation(), onDelete = {
singleLine = true, if (step == 1 && pin.isNotEmpty()) {
isError = errorMessage != null, pin = pin.dropLast(1)
modifier = Modifier.fillMaxWidth() } 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)) Spacer(modifier = Modifier.height(24.dp))
Button( Button(
onClick = { onClick = {
if (step == 1) { if (step == 1) {
if (pin.length < 4) { if (pin.length != PIN_LENGTH) {
errorMessage = pinTooShortMsg errorMessage = pinTooShortMsg
} else { } else {
confirmPin = ""
errorMessage = null
step = 2 step = 2
} }
} else { } else {
if (pin == confirmPin) { if (confirmPin.length != PIN_LENGTH) {
errorMessage = pinTooShortMsg
} else if (pin == confirmPin) {
scope.launch { scope.launch {
settingsRepository.setPinCode(pin) settingsRepository.enablePin(pin)
settingsRepository.setPinEnabled(true)
onNavigateBack() onNavigateBack()
} }
} else { } else {
@@ -1,8 +1,9 @@
package cn.airnan.a2fair.ui.settings.security package cn.airnan.a2fair.ui.settings.security
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.icons.Icons 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -11,6 +12,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cn.airnan.a2fair.R import cn.airnan.a2fair.R
import cn.airnan.a2fair.data.repository.AppLockTrigger
import cn.airnan.a2fair.data.repository.SettingsRepository import cn.airnan.a2fair.data.repository.SettingsRepository
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -22,11 +24,46 @@ fun SecuritySettingsScreen(
onNavigateToPinSetup: () -> Unit, onNavigateToPinSetup: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val isPinEnabled by settingsRepository.isPinEnabled.collectAsStateWithLifecycle(initialValue = false) val appLockSettings by settingsRepository.appLockSettings.collectAsStateWithLifecycle(
val isBiometricEnabled by settingsRepository.isBiometricEnabled.collectAsStateWithLifecycle(initialValue = false) initialValue = cn.airnan.a2fair.data.repository.AppLockSettings()
)
val pinCode by settingsRepository.pinCode.collectAsStateWithLifecycle(initialValue = null)
val isFlagSecureEnabled by settingsRepository.isFlagSecureEnabled.collectAsStateWithLifecycle(initialValue = false) val isFlagSecureEnabled by settingsRepository.isFlagSecureEnabled.collectAsStateWithLifecycle(initialValue = false)
val isPinEnabled = appLockSettings.isPinEnabled
val isBiometricEnabled = appLockSettings.isBiometricEnabled
val lockTrigger = appLockSettings.lockTrigger
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val biometricAuthenticator = rememberBiometricAuthenticator()
var showLockTriggerDialog by remember { mutableStateOf(false) }
var verificationAction by remember { mutableStateOf<SecurityVerificationAction?>(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( Scaffold(
topBar = { topBar = {
@@ -34,11 +71,12 @@ fun SecuritySettingsScreen(
title = { Text(stringResource(R.string.security)) }, title = { Text(stringResource(R.string.security)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { 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 modifier = modifier
) { padding -> ) { padding ->
Column( Column(
@@ -66,11 +104,7 @@ fun SecuritySettingsScreen(
if (checked) { if (checked) {
onNavigateToPinSetup() onNavigateToPinSetup()
} else { } else {
scope.launch { verificationAction = SecurityVerificationAction.DisablePin
settingsRepository.setPinEnabled(false)
settingsRepository.setPinCode(null)
settingsRepository.setBiometricEnabled(false)
}
} }
} }
) )
@@ -95,14 +129,77 @@ fun SecuritySettingsScreen(
Switch( Switch(
checked = isBiometricEnabled, checked = isBiometricEnabled,
onCheckedChange = { checked -> onCheckedChange = { checked ->
scope.launch { if (checked) {
settingsRepository.setBiometricEnabled(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)) 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( 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
} }
@@ -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<String?>(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))
}
}
)
}
+26 -1
View File
@@ -69,6 +69,12 @@
<string name="app_lock_pin_desc">打开应用时需要输入 PIN 码</string> <string name="app_lock_pin_desc">打开应用时需要输入 PIN 码</string>
<string name="biometric_unlock">生物识别解锁</string> <string name="biometric_unlock">生物识别解锁</string>
<string name="biometric_unlock_desc">使用指纹/面部解锁应用</string> <string name="biometric_unlock_desc">使用指纹/面部解锁应用</string>
<string name="lock_trigger_title">锁定时机</string>
<string name="lock_trigger_desc">选择离开应用后何时自动锁定</string>
<string name="lock_trigger_immediately">立即</string>
<string name="lock_trigger_after_1_minute">1 分钟</string>
<string name="lock_trigger_after_3_minutes">3 分钟</string>
<string name="lock_trigger_after_5_minutes">5 分钟</string>
<string name="block_screenshots">禁止截屏</string> <string name="block_screenshots">禁止截屏</string>
<string name="block_screenshots_desc">防止对应用内容进行截屏或录屏</string> <string name="block_screenshots_desc">防止对应用内容进行截屏或录屏</string>
@@ -76,8 +82,14 @@
<string name="set_pin">设置 PIN 码</string> <string name="set_pin">设置 PIN 码</string>
<string name="enter_new_pin">输入新 PIN 码</string> <string name="enter_new_pin">输入新 PIN 码</string>
<string name="confirm_pin">确认 PIN 码</string> <string name="confirm_pin">确认 PIN 码</string>
<string name="pin_too_short">PIN 码至少需要 4 位</string> <string name="pin_requirement">PIN 码固定为 6 位数字</string>
<string name="pin_digits_hint">请输入 6 位数字 PIN 码</string>
<string name="pin_digits_remaining">还需输入 %d 位</string>
<string name="pin_too_short">PIN 码必须为 6 位数字</string>
<string name="pins_do_not_match">两次输入的 PIN 码不一致</string> <string name="pins_do_not_match">两次输入的 PIN 码不一致</string>
<string name="delete_digit">删除</string>
<string name="reset_pin_input">重输</string>
<string name="clear_pin_input">清空</string>
<string name="next">下一步</string> <string name="next">下一步</string>
<string name="confirm">确认</string> <string name="confirm">确认</string>
@@ -91,6 +103,19 @@
<string name="try_again_in">请等待 %d 秒后重试</string> <string name="try_again_in">请等待 %d 秒后重试</string>
<string name="unlock_2fair">解锁 2FAir</string> <string name="unlock_2fair">解锁 2FAir</string>
<string name="biometric_prompt_subtitle">使用您的生物识别凭证登录</string> <string name="biometric_prompt_subtitle">使用您的生物识别凭证登录</string>
<string name="confirm_current_pin">请输入当前 PIN 码以继续</string>
<string name="enable_biometric_verification_title">验证生物识别</string>
<string name="enable_biometric_verification_desc">启用生物识别解锁前,请先完成一次生物识别验证</string>
<string name="disable_biometric_verification_title">确认关闭生物识别</string>
<string name="disable_biometric_verification_desc">关闭生物识别解锁前,请先输入当前 PIN 码。关闭后会保留 PIN 码。</string>
<string name="disable_pin_verification_title">确认关闭应用锁 PIN 码</string>
<string name="disable_pin_verification_desc">关闭应用锁前,请先输入当前 PIN 码。关闭后会同时关闭生物识别并清除已保存的 PIN 码。</string>
<string name="biometric_error_no_hardware">此设备不支持生物识别</string>
<string name="biometric_error_hw_unavailable">生物识别当前暂时不可用</string>
<string name="biometric_error_none_enrolled">请先在系统设置中录入生物识别信息</string>
<string name="biometric_error_unavailable">当前无法使用生物识别</string>
<string name="biometric_not_available_on_this_screen">当前页面无法发起生物识别</string>
<string name="security_verification_unavailable">当前没有可用的系统认证方式</string>
<!-- Backup & Restore --> <!-- Backup & Restore -->
<string name="export_successful">导出成功</string> <string name="export_successful">导出成功</string>
+26 -1
View File
@@ -69,6 +69,12 @@
<string name="app_lock_pin_desc">開啟應用程式時需要輸入 PIN 碼</string> <string name="app_lock_pin_desc">開啟應用程式時需要輸入 PIN 碼</string>
<string name="biometric_unlock">生物辨識解鎖</string> <string name="biometric_unlock">生物辨識解鎖</string>
<string name="biometric_unlock_desc">使用指紋/臉部解鎖應用程式</string> <string name="biometric_unlock_desc">使用指紋/臉部解鎖應用程式</string>
<string name="lock_trigger_title">鎖定時機</string>
<string name="lock_trigger_desc">選擇離開應用程式後何時自動鎖定</string>
<string name="lock_trigger_immediately">立即</string>
<string name="lock_trigger_after_1_minute">1 分鐘</string>
<string name="lock_trigger_after_3_minutes">3 分鐘</string>
<string name="lock_trigger_after_5_minutes">5 分鐘</string>
<string name="block_screenshots">禁止截圖</string> <string name="block_screenshots">禁止截圖</string>
<string name="block_screenshots_desc">防止對應用程式內容進行截圖或錄影</string> <string name="block_screenshots_desc">防止對應用程式內容進行截圖或錄影</string>
@@ -76,8 +82,14 @@
<string name="set_pin">設定 PIN 碼</string> <string name="set_pin">設定 PIN 碼</string>
<string name="enter_new_pin">輸入新 PIN 碼</string> <string name="enter_new_pin">輸入新 PIN 碼</string>
<string name="confirm_pin">確認 PIN 碼</string> <string name="confirm_pin">確認 PIN 碼</string>
<string name="pin_too_short">PIN 碼至少需要 4 位</string> <string name="pin_requirement">PIN 碼固定為 6 位數字</string>
<string name="pin_digits_hint">請輸入 6 位數字 PIN 碼</string>
<string name="pin_digits_remaining">還需輸入 %d 位</string>
<string name="pin_too_short">PIN 碼必須為 6 位數字</string>
<string name="pins_do_not_match">兩次輸入的 PIN 碼不一致</string> <string name="pins_do_not_match">兩次輸入的 PIN 碼不一致</string>
<string name="delete_digit">刪除</string>
<string name="reset_pin_input">重輸</string>
<string name="clear_pin_input">清空</string>
<string name="next">下一步</string> <string name="next">下一步</string>
<string name="confirm">確認</string> <string name="confirm">確認</string>
@@ -91,6 +103,19 @@
<string name="try_again_in">請等待 %d 秒後重試</string> <string name="try_again_in">請等待 %d 秒後重試</string>
<string name="unlock_2fair">解鎖 2FAir</string> <string name="unlock_2fair">解鎖 2FAir</string>
<string name="biometric_prompt_subtitle">使用您的生物辨識憑證登入</string> <string name="biometric_prompt_subtitle">使用您的生物辨識憑證登入</string>
<string name="confirm_current_pin">請輸入目前的 PIN 碼以繼續</string>
<string name="enable_biometric_verification_title">驗證生物辨識</string>
<string name="enable_biometric_verification_desc">啟用生物辨識解鎖前,請先完成一次生物辨識驗證</string>
<string name="disable_biometric_verification_title">確認關閉生物辨識</string>
<string name="disable_biometric_verification_desc">關閉生物辨識解鎖前,請先輸入目前的 PIN 碼。關閉後會保留 PIN 碼。</string>
<string name="disable_pin_verification_title">確認關閉應用程式 PIN 碼鎖定</string>
<string name="disable_pin_verification_desc">關閉應用程式鎖定前,請先輸入目前的 PIN 碼。關閉後會同時關閉生物辨識並清除已儲存的 PIN 碼。</string>
<string name="biometric_error_no_hardware">此裝置不支援生物辨識</string>
<string name="biometric_error_hw_unavailable">生物辨識目前暫時無法使用</string>
<string name="biometric_error_none_enrolled">請先在系統設定中錄入生物辨識資訊</string>
<string name="biometric_error_unavailable">目前無法使用生物辨識</string>
<string name="biometric_not_available_on_this_screen">目前頁面無法啟動生物辨識</string>
<string name="security_verification_unavailable">目前沒有可用的系統驗證方式</string>
<!-- Backup & Restore --> <!-- Backup & Restore -->
<string name="export_successful">匯出成功</string> <string name="export_successful">匯出成功</string>
+26 -1
View File
@@ -69,6 +69,12 @@
<string name="app_lock_pin_desc">Require PIN to open the app</string> <string name="app_lock_pin_desc">Require PIN to open the app</string>
<string name="biometric_unlock">Biometric Unlock</string> <string name="biometric_unlock">Biometric Unlock</string>
<string name="biometric_unlock_desc">Use fingerprint/face to unlock</string> <string name="biometric_unlock_desc">Use fingerprint/face to unlock</string>
<string name="lock_trigger_title">Lock Timing</string>
<string name="lock_trigger_desc">Choose when the app should lock after leaving it</string>
<string name="lock_trigger_immediately">Immediately</string>
<string name="lock_trigger_after_1_minute">After 1 minute</string>
<string name="lock_trigger_after_3_minutes">After 3 minutes</string>
<string name="lock_trigger_after_5_minutes">After 5 minutes</string>
<string name="block_screenshots">Block Screenshots</string> <string name="block_screenshots">Block Screenshots</string>
<string name="block_screenshots_desc">Prevent taking screenshots of the app</string> <string name="block_screenshots_desc">Prevent taking screenshots of the app</string>
@@ -76,8 +82,14 @@
<string name="set_pin">Set PIN</string> <string name="set_pin">Set PIN</string>
<string name="enter_new_pin">Enter a new PIN</string> <string name="enter_new_pin">Enter a new PIN</string>
<string name="confirm_pin">Confirm your PIN</string> <string name="confirm_pin">Confirm your PIN</string>
<string name="pin_too_short">PIN must be at least 4 characters</string> <string name="pin_requirement">PIN must be exactly 6 digits</string>
<string name="pin_digits_hint">Enter your 6-digit PIN</string>
<string name="pin_digits_remaining">%d digits remaining</string>
<string name="pin_too_short">PIN must be exactly 6 digits</string>
<string name="pins_do_not_match">PINs do not match</string> <string name="pins_do_not_match">PINs do not match</string>
<string name="delete_digit">Delete</string>
<string name="reset_pin_input">Start over</string>
<string name="clear_pin_input">Clear</string>
<string name="next">Next</string> <string name="next">Next</string>
<string name="confirm">Confirm</string> <string name="confirm">Confirm</string>
@@ -91,6 +103,19 @@
<string name="try_again_in">Try again in %ds</string> <string name="try_again_in">Try again in %ds</string>
<string name="unlock_2fair">Unlock 2FAir</string> <string name="unlock_2fair">Unlock 2FAir</string>
<string name="biometric_prompt_subtitle">Log in using your biometric credential</string> <string name="biometric_prompt_subtitle">Log in using your biometric credential</string>
<string name="confirm_current_pin">Enter your current PIN to continue</string>
<string name="enable_biometric_verification_title">Verify biometric unlock</string>
<string name="enable_biometric_verification_desc">Authenticate once with biometrics before enabling biometric unlock</string>
<string name="disable_biometric_verification_title">Confirm to turn off biometric unlock</string>
<string name="disable_biometric_verification_desc">Enter your current PIN before turning off biometric unlock. Your PIN will remain enabled.</string>
<string name="disable_pin_verification_title">Confirm to turn off app lock PIN</string>
<string name="disable_pin_verification_desc">Enter your current PIN before turning off app lock. This will also turn off biometric unlock and clear the saved PIN.</string>
<string name="biometric_error_no_hardware">This device does not support biometric authentication</string>
<string name="biometric_error_hw_unavailable">Biometric authentication is temporarily unavailable</string>
<string name="biometric_error_none_enrolled">Set up biometrics in system settings before enabling biometric unlock</string>
<string name="biometric_error_unavailable">Biometric authentication is currently unavailable</string>
<string name="biometric_not_available_on_this_screen">Biometric authentication is not available on this screen</string>
<string name="security_verification_unavailable">No system verification method is available right now</string>
<!-- Backup & Restore --> <!-- Backup & Restore -->
<string name="export_successful">Export successful</string> <string name="export_successful">Export successful</string>