修正应用锁逻辑错误

This commit is contained in:
2026-06-17 09:53:56 +08:00
parent 4ebd1babe3
commit be613749c4
10 changed files with 227 additions and 124 deletions
@@ -61,6 +61,10 @@ class MainActivity : FragmentActivity() {
val isFlagSecureEnabled by settingsRepo.isFlagSecureEnabled.collectAsStateWithLifecycle(initialValue = false)
val isLocked by appLockManager.isLocked.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
appLockManager.checkInitialLockState()
}
LaunchedEffect(isFlagSecureEnabled) {
if (isFlagSecureEnabled) {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
@@ -12,7 +12,6 @@ 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
@@ -30,40 +29,84 @@ class AppLockManager(
val isLocked: StateFlow<Boolean> = _isLocked.asStateFlow()
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()
recordBackgroundAndScheduleLock()
}
}
}
private val screenOnReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == Intent.ACTION_SCREEN_ON) {
cancelPendingLockAndClearBackgroundTime()
}
}
}
init {
appContext.registerReceiver(screenOffReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF))
appContext.registerReceiver(screenOnReceiver, IntentFilter(Intent.ACTION_SCREEN_ON))
observeSettings()
}
/**
* Called when the app first starts or is restored after process death.
* Checks the persisted last-background time against the lock trigger delay
* to decide whether the app should be locked.
*/
fun checkInitialLockState() {
if (_isLocked.value) return
scope.launch {
val settings = settingsRepository.appLockSettings.first()
if (!settings.isAppLockEnabled) return@launch
val lastBg = settingsRepository.getLastBackgroundTime()
if (lastBg <= 0L) {
// No recorded background time (fresh launch after PIN enabled)
_isLocked.value = true
return@launch
}
val delayMillis = settings.lockTrigger.delayMillis
val now = System.currentTimeMillis()
val shouldLock = if (delayMillis <= 0L) {
// Immediate: any recorded background time means lock
true
} else {
// Delayed: lock if enough time has passed since background
now >= lastBg + delayMillis
}
if (shouldLock) {
_isLocked.value = true
}
}
}
fun lock() {
pendingLockJob?.cancel()
pendingLockJob = null
lockDeadlineAtMillis = null
scope.launch {
val appLockSettings = settingsRepository.appLockSettings.first()
_isLocked.value = appLockSettings.isAppLockEnabled
val settings = settingsRepository.appLockSettings.first()
_isLocked.value = settings.isAppLockEnabled
}
}
fun unlock() {
pendingLockJob?.cancel()
pendingLockJob = null
lockDeadlineAtMillis = null
scope.launch {
settingsRepository.clearLastBackgroundTime()
}
_isLocked.value = false
}
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
cancelPendingLock()
scope.launch {
refreshLockStateWhenForegrounded()
}
@@ -71,7 +114,7 @@ class AppLockManager(
override fun onStop(owner: LifecycleOwner) {
super.onStop(owner)
scheduleLock()
recordBackgroundAndScheduleLock()
}
private fun observeSettings() {
@@ -80,65 +123,89 @@ class AppLockManager(
if (!settings.isAppLockEnabled) {
pendingLockJob?.cancel()
pendingLockJob = null
lockDeadlineAtMillis = null
_isLocked.value = false
scope.launch { settingsRepository.clearLastBackgroundTime() }
}
}
}
}
private fun scheduleLock() {
/**
* Persist the current time as the background moment, then schedule
* an in-memory lock after the configured delay.
*/
private fun recordBackgroundAndScheduleLock() {
if (_isLocked.value) return
pendingLockJob?.cancel()
pendingLockJob = null
scope.launch {
val appLockSettings = settingsRepository.appLockSettings.first()
if (!appLockSettings.isAppLockEnabled) {
lockDeadlineAtMillis = null
val settings = settingsRepository.appLockSettings.first()
if (!settings.isAppLockEnabled) {
_isLocked.value = false
return@launch
}
val delayMillis = appLockSettings.lockTrigger.delayMillis
// Persist the background timestamp so a process-kill doesn't lose it
settingsRepository.setLastBackgroundTime(System.currentTimeMillis())
val delayMillis = settings.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) {
if (latestSettings.isAppLockEnabled) {
_isLocked.value = true
}
}
}
}
private fun cancelPendingLock() {
pendingLockJob?.cancel()
pendingLockJob = null
}
private fun cancelPendingLockAndClearBackgroundTime() {
if (_isLocked.value) return // Already locked, don't interfere
pendingLockJob?.cancel()
pendingLockJob = null
scope.launch {
settingsRepository.clearLastBackgroundTime()
}
}
private suspend fun refreshLockStateWhenForegrounded() {
val appLockSettings = settingsRepository.appLockSettings.first()
if (!appLockSettings.isAppLockEnabled) {
val settings = settingsRepository.appLockSettings.first()
if (!settings.isAppLockEnabled) {
unlock()
return
}
val deadline = lockDeadlineAtMillis
if (deadline == null) {
return
val lastBg = settingsRepository.getLastBackgroundTime()
if (lastBg <= 0L) return
val delayMillis = settings.lockTrigger.delayMillis
val now = System.currentTimeMillis()
val shouldLock = if (delayMillis <= 0L) {
true
} else {
now >= lastBg + delayMillis
}
if (System.currentTimeMillis() >= deadline) {
pendingLockJob?.cancel()
pendingLockJob = null
lockDeadlineAtMillis = null
if (shouldLock) {
cancelPendingLock()
_isLocked.value = true
} else {
pendingLockJob?.cancel()
pendingLockJob = null
lockDeadlineAtMillis = null
// Still within the grace period — cancel the pending in-memory lock
cancelPendingLock()
settingsRepository.clearLastBackgroundTime()
}
}
}
@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
@@ -162,6 +163,22 @@ class SettingsRepository(private val context: Context, private val cryptoManager
}
}
suspend fun getLastBackgroundTime(): Long {
return dataStore.data.map { it[PreferencesKeys.LAST_BACKGROUND_TIME] ?: 0L }.first()
}
suspend fun setLastBackgroundTime(time: Long) {
dataStore.edit { preferences ->
preferences[PreferencesKeys.LAST_BACKGROUND_TIME] = time
}
}
suspend fun clearLastBackgroundTime() {
dataStore.edit { preferences ->
preferences.remove(PreferencesKeys.LAST_BACKGROUND_TIME)
}
}
val isFlagSecureEnabled: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[PreferencesKeys.FLAG_SECURE_ENABLED] ?: false
}
@@ -244,6 +261,7 @@ class SettingsRepository(private val context: Context, private val cryptoManager
val FAILED_ATTEMPTS = intPreferencesKey("failed_attempts")
val LOCKOUT_END_TIME = longPreferencesKey("lockout_end_time")
val APP_LOCK_TRIGGER = intPreferencesKey("app_lock_trigger")
val LAST_BACKGROUND_TIME = longPreferencesKey("last_background_time")
val THEME_MODE = intPreferencesKey("theme_mode")
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val SEED_COLOR = intPreferencesKey("seed_color")
@@ -1,5 +1,7 @@
package cn.airnan.a2fair.ui.settings.security
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -15,9 +17,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
@@ -45,12 +50,23 @@ fun PinIndicatorRow(
horizontalArrangement = Arrangement.Center
) {
repeat(PIN_LENGTH) { index ->
val isFilled = index < filledCount
val scale = remember { Animatable(1f) }
LaunchedEffect(isFilled) {
if (isFilled) {
scale.snapTo(1.4f)
scale.animateTo(1f, spring(dampingRatio = 0.35f, stiffness = 600f))
}
}
Box(
modifier = Modifier
.padding(horizontal = 6.dp)
.size(14.dp)
.scale(scale.value)
.clip(CircleShape)
.background(if (index < filledCount) activeColor else inactiveColor)
.background(if (isFilled) activeColor else inactiveColor)
)
}
}
@@ -88,10 +104,10 @@ fun PinNumberPad(
onClick = { onDigit(digit) },
modifier = Modifier
.weight(1f)
.height(56.dp),
.height(72.dp),
enabled = isEnabled
) {
Text(text = digit, style = MaterialTheme.typography.titleLarge)
Text(text = digit, style = MaterialTheme.typography.headlineMedium)
}
}
}
@@ -106,7 +122,7 @@ fun PinNumberPad(
onClick = onReset,
modifier = Modifier
.weight(1f)
.height(56.dp),
.height(72.dp),
enabled = isEnabled && isResetEnabled
) {
Text(text = resetLabel)
@@ -116,7 +132,7 @@ fun PinNumberPad(
onClick = { onDigit("0") },
modifier = Modifier
.weight(1f)
.height(56.dp),
.height(72.dp),
enabled = isEnabled
) {
Text(text = "0", style = MaterialTheme.typography.titleLarge)
@@ -126,7 +142,7 @@ fun PinNumberPad(
onClick = onDelete,
modifier = Modifier
.weight(1f)
.height(56.dp)
.height(72.dp)
.semantics { contentDescription = deleteLabel },
enabled = isEnabled && isDeleteEnabled
) {
@@ -1,6 +1,7 @@
package cn.airnan.a2fair.ui.settings.security
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
@@ -38,7 +39,7 @@ fun PinSetupScreen(
R.string.pin_digits_remaining,
PIN_LENGTH - currentPin.length
)
else -> pinRequirementMsg
else -> ""
}
Scaffold(
@@ -68,13 +69,6 @@ fun PinSetupScreen(
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = pinRequirementMsg,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
PinIndicatorRow(
filledCount = currentPin.length,
isError = errorMessage != null
@@ -90,7 +84,7 @@ fun PinSetupScreen(
MaterialTheme.colorScheme.onSurfaceVariant
}
)
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(36.dp))
PinNumberPad(
resetLabel = if (step == 1) {
@@ -131,33 +125,44 @@ fun PinSetupScreen(
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
if (step == 1) {
if (pin.length != PIN_LENGTH) {
errorMessage = pinTooShortMsg
} else {
confirmPin = ""
errorMessage = null
step = 2
}
} else {
if (confirmPin.length != PIN_LENGTH) {
errorMessage = pinTooShortMsg
} else if (pin == confirmPin) {
scope.launch {
settingsRepository.enablePin(pin)
onNavigateBack()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Button(
onClick = {
if (step == 1) {
if (pin.length != PIN_LENGTH) {
errorMessage = pinTooShortMsg
} else {
confirmPin = ""
errorMessage = null
step = 2
}
} else {
errorMessage = pinsDoNotMatchMsg
confirmPin = ""
if (confirmPin.length != PIN_LENGTH) {
errorMessage = pinTooShortMsg
} else if (pin == confirmPin) {
scope.launch {
settingsRepository.enablePin(pin)
onNavigateBack()
}
} else {
errorMessage = pinsDoNotMatchMsg
confirmPin = ""
}
}
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(if (step == 1) stringResource(R.string.next) else stringResource(R.string.confirm))
},
modifier = Modifier
.height(56.dp)
.widthIn(min = 160.dp),
shape = RoundedCornerShape(16.dp)
) {
Text(
text = if (step == 1) stringResource(R.string.next) else stringResource(R.string.confirm),
style = MaterialTheme.typography.titleMedium
)
}
}
}
}
@@ -43,9 +43,7 @@ fun SecuritySettingsScreen(
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)
@@ -112,23 +110,23 @@ fun SecuritySettingsScreen(
Spacer(modifier = Modifier.height(16.dp))
if (isPinEnabled) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(stringResource(R.string.biometric_unlock), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.biometric_unlock_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = isBiometricEnabled,
onCheckedChange = { checked ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(stringResource(R.string.biometric_unlock), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.biometric_unlock_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = isBiometricEnabled,
enabled = isPinEnabled,
onCheckedChange = { checked ->
if (checked) {
biometricAuthenticator.authenticate(
mode = BiometricAuthMode.BiometricOnly,
@@ -172,15 +170,21 @@ fun SecuritySettingsScreen(
)
}
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(16.dp))
Card(
onClick = { showLockTriggerDialog = true },
modifier = Modifier.fillMaxWidth()
if (isPinEnabled) {
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = false,
onClick = { showLockTriggerDialog = true }
)
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.lock_trigger_title),
style = MaterialTheme.typography.titleMedium
@@ -190,13 +194,12 @@ fun SecuritySettingsScreen(
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
)
}
Text(
text = stringResource(lockTrigger.toLabelRes()),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(16.dp))
@@ -233,10 +236,6 @@ fun SecuritySettingsScreen(
SecurityVerificationAction.DisableBiometric -> disableBiometricTitle
SecurityVerificationAction.DisablePin -> disablePinTitle
},
description = when (action) {
SecurityVerificationAction.DisableBiometric -> disableBiometricDesc
SecurityVerificationAction.DisablePin -> disablePinDesc
},
expectedPin = pinCode,
onDismissRequest = { verificationAction = null },
onVerified = {
@@ -17,6 +17,7 @@ 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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import cn.airnan.a2fair.R
import cn.airnan.a2fair.core.security.PIN_LENGTH
@@ -24,7 +25,6 @@ import cn.airnan.a2fair.core.security.PIN_LENGTH
@Composable
fun SecurityVerificationDialog(
title: String,
description: String,
expectedPin: String?,
onDismissRequest: () -> Unit,
onVerified: () -> Unit
@@ -32,7 +32,6 @@ fun SecurityVerificationDialog(
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) {
@@ -45,17 +44,19 @@ fun SecurityVerificationDialog(
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = title) },
title = {
Text(
text = title,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
PinIndicatorRow(
filledCount = enteredPin.length,
@@ -64,13 +65,15 @@ fun SecurityVerificationDialog(
)
Text(
text = errorMessage ?: pinHintMessage,
text = errorMessage ?: "",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = if (errorMessage != null) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
},
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(4.dp))
@@ -103,13 +103,10 @@
<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>
@@ -103,13 +103,10 @@
<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>
-3
View File
@@ -103,13 +103,10 @@
<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>