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