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>
178 lines
7.0 KiB
Kotlin
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
|
|
}
|
|
}
|
|
}
|
|
}
|