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
10 changes: 7 additions & 3 deletions CodenameOne/src/com/codename1/security/Biometrics.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions Ports/Android/build.xml
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,14 @@
<!-- Optional AR, AI and encrypted-database 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. Mirrors the excludes=
the dependencies and sources used by the app. The BiometricPrompt
backend is there for the same reason 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 excludes=
entry in nbproject/project.properties and in maven/android/pom.xml. -->
<javac destdir="build/tmp"
classpath="${javac.classpath}:build/tmp"
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">
<src path="${src.dir}" />
</javac>
<jar jarfile="${dist.jar}" basedir="build/tmp">
Expand Down
5 changes: 4 additions & 1 deletion Ports/Android/nbproject/project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
319 changes: 159 additions & 160 deletions Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <V> void runAuthenticatedCipher(final String reason, final String account,
Expand Down Expand Up @@ -707,16 +706,17 @@ private <V> 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 <V> void promptBiometric29(final String reason, final int mode, final String account,
final AsyncResource<V> result, final CipherWork<V> work,
final Cipher operationCipher) {
private <V> void promptBiometric(final String reason, final int mode, final String account,
final AsyncResource<V> result, final CipherWork<V> 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() {
Expand All @@ -725,78 +725,39 @@ 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);
}
});
}
});
}

private <V> void promptBiometricLegacy(final int mode, final String account,
final Cipher operationCipher,
final AsyncResource<V> result, final CipherWork<V> 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 <V> void runCipherWork(Cipher authedCipher, CipherWork<V> work,
final AsyncResource<V> result, int mode, String account) {
try {
Expand Down
104 changes: 104 additions & 0 deletions Ports/Android/src/com/codename1/impl/android/BiometricBackend.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading