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:
Trey T
2026-04-18 13:21:22 -05:00
parent 704c59e5cb
commit 46db133458
5 changed files with 829 additions and 42 deletions

View File

@@ -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"

View File

@@ -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,
}
}