Skip to content

Commit 0310140

Browse files
committed
feat: Add biometric authentication support for Android and iOS
- Implemented AndroidBiometricModule and AndroidBiometricPackage for biometric authentication on Android. - Created TransparentBiometricActivity to handle biometric prompts in a transparent manner. - Added HybridBiometricPromptView and HybridBiometricPromptViewManager for React Native integration. - Updated SensitiveInfo class to include biometric authentication checks before accessing sensitive data. - Enhanced iOS support with LocalAuthenticationModule and HybridBiometricPromptView for biometric prompts. - Updated README.md with troubleshooting tips for Face ID on iOS and biometric prompts on Android. - Added styles for transparent activity in Android. - Updated Podfile.lock and Info.plist for necessary permissions and dependencies. - Modified SecurityDemo component to utilize new biometric authentication features.
1 parent b047027 commit 0310140

23 files changed

Lines changed: 681 additions & 75 deletions

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,33 @@ struct CustomSecureView: View {
710710

711711
## 🔧 Troubleshooting
712712

713+
<details>
714+
<summary><strong>iOS Simulator: Face ID prompt doesn’t appear</strong></summary>
715+
716+
- Ensure Simulator has biometrics enrolled: Features → Face ID → Enrolled
717+
- When the system prompt appears, complete with Features → Face ID → Matching Face (or Non‑matching Face to test failures)
718+
- If you used allowDeviceCredential on Simulator, iOS doesn’t have a passcode; we force biometrics‑only under the hood so the prompt still appears.
719+
- If you get locked out after too many failures, reset Face ID in Simulator (toggle Enrolled off/on) and retry.
720+
721+
</details>
722+
723+
<details>
724+
<summary><strong>Android: Prompt requires a foreground Activity</strong></summary>
725+
726+
- BiometricPrompt needs an active foreground Activity (FragmentActivity). Make sure you call getItem/setItem while your app is in the foreground.
727+
- If you’re launching prompts from background services, you’ll need to bring an Activity to the front or use a transparent activity approach.
728+
729+
</details>
730+
731+
<details>
732+
<summary><strong>Pods/Headers mismatch after Nitro changes</strong></summary>
733+
734+
- If you see “file not found” for generated headers, clean Pods and DerivedData:
735+
- cd ios && pod deintegrate && pod install
736+
- Clean build folder in Xcode (or delete DerivedData)
737+
738+
</details>
739+
713740
### Common Issues
714741

715742
#### ✅ Emulator & Simulator Support

SensitiveInfo.podspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Pod::Spec.new do |s|
2525
"GCC_PREPROCESSOR_DEFINITIONS" => "$(inherited) FOLLY_NO_CONFIG FOLLY_CFG_NO_COROUTINES"
2626
}
2727

28+
s.dependency 'React-Core'
2829
s.dependency 'React-jsi'
2930
s.dependency 'React-callinvoker'
3031

