@exodus/react-native-biometrics 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,3 +2,7 @@
2
2
 
3
3
  All notable changes to this projected will be documented in this file.
4
4
  See [Conventional Commits](https://www.conventionalcommits.org) for commit guidelines.
5
+
6
+ ## 0.4.1 (2026-09-11)
7
+
8
+ - Fix authentication failing before the prompt appears on Android 9–10 when device passcode fallback is enabled ([#9](https://github.com/ExodusMovement/react-native-biometrics/pull/9)).
package/README.md CHANGED
@@ -30,7 +30,24 @@ const valid = await Biometrics.authenticate(
30
30
  | title | string | The title of the biometric prompt |
31
31
  | subtitle | string | The subtitle of the biometric prompt |
32
32
  | cancelButtonText | string | The text of the cancel button |
33
+ | fallbackToPasscode | boolean | Allow the device PIN, pattern, or password as an alternative to biometrics (default: `false`) |
33
34
 
35
+ With passcode fallback enabled, Android 9–10 (API 28–29) accepts Class 2 (weak) or
36
+ Class 3 (strong) biometrics alongside device credentials. AndroidX does not support
37
+ `BIOMETRIC_STRONG | DEVICE_CREDENTIAL` on these versions. Other Android versions,
38
+ and biometric-only authentication on all versions, continue to require Class 3
39
+ biometrics. Authentication does not use a `CryptoObject`.
40
+
41
+ ## Android tests
42
+
43
+ With JDK 17, Gradle 8.12, Android SDK 36, and build tools 36.0.0 installed:
44
+
45
+ ```sh
46
+ gradle -p test/android :biometrics:testDebugUnitTest
47
+ ```
48
+
49
+ The tests use the library's AndroidX dependency and Robolectric to validate prompt
50
+ creation on API 27, 28, 29, and 30, including the previously rejected combination.
34
51
 
35
52
  ## License
36
53
 
@@ -1,23 +1,34 @@
1
1
  package org.exodusmovement.rn.biometrics;
2
2
 
3
- import java.util.concurrent.Executor;
4
- import com.facebook.react.bridge.Promise;
5
- import com.facebook.react.bridge.ReactMethod;
6
- import com.facebook.react.bridge.ReactApplicationContext;
7
- import com.facebook.react.bridge.ReactContextBaseJavaModule;
8
- import com.facebook.react.module.annotations.ReactModule;
3
+ import java.util.concurrent.atomic.AtomicBoolean;
4
+
5
+ import android.app.Activity;
6
+ import android.os.Build;
7
+ import android.os.Handler;
8
+ import android.os.Looper;
9
9
 
10
+ import androidx.annotation.Nullable;
10
11
  import androidx.biometric.BiometricPrompt;
11
12
  import androidx.biometric.BiometricManager;
12
13
  import androidx.core.content.ContextCompat;
13
14
  import androidx.fragment.app.FragmentActivity;
14
15
 
16
+ import com.facebook.react.bridge.Promise;
17
+ import com.facebook.react.bridge.ReactMethod;
18
+ import com.facebook.react.bridge.ReactApplicationContext;
19
+ import com.facebook.react.bridge.ReactContextBaseJavaModule;
20
+ import com.facebook.react.module.annotations.ReactModule;
21
+
15
22
  @ReactModule(name = RNBiometricsModule.NAME)
16
23
  public final class RNBiometricsModule extends ReactContextBaseJavaModule {
17
24
  public static final String NAME = "Biometrics";
18
25
 
19
26
  private final ReactApplicationContext context;
20
27
 
28
+ @Nullable private BiometricPrompt activePrompt;
29
+ @Nullable private Promise activePromise;
30
+ @Nullable private AtomicBoolean activeSettled;
31
+
21
32
  public RNBiometricsModule(ReactApplicationContext context) {
22
33
  super(context);
23
34
  this.context = context;
@@ -28,6 +39,28 @@ public final class RNBiometricsModule extends ReactContextBaseJavaModule {
28
39
  return NAME;
29
40
  }
30
41
 
42
+ static BiometricPrompt.PromptInfo createPromptInfo(String title, String subtitle, String cancelButtonText, boolean fallbackToPasscode) {
43
+ int authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG;
44
+ if (fallbackToPasscode) {
45
+ // AndroidX cannot combine strong biometrics with device credentials on API 28-29.
46
+ if (Build.VERSION.SDK_INT >= 28 && Build.VERSION.SDK_INT <= 29) {
47
+ authenticators = BiometricManager.Authenticators.BIOMETRIC_WEAK;
48
+ }
49
+ authenticators |= BiometricManager.Authenticators.DEVICE_CREDENTIAL;
50
+ }
51
+
52
+ BiometricPrompt.PromptInfo.Builder builder = new BiometricPrompt.PromptInfo.Builder()
53
+ .setAllowedAuthenticators(authenticators)
54
+ .setTitle(title)
55
+ .setSubtitle(subtitle);
56
+
57
+ if (!fallbackToPasscode) {
58
+ builder.setNegativeButtonText(cancelButtonText);
59
+ }
60
+
61
+ return builder.build();
62
+ }
63
+
31
64
  @ReactMethod
32
65
  public void isSupported(Promise promise) {
33
66
  try {
@@ -40,47 +73,107 @@ public final class RNBiometricsModule extends ReactContextBaseJavaModule {
40
73
  }
41
74
 
42
75
  @ReactMethod
43
- public void authenticate(String title, String subtitle, String cancelButtonText, boolean fallbackToPasscode, Promise promise) {
44
- context.getCurrentActivity().runOnUiThread(new Runnable() {
76
+ public void authenticate(final String title, final String subtitle, final String cancelButtonText, final boolean fallbackToPasscode, final Promise promise) {
77
+ final Activity activity = context.getCurrentActivity();
78
+ if (!(activity instanceof FragmentActivity)) {
79
+ promise.reject("E_NO_ACTIVITY", "No FragmentActivity available to host BiometricPrompt");
80
+ return;
81
+ }
82
+
83
+ final FragmentActivity fragmentActivity = (FragmentActivity) activity;
84
+
85
+ fragmentActivity.runOnUiThread(new Runnable() {
45
86
  @Override
46
87
  public void run() {
47
- try {
48
- int authenticators = fallbackToPasscode
49
- ? BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL
50
- : BiometricManager.Authenticators.BIOMETRIC_STRONG;
51
-
52
- BiometricPrompt.PromptInfo.Builder promptInfoBuilder = new BiometricPrompt.PromptInfo.Builder()
53
- .setAllowedAuthenticators(authenticators)
54
- .setTitle(title)
55
- .setSubtitle(subtitle);
56
-
57
- // Negative button text cannot be set when DEVICE_CREDENTIAL is allowed.
58
- if (!fallbackToPasscode) {
59
- promptInfoBuilder.setNegativeButtonText(cancelButtonText);
88
+ cancelInternal();
89
+
90
+ final AtomicBoolean settled = new AtomicBoolean(false);
91
+
92
+ BiometricPrompt.AuthenticationCallback callback = new BiometricPrompt.AuthenticationCallback() {
93
+ @Override
94
+ public void onAuthenticationError(int errorCode, CharSequence errString) {
95
+ super.onAuthenticationError(errorCode, errString);
96
+ if (settled.compareAndSet(false, true)) {
97
+ clearSlotIfMatches(promise);
98
+ promise.resolve(false);
99
+ }
60
100
  }
61
101
 
62
- new BiometricPrompt(
63
- (FragmentActivity) context.getCurrentActivity(),
64
- ContextCompat.getMainExecutor(context),
65
- new BiometricPrompt.AuthenticationCallback() {
66
- @Override
67
- public void onAuthenticationError(int errorCode, CharSequence errString) {
68
- super.onAuthenticationError(errorCode, errString);
69
- promise.resolve(false);
70
- }
71
-
72
- @Override
73
- public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
74
- super.onAuthenticationSucceeded(result);
75
- promise.resolve(true);
76
- }
102
+ @Override
103
+ public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
104
+ super.onAuthenticationSucceeded(result);
105
+ if (settled.compareAndSet(false, true)) {
106
+ clearSlotIfMatches(promise);
107
+ promise.resolve(true);
77
108
  }
78
- ).authenticate(promptInfoBuilder.build());
109
+ }
110
+ };
111
+
112
+ try {
113
+ BiometricPrompt prompt = new BiometricPrompt(
114
+ fragmentActivity,
115
+ ContextCompat.getMainExecutor(context),
116
+ callback
117
+ );
118
+
119
+ activePrompt = prompt;
120
+ activePromise = promise;
121
+ activeSettled = settled;
79
122
 
123
+ prompt.authenticate(createPromptInfo(title, subtitle, cancelButtonText, fallbackToPasscode));
80
124
  } catch (Exception e) {
81
- promise.reject(e);
125
+ if (settled.compareAndSet(false, true)) {
126
+ clearSlotIfMatches(promise);
127
+ promise.reject(e);
128
+ }
82
129
  }
83
130
  }
84
131
  });
85
132
  }
86
- }
133
+
134
+ @ReactMethod
135
+ public void cancel() {
136
+ final Runnable runnable = new Runnable() {
137
+ @Override
138
+ public void run() {
139
+ cancelInternal();
140
+ }
141
+ };
142
+
143
+ final Activity activity = context.getCurrentActivity();
144
+ if (activity != null) {
145
+ activity.runOnUiThread(runnable);
146
+ } else {
147
+ new Handler(Looper.getMainLooper()).post(runnable);
148
+ }
149
+ }
150
+
151
+ private void cancelInternal() {
152
+ BiometricPrompt prompt = activePrompt;
153
+ Promise promise = activePromise;
154
+ AtomicBoolean settled = activeSettled;
155
+
156
+ activePrompt = null;
157
+ activePromise = null;
158
+ activeSettled = null;
159
+
160
+ if (prompt != null) {
161
+ try {
162
+ prompt.cancelAuthentication();
163
+ } catch (Exception ignored) {
164
+ }
165
+ }
166
+
167
+ if (promise != null && settled != null && settled.compareAndSet(false, true)) {
168
+ promise.resolve(false);
169
+ }
170
+ }
171
+
172
+ private void clearSlotIfMatches(Promise promise) {
173
+ if (activePromise == promise) {
174
+ activePrompt = null;
175
+ activePromise = null;
176
+ activeSettled = null;
177
+ }
178
+ }
179
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/react-native-biometrics",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "React Native Biometrics",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -8,5 +8,10 @@ export default {
8
8
  },
9
9
  authenticate: async ({ title, subtitle, cancelButtonText, fallbackToPasscode = false }) => {
10
10
  return AndroidBiometrics.authenticate(title, subtitle, cancelButtonText, fallbackToPasscode);
11
- }
11
+ },
12
+ cancel: () => {
13
+ if (typeof AndroidBiometrics?.cancel === 'function') {
14
+ AndroidBiometrics.cancel();
15
+ }
16
+ },
12
17
  }
@@ -4,5 +4,6 @@ export default {
4
4
  },
5
5
  authenticate: async () => {
6
6
  return Promise.reject('Biometrics is not supported on this platform');
7
- }
7
+ },
8
+ cancel: () => {},
8
9
  }