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

@@ -0,0 +1,177 @@
package com.tt.honeyDue.security
import androidx.biometric.BiometricManager as AndroidXBiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
/**
* P6 Stream T — Android biometric authentication wrapper.
*
* Thin layer over [androidx.biometric.BiometricPrompt] that exposes a
* suspend function returning a typed [Result] — parity with iOS
* LAContext-based unlock in the SwiftUI app.
*
* Features:
* - 3-strike lockout: after [MAX_FAILURES] consecutive failures in a row,
* the next [authenticate] call returns [Result.TooManyAttempts] WITHOUT
* showing the system prompt. The caller must drive the fallback (PIN)
* flow and reset the counter via [reset].
* - NO_HARDWARE bypass: [canAuthenticate] surfaces whether a prompt can
* even be shown, so callers can skip the lock screen entirely on
* devices without biometric hardware.
*
* The real [BiometricPrompt] is obtained via [promptFactory] so unit tests
* can inject a fake that directly invokes the callback. In production the
* default factory wires the activity + main-thread executor.
*/
class BiometricManager(
private val activity: FragmentActivity,
private val promptFactory: (BiometricPrompt.AuthenticationCallback) -> Prompter =
{ callback ->
val executor = ContextCompat.getMainExecutor(activity)
val prompt = BiometricPrompt(activity, executor, callback)
Prompter { info -> prompt.authenticate(info) }
},
private val availabilityProbe: () -> Availability = {
defaultAvailability(activity)
},
) {
/** Allows tests to intercept the [BiometricPrompt.authenticate] call. */
fun interface Prompter {
fun show(info: BiometricPrompt.PromptInfo)
}
/** High-level outcome returned by [authenticate]. */
sealed class Result {
object Success : Result()
object UserCanceled : Result()
/** 3+ consecutive failures — caller must switch to PIN fallback. */
object TooManyAttempts : Result()
/** Device lacks biometric hardware or enrollment — bypass lock. */
object NoHardware : Result()
data class Error(val code: Int, val message: String) : Result()
}
/** Result of [canAuthenticate]; drives whether to show the lock screen. */
enum class Availability {
NO_HARDWARE,
NOT_ENROLLED,
AVAILABLE,
}
private var consecutiveFailures: Int = 0
/** Quick probe — does this device support biometric auth right now? */
fun canAuthenticate(): Availability = availabilityProbe()
/** Resets the 3-strike counter — call after a successful fallback. */
fun reset() {
consecutiveFailures = 0
}
/**
* Show the biometric prompt and suspend until the user resolves it.
*
* Returns [Result.TooManyAttempts] immediately (without showing a
* prompt) when the 3-strike threshold has been crossed.
*/
suspend fun authenticate(
title: String,
subtitle: String? = null,
negativeButtonText: String = "Cancel",
): Result {
if (consecutiveFailures >= MAX_FAILURES) {
return Result.TooManyAttempts
}
return suspendCancellableCoroutine { cont ->
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
consecutiveFailures = 0
if (cont.isActive) cont.resume(Result.Success)
}
override fun onAuthenticationFailed() {
// Per Android docs, this does NOT dismiss the prompt —
// the system continues listening. We count the strike
// but do NOT resume here; resolution comes via
// onAuthenticationError or onAuthenticationSucceeded.
consecutiveFailures++
}
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence,
) {
if (!cont.isActive) return
val result = when (errorCode) {
BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_NEGATIVE_BUTTON,
BiometricPrompt.ERROR_CANCELED -> Result.UserCanceled
BiometricPrompt.ERROR_HW_NOT_PRESENT,
BiometricPrompt.ERROR_HW_UNAVAILABLE,
BiometricPrompt.ERROR_NO_BIOMETRICS -> Result.NoHardware
BiometricPrompt.ERROR_LOCKOUT,
BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> {
consecutiveFailures = MAX_FAILURES
Result.TooManyAttempts
}
else -> Result.Error(errorCode, errString.toString())
}
cont.resume(result)
}
}
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.apply { subtitle?.let(::setSubtitle) }
.setNegativeButtonText(negativeButtonText)
.setAllowedAuthenticators(
AndroidXBiometricManager.Authenticators.BIOMETRIC_STRONG or
AndroidXBiometricManager.Authenticators.BIOMETRIC_WEAK
)
.build()
promptFactory(callback).show(info)
}
}
/** Test hook — inspect current strike count. */
internal fun currentFailureCount(): Int = consecutiveFailures
/** Test hook — seed the strike count (e.g. to simulate prior failures). */
internal fun seedFailures(count: Int) {
consecutiveFailures = count
}
companion object {
/** Matches iOS LAContext 3-strike convention. */
const val MAX_FAILURES: Int = 3
private fun defaultAvailability(activity: FragmentActivity): Availability {
val mgr = AndroidXBiometricManager.from(activity)
return when (
mgr.canAuthenticate(
AndroidXBiometricManager.Authenticators.BIOMETRIC_STRONG or
AndroidXBiometricManager.Authenticators.BIOMETRIC_WEAK
)
) {
AndroidXBiometricManager.BIOMETRIC_SUCCESS -> Availability.AVAILABLE
AndroidXBiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> Availability.NOT_ENROLLED
AndroidXBiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
AndroidXBiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE,
AndroidXBiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> Availability.NO_HARDWARE
else -> Availability.NO_HARDWARE
}
}
}
}

