@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.
|
|
4
|
-
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import
|
|
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()
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
.
|
|
55
|
-
.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
}
|