android/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,5 +132,8 @@ dependencies {
132132
// Biometric authentication support
133133
implementation "androidx.biometric:biometric:1.1.0"
134134
implementation "androidx.fragment:fragment-ktx:1.6.2"
135+
136+
// Material Components for theme parent (Theme.MaterialComponents.*)
137+
implementation "com.google.android.material:material:1.12.0"
135138
}
136139

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
11
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+
<application>
3+
<activity
4+
android:name=".TransparentBiometricActivity"
5+
android:exported="false"
6+
android:theme="@style/Theme.Transparent"/>
7+
</application>
28
</manifest>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.margelo.nitro.sensitiveinfo
2+
3+
import android.app.Activity
4+
import android.content.Intent
5+
import androidx.biometric.BiometricManager
6+
import com.facebook.react.bridge.*
7+
8+
class AndroidBiometricModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext), ActivityEventListener {
9+
private var pendingPromise: Promise? = null
10+
11+
override fun getName(): String = "AndroidBiometric"
12+
13+
init {
14+
reactContext.addActivityEventListener(this)
15+
}
16+
17+
@ReactMethod
18+
fun isAvailable(promise: Promise) {
19+
val mgr = BiometricManager.from(reactContext)
20+
val can = mgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)
21+
promise.resolve(can == BiometricManager.BIOMETRIC_SUCCESS)
22+
}
23+
24+
@ReactMethod
25+
fun authenticate(options: ReadableMap?, promise: Promise) {
26+
val activity = reactContext.currentActivity
27+
if (activity == null) {
28+
promise.reject("NO_ACTIVITY", "Current activity is null")
29+
return
30+
}
31+
if (pendingPromise != null) {
32+
promise.reject("IN_PROGRESS", "Another authentication is in progress")
33+
return
34+
}
35+
36+
val intent = Intent(activity, TransparentBiometricActivity::class.java)
37+
options?.let {
38+
if (it.hasKey("promptTitle")) intent.putExtra("promptTitle", it.getString("promptTitle"))
39+
if (it.hasKey("promptSubtitle")) intent.putExtra("promptSubtitle", it.getString("promptSubtitle"))
40+
if (it.hasKey("promptDescription")) intent.putExtra("promptDescription", it.getString("promptDescription"))
41+
if (it.hasKey("cancelButtonText")) intent.putExtra("cancelButtonText", it.getString("cancelButtonText"))
42+
if (it.hasKey("allowDeviceCredential")) intent.putExtra("allowDeviceCredential", it.getBoolean("allowDeviceCredential"))
43+
}
44+
45+
pendingPromise = promise
46+
try {
47+
activity.startActivityForResult(intent, REQUEST_CODE)
48+
} catch (e: Exception) {
49+
pendingPromise = null
50+
promise.reject("LAUNCH_ERROR", e)
51+
}
52+
}
53+
54+
override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
55+
if (requestCode != REQUEST_CODE) return
56+
val promise = pendingPromise ?: return
57+
pendingPromise = null
58+
59+
val success = data?.getBooleanExtra("success", false) ?: false
60+
val error = data?.getStringExtra("error")
61+
if (error != null) {
62+
promise.reject("AUTH_ERROR", error)
63+
} else {
64+
promise.resolve(success)
65+
}
66+
}
67+
68+
override fun onNewIntent(intent: Intent) {}
69+
70+
companion object {
71+
private const val REQUEST_CODE = 42421
72+
}
73+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.margelo.nitro.sensitiveinfo
2+
3+
import com.facebook.react.ReactPackage
4+
import com.facebook.react.bridge.NativeModule
5+
import com.facebook.react.bridge.ReactApplicationContext
6+
import com.facebook.react.uimanager.ViewManager
7+
8+
class AndroidBiometricPackage : ReactPackage {
9+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
10+
listOf(AndroidBiometricModule(reactContext))
11+
12+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
13+
emptyList()
14+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.margelo.nitro.sensitiveinfo
2+
3+
import android.app.Activity
4+
import android.content.Context
5+
import androidx.biometric.BiometricManager
6+
import androidx.biometric.BiometricPrompt
7+
import androidx.core.content.ContextCompat
8+
import com.facebook.react.bridge.ReactContext
9+
import com.margelo.nitro.core.Promise
10+
import androidx.fragment.app.FragmentActivity
11+
12+
class HybridBiometricPromptView(private val context: Context) : HybridBiometricPromptViewSpec() {
13+
// Props
14+
override var promptTitle: String? = null
15+
override var promptSubtitle: String? = null
16+
override var promptDescription: String? = null
17+
override var cancelButtonText: String? = null
18+
override var allowDeviceCredential: Boolean? = null
19+
20+
override val view = android.view.View(context)
21+
22+
override fun show(): Promise<Boolean> = Promise.async {
23+
val activity = (context as? ReactContext)?.currentActivity as? FragmentActivity
24+
?: throw IllegalStateException("Current Activity is null")
25+
26+
val mgr = BiometricManager.from(context)
27+
val can = mgr.canAuthenticate(
28+
if ((allowDeviceCredential ?: false))
29+
BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
30+
else
31+
BiometricManager.Authenticators.BIOMETRIC_STRONG
32+
)
33+
if (can != BiometricManager.BIOMETRIC_SUCCESS) return@async false
34+
35+
// Synchronize result back to Promise
36+
val latch = java.util.concurrent.CountDownLatch(1)
37+
var success = false
38+
var error: Exception? = null
39+
40+
val executor = ContextCompat.getMainExecutor(context)
41+
val callback = object : BiometricPrompt.AuthenticationCallback() {
42+
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
43+
error = Exception(errString.toString())
44+
latch.countDown()
45+
}
46+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
47+
success = true
48+
latch.countDown()
49+
}
50+
override fun onAuthenticationFailed() {
51+
// ignore
52+
}
53+
}
54+
55+
val prompt = BiometricPrompt(activity, executor, callback)
56+
val builder = BiometricPrompt.PromptInfo.Builder()
57+
.setTitle(promptTitle ?: "Authenticate")
58+
.setSubtitle(promptSubtitle)
59+
.setDescription(promptDescription)
60+
61+
if (allowDeviceCredential == true) {
62+
builder.setAllowedAuthenticators(
63+
BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
64+
)
65+
} else {
66+
builder.setNegativeButtonText(cancelButtonText ?: "Cancel")
67+
}
68+
69+
activity.runOnUiThread {
70+
prompt.authenticate(builder.build())
71+
}
72+
73+
latch.await()
74+
if (error != null) throw error as Exception
75+
success
76+
}
77+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.margelo.nitro.sensitiveinfo
2+
3+
import com.facebook.react.bridge.ReactApplicationContext
4+
import com.facebook.react.uimanager.ViewManager
5+
import com.margelo.nitro.sensitiveinfo.views.HybridBiometricPromptViewManager as GeneratedManager
6+
7+
/**
8+
* Thin wrapper to expose a generated manager instance to RN.
9+
* The generated manager is final, so we provide a factory function instead of subclassing it.
10+
*/
11+
class HybridBiometricPromptViewManager private constructor() {
12+
companion object {
13+
fun create(): ViewManager<*, *> {
14+
return GeneratedManager()
15+
}
16+
}
17+
}