View File

@@ -0,0 +1,281 @@
package com.tt.honeyDue.security
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import androidx.test.core.app.ApplicationProvider
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* P6 Stream T — Robolectric tests for [BiometricManager].
*
* We don't exercise the real [androidx.biometric.BiometricPrompt] system
* UI (can't show a prompt in a unit test). Instead we inject a fake
* [BiometricManager.Prompter] and drive the
* [BiometricPrompt.AuthenticationCallback] callbacks directly — this
* verifies our wrapper's result-mapping / strike-counting contract
* without needing on-device biometric hardware.
*/
@RunWith(RobolectricTestRunner::class)
class BiometricManagerTest {
private lateinit var activity: FragmentActivity
@Before
fun setUp() {
// ApplicationProvider gives us a Context; Robolectric can build a
// fragment-capable activity controller for testing FragmentActivity.
activity = Robolectric
.buildActivity(FragmentActivity::class.java)
.create()
.get()
}
// ---------- 1. canAuthenticate surfaces NO_HARDWARE ----------
@Test
fun canAuthenticate_returnsNoHardware_whenProbeSaysSo() {
val mgr = BiometricManager(
activity = activity,
promptFactory = { _ -> BiometricManager.Prompter { /* no-op */ } },
availabilityProbe = { BiometricManager.Availability.NO_HARDWARE },
)
assertEquals(BiometricManager.Availability.NO_HARDWARE, mgr.canAuthenticate())
}
@Test
fun canAuthenticate_returnsAvailable_whenProbeSaysSo() {
val mgr = BiometricManager(
activity = activity,
promptFactory = { _ -> BiometricManager.Prompter { } },
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
assertEquals(BiometricManager.Availability.AVAILABLE, mgr.canAuthenticate())
}
// ---------- 2. Success path ----------
@Test
fun authenticate_returnsSuccess_whenCallbackFiresSucceeded() = runTest {
var capturedCallback: BiometricPrompt.AuthenticationCallback? = null
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
capturedCallback = callback
BiometricManager.Prompter { _ ->
// Simulate user succeeding.
callback.onAuthenticationSucceeded(mockk(relaxed = true))
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
val result = mgr.authenticate(title = "Unlock", subtitle = "Verify identity")
assertEquals(BiometricManager.Result.Success, result)
assertEquals(0, mgr.currentFailureCount(), "success resets strike counter")
// Sanity — the factory was actually invoked with our callback.
assertTrue(capturedCallback != null)
}
// ---------- 3. Three-strike lockout ----------
@Test
fun threeConsecutiveFailures_nextAuthenticateReturnsTooManyAttempts() = runTest {
val mgr = BiometricManager(
activity = activity,
promptFactory = { _ ->
// Prompter is irrelevant here: we pre-seed the strike count
// to simulate three prior onAuthenticationFailed hits, then
// attempt one more call — it must short-circuit WITHOUT
// calling the prompter at all.
BiometricManager.Prompter { throw AssertionError("prompt must not be shown") }
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
mgr.seedFailures(3)
val result = mgr.authenticate(title = "Unlock")
assertEquals(BiometricManager.Result.TooManyAttempts, result)
}
@Test
fun failureCounter_incrementsAcrossMultipleFailedCallbacks() = runTest {
// Verifies that onAuthenticationFailed increments the internal
// counter even though it doesn't resume the coroutine. We simulate
// 3 failures followed by a terminal ERROR_USER_CANCELED so the
// suspend call actually resolves.
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
BiometricManager.Prompter { _ ->
callback.onAuthenticationFailed()
callback.onAuthenticationFailed()
callback.onAuthenticationFailed()
callback.onAuthenticationError(
BiometricPrompt.ERROR_USER_CANCELED, "User canceled"
)
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
val result = mgr.authenticate(title = "Unlock")
assertEquals(BiometricManager.Result.UserCanceled, result)
assertEquals(
3, mgr.currentFailureCount(),
"three onAuthenticationFailed events should bump strike count to 3",
)
// A follow-up call must now short-circuit to TooManyAttempts.
val secondAttempt = mgr.authenticate(title = "Unlock")
assertEquals(BiometricManager.Result.TooManyAttempts, secondAttempt)
}
// ---------- 4. USER_CANCELED maps to UserCanceled ----------
@Test
fun onAuthenticationError_userCanceled_mapsToUserCanceled() = runTest {
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
BiometricManager.Prompter { _ ->
callback.onAuthenticationError(
BiometricPrompt.ERROR_USER_CANCELED,
"User canceled",
)
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
val result = mgr.authenticate(title = "Unlock")
assertEquals(BiometricManager.Result.UserCanceled, result)
}
@Test
fun onAuthenticationError_negativeButton_mapsToUserCanceled() = runTest {
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
BiometricManager.Prompter { _ ->
callback.onAuthenticationError(
BiometricPrompt.ERROR_NEGATIVE_BUTTON,
"Cancel",
)
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
val result = mgr.authenticate(title = "Unlock")
assertEquals(BiometricManager.Result.UserCanceled, result)
}
// ---------- 5. Hardware-absent error maps to NoHardware ----------
@Test
fun onAuthenticationError_hwNotPresent_mapsToNoHardware() = runTest {
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
BiometricManager.Prompter { _ ->
callback.onAuthenticationError(
BiometricPrompt.ERROR_HW_NOT_PRESENT,
"No hardware",
)
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
val result = mgr.authenticate(title = "Unlock")
assertEquals(BiometricManager.Result.NoHardware, result)
}
// ---------- 6. Lockout error maps to TooManyAttempts ----------
@Test
fun onAuthenticationError_lockout_mapsToTooManyAttemptsAndSaturatesCounter() = runTest {
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
BiometricManager.Prompter { _ ->
callback.onAuthenticationError(
BiometricPrompt.ERROR_LOCKOUT,
"Too many attempts",
)
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
val result = mgr.authenticate(title = "Unlock")
assertEquals(BiometricManager.Result.TooManyAttempts, result)
assertEquals(BiometricManager.MAX_FAILURES, mgr.currentFailureCount())
}
// ---------- 7. Other errors map to Result.Error with code + message ----------
@Test
fun onAuthenticationError_unknownError_mapsToResultError() = runTest {
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
BiometricManager.Prompter { _ ->
callback.onAuthenticationError(
/* code = */ 9999,
/* msg = */ "Something went wrong",
)
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
val result = mgr.authenticate(title = "Unlock")
val err = assertIs<BiometricManager.Result.Error>(result)
assertEquals(9999, err.code)
assertEquals("Something went wrong", err.message)
}
// ---------- 8. reset() clears the strike counter ----------
@Test
fun reset_clearsFailureCounter_allowsFuturePromptsAgain() = runTest {
var promptsShown = 0
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
BiometricManager.Prompter { _ ->
promptsShown++
callback.onAuthenticationSucceeded(mockk(relaxed = true))
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
mgr.seedFailures(BiometricManager.MAX_FAILURES)
// Before reset — locked out.
assertEquals(BiometricManager.Result.TooManyAttempts, mgr.authenticate("Unlock"))
assertEquals(0, promptsShown, "locked-out call must NOT show the prompt")
mgr.reset()
// After reset — prompt is allowed and resolves with success.
val afterReset = mgr.authenticate("Unlock")
assertEquals(BiometricManager.Result.Success, afterReset)
assertEquals(1, promptsShown)
}
}

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

View File

@@ -0,0 +1,117 @@
package com.tt.honeyDue.ui.screens
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* P6 Stream T — unit tests for [BiometricLockState].
*
* Pure-Kotlin state-machine tests (no Android/iOS dependencies).
*/
class BiometricLockScreenStateTest {
@Test
fun initialState_onAppearAvailable_triggersPromptAndMovesToPrompting() {
var unlockCalls = 0
val state = BiometricLockState(onUnlock = { unlockCalls++ })
val shouldPrompt = state.onAppear(BiometricLockState.Availability.AVAILABLE)
assertTrue(shouldPrompt, "onAppear should return true when biometrics are available")
assertEquals(BiometricLockState.Phase.PROMPTING, state.phase)
assertEquals(0, unlockCalls, "unlock must not fire before biometric success")
}
@Test
fun onAppear_noHardware_bypassesLockAndInvokesUnlock() {
var unlockCalls = 0
val state = BiometricLockState(onUnlock = { unlockCalls++ })
val shouldPrompt = state.onAppear(BiometricLockState.Availability.NO_HARDWARE)
assertFalse(shouldPrompt, "NO_HARDWARE should skip the prompt")
assertEquals(BiometricLockState.Phase.BYPASSED, state.phase)
assertEquals(1, unlockCalls, "NO_HARDWARE should release the lock exactly once")
}
@Test
fun threeConsecutiveFailures_surfaceFallbackPinUi() {
val state = BiometricLockState(onUnlock = {})
state.onAppear(BiometricLockState.Availability.AVAILABLE)
assertFalse(state.onBiometricFailure(), "1st failure should not surface fallback")
assertFalse(state.onBiometricFailure(), "2nd failure should not surface fallback")
val thirdCrossedThreshold = state.onBiometricFailure()
assertTrue(thirdCrossedThreshold, "3rd failure should cross 3-strike threshold")
assertEquals(BiometricLockState.Phase.FALLBACK_PIN, state.phase)
assertEquals(3, state.failureCount)
}
@Test
fun onBiometricSuccess_invokesUnlockCallbackAndMarksUnlocked() {
var unlockCalls = 0
val state = BiometricLockState(onUnlock = { unlockCalls++ })
state.onAppear(BiometricLockState.Availability.AVAILABLE)
state.onBiometricSuccess()
assertEquals(1, unlockCalls, "onUnlock must be invoked exactly once")
assertEquals(BiometricLockState.Phase.UNLOCKED, state.phase)
assertEquals(0, state.failureCount)
}
@Test
fun onBiometricSuccess_idempotent_doesNotDoubleInvokeUnlock() {
var unlockCalls = 0
val state = BiometricLockState(onUnlock = { unlockCalls++ })
state.onAppear(BiometricLockState.Availability.AVAILABLE)
state.onBiometricSuccess()
state.onBiometricSuccess()
assertEquals(1, unlockCalls, "unlock callback must not double-fire")
}
@Test
fun onRetry_afterThreeFailures_staysOnFallbackAndReturnsFalse() {
val state = BiometricLockState(onUnlock = {})
state.onAppear(BiometricLockState.Availability.AVAILABLE)
repeat(3) { state.onBiometricFailure() }
val retriedSuccessfully = state.onRetry()
assertFalse(retriedSuccessfully, "retry after lockout must not resume prompt")
assertEquals(BiometricLockState.Phase.FALLBACK_PIN, state.phase)
}
@Test
fun onPinEntered_correctPin_unlocks() {
var unlockCalls = 0
val state = BiometricLockState(onUnlock = { unlockCalls++ })
state.onAppear(BiometricLockState.Availability.AVAILABLE)
repeat(3) { state.onBiometricFailure() }
val accepted = state.onPinEntered("1234", expectedPin = "1234")
assertTrue(accepted)
assertEquals(1, unlockCalls)
assertEquals(BiometricLockState.Phase.UNLOCKED, state.phase)
}
@Test
fun onPinEntered_wrongPin_keepsFallbackVisibleAndDoesNotUnlock() {
var unlockCalls = 0
val state = BiometricLockState(onUnlock = { unlockCalls++ })
state.onAppear(BiometricLockState.Availability.AVAILABLE)
repeat(3) { state.onBiometricFailure() }
val accepted = state.onPinEntered("0000", expectedPin = "1234")
assertFalse(accepted)
assertEquals(0, unlockCalls)
assertEquals(BiometricLockState.Phase.FALLBACK_PIN, state.phase)
}
}