diff --git a/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt b/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt index afdfca4..79e3a19 100644 --- a/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt +++ b/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt @@ -1,9 +1,12 @@ package com.simplebiometrics +import android.util.Log import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.UiThreadUtil.runOnUiThread @@ -12,7 +15,23 @@ import com.facebook.react.module.annotations.ReactModule @ReactModule(name = SimpleBiometricsModule.NAME) class SimpleBiometricsModule(reactContext: ReactApplicationContext) : - NativeSimpleBiometricsSpec(reactContext) { + NativeSimpleBiometricsSpec(reactContext), LifecycleEventListener { + + data class PendingAuthRequest( + val promptTitle: String?, + val promptMessage: String?, + val allowDeviceCredentials: Boolean, + val promise: Promise + ) + + private var pendingAuthRequest: PendingAuthRequest? = null + private var isAuthenticationActive = false + private var isHostResumed = false + private var launchAttemptScheduled = false + + init { + reactContext.addLifecycleEventListener(this) + } override fun getName(): String { return NAME @@ -20,6 +39,34 @@ class SimpleBiometricsModule(reactContext: ReactApplicationContext) : companion object { const val NAME = "SimpleBiometrics" + private const val LOG_TAG = "SimpleBiometrics" + } + + override fun onHostResume() { + isHostResumed = true + Log.d(LOG_TAG, "onHostResume pending=${pendingAuthRequest != null} active=$isAuthenticationActive scheduled=$launchAttemptScheduled") + launchPendingAuthentication() + } + + override fun onHostPause() { + Log.d(LOG_TAG, "onHostPause pending=${pendingAuthRequest != null} active=$isAuthenticationActive") + isHostResumed = false + launchAttemptScheduled = false + } + + override fun onHostDestroy() { + Log.d(LOG_TAG, "onHostDestroy pending=${pendingAuthRequest != null} active=$isAuthenticationActive") + isHostResumed = false + isAuthenticationActive = false + } + + override fun invalidate() { + reactApplicationContext.removeLifecycleEventListener(this) + pendingAuthRequest = null + isAuthenticationActive = false + isHostResumed = false + launchAttemptScheduled = false + super.invalidate() } /** @@ -61,44 +108,131 @@ class SimpleBiometricsModule(reactContext: ReactApplicationContext) : promise: Promise? ) { runOnUiThread { + if (promise == null) { + Log.d(LOG_TAG, "requestBioAuth ignored because promise was null") + return@runOnUiThread + } + + if (pendingAuthRequest != null) { + Log.d(LOG_TAG, "requestBioAuth rejected because another request is already pending") + promise.reject("BIOMETRIC_IN_PROGRESS", "Another biometric authentication request is already in progress") + return@runOnUiThread + } + + pendingAuthRequest = PendingAuthRequest( + promptTitle = promptTitle, + promptMessage = promptMessage, + allowDeviceCredentials = allowDeviceCredentials, + promise = promise + ) + + Log.d( + LOG_TAG, + "requestBioAuth queued hostResumed=$isHostResumed active=$isAuthenticationActive title=${promptTitle ?: ""}" + ) + + launchPendingAuthentication() + } + } + + private fun launchPendingAuthentication() { + runOnUiThread { + val request = pendingAuthRequest ?: return@runOnUiThread + if (isAuthenticationActive) { + Log.d(LOG_TAG, "launchPendingAuthentication skipped because authentication is already active") + return@runOnUiThread + } + + if (!isHostResumed) { + Log.d(LOG_TAG, "launchPendingAuthentication skipped because host is not resumed") + return@runOnUiThread + } + + val activity = reactApplicationContext.currentActivity as? FragmentActivity ?: return@runOnUiThread + + Log.d( + LOG_TAG, + "launchPendingAuthentication activity=${activity::class.java.simpleName} lifecycle=${activity.lifecycle.currentState} finishing=${activity.isFinishing} destroyed=${activity.isDestroyed}" + ) + + if (activity.isFinishing || activity.isDestroyed) { + Log.d(LOG_TAG, "launchPendingAuthentication aborted because activity is finishing or destroyed") + return@runOnUiThread + } + + if (!activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + if (!launchAttemptScheduled) { + Log.d(LOG_TAG, "launchPendingAuthentication deferring until decorView post because lifecycle is ${activity.lifecycle.currentState}") + launchAttemptScheduled = true + activity.window?.decorView?.post { + launchAttemptScheduled = false + Log.d(LOG_TAG, "launchPendingAuthentication retrying after decorView post") + launchPendingAuthentication() + } ?: run { + launchAttemptScheduled = false + Log.d(LOG_TAG, "launchPendingAuthentication could not schedule decorView post") + } + } + return@runOnUiThread + } + try { + isAuthenticationActive = true + Log.d(LOG_TAG, "launchPendingAuthentication showing BiometricPrompt allowDeviceCredentials=${request.allowDeviceCredentials}") val context = reactApplicationContext - val activity = this.reactApplicationContext.currentActivity val mainExecutor = ContextCompat.getMainExecutor(context) val authenticationCallback: BiometricPrompt.AuthenticationCallback = object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { super.onAuthenticationError(errorCode, errString) - promise!!.reject(java.lang.Exception(errString.toString())) + isAuthenticationActive = false + Log.d( + LOG_TAG, + "onAuthenticationError code=$errorCode message=$errString hostResumed=$isHostResumed pending=${pendingAuthRequest != null}" + ) + if (errorCode == BiometricPrompt.ERROR_CANCELED && !isHostResumed) { + Log.d(LOG_TAG, "onAuthenticationError preserving pending request for next resume") + return + } + + pendingAuthRequest = null + if (errorCode == BiometricPrompt.ERROR_CANCELED) { + request.promise.reject("BIOMETRIC_SYSTEM_CANCELED", errString.toString()) + } else { + request.promise.reject(java.lang.Exception(errString.toString())) + } } override fun onAuthenticationSucceeded( result: BiometricPrompt.AuthenticationResult ) { super.onAuthenticationSucceeded(result) - promise!!.resolve(true) + isAuthenticationActive = false + pendingAuthRequest = null + Log.d(LOG_TAG, "onAuthenticationSucceeded") + request.promise.resolve(true) } } - if (activity != null) { - val prompt = BiometricPrompt( - activity as FragmentActivity, mainExecutor, - authenticationCallback - ) - - val authenticators = getAllowedAuthenticators(allowDeviceCredentials) - val promptInfo: BiometricPrompt.PromptInfo = BiometricPrompt.PromptInfo.Builder() - .setAllowedAuthenticators(authenticators) - .setTitle(promptTitle ?: "") - .setSubtitle(promptMessage) - .build() - - prompt.authenticate(promptInfo) - } else { - throw java.lang.Exception("null activity") - } + val prompt = BiometricPrompt( + activity, + mainExecutor, + authenticationCallback + ) + + val authenticators = getAllowedAuthenticators(request.allowDeviceCredentials) + val promptInfo: BiometricPrompt.PromptInfo = BiometricPrompt.PromptInfo.Builder() + .setAllowedAuthenticators(authenticators) + .setTitle(request.promptTitle ?: "") + .setSubtitle(request.promptMessage) + .build() + + prompt.authenticate(promptInfo) } catch (e: java.lang.Exception) { - promise!!.reject(e) + isAuthenticationActive = false + pendingAuthRequest = null + Log.d(LOG_TAG, "launchPendingAuthentication failed: ${e.message}", e) + request.promise.reject(e) } } } diff --git a/package.json b/package.json index 17af07a..342a38f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-simple-biometrics", - "version": "3.0.2", + "version": "3.0.6", "description": "simple interface to verify user authenticity using on-device biometrics", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts",