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