From ece3bdebb38429a63bb8827d895374ce37545f3c Mon Sep 17 00:00:00 2001 From: Mathew Waller Date: Mon, 15 Jun 2026 09:19:45 +0100 Subject: [PATCH 1/7] Preventing biometric prompt from looping when pressing recent nav button on older versions of android. --- .../java/com/simplebiometrics/SimpleBiometricsModule.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt b/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt index afdfca4..0475814 100644 --- a/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt +++ b/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt @@ -69,7 +69,11 @@ class SimpleBiometricsModule(reactContext: ReactApplicationContext) : object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { super.onAuthenticationError(errorCode, errString) - promise!!.reject(java.lang.Exception(errString.toString())) + if(errorCode == BiometricPrompt.ERROR_CANCLED) { + promise!!.reject("BIOMETRIC_SYSTEM_CANCELED", errString.toString()) + } else { + promise!!.reject(java.lang.Exception(errString.toString())) + } } override fun onAuthenticationSucceeded( @@ -80,7 +84,7 @@ class SimpleBiometricsModule(reactContext: ReactApplicationContext) : } } - if (activity != null) { + if (activity != null && !activity.isFinishing && !(activity as FragmentActivity).isDestroyed) { val prompt = BiometricPrompt( activity as FragmentActivity, mainExecutor, authenticationCallback From 68949f26d71392e3ad548936b7c294b640b165ff Mon Sep 17 00:00:00 2001 From: Mathew Waller Date: Mon, 15 Jun 2026 09:22:48 +0100 Subject: [PATCH 2/7] Bumping version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 17af07a..bfb534b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-simple-biometrics", - "version": "3.0.2", + "version": "3.0.3", "description": "simple interface to verify user authenticity using on-device biometrics", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", From cb3531f801984e594ed0f6e0540de66fc19921f9 Mon Sep 17 00:00:00 2001 From: Mathew Waller Date: Mon, 15 Jun 2026 09:29:45 +0100 Subject: [PATCH 3/7] Correcting typo ERROR_CANCELED --- .../main/java/com/simplebiometrics/SimpleBiometricsModule.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt b/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt index 0475814..1390cd5 100644 --- a/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt +++ b/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt @@ -69,7 +69,7 @@ class SimpleBiometricsModule(reactContext: ReactApplicationContext) : object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { super.onAuthenticationError(errorCode, errString) - if(errorCode == BiometricPrompt.ERROR_CANCLED) { + if(errorCode == BiometricPrompt.ERROR_CANCELED) { promise!!.reject("BIOMETRIC_SYSTEM_CANCELED", errString.toString()) } else { promise!!.reject(java.lang.Exception(errString.toString())) From 23c84a867702b3af8cac44a3f85c395f492edafb Mon Sep 17 00:00:00 2001 From: Mathew Waller Date: Mon, 15 Jun 2026 09:30:09 +0100 Subject: [PATCH 4/7] Bumping version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bfb534b..659a579 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-simple-biometrics", - "version": "3.0.3", + "version": "3.0.4", "description": "simple interface to verify user authenticity using on-device biometrics", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", From 28f03bb834a3c506551d276f2aa1f915f13b84d0 Mon Sep 17 00:00:00 2001 From: Mathew Waller Date: Mon, 15 Jun 2026 09:54:20 +0100 Subject: [PATCH 5/7] Adding android lifecycle to relaunch biometric when app is backgrounded --- .../SimpleBiometricsModule.kt | 124 ++++++++++++++---- 1 file changed, 100 insertions(+), 24 deletions(-) diff --git a/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt b/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt index 1390cd5..68506cd 100644 --- a/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt +++ b/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt @@ -4,6 +4,8 @@ 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 +14,21 @@ 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 launchAttemptScheduled = false + + init { + reactContext.addLifecycleEventListener(this) + } override fun getName(): String { return NAME @@ -22,6 +38,25 @@ class SimpleBiometricsModule(reactContext: ReactApplicationContext) : const val NAME = "SimpleBiometrics" } + override fun onHostResume() { + launchPendingAuthentication() + } + + override fun onHostPause() { + // No-op; pending auth remains queued until host resumes. + } + + override fun onHostDestroy() { + // No-op; pending auth remains queued until activity is available again. + } + + override fun invalidate() { + reactApplicationContext.removeLifecycleEventListener(this) + pendingAuthRequest = null + launchAttemptScheduled = false + super.invalidate() + } + /** * Helper to choose allowed authenticators depending on API level and JS param. */ @@ -61,18 +96,60 @@ class SimpleBiometricsModule(reactContext: ReactApplicationContext) : promise: Promise? ) { runOnUiThread { + if (promise == null) { + return@runOnUiThread + } + + if (pendingAuthRequest != null) { + 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 + ) + + launchPendingAuthentication() + } + } + + private fun launchPendingAuthentication() { + runOnUiThread { + val request = pendingAuthRequest ?: return@runOnUiThread + val activity = reactApplicationContext.currentActivity as? FragmentActivity ?: return@runOnUiThread + + if (activity.isFinishing || activity.isDestroyed) { + return@runOnUiThread + } + + if (!activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + if (!launchAttemptScheduled) { + launchAttemptScheduled = true + activity.window?.decorView?.post { + launchAttemptScheduled = false + launchPendingAuthentication() + } ?: run { + launchAttemptScheduled = false + } + } + return@runOnUiThread + } + try { 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) - if(errorCode == BiometricPrompt.ERROR_CANCELED) { - promise!!.reject("BIOMETRIC_SYSTEM_CANCELED", errString.toString()) + pendingAuthRequest = null + if (errorCode == BiometricPrompt.ERROR_CANCELED) { + request.promise.reject("BIOMETRIC_SYSTEM_CANCELED", errString.toString()) } else { - promise!!.reject(java.lang.Exception(errString.toString())) + request.promise.reject(java.lang.Exception(errString.toString())) } } @@ -80,29 +157,28 @@ class SimpleBiometricsModule(reactContext: ReactApplicationContext) : result: BiometricPrompt.AuthenticationResult ) { super.onAuthenticationSucceeded(result) - promise!!.resolve(true) + pendingAuthRequest = null + request.promise.resolve(true) } } - if (activity != null && !activity.isFinishing && !(activity as FragmentActivity).isDestroyed) { - 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) + pendingAuthRequest = null + request.promise.reject(e) } } } From a1793b7b38bf27dea223128007ef1211267999d5 Mon Sep 17 00:00:00 2001 From: Mathew Waller Date: Mon, 15 Jun 2026 09:54:44 +0100 Subject: [PATCH 6/7] Bumping version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 659a579..5a37be0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-simple-biometrics", - "version": "3.0.4", + "version": "3.0.5", "description": "simple interface to verify user authenticity using on-device biometrics", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", From 1f61bc7fc831f3071729eab4c596eb44f9336e52 Mon Sep 17 00:00:00 2001 From: Mathew Waller Date: Mon, 15 Jun 2026 12:47:25 +0100 Subject: [PATCH 7/7] Adding queue for prompt to ensure we only ever display one at a time and it is not retriggered if already open. --- .../SimpleBiometricsModule.kt | 58 ++++++++++++++++++- package.json | 2 +- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt b/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt index 68506cd..79e3a19 100644 --- a/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt +++ b/android/src/main/java/com/simplebiometrics/SimpleBiometricsModule.kt @@ -1,5 +1,6 @@ package com.simplebiometrics +import android.util.Log import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat @@ -24,6 +25,8 @@ class SimpleBiometricsModule(reactContext: ReactApplicationContext) : ) private var pendingAuthRequest: PendingAuthRequest? = null + private var isAuthenticationActive = false + private var isHostResumed = false private var launchAttemptScheduled = false init { @@ -36,23 +39,32 @@ 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() { - // No-op; pending auth remains queued until host resumes. + Log.d(LOG_TAG, "onHostPause pending=${pendingAuthRequest != null} active=$isAuthenticationActive") + isHostResumed = false + launchAttemptScheduled = false } override fun onHostDestroy() { - // No-op; pending auth remains queued until activity is available again. + 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() } @@ -97,10 +109,12 @@ class SimpleBiometricsModule(reactContext: ReactApplicationContext) : ) { 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 } @@ -112,6 +126,11 @@ class SimpleBiometricsModule(reactContext: ReactApplicationContext) : promise = promise ) + Log.d( + LOG_TAG, + "requestBioAuth queued hostResumed=$isHostResumed active=$isAuthenticationActive title=${promptTitle ?: ""}" + ) + launchPendingAuthentication() } } @@ -119,32 +138,63 @@ class SimpleBiometricsModule(reactContext: ReactApplicationContext) : 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 mainExecutor = ContextCompat.getMainExecutor(context) val authenticationCallback: BiometricPrompt.AuthenticationCallback = object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { super.onAuthenticationError(errorCode, errString) + 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()) @@ -157,7 +207,9 @@ class SimpleBiometricsModule(reactContext: ReactApplicationContext) : result: BiometricPrompt.AuthenticationResult ) { super.onAuthenticationSucceeded(result) + isAuthenticationActive = false pendingAuthRequest = null + Log.d(LOG_TAG, "onAuthenticationSucceeded") request.promise.resolve(true) } } @@ -177,7 +229,9 @@ class SimpleBiometricsModule(reactContext: ReactApplicationContext) : prompt.authenticate(promptInfo) } catch (e: java.lang.Exception) { + 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 5a37be0..342a38f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-simple-biometrics", - "version": "3.0.5", + "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",