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
|
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"
|
||||||
|
|||||||
@@ -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