Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -599,9 +599,10 @@ Result from isAvailable() method indicating biometric authentication availabilit

#### IsAvailableOptions

| Prop | Type | Description |
| ----------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`useFallback`** | <code>boolean</code> | Whether passcode or device credentials should count toward biometric availability when no biometric is enrolled or available. - On iOS, this affects both `isAvailable()` and `verifyIdentity()`. - On Android, this is honored by `isAvailable()` only — the native check computes `fallbackAvailable = useFallback && deviceIsSecure` and reports availability accordingly. The `verifyIdentity()` flow ignores this option on Android due to BiometricPrompt API constraints (DEVICE_CREDENTIAL authenticator and negative button are mutually exclusive); use <a href="#biometricoptions">`BiometricOptions.useFallback`</a> (iOS-only) to control the auth-dialog fallback there. |
| Prop | Type | Description | Default | Since |
| -------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----- |
| **`useFallback`** | <code>boolean</code> | Whether passcode or device credentials should count toward biometric availability when no biometric is enrolled or available. - On iOS, this affects both `isAvailable()` and `verifyIdentity()`. - On Android, this is honored by `isAvailable()` only — the native check computes `fallbackAvailable = useFallback && deviceIsSecure` and reports availability accordingly. The `verifyIdentity()` flow ignores this option on Android due to BiometricPrompt API constraints (DEVICE_CREDENTIAL authenticator and negative button are mutually exclusive); use <a href="#biometricoptions">`BiometricOptions.useFallback`</a> (iOS-only) to control the auth-dialog fallback there. | | |
| **`preferMultipleBiometryType`** | <code>boolean</code> | Android only. Report <a href="#biometrytype">`BiometryType.MULTIPLE`</a> whenever a fingerprint is enrolled and the device also advertises a face or iris sensor, even when that second modality cannot be confirmed as enrolled. You normally do not need this. On Android 12 and newer a face enrolled next to a fingerprint is usually detected and reported as <a href="#biometrytype">`BiometryType.MULTIPLE`</a> on its own. Detection is best-effort, and is not possible on Android 11 and older, for the rare Class 3 (Strong) face sensor, or on a vendor build whose biometric labels name only the preferred modality. Undetected devices report `BiometryType.FINGERPRINT`, because most devices advertise a face sensor the user never enrolled. Set this to `true` if your UI would rather over-report `MULTIPLE` there. `verifyIdentity()` is unaffected and still offers every enrolled biometric the device supports; this only changes the reported type. The most recent value is reused for `biometryChange` events. | <code>false</code> | 8.6.5 |


