完善生物识别功能
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Boolean> = _isLocked.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main)
|
||||
private var pendingLockJob: Job? = null
|
||||
private var lockDeadlineAtMillis: Long? = null
|
||||
|
||||
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 isPinEnabled = settingsRepository.isPinEnabled.first()
|
||||
if (isPinEnabled) {
|
||||
_isLocked.value = true
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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<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) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
suspend fun setPinEnabled(enabled: Boolean) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.PIN_ENABLED] = enabled
|
||||
private val storedBiometricEnabled: Flow<Boolean> = dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.BIOMETRIC_ENABLED] ?: false
|
||||
}
|
||||
|
||||
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 ->
|
||||
@@ -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?) {
|
||||
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<Boolean> = 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<Boolean> = 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<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 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")
|
||||
|
||||
@@ -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
|
||||
|
||||
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,19 +21,24 @@ 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 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 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<String?>(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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,124 +119,87 @@ fun LockScreen(
|
||||
)
|
||||
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)
|
||||
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 {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
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
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(48.dp))
|
||||
val updatedPin = enteredPin + digit
|
||||
enteredPin = updatedPin
|
||||
errorMessage = null
|
||||
if (updatedPin.length == PIN_LENGTH) {
|
||||
submitPin(updatedPin)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (pinCode != null && enteredPin == pinCode) {
|
||||
scope.launch {
|
||||
settingsRepo.setFailedAttempts(0)
|
||||
settingsRepo.setLockoutEndTime(0L)
|
||||
},
|
||||
onDelete = {
|
||||
if (remainingLockOutTime == 0L && enteredPin.isNotEmpty()) {
|
||||
enteredPin = enteredPin.dropLast(1)
|
||||
errorMessage = null
|
||||
}
|
||||
onUnlock()
|
||||
} else {
|
||||
val newAttempts = failedAttempts + 1
|
||||
errorMessage = incorrectPinMsg
|
||||
},
|
||||
onReset = {
|
||||
if (remainingLockOutTime == 0L && enteredPin.isNotEmpty()) {
|
||||
enteredPin = ""
|
||||
scope.launch {
|
||||
settingsRepo.setFailedAttempts(newAttempts)
|
||||
if (newAttempts >= 5) {
|
||||
val lockoutTime = System.currentTimeMillis() + 30000L // 30s lockout
|
||||
settingsRepo.setLockoutEndTime(lockoutTime)
|
||||
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 = {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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.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
|
||||
|
||||
@@ -29,8 +28,18 @@ fun PinSetupScreen(
|
||||
|
||||
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 = {
|
||||
@@ -59,46 +68,85 @@ fun PinSetupScreen(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = if (step == 1) pin else confirmPin,
|
||||
onValueChange = {
|
||||
if (step == 1) {
|
||||
pin = it
|
||||
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 {
|
||||
confirmPin = it
|
||||
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 += digit
|
||||
} else {
|
||||
confirmPin += digit
|
||||
}
|
||||
errorMessage = null
|
||||
},
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
isError = errorMessage != null,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
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
|
||||
)
|
||||
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 {
|
||||
|
||||
+187
-10
@@ -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<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(
|
||||
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 ->
|
||||
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(checked)
|
||||
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
|
||||
}
|
||||
|
||||
+116
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -69,6 +69,12 @@
|
||||
<string name="app_lock_pin_desc">打开应用时需要输入 PIN 码</string>
|
||||
<string name="biometric_unlock">生物识别解锁</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_desc">防止对应用内容进行截屏或录屏</string>
|
||||
|
||||
@@ -76,8 +82,14 @@
|
||||
<string name="set_pin">设置 PIN 码</string>
|
||||
<string name="enter_new_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="delete_digit">删除</string>
|
||||
<string name="reset_pin_input">重输</string>
|
||||
<string name="clear_pin_input">清空</string>
|
||||
<string name="next">下一步</string>
|
||||
<string name="confirm">确认</string>
|
||||
|
||||
@@ -91,6 +103,19 @@
|
||||
<string name="try_again_in">请等待 %d 秒后重试</string>
|
||||
<string name="unlock_2fair">解锁 2FAir</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 -->
|
||||
<string name="export_successful">导出成功</string>
|
||||
|
||||
@@ -69,6 +69,12 @@
|
||||
<string name="app_lock_pin_desc">開啟應用程式時需要輸入 PIN 碼</string>
|
||||
<string name="biometric_unlock">生物辨識解鎖</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_desc">防止對應用程式內容進行截圖或錄影</string>
|
||||
|
||||
@@ -76,8 +82,14 @@
|
||||
<string name="set_pin">設定 PIN 碼</string>
|
||||
<string name="enter_new_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="delete_digit">刪除</string>
|
||||
<string name="reset_pin_input">重輸</string>
|
||||
<string name="clear_pin_input">清空</string>
|
||||
<string name="next">下一步</string>
|
||||
<string name="confirm">確認</string>
|
||||
|
||||
@@ -91,6 +103,19 @@
|
||||
<string name="try_again_in">請等待 %d 秒後重試</string>
|
||||
<string name="unlock_2fair">解鎖 2FAir</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 -->
|
||||
<string name="export_successful">匯出成功</string>
|
||||
|
||||
@@ -69,6 +69,12 @@
|
||||
<string name="app_lock_pin_desc">Require PIN to open the app</string>
|
||||
<string name="biometric_unlock">Biometric 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_desc">Prevent taking screenshots of the app</string>
|
||||
|
||||
@@ -76,8 +82,14 @@
|
||||
<string name="set_pin">Set PIN</string>
|
||||
<string name="enter_new_pin">Enter a new 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="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="confirm">Confirm</string>
|
||||
|
||||
@@ -91,6 +103,19 @@
|
||||
<string name="try_again_in">Try again in %ds</string>
|
||||
<string name="unlock_2fair">Unlock 2FAir</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 -->
|
||||
<string name="export_successful">Export successful</string>
|
||||
|
||||
Reference in New Issue
Block a user