FingerprintManager error codes documented at - * developer.android.com; - * the constants missing from the compile-time android.jar are inlined below.
- */ +/// Android backing for [Biometrics]. Mirrors the dual-path behaviour of the +/// historical `FingerprintScanner` cn1lib but completes per-call +/// [AsyncResource] instances instead of a shared static callback. +/// +/// Neither Android biometric API can be named from this file. `BiometricPrompt` +/// is newer than the `android.jar` the port jar compiles against, and +/// `FingerprintManager` was removed in API 37, so naming it broke every +/// generated application built against a 37 platform (issue #5701). Both live +/// behind [BiometricBackend], one implementation per package, resolved once by +/// [AndroidBiometrics#backend]; the legacy one reaches its API through +/// `FingerprintManagerCompat` so that it keeps working on an API 23-28 device +/// no matter which platform the application was compiled against. public final class AndroidBiometrics extends Biometrics { - // FingerprintManager constants not in the cn1-binaries android.jar. - private static final int FINGERPRINT_ERROR_NO_FINGERPRINTS = 11; - private static final int FINGERPRINT_ERROR_HW_NOT_PRESENT = 12; + // Android biometric error codes. BiometricPrompt and FingerprintManager + // number these identically -- 1 through 12 are the same constants under two + // names in AOSP -- which is why one mapping serves both backends. Inlined + // because neither class can be named here, and because the two above 12 + // exist on androidx's BiometricPrompt rather than the framework's. + private static final int ERROR_HW_UNAVAILABLE = 1; + private static final int ERROR_UNABLE_TO_PROCESS = 2; + private static final int ERROR_CANCELED = 5; + private static final int ERROR_LOCKOUT = 7; + private static final int ERROR_LOCKOUT_PERMANENT = 9; + private static final int ERROR_USER_CANCELED = 10; + private static final int ERROR_NO_BIOMETRICS = 11; + private static final int ERROR_HW_NOT_PRESENT = 12; + private static final int ERROR_NEGATIVE_BUTTON = 13; + private static final int ERROR_NO_DEVICE_CREDENTIAL = 14; // Probe key tying biometric success to a real KeyStore unlock so the // success callback cannot be bypassed by app hooking tools (Frida etc.). @@ -76,16 +86,11 @@ public final class AndroidBiometrics extends Biometrics { private static final String ANDROID_KEY_STORE = "AndroidKeyStore"; private static final byte[] PROBE_PLAINTEXT = new byte[]{0x42}; - // BiometricPrompt error codes (API 28+) -- values are stable per AOSP. - static final int BIOMETRIC_ERROR_HW_UNAVAILABLE = 1; - static final int BIOMETRIC_ERROR_HW_NOT_PRESENT = 12; - static final int BIOMETRIC_ERROR_LOCKOUT = 7; - static final int BIOMETRIC_ERROR_LOCKOUT_PERMANENT = 9; - static final int BIOMETRIC_ERROR_NO_BIOMETRICS = 11; - static final int BIOMETRIC_ERROR_USER_CANCELED = 10; - static final int BIOMETRIC_ERROR_NEGATIVE_BUTTON = 13; - static final int BIOMETRIC_ERROR_CANCELED = 5; - static final int BIOMETRIC_ERROR_NO_DEVICE_CREDENTIAL = 14; + /// The backend this device and this build have, or `null` when there is + /// none. Resolved once at class initialisation: the answer cannot change + /// while the process lives, and a lazily initialised static would be a + /// SpotBugs finding in a tree that gates on zero of them. + private static final BiometricBackend BACKEND = resolveBackend(); private CancellationSignal cancellationSignal; private AsyncResourceOnly invoked from code paths guarded by - * {@code Build.VERSION.SDK_INT >= 29} in {@link AndroidBiometrics} and - * {@link AndroidSecureStorage}; on older devices the reflection class is - * never loaded.
- */ -final class BiometricsApi29 { - - interface AuthCallback { - void onSuccess(); - - void onError(int errorCode, String errString); - } - - interface CipherAuthCallback { - /** Invoked with the authenticated {@code javax.crypto.Cipher}. */ - void onSuccess(Object cipher); - - void onError(int errorCode, String errString); - } - - private BiometricsApi29() { - } - - /** {@code BiometricManager.canAuthenticate() == BIOMETRIC_SUCCESS}. */ - static boolean canAuthenticate(Activity act) { - try { - Object bm = act.getSystemService("biometric"); - if (bm == null) { - return false; - } - Object result = bm.getClass().getMethod("canAuthenticate").invoke(bm); - return ((Integer) result).intValue() == 0; // BIOMETRIC_SUCCESS == 0 - } catch (Throwable t) { - Log.e(t); - return false; - } - } - - /** Builds + shows a BiometricPrompt for plain authentication (no crypto). */ - static void authenticate(Activity act, String title, String subtitle, - String description, String negative, - CancellationSignal cs, AuthCallback cb) { - try { - Object prompt = buildPrompt(act, title, subtitle, description, negative, cb, null); - Class> promptCls = Class.forName("android.hardware.biometrics.BiometricPrompt"); - Class> authCbCls = Class.forName("android.hardware.biometrics.BiometricPrompt$AuthenticationCallback"); - Executor exec = mainExecutor(act); - Method authM = promptCls.getMethod("authenticate", CancellationSignal.class, - Executor.class, authCbCls); - authM.invoke(prompt, cs, exec, makeAuthProxy(authCbCls, cb, null)); - } catch (Throwable t) { - Log.e(t); - cb.onError(AndroidBiometrics.BIOMETRIC_ERROR_HW_UNAVAILABLE, - "Failed to invoke BiometricPrompt: " + t.getMessage()); - } - } - - /** - * Builds + shows a BiometricPrompt that wraps a CryptoObject around the - * supplied {@code javax.crypto.Cipher}; on success the same cipher - * (now authenticated) is passed to {@code cb.onSuccess}. - */ - static void authenticateWithCipher(Activity act, String title, String subtitle, - String description, String negative, - Object cipher, CancellationSignal cs, - CipherAuthCallback cb) { - try { - Object prompt = buildPrompt(act, title, subtitle, description, negative, - null, cb); - Class> promptCls = Class.forName("android.hardware.biometrics.BiometricPrompt"); - Class> cryptoCls = Class.forName("android.hardware.biometrics.BiometricPrompt$CryptoObject"); - Class> authCbCls = Class.forName("android.hardware.biometrics.BiometricPrompt$AuthenticationCallback"); - Constructor> cryptoCtor = cryptoCls.getConstructor(Class.forName("javax.crypto.Cipher")); - Object crypto = cryptoCtor.newInstance(cipher); - Executor exec = mainExecutor(act); - Method authM = promptCls.getMethod("authenticate", cryptoCls, - CancellationSignal.class, Executor.class, authCbCls); - authM.invoke(prompt, crypto, cs, exec, makeAuthProxy(authCbCls, null, cb)); - } catch (Throwable t) { - Log.e(t); - cb.onError(AndroidBiometrics.BIOMETRIC_ERROR_HW_UNAVAILABLE, - "Failed to invoke BiometricPrompt with cipher: " + t.getMessage()); - } - } - - private static Object buildPrompt(final Activity act, String title, String subtitle, - String description, String negative, - final AuthCallback acb, final CipherAuthCallback ccb) throws Exception { - Class> builderCls = Class.forName("android.hardware.biometrics.BiometricPrompt$Builder"); - Object builder = builderCls.getConstructor(Context.class).newInstance(act); - builderCls.getMethod("setTitle", CharSequence.class).invoke(builder, title); - if (subtitle != null) { - builderCls.getMethod("setSubtitle", CharSequence.class).invoke(builder, subtitle); - } - if (description != null) { - builderCls.getMethod("setDescription", CharSequence.class).invoke(builder, description); - } - Executor exec = mainExecutor(act); - DialogInterface.OnClickListener neg = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface d, int which) { - if (acb != null) { - acb.onError(AndroidBiometrics.BIOMETRIC_ERROR_USER_CANCELED, "Cancelled"); - } else if (ccb != null) { - ccb.onError(AndroidBiometrics.BIOMETRIC_ERROR_USER_CANCELED, "Cancelled"); - } - } - }; - builderCls.getMethod("setNegativeButton", CharSequence.class, Executor.class, - DialogInterface.OnClickListener.class).invoke(builder, negative, exec, neg); - return builderCls.getMethod("build").invoke(builder); - } - - private static Object makeAuthProxy(Class> authCbCls, final AuthCallback acb, - final CipherAuthCallback ccb) { - InvocationHandler handler = new InvocationHandler() { - @Override - public Object invoke(Object proxy, Method method, Object[] args) { - String name = method.getName(); - try { - if ("onAuthenticationSucceeded".equals(name)) { - if (ccb != null) { - // BiometricPrompt$AuthenticationResult.getCryptoObject().getCipher() - Object ar = args[0]; - Object crypto = ar.getClass().getMethod("getCryptoObject").invoke(ar); - Object cipher = crypto.getClass().getMethod("getCipher").invoke(crypto); - ccb.onSuccess(cipher); - } else if (acb != null) { - acb.onSuccess(); - } - } else if ("onAuthenticationError".equals(name)) { - int code = ((Integer) args[0]).intValue(); - String msg = args[1] == null ? "" : args[1].toString(); - if (ccb != null) { - ccb.onError(code, msg); - } else if (acb != null) { - acb.onError(code, msg); - } - } - // onAuthenticationFailed / Help: ignore (soft-failure stream). - } catch (Throwable t) { - Log.e(t); - } - return null; - } - }; - return Proxy.newProxyInstance(authCbCls.getClassLoader(), - new Class>[]{authCbCls}, handler); - } - - /** Activity.getMainExecutor() is API 28+; fall back to a Handler-backed executor. */ - static Executor mainExecutor(Context ctx) { - try { - return (Executor) Context.class.getMethod("getMainExecutor").invoke(ctx); - } catch (Throwable t) { - final Handler h = new Handler(Looper.getMainLooper()); - return new Executor() { - @Override - public void execute(Runnable r) { - h.post(r); - } - }; - } - } -} diff --git a/Ports/Android/src/com/codename1/impl/android/biometrics/BiometricPromptBackend.java b/Ports/Android/src/com/codename1/impl/android/biometrics/BiometricPromptBackend.java new file mode 100644 index 00000000000..11e1749df91 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/biometrics/BiometricPromptBackend.java @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android.biometrics; + +import android.app.Activity; +import android.content.DialogInterface; +import android.content.pm.PackageManager; +import android.hardware.biometrics.BiometricPrompt; +import android.os.CancellationSignal; + +import com.codename1.impl.android.BiometricBackend; +import com.codename1.io.Log; + +import java.lang.reflect.Method; +import java.util.concurrent.Executor; + +import javax.crypto.Cipher; + +/// The API 29+ half of [BiometricBackend], written against +/// `android.hardware.biometrics` with no reflection at all. +/// +/// #### Why the package is separate +/// +/// `BiometricPrompt` is API 28 and the `android.jar` the Android port jar +/// compiles against is API 27, so this file is excluded from that compile (see +/// `maven/android/pom.xml`, next to the `ar`, `ai`, `cipher` and `nearby` +/// exclusions) and is compiled inside the generated application instead. +/// +/// API 28 is the whole of its floor, which is also the lowest `compileSdk` the +/// builder generates. Everything newer that it needs -- `BiometricManager` is +/// API 29, `canAuthenticate(int)` and `Authenticators` are API 30 -- is reached +/// by name in [#canAuthenticate] rather than compiled against, so no realistic +/// build has to do without this backend. That matters more than it looks: +/// while the floor was 30, a project pinned to 28 or 29 lost the file, and its +/// APK then had no biometrics at all on an API 37 device, where the legacy +/// backend's platform API no longer exists. +/// +/// It used to be reached by reflection from a single always-compiled file, and +/// that did not work: `BiometricPrompt.AuthenticationCallback` is an abstract +/// **class**, and `java.lang.reflect.Proxy` refuses anything that is not an +/// interface, so every call ended in `IllegalArgumentException` and the caller +/// was told the hardware was unavailable. A real subclass is the only way to +/// pass that callback, and a real subclass needs the type at compile time. +public final class BiometricPromptBackend implements BiometricBackend { + + /// `BiometricManager.BIOMETRIC_SUCCESS`, which is API 29 and so cannot be + /// named from anything compiled against the port's own `android.jar`. It is + /// zero and has always been zero. + private static final int BIOMETRIC_SUCCESS = 0; + + /// `Context.BIOMETRIC_SERVICE`, which is API 29 and so cannot be named + /// here. It is "biometric" and has always been "biometric". + private static final String BIOMETRIC_SERVICE = "biometric"; + + /// `BiometricManager.Authenticators.BIOMETRIC_STRONG`, the Class 3 tier. + /// API 30, so inlined like the rest; 15 is the AOSP value. + private static final int BIOMETRIC_STRONG = 15; + + /// `BiometricPrompt.BIOMETRIC_ERROR_USER_CANCELED` and + /// `BIOMETRIC_ERROR_HW_UNAVAILABLE`. Inlined rather than named: they became + /// public fields on `BiometricPrompt` only in API 29, so naming them would + /// tie this file to a floor it does not otherwise need. The values are AOSP + /// constants and have never moved. + private static final int ERROR_USER_CANCELED = 10; + private static final int ERROR_HW_UNAVAILABLE = 1; + + /// Loaded reflectively by `AndroidBiometrics.backend()`. + public BiometricPromptBackend() { + } + + /// Whether a **Class 3** biometric can authenticate right now. + /// + /// Class 3 and not merely "any biometric", because every caller in the port + /// authenticates with a `CryptoObject`, and `BiometricPrompt.authenticate` + /// defaults a crypto prompt to `Authenticators.BIOMETRIC_STRONG` and + /// rejects anything weaker outright ("Only Strong biometrics supported with + /// crypto"). The deprecated no-argument `canAuthenticate()` is defined by + /// AOSP as `canAuthenticate(Authenticators.BIOMETRIC_WEAK)`, so on an API + /// 30+ device with only a Class 2 face enrolled it answers success and the + /// prompt then cannot possibly succeed -- + /// [com.codename1.security.Biometrics#canAuthenticate] would promise + /// something [#authenticate] always fails. + /// + /// API 29 falls back to the no-argument call: the tiers do not exist + /// there, so it is the only query that platform has. The fallback is driven + /// by the method being absent rather than by a version check, which is the + /// same question asked directly. + /// + /// `BiometricManager` is reached by name because it is API 29 and this file + /// is held to 28. That is safe here and was not for the callback below: + /// this is a plain method on a concrete class, whereas + /// `BiometricPrompt.AuthenticationCallback` is an abstract *class* that + /// `java.lang.reflect.Proxy` cannot implement -- the mistake that made the + /// old `BiometricsApi29` inert on every device. + @Override + public boolean canAuthenticate(Activity activity) { + Object manager = activity.getSystemService(BIOMETRIC_SERVICE); + if (manager == null) { + return false; + } + Object result; + try { + Method strong = manager.getClass() + .getMethod("canAuthenticate", int.class); + result = strong.invoke(manager, Integer.valueOf(BIOMETRIC_STRONG)); + } catch (NoSuchMethodException api29) { + result = canAuthenticateAny(manager); + } catch (Throwable t) { + Log.e(t); + return false; + } + // Tested rather than cast inside the catch: a failed cast does not + // throw under ParparVM, and scripts/check-cast-semantics.sh holds the + // whole tree -- Android sources included -- to the guarded shape. + return result instanceof Integer + && ((Integer) result).intValue() == BIOMETRIC_SUCCESS; + } + + /// `BiometricManager.canAuthenticate()`, the API 29 query, for the one + /// platform that has it and not the authenticator-aware overload. + private static Object canAuthenticateAny(Object manager) { + try { + return manager.getClass().getMethod("canAuthenticate") + .invoke(manager); + } catch (Throwable t) { + Log.e(t); + return null; + } + } + + /// Android exposes no per-modality enrolment query, so this answers + /// "fingerprint hardware exists and a usable biometric is enrolled", with + /// [#canAuthenticate] deciding what usable means. + /// + /// The legacy backend could be exact, because `FingerprintManager` only + /// ever knew about fingerprints. From API 29 the honest options are this or + /// nothing, and reporting nothing would make + /// [com.codename1.security.Biometrics#getAvailableBiometrics] empty on + /// every fingerprint-only phone. + @Override + public boolean hasEnrolledFingerprints(Activity activity) { + PackageManager pm = activity.getPackageManager(); + return pm.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) + && canAuthenticate(activity); + } + + @Override + public void authenticate(Activity activity, String title, String subtitle, + String description, String negativeButton, + Cipher cipher, CancellationSignal cancel, + final Callback callback) { + try { + BiometricPrompt.Builder b = new BiometricPrompt.Builder(activity); + b.setTitle(title); + if (subtitle != null) { + b.setSubtitle(subtitle); + } + if (description != null) { + b.setDescription(description); + } + Executor exec = activity.getMainExecutor(); + b.setNegativeButton(negativeButton, exec, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface d, int which) { + callback.onError(ERROR_USER_CANCELED, + "Cancelled"); + } + }); + b.build().authenticate(new BiometricPrompt.CryptoObject(cipher), + cancel, exec, new BiometricPrompt.AuthenticationCallback() { + @Override + public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult r) { + BiometricPrompt.CryptoObject crypto = r.getCryptoObject(); + callback.onSuccess(crypto == null ? null : crypto.getCipher()); + } + + @Override + public void onAuthenticationError(int code, CharSequence err) { + callback.onError(code, err == null ? "" : err.toString()); + } + + // onAuthenticationFailed and onAuthenticationHelp are + // the soft-failure stream: the prompt stays up and + // retries, and ends in onAuthenticationError when it + // gives up. Completing the AsyncResource on them would + // fail the call while the user is still trying. + }); + } catch (Throwable t) { + Log.e(t); + callback.onError(ERROR_HW_UNAVAILABLE, + "Failed to show the biometric prompt: " + t.getMessage()); + } + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/fingerprint/FingerprintBackend.java b/Ports/Android/src/com/codename1/impl/android/fingerprint/FingerprintBackend.java new file mode 100644 index 00000000000..aa4d4580ab7 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/fingerprint/FingerprintBackend.java @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android.fingerprint; + +import android.app.Activity; +import android.os.Build; +import android.os.CancellationSignal; +import android.support.v4.hardware.fingerprint.FingerprintManagerCompat; + +import com.codename1.impl.android.BiometricBackend; + +import javax.crypto.Cipher; + +/// The API 23-28 half of [BiometricBackend]. +/// +/// #### Why this goes through the support library +/// +/// The platform class it stands on, `android.hardware.fingerprint +/// .FingerprintManager`, was deprecated in API 28 and **removed in API 37**, +/// along with `Context.FINGERPRINT_SERVICE`. Naming it from a file that every +/// generated application compiles is what broke issue #5701: a Hello World +/// generated against an API 37 platform failed `compileDebugJavaWithJavac` on +/// port sources the developer never wrote. +/// +/// `FingerprintManagerCompat` solves that without giving anything up. The +/// support library ships the type itself, so this file compiles against every +/// platform Codename One supports -- 37 included -- while still calling the +/// platform API underneath on the devices that have it. That matters because +/// an application compiled against API 37 still *runs* on API 23-28, where this +/// is the only biometric API there is; deleting the file for a modern +/// `compileSdk` would have taken fingerprint away from those devices. +/// +/// The `android.support.v4` spelling is deliberate and is what the other ten +/// support-library users in this port write. The port jar compiles it against +/// cn1-binaries' `support-compat-v4` jar, and `AndroidGradleBuilder` rewrites +/// it to `androidx.core.hardware.fingerprint.FingerprintManagerCompat` for +/// every AndroidX application through `androidx-class-mapping.csv`. +/// +/// Reflection would not have been an option: `FingerprintManagerCompat +/// .AuthenticationCallback` is an abstract *class*, and +/// `java.lang.reflect.Proxy` implements interfaces only. That mistake is +/// exactly what made the old `BiometricsApi29` inert on every device. +public final class FingerprintBackend implements BiometricBackend { + + /// The API level that removed `android.hardware.fingerprint`. + /// + /// The compat class is a *compile-time* shield, not a run-time one. Its + /// `isHardwareDetected` and friends are guarded by + /// `SDK_INT >= 23` with no upper bound, and behind that guard they ask for + /// the platform `FingerprintManager` by class -- so on a device that no + /// longer has one, the first call raises `NoClassDefFoundError` rather than + /// returning false. Letting it run is therefore not an option; answering + /// "no fingerprint hardware" before touching it is what keeps that off + /// every caller's path. + /// + /// Nothing is lost by it. The modern backend compiles down to `compileSdk` + /// 28 and so is present in every build the builder generates, which means + /// an API 37 device is served by `BiometricPrompt` and never arrives here. + private static final int FINGERPRINT_REMOVED_IN_SDK = 37; + + /// The number of soft failures -- a finger that touched the sensor and was + /// not recognised -- tolerated before the call is failed. `FingerprintManager` + /// draws no UI of its own, so unlike `BiometricPrompt` there is nothing on + /// screen to give up on its own. + private static final int MAX_SOFT_FAILURES = 5; + + /// `FINGERPRINT_ERROR_HW_UNAVAILABLE`, `FINGERPRINT_ERROR_NO_FINGERPRINTS` + /// and `FINGERPRINT_ERROR_UNABLE_TO_PROCESS`. Inlined because the class that + /// declares them is the one this file exists to avoid naming; the values are + /// AOSP constants and have never moved. + private static final int HW_UNAVAILABLE = 1; + private static final int NOT_RECOGNIZED = 2; + private static final int NO_FINGERPRINTS = 11; + + /// Loaded reflectively by `AndroidBiometrics.backend()`. + public FingerprintBackend() { + } + + private static FingerprintManagerCompat manager(Activity activity) { + if (Build.VERSION.SDK_INT >= FINGERPRINT_REMOVED_IN_SDK) { + return null; + } + return FingerprintManagerCompat.from(activity); + } + + @Override + public boolean canAuthenticate(Activity activity) { + FingerprintManagerCompat fpm = manager(activity); + return fpm != null && fpm.isHardwareDetected() + && fpm.hasEnrolledFingerprints(); + } + + @Override + public boolean hasEnrolledFingerprints(Activity activity) { + FingerprintManagerCompat fpm = manager(activity); + return fpm != null && fpm.hasEnrolledFingerprints(); + } + + @Override + public void authenticate(Activity activity, String title, String subtitle, + String description, String negativeButton, + Cipher cipher, CancellationSignal cancel, + final Callback callback) { + // title/subtitle/description/negativeButton are deliberately unused: + // FingerprintManager has no system-drawn prompt, so the copy has + // nowhere to go. BiometricPrompt, which does draw one, is what every + // device from API 29 gets. + FingerprintManagerCompat fpm = manager(activity); + if (fpm == null || !fpm.isHardwareDetected()) { + callback.onError(HW_UNAVAILABLE, "No fingerprint hardware"); + return; + } + if (!fpm.hasEnrolledFingerprints()) { + callback.onError(NO_FINGERPRINTS, "No fingerprints enrolled"); + return; + } + fpm.authenticate(new FingerprintManagerCompat.CryptoObject(cipher), 0, + bridgeCancellation(cancel), + new FingerprintManagerCompat.AuthenticationCallback() { + private int failures; + + @Override + public void onAuthenticationError(int errorCode, CharSequence errString) { + callback.onError(errorCode, + errString == null ? "" : errString.toString()); + } + + @Override + public void onAuthenticationSucceeded(FingerprintManagerCompat.AuthenticationResult r) { + FingerprintManagerCompat.CryptoObject crypto = r.getCryptoObject(); + callback.onSuccess(crypto == null ? null : crypto.getCipher()); + } + + @Override + public void onAuthenticationFailed() { + if (failures++ > MAX_SOFT_FAILURES) { + callback.onError(NOT_RECOGNIZED, "Authentication failed"); + } + } + }, null); + } + + /// Follows the caller's `android.os.CancellationSignal` with the support + /// library one this API takes. + /// + /// The two are separate types and the old support-library jar has no + /// overload for the platform one, so the cancel has to be forwarded. A + /// signal that is already cancelled invokes the listener immediately, which + /// is what makes this safe to install after the fact. + private static android.support.v4.os.CancellationSignal bridgeCancellation( + CancellationSignal cancel) { + final android.support.v4.os.CancellationSignal compat = + new android.support.v4.os.CancellationSignal(); + if (cancel != null) { + cancel.setOnCancelListener(new CancellationSignal.OnCancelListener() { + @Override + public void onCancel() { + compat.cancel(); + } + }); + } + return compat; + } +} diff --git a/docs/developer-guide/Biometric-Authentication.asciidoc b/docs/developer-guide/Biometric-Authentication.asciidoc index 9b2bc1b6ba0..215a31320e5 100644 --- a/docs/developer-guide/Biometric-Authentication.asciidoc +++ b/docs/developer-guide/Biometric-Authentication.asciidoc @@ -25,7 +25,7 @@ Entries are bound to the current set of enrolled biometrics. If the user enrols | Platform | Implementation | Notes | iOS | `LocalAuthentication.framework` (`LAContext`) + `Security.framework` keychain | Add the `ios.NSFaceIDUsageDescription` build hint when targeting Face ID hardware. | Android API 29+ | `BiometricPrompt` | Face / iris / fingerprint per `PackageManager` features. -| Android API 23-28| `FingerprintManager` | Fingerprint only. +| Android API 23-28| `FingerprintManagerCompat` | Fingerprint only. Goes through the support library rather than `android.hardware.fingerprint` directly, which Android removed in API 37, so an app compiled against any platform still authenticates on these devices. | JavaSE simulator | `Simulate -> Biometric Simulation` submenu | Toggle hardware availability, per-modality enrolment, and the next-call outcome. | All other ports | Non-supporting fallback | `canAuthenticate()` returns `false`; `authenticate()` completes with `BiometricError.NOT_AVAILABLE`. |=== diff --git a/maven/android/pom.xml b/maven/android/pom.xml index d908985247e..adabb3acc30 100644 --- a/maven/android/pom.xml +++ b/maven/android/pom.xml @@ -93,6 +93,19 @@ generated app after PlatformFeatureCatalog injects their ML Kit/LiteRT dependencies. -->