diff --git a/CodenameOne/src/com/codename1/security/Biometrics.java b/CodenameOne/src/com/codename1/security/Biometrics.java index 8e822900ac0..ce3a7d19f68 100644 --- a/CodenameOne/src/com/codename1/security/Biometrics.java +++ b/CodenameOne/src/com/codename1/security/Biometrics.java @@ -59,9 +59,13 @@ /// - **iOS** -- uses `LocalAuthentication.framework` (`LAContext`). Touch ID /// and Face ID on supported devices. Add the `ios.NSFaceIDUsageDescription` /// build hint when targeting Face ID hardware. -/// - **Android** -- uses `BiometricPrompt` on API 29+ (Android 10) and the -/// legacy `FingerprintManager` on API 23-28. Fingerprint, face, and iris -/// modalities are reported per `PackageManager` features. +/// - **Android** -- uses `BiometricPrompt` on API 29+ (Android 10) and +/// `FingerprintManagerCompat` on API 23-28. Fingerprint, face, and iris +/// modalities are reported per `PackageManager` features. The legacy path +/// 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 an API +/// 23-28 device. /// - **JavaSE simulator** -- behaves as a real device with no enrolled /// biometrics by default. The `Simulate -> Biometric Simulation` submenu /// in the simulator lets you toggle hardware availability, enrolled diff --git a/Ports/Android/build.xml b/Ports/Android/build.xml index 62442b28330..962484d56f7 100644 --- a/Ports/Android/build.xml +++ b/Ports/Android/build.xml @@ -113,11 +113,14 @@ + excludes="com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**,com/codename1/impl/android/biometrics/**,com/codename1/impl/android/BillingSupport.java"> diff --git a/Ports/Android/nbproject/project.properties b/Ports/Android/nbproject/project.properties index df308292fab..e4d6e4ef090 100644 --- a/Ports/Android/nbproject/project.properties +++ b/Ports/Android/nbproject/project.properties @@ -29,8 +29,11 @@ endorsed.classpath= # Optional AR and AI implementations compile against dependencies which are # not in cn1-binaries. They are compiled inside user app builds where the # Android builder adds only the dependencies and sources used by the app. +# The BiometricPrompt backend is the same arrangement against a newer SDK +# rather than a missing dependency: android.hardware.biometrics is API 28 to 30 +# and the cn1-binaries android.jar is API 27. # Mirrors the maven-compiler excludes in maven/android/pom.xml. -excludes=com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**,com/codename1/impl/android/BillingSupport.java +excludes=com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**,com/codename1/impl/android/biometrics/**,com/codename1/impl/android/BillingSupport.java file.reference.android-support-v7-appcompat.jar=../../../cn1-binaries/android/android-support-v7-appcompat.jar file.reference.android-support-v7-cardview.jar=../../../cn1-binaries/android/android-support-v7-cardview.jar file.reference.android-support-v7-gridlayout.jar=../../../cn1-binaries/android/android-support-v7-gridlayout.jar diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java b/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java index 2f484850eb1..c968679ef8b 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java @@ -25,7 +25,6 @@ import android.Manifest; import android.app.Activity; import android.content.pm.PackageManager; -import android.hardware.fingerprint.FingerprintManager; import android.os.Build; import android.os.CancellationSignal; import android.os.Looper; @@ -50,24 +49,35 @@ import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; -/** - * Android backing for {@link Biometrics}. Uses - * {@code BiometricPrompt} on API 29+ (via reflection — the cn1-binaries - * android.jar predates API 28 so direct calls would not compile) and the - * legacy {@code FingerprintManager} on API 23-28. Mirrors the dual-path - * behaviour of the historical {@code FingerprintScanner} cn1lib but completes - * per-call {@link AsyncResource} instances instead of a shared static - * callback. - * - *

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 AsyncResource pending; @@ -137,35 +142,28 @@ private static final class CollectAvailableBiometricsRunnable implements Runnabl } private static void collectAvailableBiometrics(List out) { + if (BACKEND == null) { + return; + } try { Activity act = AndroidNativeUtil.getActivity(); PackageManager pm = act.getPackageManager(); - boolean okBio; if (Build.VERSION.SDK_INT >= 29) { if (!AndroidNativeUtil.checkForPermission("android.permission.USE_BIOMETRIC", "Authorize using biometrics")) { return; } - okBio = BiometricsApi29.canAuthenticate(act); - } else { - if (!AndroidNativeUtil.checkForPermission(Manifest.permission.USE_FINGERPRINT, - "Authorize using fingerprint")) { - return; - } - FingerprintManager fpm = (FingerprintManager) - act.getSystemService(Activity.FINGERPRINT_SERVICE); - okBio = fpm != null && fpm.isHardwareDetected() - && fpm.hasEnrolledFingerprints(); + } else if (!AndroidNativeUtil.checkForPermission( + Manifest.permission.USE_FINGERPRINT, + "Authorize using fingerprint")) { + return; } - if (!okBio) { + if (!BACKEND.canAuthenticate(act)) { return; } - if (pm.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)) { - FingerprintManager fpm = (FingerprintManager) - act.getSystemService(Activity.FINGERPRINT_SERVICE); - if (fpm != null && fpm.hasEnrolledFingerprints()) { - out.add(BiometricType.FINGERPRINT); - } + if (pm.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) + && BACKEND.hasEnrolledFingerprints(act)) { + out.add(BiometricType.FINGERPRINT); } if (Build.VERSION.SDK_INT >= 29) { if (pm.hasSystemFeature("android.hardware.biometrics.face")) { @@ -188,6 +186,11 @@ public AsyncResource authenticate(final AuthenticationOptions opts) { "Android API 23 (Marshmallow) required for biometric authentication")); return result; } + if (BACKEND == null) { + result.error(new BiometricException(BiometricError.NOT_AVAILABLE, + "No biometric API is available to this build")); + return result; + } final String reason = opts == null || opts.getReason() == null ? "Authenticate" : opts.getReason(); final String title = opts == null || opts.getTitle() == null @@ -207,108 +210,56 @@ public AsyncResource authenticate(final AuthenticationOptions opts) { // initProbeCipher already errored the result. return result; } - if (Build.VERSION.SDK_INT >= 29) { - runOnUi(new Runnable() { - @Override - public void run() { - if (cancellationSignal != null) { - cancellationSignal.cancel(); - } - cancellationSignal = new CancellationSignal(); - BiometricsApi29.authenticateWithCipher( - AndroidNativeUtil.getActivity(), - title, subtitle, description, negative, - probeCipher, - cancellationSignal, - new BiometricsApi29.CipherAuthCallback() { - @Override - public void onSuccess(Object authedCipher) { - if (verifyProbeCipher((Cipher) authedCipher)) { - completeSuccess(result); - } else { - completeError(result, BiometricError.AUTHENTICATION_FAILED, - "Probe cipher rejected -- biometric success may have been spoofed"); - } - } - - @Override - public void onError(int code, String msg) { - completeError(result, mapBiometricPromptError(code), msg); - } - }); - } - }); - } else { - authenticateLegacy(result, probeCipher); - } - return result; - } - - private void authenticateLegacy(final AsyncResource result, final Cipher probeCipher) { runOnUi(new Runnable() { @Override public void run() { - if (!AndroidNativeUtil.checkForPermission(Manifest.permission.USE_FINGERPRINT, - "Authorize using fingerprint")) { + if (Build.VERSION.SDK_INT < 29 + && !AndroidNativeUtil.checkForPermission( + Manifest.permission.USE_FINGERPRINT, + "Authorize using fingerprint")) { completeError(result, BiometricError.NOT_AVAILABLE, "USE_FINGERPRINT permission denied"); return; } - FingerprintManager fpm = (FingerprintManager) - AndroidNativeUtil.getActivity() - .getSystemService(Activity.FINGERPRINT_SERVICE); - if (fpm == null || !fpm.isHardwareDetected()) { - completeError(result, BiometricError.NOT_AVAILABLE, - "No fingerprint hardware"); - return; - } - if (!fpm.hasEnrolledFingerprints()) { - completeError(result, BiometricError.NOT_ENROLLED, "No fingerprints enrolled"); - return; - } if (cancellationSignal != null) { cancellationSignal.cancel(); } final CancellationSignal cs = new CancellationSignal(); cancellationSignal = cs; - FingerprintManager.AuthenticationCallback cb = new FingerprintManager.AuthenticationCallback() { - int failures; - - @Override - public void onAuthenticationError(int errorCode, CharSequence errString) { - completeError(result, mapFingerprintManagerError(errorCode), - errString == null ? "" : errString.toString()); - } - - @Override - public void onAuthenticationSucceeded(FingerprintManager.AuthenticationResult r) { - cs.cancel(); - // Require the OS to return the same CryptoObject we - // passed in, and confirm by running an actual crypto - // operation on the unlocked cipher. A hooked / spoofed - // success callback either lacks the CryptoObject or - // hits a Keystore-locked cipher and doFinal() throws. - FingerprintManager.CryptoObject crypto = r.getCryptoObject(); - if (crypto == null || !verifyProbeCipher(crypto.getCipher())) { - completeError(result, BiometricError.AUTHENTICATION_FAILED, - "Probe cipher rejected -- biometric success may have been spoofed"); - return; - } - completeSuccess(result); - } - - @Override - public void onAuthenticationFailed() { - if (failures++ > 5) { - cs.cancel(); - completeError(result, BiometricError.AUTHENTICATION_FAILED, - "Authentication failed"); - } - } - }; - fpm.authenticate(new FingerprintManager.CryptoObject(probeCipher), cs, 0, cb, null); + BACKEND.authenticate(AndroidNativeUtil.getActivity(), + title, subtitle, description, negative, + probeCipher, cs, new BiometricBackend.Callback() { + @Override + public void onSuccess(Cipher authedCipher) { + cs.cancel(); + // Confirm the unlock by running an actual + // crypto operation on the cipher the OS handed + // back. A hooked or spoofed success callback + // either has no CryptoObject at all or reaches + // a still-locked cipher, and doFinal() throws. + if (verifyProbeCipher(authedCipher)) { + completeSuccess(result); + } else { + completeError(result, BiometricError.AUTHENTICATION_FAILED, + "Probe cipher rejected -- biometric success may have been spoofed"); + } + } + + @Override + public void onError(int code, String msg) { + // The legacy backend gives up after a run of + // unrecognised touches while the sensor is + // still listening, so the signal has to be + // cancelled here rather than left to the OS. + // Cancelling one the OS has already finished + // with is a no-op. + cs.cancel(); + completeError(result, mapBiometricError(code), msg); + } + }); } }); + return result; } /// Initialises an AES/CBC/PKCS7 Cipher under the Keystore probe key in @@ -446,47 +397,88 @@ public void run() { } } - static BiometricError mapBiometricPromptError(int code) { + /// Translates an Android biometric error code into the portable + /// [BiometricError]. One mapping for both backends: the `FINGERPRINT_ERROR_` + /// and `BIOMETRIC_ERROR_` constants are the same numbers in AOSP. + static BiometricError mapBiometricError(int code) { switch (code) { - case BIOMETRIC_ERROR_HW_UNAVAILABLE: - case BIOMETRIC_ERROR_HW_NOT_PRESENT: + case ERROR_HW_UNAVAILABLE: + case ERROR_HW_NOT_PRESENT: return BiometricError.NOT_AVAILABLE; - case BIOMETRIC_ERROR_LOCKOUT: + case ERROR_UNABLE_TO_PROCESS: + // Also what the legacy backend reports once a finger has + // touched the sensor too many times without being recognised; + // FingerprintManager has no dedicated code for that. + return BiometricError.AUTHENTICATION_FAILED; + case ERROR_LOCKOUT: return BiometricError.LOCKED_OUT; - case BIOMETRIC_ERROR_LOCKOUT_PERMANENT: + case ERROR_LOCKOUT_PERMANENT: return BiometricError.PERMANENTLY_LOCKED_OUT; - case BIOMETRIC_ERROR_NO_BIOMETRICS: + case ERROR_NO_BIOMETRICS: return BiometricError.NOT_ENROLLED; - case BIOMETRIC_ERROR_USER_CANCELED: - case BIOMETRIC_ERROR_NEGATIVE_BUTTON: + case ERROR_USER_CANCELED: + case ERROR_NEGATIVE_BUTTON: return BiometricError.USER_CANCELED; - case BIOMETRIC_ERROR_CANCELED: + case ERROR_CANCELED: return BiometricError.SYSTEM_CANCELED; - case BIOMETRIC_ERROR_NO_DEVICE_CREDENTIAL: + case ERROR_NO_DEVICE_CREDENTIAL: return BiometricError.PASSCODE_NOT_SET; default: return BiometricError.UNKNOWN; } } - static BiometricError mapFingerprintManagerError(int code) { - switch (code) { - case FingerprintManager.FINGERPRINT_ERROR_HW_UNAVAILABLE: - case FINGERPRINT_ERROR_HW_NOT_PRESENT: - return BiometricError.NOT_AVAILABLE; - case FingerprintManager.FINGERPRINT_ERROR_LOCKOUT: - return BiometricError.LOCKED_OUT; - case FingerprintManager.FINGERPRINT_ERROR_LOCKOUT_PERMANENT: - return BiometricError.PERMANENTLY_LOCKED_OUT; - case FINGERPRINT_ERROR_NO_FINGERPRINTS: - return BiometricError.NOT_ENROLLED; - case FingerprintManager.FINGERPRINT_ERROR_USER_CANCELED: - return BiometricError.USER_CANCELED; - case FingerprintManager.FINGERPRINT_ERROR_CANCELED: - return BiometricError.SYSTEM_CANCELED; - default: - return BiometricError.UNKNOWN; + /// The backend for this device, or `null` when neither package survived + /// into this build. + /// + /// Both are loaded by name because the modern one cannot be linked: it is + /// excluded from the port jar compile, and deleted from a generated + /// application whose `compileSdk` is below 30. The fallback to the legacy + /// backend is what covers such a build, and the legacy backend is loaded + /// the same way for symmetry rather than necessity. + private static BiometricBackend resolveBackend() { + Object instance = null; + if (Build.VERSION.SDK_INT >= 29) { + try { + // The class name is a literal AT the Class.forName call, not a + // parameter threaded into a shared helper. R8 keeps a class + // named by a constant string there and cannot see one that + // arrives through a variable, and a release build renaming + // these would leave the lookup failing with no biometrics and + // no error -- the same shape as the absence this catch treats + // as normal. + instance = Class.forName( + "com.codename1.impl.android.biometrics.BiometricPromptBackend") + .newInstance(); + } catch (Throwable absent) { + // Only reachable on a build compiled below API 28, where + // BiometricPrompt does not exist and the builder deleted the + // package. Nothing the builder generates goes there, so in + // practice every device from API 29 up -- 37 included -- is + // served by BiometricPrompt. + instance = null; + } + } + if (!(instance instanceof BiometricBackend) && Build.VERSION.SDK_INT >= 23) { + try { + instance = Class.forName( + "com.codename1.impl.android.fingerprint.FingerprintBackend") + .newInstance(); + } catch (Throwable absent) { + // Not expected: the legacy backend goes through + // FingerprintManagerCompat and so compiles against every + // platform, and nothing deletes it. Kept because the load is + // by name and a rename or a stripped package must degrade + // rather than throw out of a static initialiser. + instance = null; + } } + // Tested rather than cast inside the catch. A failed cast does not + // throw under ParparVM, so a catch around one is a handler that never + // runs, and scripts/check-cast-semantics.sh holds the whole tree -- + // Android sources included -- to the guarded shape. + return instance instanceof BiometricBackend + ? (BiometricBackend) instance : null; } @Override @@ -508,6 +500,13 @@ public void run() { return true; } + /// The resolved backend, or `null` when this device and this build have + /// none. Shared with [AndroidSecureStorage], which prompts with the same + /// API for a different cipher. + static BiometricBackend backend() { + return BACKEND; + } + static void runOnUi(Runnable r) { if (Looper.getMainLooper().getThread() == Thread.currentThread()) { r.run(); diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java index 997eca5e895..7aeeb4d6c43 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java @@ -22,10 +22,8 @@ */ package com.codename1.impl.android; -import android.app.Activity; import android.content.Context; import android.content.SharedPreferences; -import android.hardware.fingerprint.FingerprintManager; import android.os.Build; import android.os.CancellationSignal; import android.security.keystore.KeyGenParameterSpec; @@ -665,8 +663,9 @@ private void warnLegacyPlainStorage() { /** * Generic helper that initialises the cipher under the keystore key, - * prompts the user via {@code BiometricPrompt} (or legacy - * {@code FingerprintManager}), and on success runs the supplied + * prompts the user through {@link BiometricBackend} -- whichever of + * {@code BiometricPrompt} and the legacy {@code FingerprintManager} this + * device and this build have -- and on success runs the supplied * {@link CipherWork} against the authenticated cipher. */ private void runAuthenticatedCipher(final String reason, final String account, @@ -707,16 +706,17 @@ private void runAuthenticatedCipher(final String reason, final String accoun // Carried as a parameter from here on. It belongs to this operation and to no // other, which is what stops a concurrent call from handing its cipher to this // prompt. - if (Build.VERSION.SDK_INT >= 29) { - promptBiometric29(reason, mode, account, result, work, operationCipher); - } else { - promptBiometricLegacy(mode, account, operationCipher, result, work); - } + promptBiometric(reason, mode, account, result, work, operationCipher); } - private void promptBiometric29(final String reason, final int mode, final String account, - final AsyncResource result, final CipherWork work, - final Cipher operationCipher) { + private void promptBiometric(final String reason, final int mode, final String account, + final AsyncResource result, final CipherWork work, + final Cipher operationCipher) { + final BiometricBackend backend = AndroidBiometrics.backend(); + if (backend == null) { + failResult(result, BiometricError.NOT_AVAILABLE, "No biometric hardware"); + return; + } AndroidBiometrics.runOnUi(new Runnable() { @Override public void run() { @@ -725,23 +725,32 @@ public void run() { } final CancellationSignal cs = new CancellationSignal(); cancellationSignal = cs; - BiometricsApi29.authenticateWithCipher( - AndroidNativeUtil.getActivity(), + backend.authenticate(AndroidNativeUtil.getActivity(), reason == null ? "Authenticate" : reason, null, null, "Cancel", - operationCipher, - cs, - new BiometricsApi29.CipherAuthCallback() { + operationCipher, cs, new BiometricBackend.Callback() { @Override - public void onSuccess(Object authedCipher) { + public void onSuccess(Cipher authedCipher) { cs.cancel(); - runCipherWork((Cipher) authedCipher, work, result, mode, account); + if (authedCipher == null) { + // The OS reported success without handing + // back the CryptoObject we passed in, so + // nothing proves a real unlock happened. + failResult(result, BiometricError.AUTHENTICATION_FAILED, + "Authenticated cipher missing -- " + + "biometric success may have been spoofed"); + return; + } + runCipherWork(authedCipher, work, result, mode, account); } @Override public void onError(int errorCode, String errString) { + // See AndroidBiometrics: the legacy backend can + // report failure with the sensor still armed. + cs.cancel(); failResult(result, - AndroidBiometrics.mapBiometricPromptError(errorCode), + AndroidBiometrics.mapBiometricError(errorCode), errString == null ? "" : errString); } }); @@ -749,54 +758,6 @@ public void onError(int errorCode, String errString) { }); } - private void promptBiometricLegacy(final int mode, final String account, - final Cipher operationCipher, - final AsyncResource result, final CipherWork work) { - AndroidBiometrics.runOnUi(new Runnable() { - @Override - public void run() { - FingerprintManager fpm = (FingerprintManager) - AndroidNativeUtil.getActivity() - .getSystemService(Activity.FINGERPRINT_SERVICE); - if (fpm == null) { - failResult(result, BiometricError.NOT_AVAILABLE, "No fingerprint hardware"); - return; - } - if (cancellationSignal != null) { - cancellationSignal.cancel(); - } - final CancellationSignal cs = new CancellationSignal(); - cancellationSignal = cs; - FingerprintManager.CryptoObject crypto = - new FingerprintManager.CryptoObject(operationCipher); - fpm.authenticate(crypto, cs, 0, new FingerprintManager.AuthenticationCallback() { - int failures; - - @Override - public void onAuthenticationError(int errorCode, CharSequence errString) { - failResult(result, AndroidBiometrics.mapFingerprintManagerError(errorCode), - errString == null ? "" : errString.toString()); - } - - @Override - public void onAuthenticationSucceeded(FingerprintManager.AuthenticationResult r) { - cs.cancel(); - runCipherWork(r.getCryptoObject().getCipher(), work, result, mode, account); - } - - @Override - public void onAuthenticationFailed() { - if (failures++ > 5) { - cs.cancel(); - failResult(result, BiometricError.AUTHENTICATION_FAILED, - "Authentication failed"); - } - } - }, null); - } - }); - } - private void runCipherWork(Cipher authedCipher, CipherWork work, final AsyncResource result, int mode, String account) { try { diff --git a/Ports/Android/src/com/codename1/impl/android/BiometricBackend.java b/Ports/Android/src/com/codename1/impl/android/BiometricBackend.java new file mode 100644 index 00000000000..5ff4eddd31b --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/BiometricBackend.java @@ -0,0 +1,104 @@ +/* + * 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; + +import android.app.Activity; +import android.os.CancellationSignal; + +import javax.crypto.Cipher; + +/// The one biometric operation set Codename One needs from Android, expressed +/// so that neither of its two implementations has to be visible to the code +/// that calls it. +/// +/// #### Why this exists +/// +/// Android has shipped two mutually exclusive biometric APIs and the port has +/// to compile against a range of SDKs that contains neither of them in full: +/// +/// - `android.hardware.fingerprint.FingerprintManager` arrived in API 23 and +/// was **removed in API 37**. An app generated against a 37 platform does +/// not compile if any source names it -- which is exactly what issue #5701 +/// reported, in an unmodified Hello World. The legacy backend therefore goes +/// through the support library's `FingerprintManagerCompat`, which keeps the +/// API 23-28 devices an API 37 build still runs on. +/// - `android.hardware.biometrics.BiometricPrompt` arrived in API 28, which is +/// newer than the `android.jar` the port jar itself is compiled against. +/// +/// Neither can be named from a file that has to compile everywhere, and +/// neither can be reached by `java.lang.reflect.Proxy` either, because the +/// callback both of them take is an abstract *class* and `Proxy` implements +/// interfaces only. So each lives in its own package -- `biometrics` for the +/// modern one, `fingerprint` for the legacy one -- with an implementation that +/// names its API at compile time, and `AndroidBiometrics.backend()` loads +/// whichever one this device and this build actually have. See +/// [AndroidNearbyBridge][com.codename1.impl.android.AndroidNearbyBridge] for +/// the same arrangement applied to a different missing dependency. +/// +/// Error codes are the raw Android ones. The two APIs happen to number them +/// identically (`HW_UNAVAILABLE` 1 through `HW_NOT_PRESENT` 12), so +/// [AndroidBiometrics#mapBiometricError] is one mapping rather than two. +public interface BiometricBackend { + + /// Whether the device has usable, enrolled biometric hardware right now. + boolean canAuthenticate(Activity activity); + + /// Whether at least one **fingerprint** is enrolled, as opposed to any + /// other biometric modality. Used to answer + /// [com.codename1.security.Biometrics#getAvailableBiometrics], which + /// reports modalities separately. + boolean hasEnrolledFingerprints(Activity activity); + + /// Prompts the user and, on success, hands back the same `cipher` with the + /// keystore unlock applied to it. + /// + /// The cipher is not optional: every caller in the port passes a + /// `CryptoObject`-backed one so that success can be proven by a real + /// crypto operation rather than by the callback having fired. A hooked + /// callback reaches a still-locked cipher and `doFinal` throws. + /// + /// #### Parameters + /// + /// - `title`, `subtitle`, `description`, `negativeButton`: prompt copy. + /// The legacy backend has no system-drawn prompt and ignores them. + /// - `cancel`: cancels the prompt; the caller owns it. + void authenticate(Activity activity, String title, String subtitle, + String description, String negativeButton, Cipher cipher, + CancellationSignal cancel, Callback callback); + + /// Completion of a single [BiometricBackend#authenticate] call. Exactly one + /// method is invoked, on the UI thread. + public interface Callback { + + /// The user authenticated and `authenticatedCipher` is unlocked. + void onSuccess(Cipher authenticatedCipher); + + /// Authentication ended without success. + /// + /// #### Parameters + /// + /// - `code`: an Android biometric error code, mapped by + /// [AndroidBiometrics#mapBiometricError] + void onError(int code, String message); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/BiometricsApi29.java b/Ports/Android/src/com/codename1/impl/android/BiometricsApi29.java deleted file mode 100644 index 28fa79875b7..00000000000 --- a/Ports/Android/src/com/codename1/impl/android/BiometricsApi29.java +++ /dev/null @@ -1,212 +0,0 @@ -/* - * Copyright (c) 2012, 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; - -import android.app.Activity; -import android.content.Context; -import android.content.DialogInterface; -import android.os.CancellationSignal; -import android.os.Handler; -import android.os.Looper; - -import com.codename1.io.Log; - -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationHandler; -import java.lang.reflect.Method; -import java.lang.reflect.Proxy; -import java.util.concurrent.Executor; - -/** - * Reflection adapter for {@code android.hardware.biometrics.BiometricPrompt} - * (API 28+) and {@code android.hardware.biometrics.BiometricManager} - * (API 29+). The cn1-binaries {@code android.jar} predates API 28, so direct - * symbol references would fail to compile; reflection lets us call these APIs - * at runtime on supported devices without lifting the compile-time SDK - * requirement of the Android port. - * - *

Only 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. --> com/codename1/impl/android/ai/** + + com/codename1/impl/android/biometrics/**