From 46db133458f268ee365bf7deced77f0ecf1e607f Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 13:21:22 -0500 Subject: [PATCH] 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) --- .../tt/honeyDue/security/BiometricManager.kt | 177 +++++++++++ .../honeyDue/security/BiometricManagerTest.kt | 281 ++++++++++++++++++ .../ui/screens/BiometricLockScreen.kt | 175 ++++++++--- .../honeyDue/ui/screens/BiometricLockState.kt | 121 ++++++++ .../screens/BiometricLockScreenStateTest.kt | 117 ++++++++ 5 files changed, 829 insertions(+), 42 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/tt/honeyDue/security/BiometricManager.kt create mode 100644 composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/security/BiometricManagerTest.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/BiometricLockState.kt create mode 100644 composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/BiometricLockScreenStateTest.kt diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/security/BiometricManager.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/security/BiometricManager.kt new file mode 100644 index 0000000..6a9b061 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/security/BiometricManager.kt @@ -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 + } + } + } +} diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/security/BiometricManagerTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/security/BiometricManagerTest.kt new file mode 100644 index 0000000..b6ca47b --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/security/BiometricManagerTest.kt @@ -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(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) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/BiometricLockScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/BiometricLockScreen.kt index 672fdcb..5111ff2 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/BiometricLockScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/BiometricLockScreen.kt @@ -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(null) } + var showFallback by remember { mutableStateOf(false) } + var pinInput by remember { mutableStateOf("") } + var pinError by remember { mutableStateOf(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" diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/BiometricLockState.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/BiometricLockState.kt new file mode 100644 index 0000000..415ddce --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/BiometricLockState.kt @@ -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, + } +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/BiometricLockScreenStateTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/BiometricLockScreenStateTest.kt new file mode 100644 index 0000000..88e0031 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/BiometricLockScreenStateTest.kt @@ -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) + } +}