完善生物识别功能
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
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() {
|
fun lock() {
|
||||||
|
pendingLockJob?.cancel()
|
||||||
|
pendingLockJob = null
|
||||||
|
lockDeadlineAtMillis = null
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val isPinEnabled = settingsRepository.isPinEnabled.first()
|
val appLockSettings = settingsRepository.appLockSettings.first()
|
||||||
if (isPinEnabled) {
|
_isLocked.value = appLockSettings.isAppLockEnabled
|
||||||
_isLocked.value = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,19 +21,24 @@ 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()
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,124 +119,87 @@ fun LockScreen(
|
|||||||
)
|
)
|
||||||
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) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
Row(
|
PinNumberPad(
|
||||||
modifier = Modifier.fillMaxWidth(0.8f),
|
resetLabel = stringResource(R.string.clear_pin_input),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
deleteLabel = stringResource(R.string.delete_digit),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
onDigit = { digit ->
|
||||||
) {
|
if (remainingLockOutTime > 0L || enteredPin.length >= PIN_LENGTH) {
|
||||||
if (isBiometricEnabled && remainingLockOutTime == 0L) {
|
return@PinNumberPad
|
||||||
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 {
|
val updatedPin = enteredPin + digit
|
||||||
Spacer(modifier = Modifier.width(48.dp))
|
enteredPin = updatedPin
|
||||||
|
errorMessage = null
|
||||||
|
if (updatedPin.length == PIN_LENGTH) {
|
||||||
|
submitPin(updatedPin)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
Button(
|
onDelete = {
|
||||||
onClick = {
|
if (remainingLockOutTime == 0L && enteredPin.isNotEmpty()) {
|
||||||
if (pinCode != null && enteredPin == pinCode) {
|
enteredPin = enteredPin.dropLast(1)
|
||||||
scope.launch {
|
errorMessage = null
|
||||||
settingsRepo.setFailedAttempts(0)
|
|
||||||
settingsRepo.setLockoutEndTime(0L)
|
|
||||||
}
|
}
|
||||||
onUnlock()
|
},
|
||||||
} else {
|
onReset = {
|
||||||
val newAttempts = failedAttempts + 1
|
if (remainingLockOutTime == 0L && enteredPin.isNotEmpty()) {
|
||||||
errorMessage = incorrectPinMsg
|
|
||||||
enteredPin = ""
|
enteredPin = ""
|
||||||
scope.launch {
|
errorMessage = null
|
||||||
settingsRepo.setFailedAttempts(newAttempts)
|
|
||||||
if (newAttempts >= 5) {
|
|
||||||
val lockoutTime = System.currentTimeMillis() + 30000L // 30s lockout
|
|
||||||
settingsRepo.setLockoutEndTime(lockoutTime)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
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.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
|
||||||
|
|
||||||
@@ -29,8 +28,18 @@ fun PinSetupScreen(
|
|||||||
|
|
||||||
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 = {
|
||||||
@@ -59,46 +68,85 @@ fun PinSetupScreen(
|
|||||||
)
|
)
|
||||||
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,
|
||||||
if (step == 1) {
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
pin = it
|
)
|
||||||
|
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 {
|
} 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
|
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)
|
||||||
|
|
||||||
if (errorMessage != null) {
|
|
||||||
Text(
|
|
||||||
text = errorMessage!!,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
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))
|
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 {
|
||||||
|
|||||||
+187
-10
@@ -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 ->
|
||||||
|
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 {
|
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))
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+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="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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user