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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user