P6 Stream T: finish BiometricLockScreen + BiometricManager
BiometricPrompt wrapper with 3-strike lockout + NO_HARDWARE bypass. BiometricLockScreen with auto-prompt on mount + PIN fallback after 3 failures. PIN wiring marked TODO for secure-storage follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package com.tt.honeyDue.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Fingerprint
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
@@ -9,8 +10,10 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tt.honeyDue.platform.BiometricAuthPerformer
|
||||
import com.tt.honeyDue.platform.BiometricResult
|
||||
import com.tt.honeyDue.platform.rememberBiometricAuth
|
||||
import com.tt.honeyDue.ui.theme.*
|
||||
@@ -18,33 +21,67 @@ import honeydue.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Lock screen shown when the app returns to foreground with biometric lock enabled.
|
||||
* Displays app logo and triggers biometric authentication.
|
||||
* Follows existing design system (OrganicDesign, theme colors).
|
||||
* P6 Stream T — Lock screen shown when the app requires re-authentication.
|
||||
*
|
||||
* Parity with iOS LAContext-based unlock:
|
||||
* 1. On mount, auto-triggers the biometric prompt (unless the device has
|
||||
* no biometric hardware, in which case we bypass).
|
||||
* 2. Counts consecutive failures locally; after 3 failures the PIN
|
||||
* fallback UI is surfaced. PIN check is currently a TODO placeholder
|
||||
* wired to [TODO_FALLBACK_PIN] — follow up to read from encrypted
|
||||
* secure storage.
|
||||
* 3. Honeycomb-themed background via [WarmGradientBackground] +
|
||||
* [OrganicIconContainer] to match the rest of the design system.
|
||||
*/
|
||||
@Composable
|
||||
fun BiometricLockScreen(
|
||||
onUnlocked: () -> Unit
|
||||
onUnlocked: () -> Unit,
|
||||
biometricAuth: BiometricAuthPerformer = rememberBiometricAuth(),
|
||||
) {
|
||||
val biometricAuth = rememberBiometricAuth()
|
||||
val lockState = remember { BiometricLockState(onUnlock = onUnlocked) }
|
||||
|
||||
var authError by remember { mutableStateOf<String?>(null) }
|
||||
var showFallback by remember { mutableStateOf(false) }
|
||||
var pinInput by remember { mutableStateOf("") }
|
||||
var pinError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val promptTitle = stringResource(Res.string.biometric_prompt_title)
|
||||
val promptSubtitle = stringResource(Res.string.biometric_prompt_subtitle)
|
||||
|
||||
// Auto-trigger biometric prompt on appear
|
||||
LaunchedEffect(Unit) {
|
||||
// Callback that maps platform result back onto the state machine.
|
||||
fun triggerPrompt() {
|
||||
biometricAuth.authenticate(
|
||||
title = promptTitle,
|
||||
subtitle = promptSubtitle
|
||||
subtitle = promptSubtitle,
|
||||
) { result ->
|
||||
when (result) {
|
||||
is BiometricResult.Success -> onUnlocked()
|
||||
is BiometricResult.Failed -> authError = result.message
|
||||
is BiometricResult.NotAvailable -> onUnlocked() // Fallback: unlock if biometric unavailable
|
||||
is BiometricResult.Success -> lockState.onBiometricSuccess()
|
||||
is BiometricResult.Failed -> {
|
||||
authError = result.message
|
||||
if (lockState.onBiometricFailure()) {
|
||||
showFallback = true
|
||||
}
|
||||
}
|
||||
is BiometricResult.NotAvailable -> {
|
||||
// NO_HARDWARE bypass — release the lock.
|
||||
lockState.onBiometricSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-trigger biometric prompt on first composition.
|
||||
LaunchedEffect(Unit) {
|
||||
val availability = if (biometricAuth.isBiometricAvailable()) {
|
||||
BiometricLockState.Availability.AVAILABLE
|
||||
} else {
|
||||
BiometricLockState.Availability.NO_HARDWARE
|
||||
}
|
||||
if (lockState.onAppear(availability)) {
|
||||
triggerPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
@@ -57,7 +94,6 @@ fun BiometricLockScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// Lock icon
|
||||
OrganicIconContainer(
|
||||
icon = Icons.Default.Lock,
|
||||
size = 96.dp,
|
||||
@@ -82,9 +118,8 @@ fun BiometricLockScreen(
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (authError != null) {
|
||||
if (authError != null && !showFallback) {
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
||||
|
||||
Text(
|
||||
text = authError ?: "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -95,39 +130,95 @@ fun BiometricLockScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
|
||||
|
||||
// Retry button
|
||||
Button(
|
||||
onClick = {
|
||||
authError = null
|
||||
biometricAuth.authenticate(
|
||||
title = promptTitle,
|
||||
subtitle = promptSubtitle
|
||||
) { result ->
|
||||
when (result) {
|
||||
is BiometricResult.Success -> onUnlocked()
|
||||
is BiometricResult.Failed -> authError = result.message
|
||||
is BiometricResult.NotAvailable -> onUnlocked()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Fingerprint,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
|
||||
if (showFallback) {
|
||||
// ----- Fallback PIN UI (after 3 biometric failures) -----
|
||||
Text(
|
||||
text = stringResource(Res.string.biometric_unlock_button),
|
||||
text = "Enter PIN to unlock",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.md))
|
||||
OutlinedTextField(
|
||||
value = pinInput,
|
||||
onValueChange = {
|
||||
if (it.length <= 4 && it.all(Char::isDigit)) {
|
||||
pinInput = it
|
||||
pinError = null
|
||||
}
|
||||
},
|
||||
label = { Text("4-digit PIN") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
isError = pinError != null,
|
||||
supportingText = pinError?.let { { Text(it) } },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.md))
|
||||
Button(
|
||||
onClick = {
|
||||
// TODO(P6-T follow-up): replace TODO_FALLBACK_PIN
|
||||
// with a call to EncryptedSharedPreferences /
|
||||
// iOS Keychain via TokenManager-style secure
|
||||
// storage. See BiometricLockScreen.kt:line
|
||||
// for the PIN constant below.
|
||||
if (!lockState.onPinEntered(pinInput, TODO_FALLBACK_PIN)) {
|
||||
pinError = "Incorrect PIN"
|
||||
}
|
||||
},
|
||||
enabled = pinInput.length == 4,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Text(
|
||||
text = "Unlock",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// ----- Default biometric retry button -----
|
||||
Button(
|
||||
onClick = {
|
||||
authError = null
|
||||
if (lockState.onRetry()) {
|
||||
triggerPrompt()
|
||||
} else {
|
||||
// 3-strike reached — flip to PIN fallback.
|
||||
showFallback = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Fingerprint,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
|
||||
Text(
|
||||
text = stringResource(Res.string.biometric_unlock_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(P6-T follow-up): wire to secure storage.
|
||||
*
|
||||
* Currently a hard-coded placeholder so the fallback PIN entry path has
|
||||
* a concrete value to compare against. Before shipping, replace reads of
|
||||
* this constant with a lookup in EncryptedSharedPreferences (Android) /
|
||||
* Keychain (iOS) via the same pattern used by TokenManager.
|
||||
*/
|
||||
internal const val TODO_FALLBACK_PIN: String = "0000"
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.tt.honeyDue.ui.screens
|
||||
|
||||
/**
|
||||
* P6 Stream T — BiometricLockScreen state machine (platform-independent).
|
||||
*
|
||||
* Pure logic behind [BiometricLockScreen] so it can be unit-tested in
|
||||
* commonTest without a Robolectric runtime or real BiometricPrompt.
|
||||
*
|
||||
* The state machine tracks:
|
||||
* - whether the initial auto-prompt has been triggered,
|
||||
* - the running count of failures (for the 3-strike PIN fallback),
|
||||
* - whether the fallback PIN UI is currently surfaced,
|
||||
* - and the terminal "unlocked" state.
|
||||
*/
|
||||
class BiometricLockState(
|
||||
/** Max biometric failures before we surface the PIN fallback. */
|
||||
val maxFailures: Int = 3,
|
||||
private val onUnlock: () -> Unit,
|
||||
) {
|
||||
/** Discrete UI-visible phases. */
|
||||
enum class Phase {
|
||||
/** Initial — no prompt has been requested yet. */
|
||||
IDLE,
|
||||
|
||||
/** A biometric prompt has been (or is being) shown. */
|
||||
PROMPTING,
|
||||
|
||||
/** PIN fallback is visible after 3-strike lockout. */
|
||||
FALLBACK_PIN,
|
||||
|
||||
/** Terminal: user is authenticated; [onUnlock] has been invoked. */
|
||||
UNLOCKED,
|
||||
|
||||
/** Terminal: device has no biometric hardware — bypass lock. */
|
||||
BYPASSED,
|
||||
}
|
||||
|
||||
var phase: Phase = Phase.IDLE
|
||||
private set
|
||||
|
||||
var failureCount: Int = 0
|
||||
private set
|
||||
|
||||
/**
|
||||
* Called once on mount. Returns true iff the caller should actually
|
||||
* show the biometric prompt. Handles the NO_HARDWARE bypass branch.
|
||||
*/
|
||||
fun onAppear(availability: Availability): Boolean {
|
||||
if (phase != Phase.IDLE) return false
|
||||
return when (availability) {
|
||||
Availability.NO_HARDWARE -> {
|
||||
phase = Phase.BYPASSED
|
||||
onUnlock()
|
||||
false
|
||||
}
|
||||
Availability.AVAILABLE, Availability.NOT_ENROLLED -> {
|
||||
phase = Phase.PROMPTING
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller reports the biometric prompt succeeded. */
|
||||
fun onBiometricSuccess() {
|
||||
if (phase == Phase.UNLOCKED) return
|
||||
phase = Phase.UNLOCKED
|
||||
failureCount = 0
|
||||
onUnlock()
|
||||
}
|
||||
|
||||
/**
|
||||
* Caller reports a failed biometric attempt. Returns true iff the
|
||||
* fallback PIN UI should be shown (3-strike threshold crossed).
|
||||
*/
|
||||
fun onBiometricFailure(): Boolean {
|
||||
if (phase == Phase.UNLOCKED) return false
|
||||
failureCount++
|
||||
return if (failureCount >= maxFailures) {
|
||||
phase = Phase.FALLBACK_PIN
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** User tapped "Retry" on the lock screen — show the prompt again. */
|
||||
fun onRetry(): Boolean {
|
||||
if (phase == Phase.UNLOCKED || phase == Phase.BYPASSED) return false
|
||||
if (failureCount >= maxFailures) {
|
||||
phase = Phase.FALLBACK_PIN
|
||||
return false
|
||||
}
|
||||
phase = Phase.PROMPTING
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* PIN entered in fallback. Compares against [expectedPin] (a TODO —
|
||||
* see [BiometricLockScreen] for wiring to secure storage). Returns
|
||||
* true on success; resets state and invokes [onUnlock].
|
||||
*/
|
||||
fun onPinEntered(pin: String, expectedPin: String): Boolean {
|
||||
if (phase != Phase.FALLBACK_PIN) return false
|
||||
if (pin != expectedPin) return false
|
||||
phase = Phase.UNLOCKED
|
||||
failureCount = 0
|
||||
onUnlock()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Availability flag passed in from the platform manager — mirrors
|
||||
* [com.tt.honeyDue.security.BiometricManager.Availability] but lives
|
||||
* in commonMain so the state machine is platform-agnostic.
|
||||
*/
|
||||
enum class Availability {
|
||||
NO_HARDWARE,
|
||||
NOT_ENROLLED,
|
||||
AVAILABLE,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user