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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user