Files
honeyDueKMP/composeApp/src/androidMain/kotlin/com/tt/honeyDue/security/BiometricManager.kt
Trey T 46db133458 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>
2026-04-18 13:21:22 -05:00

178 lines
7.0 KiB
Kotlin

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