#### PluginListenerHandle
Expand Down
133 changes: 121 additions & 12 deletions android/src/main/java/ee/forgr/biometric/NativeBiometric.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.security.keystore.StrongBoxUnavailableException;
import android.text.TextUtils;
import android.util.Base64;
import androidx.activity.result.ActivityResult;
import androidx.annotation.RequiresApi;
import androidx.biometric.BiometricManager;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
Expand Down Expand Up @@ -84,19 +86,25 @@ public class NativeBiometric extends Plugin {

private SharedPreferences encryptedSharedPreferences;

/**
* Last value passed to {@code isAvailable()}, reused so the {@code biometryChange} event
* reports the same biometry type the app asked for.
*/
private boolean preferMultipleBiometryType = false;

@Override
protected void handleOnResume() {
super.handleOnResume();
// Notify listeners when app resumes from background
JSObject result = checkBiometryAvailability(false);
JSObject result = checkBiometryAvailability(false, this.preferMultipleBiometryType);
notifyListeners("biometryChange", result);
}

/**
* Check biometry availability and return result as JSObject.
* This is a helper method used by both isAvailable() and handleOnResume().
*/
private JSObject checkBiometryAvailability(boolean useFallback) {
private JSObject checkBiometryAvailability(boolean useFallback, boolean preferMultipleBiometryType) {
JSObject ret = new JSObject();

BiometricManager biometricManager = BiometricManager.from(getContext());
Expand All @@ -116,7 +124,7 @@ private JSObject checkBiometryAvailability(boolean useFallback) {
boolean fallbackAvailable = useFallback && deviceIsSecure;

// Determine biometry type
int biometryType = detectBiometryType(biometricManager);
int biometryType = detectBiometryType(hasStrongBiometric || hasWeakBiometric, preferMultipleBiometryType);
ret.put("biometryType", biometryType);

// Device is secure if it has PIN/pattern/password
Expand Down Expand Up @@ -162,7 +170,8 @@ private JSObject checkBiometryAvailability(boolean useFallback) {
@PluginMethod
public void isAvailable(PluginCall call) {
boolean useFallback = Boolean.TRUE.equals(call.getBoolean("useFallback", false));
JSObject result = checkBiometryAvailability(useFallback);
this.preferMultipleBiometryType = Boolean.TRUE.equals(call.getBoolean("preferMultipleBiometryType", false));
JSObject result = checkBiometryAvailability(useFallback, this.preferMultipleBiometryType);
call.resolve(result);
}

Expand All @@ -172,24 +181,124 @@ public void isAvailable(PluginCall call) {
* so we check for hardware features. This is informational only - always use
* isAvailable for logic decisions as hardware presence doesn't guarantee availability.
*/
private int detectBiometryType(BiometricManager biometricManager) {
private int detectBiometryType(boolean anyBiometricEnrolled, boolean preferMultipleBiometryType) {
PackageManager pm = getContext().getPackageManager();

boolean hasFingerprint = pm.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT);
boolean hasFace = pm.hasSystemFeature(PackageManager.FEATURE_FACE);
boolean hasIris = pm.hasSystemFeature(PackageManager.FEATURE_IRIS);

boolean fingerprintEnrolled = hasFingerprint && isFingerprintEnrolled();
boolean otherBiometricEnrolled;
if (fingerprintEnrolled) {
otherBiometricEnrolled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && isWeakBiometricEnrolledBesidesFingerprint();
} else {
// BiometricManager reports that some biometric is enrolled, but not which one. With no
// fingerprint enrolled on a device advertising a face or iris sensor, the enrolled
// biometric can only be the face or iris one.
otherBiometricEnrolled = anyBiometricEnrolled && (hasFace || hasIris);
}

return resolveBiometryType(
hasFingerprint,
hasFace,
hasIris,
fingerprintEnrolled,
otherBiometricEnrolled,
this.deviceHasCredentials(),
preferMultipleBiometryType
);
}

/**
* Whether a Class 2 (Weak) biometric — in practice face unlock — is enrolled next to the
* fingerprint. Android exposes no per-modality enrollment API, but since API 31 the labels
* {@code BiometricManager.getStrings()} produces are built from the sensors that are actually
* enrolled and strong enough for the requested class: one enrolled modality yields its own
* label, several yield a generic one.
* <p>
* Asking for both classes therefore answers the question without parsing anything: an enrolled
* fingerprint is Class 3, so the Class 3 and Class 2 labels can only differ when a weaker
* modality is enrolled on top of it. Comparing the two labels to each other rather than to a
* hardcoded string keeps this locale-independent.
* <p>
* The label is documented as free to name the user's preferred modality when several qualify,
* which AOSP does not do but a vendor build may. That can only hide a second modality, never
* invent one, so the failure mode is reporting FINGERPRINT as before rather than a wrong
* MULTIPLE, and {@code preferMultipleBiometryType} remains the way out.
* <p>
* Only meaningful when a fingerprint is enrolled; the caller guarantees that.
*/
@RequiresApi(Build.VERSION_CODES.S)
private boolean isWeakBiometricEnrolledBesidesFingerprint() {
try {
android.hardware.biometrics.BiometricManager manager = getContext().getSystemService(
android.hardware.biometrics.BiometricManager.class
);
if (manager == null) {
return false;
}
CharSequence strongLabel = manager
.getStrings(android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_STRONG)
.getButtonLabel();
CharSequence weakLabel = manager
.getStrings(android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_WEAK)
.getButtonLabel();
return strongLabel != null && weakLabel != null && !TextUtils.equals(strongLabel, weakLabel);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (Exception ignored) {
return false;
}
}

/**
* Maps hardware features plus the enrollment state we can observe to a biometry type.
* <p>
* Advertised hardware is not enrollment: many devices report {@code FEATURE_FACE} even when the
* user never enrolled a face, so counting hardware alone reported MULTIPLE to fingerprint-only
* users (issue #49), while returning FINGERPRINT for every fingerprint hid the second modality
* from users who enrolled both (issue #110). Deciding on enrolled modalities settles both: one
* enrolled modality reports itself, two report MULTIPLE.
* <p>
* {@code otherBiometricEnrolled} is best-effort. Where it cannot be established — Android 11 and
* older, or a Class 3 face sensor, which is indistinguishable from a lone fingerprint — the
* device falls back to the #49 reading, and {@code preferMultipleBiometryType} lets an app that
* covers several biometrics ask for MULTIPLE instead.
*/
static int resolveBiometryType(
boolean hasFingerprint,
boolean hasFace,
boolean hasIris,
boolean fingerprintEnrolled,
boolean otherBiometricEnrolled,
boolean deviceHasCredentials,
boolean preferMultipleBiometryType
) {
// Fingerprint plus a face or iris enrolled next to it.
if (fingerprintEnrolled && otherBiometricEnrolled) {
return MULTIPLE;
}

// Only the face or iris sensor is enrolled, whatever else the device advertises. A device
// advertising both cannot tell us which of the two it is, so it stays MULTIPLE.
if (otherBiometricEnrolled && !(hasFace && hasIris)) {
if (hasFace) {
return FACE_AUTHENTICATION;
} else if (hasIris) {
return IRIS_AUTHENTICATION;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Prefer FINGERPRINT when enrolled, even on devices advertising other biometric sensors,
// unless the app asked for the undetectable case to be reported as MULTIPLE.
if (fingerprintEnrolled && !preferMultipleBiometryType) {
return FINGERPRINT;
}

int typeCount = 0;
if (hasFingerprint) typeCount++;
if (hasFace) typeCount++;
if (hasIris) typeCount++;

// Prefer FINGERPRINT when enrolled, even on devices advertising multiple biometric sensors.
// This avoids returning MULTIPLE in common cases where only fingerprint is actually enabled.
if (hasFingerprint && isFingerprintEnrolled()) {
return FINGERPRINT;
}

if (typeCount > 1) {
return MULTIPLE; // Multiple biometry types available
} else if (hasFingerprint) {
Expand All @@ -202,7 +311,7 @@ private int detectBiometryType(BiometricManager biometricManager) {

// If no biometric sensors are available but device has credentials (PIN/pattern/password)
// return DEVICE_CREDENTIAL type
if (this.deviceHasCredentials()) {
if (deviceHasCredentials) {
return DEVICE_CREDENTIAL;
}

Expand Down
Loading