android/src/main/java/com/margelo/nitro/sensitiveinfo/SensitiveInfo.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
package com.margelo.nitro.sensitiveinfo
22

33
import android.content.Context
4+
import android.app.Activity
45
import android.os.Build
56
import android.security.keystore.KeyGenParameterSpec
67
import android.security.keystore.KeyProperties
78
import androidx.biometric.BiometricManager
9+
import androidx.biometric.BiometricPrompt
10+
import androidx.core.content.ContextCompat
11+
import androidx.fragment.app.FragmentActivity
812
import androidx.security.crypto.EncryptedSharedPreferences
913
import androidx.security.crypto.MasterKeys
1014
import com.facebook.proguard.annotations.DoNotStrip
15+
import com.facebook.react.bridge.ReactApplicationContext
1116
import com.margelo.nitro.core.Promise
1217
import com.margelo.nitro.NitroModules
1318
import java.security.KeyStore
19+
import java.util.concurrent.CountDownLatch
20+
import java.util.concurrent.TimeUnit
1421
import javax.crypto.KeyGenerator
1522

1623
/**
@@ -130,6 +137,7 @@ class SensitiveInfo : HybridSensitiveInfoSpec() {
130137
*/
131138
@DoNotStrip
132139
override fun getItem(key: String, options: StorageOptions?): Promise<String?> = Promise.async {
140+
authenticateIfNeeded(options)
133141
val securityLevel = options?.securityLevel
134142
val prefs = getPreferencesForSecurityLevel(securityLevel)
135143
prefs.getString(key, null)
@@ -140,6 +148,7 @@ class SensitiveInfo : HybridSensitiveInfoSpec() {
140148
*/
141149
@DoNotStrip
142150
override fun setItem(key: String, value: String, options: StorageOptions?): Promise<Unit> = Promise.async {
151+
authenticateIfNeeded(options)
143152
val securityLevel = options?.securityLevel
144153
val prefs = getPreferencesForSecurityLevel(securityLevel)
145154
prefs.edit().putString(key, value).apply()
@@ -150,6 +159,7 @@ class SensitiveInfo : HybridSensitiveInfoSpec() {
150159
*/
151160
@DoNotStrip
152161
override fun removeItem(key: String, options: StorageOptions?): Promise<Unit> = Promise.async {
162+
authenticateIfNeeded(options)
153163
val securityLevel = options?.securityLevel
154164
val prefs = getPreferencesForSecurityLevel(securityLevel)
155165
prefs.edit().remove(key).apply()
@@ -160,6 +170,7 @@ class SensitiveInfo : HybridSensitiveInfoSpec() {
160170
*/
161171
@DoNotStrip
162172
override fun getAllItems(options: StorageOptions?): Promise<Map<String, String>> = Promise.async {
173+
authenticateIfNeeded(options)
163174
val securityLevel = options?.securityLevel
164175
val prefs = getPreferencesForSecurityLevel(securityLevel)
165176
prefs.all.mapValues { it.value as? String ?: "" }
@@ -170,6 +181,7 @@ class SensitiveInfo : HybridSensitiveInfoSpec() {
170181
*/
171182
@DoNotStrip
172183
override fun clear(options: StorageOptions?): Promise<Unit> = Promise.async {
184+
authenticateIfNeeded(options)
173185
val securityLevel = options?.securityLevel
174186
val prefs = getPreferencesForSecurityLevel(securityLevel)
175187
prefs.edit().clear().apply()
@@ -227,3 +239,68 @@ class SensitiveInfo : HybridSensitiveInfoSpec() {
227239
}
228240
}
229241
}
242+
243+
// --- Biometric helper ---
244+
private fun SensitiveInfo.authenticateIfNeeded(options: StorageOptions?) {
245+
if (options?.securityLevel != SecurityLevel.BIOMETRIC) return
246+
247+
val reactContext = NitroModules.applicationContext as? ReactApplicationContext
248+
?: throw IllegalStateException("ReactApplicationContext is null")
249+
val activity = reactContext.currentActivity as? FragmentActivity
250+
?: throw IllegalStateException("Current Activity is null for biometric prompt")
251+
252+
val manager = BiometricManager.from(activity)
253+
val allowCredential = options.biometricOptions?.allowDeviceCredential == true
254+
val authenticators = if (allowCredential)
255+
BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
256+
else
257+
BiometricManager.Authenticators.BIOMETRIC_STRONG
258+
259+
val can = manager.canAuthenticate(authenticators)
260+
if (can != BiometricManager.BIOMETRIC_SUCCESS) {
261+
// Skip prompting so the storage layer can apply its own fallback choice
262+
return
263+
}
264+
265+
// Set up synchronization and state
266+
val latch = CountDownLatch(1)
267+
var success = false
268+
var error: Exception? = null
269+
270+
val executor = ContextCompat.getMainExecutor(activity)
271+
activity.runOnUiThread {
272+
val callback = object : BiometricPrompt.AuthenticationCallback() {
273+
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
274+
error = Exception(errString.toString())
275+
latch.countDown()
276+
}
277+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
278+
success = true
279+
latch.countDown()
280+
}
281+
override fun onAuthenticationFailed() {
282+
// Ignored; prompt stays open until user cancels or succeeds
283+
}
284+
}
285+
286+
val prompt = BiometricPrompt(activity, executor, callback)
287+
val builder = BiometricPrompt.PromptInfo.Builder()
288+
.setTitle(options.biometricOptions?.promptTitle ?: "Authenticate")
289+
.setSubtitle(options.biometricOptions?.promptSubtitle)
290+
.setDescription(options.biometricOptions?.promptDescription)
291+
292+
if (allowCredential) {
293+
builder.setAllowedAuthenticators(authenticators)
294+
} else {
295+
builder.setNegativeButtonText(options.biometricOptions?.cancelButtonText ?: "Cancel")
296+
}
297+
298+
prompt.authenticate(builder.build())
299+
}
300+
301+
// Wait for result (with a generous timeout to avoid deadlocks)
302+
latch.await(2, TimeUnit.MINUTES)
303+
if (!success) {
304+
throw (error ?: Exception("Authentication failed"))
305+
}
306+
}

0 commit comments

Comments
 (0)