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 package com.tt.honeyDue.ui.screens
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
@@ -9,8 +10,10 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tt.honeyDue.platform.BiometricAuthPerformer
import com.tt.honeyDue.platform.BiometricResult import com.tt.honeyDue.platform.BiometricResult
import com.tt.honeyDue.platform.rememberBiometricAuth import com.tt.honeyDue.platform.rememberBiometricAuth
import com.tt.honeyDue.ui.theme.* import com.tt.honeyDue.ui.theme.*
@@ -18,33 +21,67 @@ import honeydue.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
/** /**
* Lock screen shown when the app returns to foreground with biometric lock enabled. * P6 Stream T — Lock screen shown when the app requires re-authentication.
* Displays app logo and triggers biometric authentication. *
* Follows existing design system (OrganicDesign, theme colors). * 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 @Composable
fun BiometricLockScreen( 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 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 promptTitle = stringResource(Res.string.biometric_prompt_title)
val promptSubtitle = stringResource(Res.string.biometric_prompt_subtitle) val promptSubtitle = stringResource(Res.string.biometric_prompt_subtitle)
// Auto-trigger biometric prompt on appear // Callback that maps platform result back onto the state machine.
LaunchedEffect(Unit) { fun triggerPrompt() {
biometricAuth.authenticate( biometricAuth.authenticate(
title = promptTitle, title = promptTitle,
subtitle = promptSubtitle subtitle = promptSubtitle,
) { result -> ) { result ->
when (result) { when (result) {
is BiometricResult.Success -> onUnlocked() is BiometricResult.Success -> lockState.onBiometricSuccess()
is BiometricResult.Failed -> authError = result.message is BiometricResult.Failed -> {
is BiometricResult.NotAvailable -> onUnlocked() // Fallback: unlock if biometric unavailable 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( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
@@ -57,7 +94,6 @@ fun BiometricLockScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
// Lock icon
OrganicIconContainer( OrganicIconContainer(
icon = Icons.Default.Lock, icon = Icons.Default.Lock,
size = 96.dp, size = 96.dp,
@@ -82,9 +118,8 @@ fun BiometricLockScreen(
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
if (authError != null) { if (authError != null && !showFallback) {
Spacer(modifier = Modifier.height(OrganicSpacing.lg)) Spacer(modifier = Modifier.height(OrganicSpacing.lg))
Text( Text(
text = authError ?: "", text = authError ?: "",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -95,39 +130,95 @@ fun BiometricLockScreen(
Spacer(modifier = Modifier.height(OrganicSpacing.xl)) Spacer(modifier = Modifier.height(OrganicSpacing.xl))
// Retry button if (showFallback) {
Button( // ----- Fallback PIN UI (after 3 biometric failures) -----
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))
Text( Text(
text = stringResource(Res.string.biometric_unlock_button), text = "Enter PIN to unlock",
style = MaterialTheme.typography.titleMedium, 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)
}
}