Biometric Change Detection
This feature allows you to subscribe to biometric changes on the device, such as when biometrics are enabled/disabled, enrolled/unenrolled, or when hardware availability changes.
Overview
The biometric change detection system monitors the device's biometric state and emits events when changes occur. This is useful for:
- Detecting when users add or remove biometric enrollments
- Monitoring biometric hardware availability
- Responding to biometric security changes in real-time
- Updating UI based on biometric capabilities
New Architecture Support: This implementation uses React Native's new architecture (TurboModules) with codegen-generated EventEmitters, while maintaining backward compatibility through dual event emission.
API
Functions
subscribeToBiometricChanges(callback: (event: BiometricChangeEvent) => void): EventSubscription
Subscribes to biometric change events. Note that subscribing alone does not start detection - you must call startBiometricChangeDetection() to begin monitoring.
Parameters:
callback: Function that will be called when biometric changes are detected
Returns:
EventSubscription: Subscription object that can be used to unsubscribe
Example:
const subscription = subscribeToBiometricChanges((event) => {
console.log('Biometric change:', event);
});
unsubscribeFromBiometricChanges(subscription: EventSubscription): void
Unsubscribes from biometric change events.
Parameters:
subscription: The subscription object returned fromsubscribeToBiometricChanges
Example:
unsubscribeFromBiometricChanges(subscription);
startBiometricChangeDetection(): Promise<void>
Starts monitoring for biometric changes. Must be called explicitly to begin detection.
Returns:
Promise<void>: Resolves when detection has started
Example:
await startBiometricChangeDetection();
console.log('Detection started');
stopBiometricChangeDetection(): Promise<void>
Stops monitoring for biometric changes. Useful for conserving battery and resources when detection is not needed.
Returns:
Promise<void>: Resolves when detection has stopped
Example:
await stopBiometricChangeDetection();
console.log('Detection stopped');
Types
BiometricChangeEvent
interface BiometricChangeEvent {
timestamp: number; // Unix timestamp when the change occurred
changeType: string; // Type of change (see Change Types below)
biometryType: string; // Current biometry type available
available: boolean; // Whether biometrics are currently available
enrolled: boolean; // Whether biometrics are enrolled
}
Change Types
BIOMETRIC_ENABLED: Biometrics became availableBIOMETRIC_DISABLED: Biometrics became unavailableENROLLMENT_CHANGED: Biometric enrollments were added or removedHARDWARE_UNAVAILABLE: Biometric hardware became unavailableSTATE_CHANGED: General state change
Usage Example
Complete Example with Manual Control
import React, { useCallback, useEffect, useState } from 'react';
import { View, Text, TouchableOpacity, Alert } from 'react-native';
import {
subscribeToBiometricChanges,
unsubscribeFromBiometricChanges,
startBiometricChangeDetection,
stopBiometricChangeDetection,
} from '@sbaiahmed1/react-native-biometrics';
import type { BiometricChangeEvent } from '@sbaiahmed1/react-native-biometrics';
import type { EventSubscription } from 'react-native';
const MyComponent = () => {
const [subscription, setSubscription] = useState<EventSubscription | null>(null);
const [isDetecting, setIsDetecting] = useState(false);
const handleBiometricChange = useCallback((event: BiometricChangeEvent) => {
console.log('Biometric change detected:', {
changeType: event.changeType,
biometryType: event.biometryType,
available: event.available,
enrolled: event.enrolled,
timestamp: new Date(event.timestamp).toISOString(),
});
// Handle different change types
switch (event.changeType) {
case 'BIOMETRIC_ENABLED':
Alert.alert('Biometrics Enabled', 'Biometric authentication is now available');
break;
case 'BIOMETRIC_DISABLED':
Alert.alert('Biometrics Disabled', 'Biometric authentication is no longer available');
break;
case 'ENROLLMENT_CHANGED':
Alert.alert('Enrollment Changed', 'Biometric enrollments have been modified');
break;
case 'STATE_CHANGED':
console.log('Biometric state changed');
break;
}
}, []);
useEffect(() => {
// Subscribe to events on mount
const sub = subscribeToBiometricChanges(handleBiometricChange);
setSubscription(sub);
// Cleanup on unmount
return () => {
if (sub) {
unsubscribeFromBiometricChanges(sub);
}
// Stop detection when component unmounts
stopBiometricChangeDetection();
};
}, [handleBiometricChange]);
const handleStartDetection = async () => {
try {
await startBiometricChangeDetection();
setIsDetecting(true);
console.log('Detection started');
} catch (error) {
console.error('Failed to start detection:', error);
}
};
const handleStopDetection = async () => {
try {
await stopBiometricChangeDetection();
setIsDetecting(false);
console.log('Detection stopped');
} catch (error) {
console.error('Failed to stop detection:', error);
}
};
return (
<View>
<Text>Biometric Change Detection</Text>
<Text>Status: {isDetecting ? 'Detecting' : 'Stopped'}</Text>
<TouchableOpacity onPress={handleStartDetection}>
<Text>Start Detection</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleStopDetection}>
<Text>Stop Detection</Text>
</TouchableOpacity>
</View>
);
};
Simplified Auto-Start Example
If you want detection to start automatically when your component mounts:
useEffect(() => {
// Subscribe and start detection
const sub = subscribeToBiometricChanges(handleBiometricChange);
startBiometricChangeDetection();
// Cleanup on unmount
return () => {
unsubscribeFromBiometricChanges(sub);
stopBiometricChangeDetection();
};
}, [handleBiometricChange]);
Platform-Specific Behavior
iOS
iOS has a sophisticated biometric change detection implementation using Apple's Local Authentication framework:
Core Technologies:
- Uses
LAContextto check biometric availability - Monitors
UIApplication.didBecomeActiveNotificationto detect when app returns to foreground - Tracks
evaluatedPolicyDomainState- Apple's built-in mechanism for detecting enrollment changes - Automatically starts/stops detection when event listeners are added/removed
Enhanced State Detection: The iOS implementation tracks multiple state indicators:
- Availability (
available): Whether biometrics can be used - Biometry Type (
biometryType): Face ID, Touch ID, or Optic ID (iOS 17+) - Domain State (
domainState): Cryptographic hash that changes when enrollments change - Enrolled Count (
enrolledCount): Whether any biometrics are enrolled (1 or 0)
Change Type Detection:
BIOMETRIC_ENABLED: Device transitions from no biometrics to having biometrics availableBIOMETRIC_DISABLED: Device transitions from biometrics available to unavailableENROLLMENT_CHANGED: Domain state changes (fingerprints/faces added or removed)HARDWARE_UNAVAILABLE: Biometry type changes (hardware replaced)
Advantages over Android:
- Better enrollment detection: iOS's
evaluatedPolicyDomainStateis a cryptographic hash that changes whenever ANY enrollment is added/removed, making it much more reliable than Android's BiometricManager - Event-driven: Both platforms now use lifecycle-based event detection for battery efficiency
- Auto-lifecycle: Automatically starts when listeners subscribe and stops when they unsubscribe
How It Works:
When you call subscribeToBiometricChanges(), the iOS native module automatically:
- Captures the initial biometric state
- Sets up a notification observer for
UIApplication.didBecomeActiveNotification - Checks for changes every time the app becomes active
- Emits events when domain state or availability changes
Android
Our Android implementation uses advanced state tracking to detect biometric changes:
Core Technologies:
- Uses
BiometricManagerAPI to monitor biometric state - Monitors
LifecycleEventListenerto detect when app returns to foreground (similar to iOS) - Checks biometric state when app resumes via
onHostResume()callback - Tracks biometric-protected keys in Android KeyStore as a proxy for enrollment changes
- Monitors hardware availability, enrollment status, and status codes
Enhanced State Detection: The implementation tracks multiple state indicators to maximize change detection:
- Availability (
available): Whether biometrics can be used (BIOMETRIC_SUCCESS) - Enrollment (
enrolled): Whether any biometrics are enrolled - Key Count (
keyCount): Number of biometric-protected keys in KeyStore - Status Code (
statusCode): Raw BiometricManager status code
Change Type Detection:
BIOMETRIC_ENABLED: Device transitions from no biometrics to having biometrics availableBIOMETRIC_DISABLED: Device transitions from biometrics available to unavailableHARDWARE_UNAVAILABLE: Biometric hardware became unavailableENROLLMENT_CHANGED: KeyStore biometric key count changesSTATE_CHANGED: Other state changes (e.g. BiometricManager status code changes)
Platform Limitations:
Android's BiometricManager.canAuthenticate() has limitations:
- Returns same status (BIOMETRIC_SUCCESS) whether device has 1 or 5 fingerprints enrolled
- Cannot detect when additional fingerprints are added to an already-enrolled device
- Can only reliably detect:
- First enrollment (None → Enrolled)
- Complete removal of all enrollments (Enrolled → None)
- Biometric enable/disable state changes
enrolledis inferred ascanAuthenticate() != BIOMETRIC_ERROR_NONE_ENROLLED, so it can readtruewhen no biometric hardware exists or the hardware is temporarily unavailable — onlyBIOMETRIC_ERROR_NONE_ENROLLEDproves non-enrollment
To work around these limitations, we track additional state like KeyStore key counts, but some enrollment changes may still not be detectable.
Best Practices
-
Always unsubscribe: Make sure to unsubscribe when your component unmounts to prevent memory leaks.
-
Platform-Specific Start/Stop:
- Android: Must explicitly call
startBiometricChangeDetection()to begin monitoring - iOS: Automatically starts when you subscribe (calls to start/stop on iOS are optional for consistency)
- For cross-platform apps, calling
startBiometricChangeDetection()after subscribing works on both platforms
- Android: Must explicitly call
-
Check current state on mount: When your component mounts, you may want to call
isSensorAvailable()to get the current biometric state, as the change listener only fires on actual changes. -
Debounce rapid changes: On some devices, multiple events might fire in quick succession. Consider debouncing your event handler if needed.
-
User experience: Consider showing user-friendly messages when biometric changes are detected, especially for security-related changes.
-
Error handling: Wrap your event handler in try-catch blocks to handle any unexpected errors gracefully.
-
Battery conservation: Both platforms use lifecycle-based detection (event-driven, not polling) for efficient battery usage
Example Use Cases
Security Monitoring
const handleBiometricChange = (event: BiometricChangeEvent) => {
if (event.changeType === 'BIOMETRIC_DISABLED') {
// Biometrics were disabled - might want to require re-authentication
showSecurityAlert('Biometric authentication has been disabled');
}
};
UI Updates
const [biometricAvailable, setBiometricAvailable] = useState(false);
const handleBiometricChange = (event: BiometricChangeEvent) => {
setBiometricAvailable(event.available && event.enrolled);
};
// In your render method
{biometricAvailable && (
<TouchableOpacity onPress={authenticateWithBiometrics}>
<Text>Use Biometric Authentication</Text>
</TouchableOpacity>
)}
Analytics
const handleBiometricChange = (event: BiometricChangeEvent) => {
// Track biometric usage patterns
analytics.track('biometric_change', {
changeType: event.changeType,
biometryType: event.biometryType,
available: event.available,
enrolled: event.enrolled,
});
};
Troubleshooting
Events Not Firing
- Ensure you're properly subscribing to events
- Check that the subscription is not being garbage collected
- Verify that biometric settings are actually changing on the device
Multiple Events
- Some devices may fire multiple events for a single change
- Consider debouncing or deduplicating events based on timestamp
- Use the
changeTypefield to filter relevant events
Performance
- Both platforms use lifecycle-based detection (checking on app resume) - no continuous polling needed
- Unsubscribe when not needed to save resources
- Avoid heavy processing in the event handler
Migration from Manual Polling
If you were previously manually checking biometric status, you can replace that with lifecycle-based event detection:
// Old approach - manual polling
setInterval(async () => {
const result = await isSensorAvailable();
// Update UI based on result
}, 5000);
// New approach - lifecycle-based detection
subscribeToBiometricChanges((event) => {
// Update UI based on event
// Automatically checked when app resumes
});
startBiometricChangeDetection();
This provides better performance, battery life, and user experience by checking only when the app comes to the foreground.
Technical Implementation
Architecture Overview
This biometric change detection feature is built using React Native's new architecture (TurboModules + Fabric) with backward compatibility for the old architecture.
Key Components
1. TypeScript Codegen Spec (NativeReactNativeBiometrics.ts):
export interface Spec extends TurboModule {
// Event emitter using codegen-generated EventEmitter type
readonly onBiometricChange: EventEmitter<BiometricChangeEvent>;
// Manual control methods
startBiometricChangeDetection(): Promise<void>;
stopBiometricChangeDetection(): Promise<void>;
}
2. Native Android Module (ReactNativeBiometricsModule.kt):
- Extends
NativeReactNativeBiometricsSpec(generated by codegen) - Implements TurboModule interface for new architecture
- Provides dual event emission for cross-architecture compatibility
3. Shared Implementation (ReactNativeBiometricsSharedImpl.kt):
- Contains platform-agnostic biometric detection logic
- Manages state tracking and change detection
- Handles lifecycle events (onResume, onPause, onDestroy)
4. TypeScript API (index.tsx):
- Exposes user-friendly functions
- Wraps native event emitter for compatibility
- Provides TypeScript type safety
Dual Event Emission Pattern
One of the critical challenges was making events work with both the new and old React Native architectures. Here's how we solved it:
The Problem:
- New architecture uses codegen-generated
emitOnBiometricChange()method - Old architecture/NativeEventEmitter expects events via
DeviceEventManagerModule - Using only one approach breaks compatibility with the other
The Solution - Dual Emission:
sharedImpl.setBiometricChangeListener { event ->
// Method 1: New architecture - codegen EventEmitter
try {
emitOnBiometricChange(event)
Log.d(TAG, "Emitted via new arch method")
} catch (e: Exception) {
Log.e(TAG, "Failed to emit via new arch: ${e.message}")
}
// Method 2: Old architecture - DeviceEventManagerModule
try {
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
?.emit("onBiometricChange", event)
Log.d(TAG, "Emitted via DeviceEventManagerModule")
} catch (e: Exception) {
Log.e(TAG, "Failed to emit via DeviceEventManagerModule: ${e.message}")
}
}
This ensures events are received regardless of which architecture the app uses.
WritableMap Consumption Pattern
React Native's WritableMap has a critical limitation: it can only be read once. After reading a value, the map is "consumed" and throws errors on subsequent reads.
The Problem:
// This crashes!
val state = getCurrentBiometricState()
val available = state.getBoolean("available") // First read - OK
// ... later ...
val previousAvailable = state.getBoolean("available") // Second read - CRASH!
// Error: "Map already consumed"
The Solution:
- Extract all values immediately before any consumption:
val currentAvailable = currentState.getBoolean("available")
val currentEnrolled = currentState.getBoolean("enrolled")
val currentKeyCount = currentState.getInt("keyCount")
val currentStatusCode = currentState.getInt("statusCode")
- Use a copy helper for storing state:
private fun copyMap(original: WritableMap): WritableMap {
val copy = Arguments.createMap()
copy.merge(original) // Deep copy
return copy
}
// Store a copy, not the original
lastBiometricState = copyMap(currentState)
Enhanced Android State Tracking
To work around Android BiometricManager limitations, we implemented multi-factor state tracking:
private fun getCurrentBiometricState(): WritableMap {
val state = Arguments.createMap()
val biometricManager = BiometricManager.from(reactContext)
val canAuthenticateResult = biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_WEAK
)
// Basic state
val available = canAuthenticateResult == BiometricManager.BIOMETRIC_SUCCESS
val enrolled = canAuthenticateResult != BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
state.putBoolean("available", available)
state.putBoolean("enrolled", enrolled)
state.putInt("statusCode", canAuthenticateResult)
state.putLong("timestamp", System.currentTimeMillis())
// Enhanced tracking: Count biometric-protected keys
var biometricKeyCount = 0
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val aliases = keyStore.aliases()
while (aliases.hasMoreElements()) {
val alias = aliases.nextElement()
try {
val entry = keyStore.getEntry(alias, null)
if (entry is KeyStore.PrivateKeyEntry) {
biometricKeyCount++
}
} catch (e: Exception) {
// Key might be inaccessible
}
}
state.putInt("keyCount", biometricKeyCount)
return state
}
This multi-factor approach detects more changes than using BiometricManager alone.
Manual Start/Stop Control
Detection is not auto-started to give developers explicit control:
Why Manual Control?
- Battery Conservation: Apps can stop detection when not needed
- Resource Management: Avoid unnecessary lifecycle monitoring when app is backgrounded
- User Privacy: Only monitor when user expects it
- Flexibility: Developers choose when to start/stop
Lifecycle Integration:
// In init block - set up listener but don't auto-start
init {
sharedImpl.setBiometricChangeListener { event ->
// Dual emission here...
}
// Note: Detection NOT started here - must call startBiometricChangeDetection()
}
// Cleanup on module destruction
override fun invalidate() {
sharedImpl.stopBiometricChangeDetection()
super.invalidate()
}
Event Flow Diagram
┌─────────────────────────────────────────────────────────────────┐
│ JavaScript Layer │
├─────────────────────────────────────────────────────────────────┤
│ 1. subscribeToBiometricChanges(callback) │
│ 2. startBiometricChangeDetection() │
│ │
│ [Receives events via NativeEventEmitter] │
└──────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Native Bridge (TurboModule) │
├─────────────────────────────────────────────────────────────────┤
│ ReactNativeBiometricsModule.kt │
│ - Dual event emission: │
│ • emitOnBiometricChange() [New Arch] │
│ • DeviceEventManagerModule.emit() [Old Arch] │
└──────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Shared Implementation │
├─────────────────────────────────────────────────────────────────┤
│ ReactNativeBiometricsSharedImpl.kt │
│ - Lifecycle event listener (onHostResume/onHostPause) │
│ - State comparison (available, enrolled, keyCount, statusCode) │
│ - Change type determination │
│ - Checks biometric state when app resumes │
└──────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Android System APIs │
├─────────────────────────────────────────────────────────────────┤
│ - BiometricManager.canAuthenticate() │
│ - KeyStore (AndroidKeyStore) │
│ - Activity Lifecycle Events │
└─────────────────────────────────────────────────────────────────┘
Debugging and Development
During development, we used extensive debugging to diagnose event flow:
Debug Strategies:
- Toast notifications for module initialization
- Alert dialogs for biometric state changes
- Console logging at every critical point
- Dual emission logging to verify both paths work
These can be removed or disabled in production builds.
Known Issues and Workarounds
Issue 1: Android Cannot Detect Additional Enrollments
- Symptom: Adding 2nd, 3rd fingerprint not detected
- Cause: BiometricManager limitation (same status for 1 or 5 fingerprints)
- Workaround: Track KeyStore key count, but still limited
- Status: Platform limitation, no complete solution
Issue 2: NativeEventEmitter Warning
- Symptom: "addListener method required" warning
- Cause: TurboModule EventEmitter not fully compatible with NativeEventEmitter
- Solution: Dual emission pattern bridges the gap
Issue 3: WritableMap Consumption
- Symptom: "Map already consumed" crashes
- Cause: React Native WritableMap design
- Solution: Extract values immediately, use copyMap helper
Performance Considerations
Lifecycle-Based Detection:
- Checks biometric state only when app resumes (not continuously)
- Event-driven approach is battery efficient
- No background processing while app is inactive
- Only active when detection is started
Memory Management:
- Proper cleanup in
invalidate()and component unmount - Lifecycle listeners removed when detection stopped
- Lifecycle listeners removed on destroy
CPU Usage:
- Minimal: Only checking BiometricManager status on app resume
- KeyStore enumeration lightweight
- No continuous background processing
Testing Recommendations
Manual Testing:
- Start detection
- Go to device Settings → Biometrics
- Add/remove fingerprints or face data
- Return to app (bring to foreground)
- Verify events are received when app resumes
Automated Testing:
- Mock BiometricManager responses
- Test state change detection logic
- Verify event emission
- Test lifecycle cleanup
Future Improvements
Potential enhancements for future versions:
- Unified Start/Stop API: Add explicit
startBiometricChangeDetection()andstopBiometricChangeDetection()methods to iOS for API consistency with Android (currently iOS auto-starts/stops) - Broadcast Receiver (Android): Use Android system broadcasts for even more immediate detection (if available)
- Event Batching: Deduplicate rapid consecutive events on both platforms
- Detailed Change Info: Include which specific biometric type changed (e.g., "fingerprint 2 added")
- Background Detection (iOS): Optionally detect changes even when app is in background
Contributing
If you'd like to improve this feature:
- See
CONTRIBUTING.mdfor guidelines - Android code:
android/src/main/java/com/sbaiahmed1/reactnativebiometrics/ - TypeScript:
src/index.tsxandsrc/NativeReactNativeBiometrics.ts - Example:
example/BiometricChangeExample.tsx