/*
* Copyright 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.biometric;
import android.annotation.SuppressLint;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.ViewModelProvider;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.security.Signature;
import java.util.concurrent.Executor;
import javax.crypto.Cipher;
import javax.crypto.Mac;
/**
* A class that manages a system-provided biometric prompt. On devices running Android 9.0 (API 28)
* and above, this will show a system-provided authentication prompt, using one of the device's
* supported biometric modalities (fingerprint, iris, face, etc). Prior to Android 9.0, this will
* instead show a custom fingerprint authentication dialog. The prompt will persist across
* configuration changes unless explicitly canceled. For security reasons, the prompt will be
* dismissed when the client application is no longer in the foreground.
*
* <p>To persist authentication across configuration changes, developers should (re)create the
* prompt every time the activity/fragment is created. Instantiating the prompt with a new
* callback early in the fragment/activity lifecycle (e.g. in {@code onCreate()}) will allow the
* ongoing authentication session's callbacks to be received by the new fragment/activity instance.
* Note that {@code cancelAuthentication()} should not be called, and {@code authenticate()} does
* not need to be invoked during activity/fragment creation.
*/
public class BiometricPrompt implements BiometricConstants {
private static final String TAG = "BiometricPromptCompat";
/**
* An error code that may be returned during authentication.
*/
@IntDef({
ERROR_HW_UNAVAILABLE,
ERROR_UNABLE_TO_PROCESS,
ERROR_TIMEOUT,
ERROR_NO_SPACE,
ERROR_CANCELED,
ERROR_LOCKOUT,
ERROR_VENDOR,
ERROR_LOCKOUT_PERMANENT,
ERROR_USER_CANCELED,
ERROR_NO_BIOMETRICS,
ERROR_HW_NOT_PRESENT,
ERROR_NEGATIVE_BUTTON,
ERROR_NO_DEVICE_CREDENTIAL
})
@Retention(RetentionPolicy.SOURCE)
@interface BiometricError {}
/**
* Tag used to identify the {@link BiometricFragment} attached to the client activity/fragment.
*/
private static final String BIOMETRIC_FRAGMENT_TAG = "androidx.biometric.BiometricFragment";
// Bundle keys used to store and retrieve PromptInfo options.
private static final String KEY_TITLE = "title";
private static final String KEY_SUBTITLE = "subtitle";
private static final String KEY_DESCRIPTION = "description";
private static final String KEY_NEGATIVE_TEXT = "negative_text";
private static final String KEY_REQUIRE_CONFIRMATION = "require_confirmation";
private static final String KEY_ALLOW_DEVICE_CREDENTIAL = "allow_device_credential";
/**
* A wrapper class for the crypto objects supported by {@link BiometricPrompt}. Currently, the
* framework supports {@link Signature}, {@link Cipher}, and {@link Mac} objects.
*/
public static class CryptoObject {
private final Signature mSignature;
private final Cipher mCipher;
private final Mac mMac;
/**
* Creates a {@link CryptoObject} that wraps the given {@link Signature} object.
*
* @param signature The {@link Signature} to be associated with this {@link CryptoObject}.
*/
public CryptoObject(@NonNull Signature signature) {
mSignature = signature;
mCipher = null;
mMac = null;
}
/**
* Creates a {@link CryptoObject} that wraps the given {@link Cipher} object.
*
* @param cipher The {@link Cipher} to be associated with this {@link CryptoObject}.
*/
public CryptoObject(@NonNull Cipher cipher) {
mCipher = cipher;
mSignature = null;
mMac = null;
}
/**
* Creates a {@link CryptoObject} that wraps the given {@link Mac} object.
*
* @param mac The {@link Mac} to be associated with this {@link CryptoObject}.
*/
public CryptoObject(@NonNull Mac mac) {
mMac = mac;
mCipher = null;
mSignature = null;
}
/**
* Gets the {@link Signature} object associated with this crypto object.
*
* @return The {@link Signature}, or {@code null} if none is associated with this object.
*/
@Nullable
public Signature getSignature() {
return mSignature;
}
/**
* Gets the {@link Cipher} object associated with this crypto object.
*
* @return The {@link Cipher}, or {@code null} if none is associated with this object.
*/
@Nullable
public Cipher getCipher() {
return mCipher;
}
/**
* Gets the {@link Mac} object associated with this crypto object.
*
* @return The {@link Mac}, or {@code null} if none is associated with this object.
*/
@Nullable
public Mac getMac() {
return mMac;
}
}
/**
* A container for data passed to {@link AuthenticationCallback#onAuthenticationSucceeded(
* AuthenticationResult)} when the user has successfully authenticated.
*/
public static class AuthenticationResult {
private final CryptoObject mCryptoObject;
AuthenticationResult(CryptoObject crypto) {
mCryptoObject = crypto;
}
/**
* Gets the {@link CryptoObject} associated with this transaction.
*
* @return The {@link CryptoObject} provided to {@code authenticate()}.
*/
@Nullable
public CryptoObject getCryptoObject() {
return mCryptoObject;
}
}
/**
* A collection of methods that may be invoked by {@link BiometricPrompt} during authentication.
*/
public abstract static class AuthenticationCallback {
/**
* Called when an unrecoverable error has been encountered and authentication has stopped.
*
* <p>After this method is called, no further events will be sent for the current
* authentication session.
*
* @param errorCode An integer ID associated with the error.
* @param errString A human-readable string that describes the error.
*/
public void onAuthenticationError(
@BiometricError int errorCode, @NonNull CharSequence errString) {}
/**
* Called when a biometric (e.g. fingerprint, face, etc.) is recognized, indicating that the
* user has successfully authenticated.
*
* <p>After this method is called, no further events will be sent for the current
* authentication session.
*
* @param result An object containing authentication-related data.
*/
public void onAuthenticationSucceeded(@NonNull AuthenticationResult result) {}
/**
* Called when a biometric (e.g. fingerprint, face, etc.) is presented but not recognized as
* belonging to the user.
*/
public void onAuthenticationFailed() {}
}
/**
* A set of configurable options for how the {@link BiometricPrompt} should appear and behave.
*/
public static class PromptInfo {
/**
* A builder used to set individual options for the {@link PromptInfo} class.
*/
public static class Builder {
private final Bundle mBundle = new Bundle();
/**
* Required: Sets the title for the prompt.
*
* @param title The title to be displayed on the prompt.
* @return This builder.
*/
@NonNull
public Builder setTitle(@NonNull CharSequence title) {
mBundle.putCharSequence(KEY_TITLE, title);
return this;
}
/**
* Optional: Sets the subtitle for the prompt.
*
* @param subtitle The subtitle to be displayed on the prompt.
* @return This builder.
*/
@NonNull
public Builder setSubtitle(@Nullable CharSequence subtitle) {
mBundle.putCharSequence(KEY_SUBTITLE, subtitle);
return this;
}
/**
* Optional: Sets the description for the prompt.
*
* @param description The description to be displayed on the prompt.
* @return This builder.
*/
@NonNull
public Builder setDescription(@Nullable CharSequence description) {
mBundle.putCharSequence(KEY_DESCRIPTION, description);
return this;
}
/**
* Required: Sets the text for the negative button on the prompt.
*
* <p>Note that this option is incompatible with
* {@link PromptInfo.Builder#setDeviceCredentialAllowed(boolean)} and must NOT be set
* if the latter is enabled.
*
* @param negativeButtonText The label to be used for the negative button on the prompt.
* @return This builder.
*/
@NonNull
public Builder setNegativeButtonText(@NonNull CharSequence negativeButtonText) {
mBundle.putCharSequence(KEY_NEGATIVE_TEXT, negativeButtonText);
return this;
}
/**
* Optional: Sets a system hint for whether to require explicit user confirmation after
* a passive biometric (e.g. iris or face) has been recognized but before
* {@link AuthenticationCallback#onAuthenticationSucceeded(AuthenticationResult)} is
* called. Defaults to {@code true}.
*
* <p>Disabling this option is generally only appropriate for frequent, low-value
* transactions, such as re-authenticating for a previously authorized application.
*
* <p>Also note that, as it is merely a hint, this option may be ignored by the system.
* For example, the system may choose to instead always require confirmation if the user
* has disabled passive authentication for their device in Settings. Additionally, this
* option will be ignored on devices running OS versions prior to Android 10 (API 29).
*
* @param confirmationRequired Whether this option should be enabled.
* @return This builder.
*/
@NonNull
public Builder setConfirmationRequired(boolean confirmationRequired) {
mBundle.putBoolean(KEY_REQUIRE_CONFIRMATION, confirmationRequired);
return this;
}
/**
* Optional: Sets whether the user should be given the option to authenticate with
* their device PIN, pattern, or password instead of a biometric. Defaults to
* {@code false}.
*
* <p>Note that this option is incompatible with
* {@link PromptInfo.Builder#setNegativeButtonText(CharSequence)} and must NOT be
* enabled if the latter is set.
*
* <p>Before enabling this option, developers should check whether the device is secure
* by calling {@link android.app.KeyguardManager#isDeviceSecure()}. If the device is not
* secure, authentication will fail with {@link #ERROR_NO_DEVICE_CREDENTIAL}.
*
* <p>On versions prior to Android 10 (API 29), calls to
* {@link #cancelAuthentication()} will not work as expected after the
* user has chosen to authenticate with their device credential. This is because the
* library internally launches a separate activity (by calling
* {@link android.app.KeyguardManager#createConfirmDeviceCredentialIntent(CharSequence,
* CharSequence)}) that does not have a public API for cancellation.
*
* @param deviceCredentialAllowed Whether this option should be enabled.
* @return This builder.
*/
@SuppressWarnings("deprecation")
@NonNull
public Builder setDeviceCredentialAllowed(boolean deviceCredentialAllowed) {
mBundle.putBoolean(KEY_ALLOW_DEVICE_CREDENTIAL, deviceCredentialAllowed);
return this;
}
/**
* Creates a {@link PromptInfo} object with the specified options.
*
* @return The {@link PromptInfo} object.
* @throws IllegalArgumentException If any required option is not set, or if any
* illegal combination of options is present.
*/
@NonNull
public PromptInfo build() {
final CharSequence title = mBundle.getCharSequence(KEY_TITLE);
final CharSequence negativeText = mBundle.getCharSequence(KEY_NEGATIVE_TEXT);
final boolean allowDeviceCredential =
mBundle.getBoolean(KEY_ALLOW_DEVICE_CREDENTIAL);
if (TextUtils.isEmpty(title)) {
throw new IllegalArgumentException("Title must be set and non-empty.");
}
if (TextUtils.isEmpty(negativeText) && !allowDeviceCredential) {
throw new IllegalArgumentException("Negative text must be set and non-empty.");
}
if (!TextUtils.isEmpty(negativeText) && allowDeviceCredential) {
throw new IllegalArgumentException("Negative text must not be set if device "
+ "credential authentication is allowed.");
}
return new PromptInfo(mBundle);
}
}
@NonNull private Bundle mBundle;
// Prevent direct instantiation.
@SuppressWarnings("WeakerAccess") /* synthetic access */
PromptInfo(@NonNull Bundle bundle) {
mBundle = bundle;
}
/**
* Gets a bundle containing the options that have been set for the prompt.
*
* @return A bundle of set options.
*/
@NonNull
Bundle getBundle() {
return mBundle;
}
/**
* See {@link Builder#setTitle(CharSequence)}.
*
* @return The title to be displayed on the prompt.
*/
@NonNull
public CharSequence getTitle() {
return mBundle.getCharSequence(KEY_TITLE, "");
}
/**
* See {@link Builder#setSubtitle(CharSequence)}.
*
* @return The subtitle to be displayed on the prompt.
*/
@Nullable
public CharSequence getSubtitle() {
return mBundle.getCharSequence(KEY_SUBTITLE);
}
/**
* See {@link Builder#setDescription(CharSequence)}.
*
* @return The description to be displayed on the prompt.
*/
@Nullable
public CharSequence getDescription() {
return mBundle.getCharSequence(KEY_DESCRIPTION);
}
/**
* See {@link Builder#setNegativeButtonText(CharSequence)}.
*
* @return The label to be used for the negative button on the prompt.
*/
@NonNull
public CharSequence getNegativeButtonText() {
return mBundle.getCharSequence(KEY_NEGATIVE_TEXT, "");
}
/**
* See {@link Builder#setConfirmationRequired(boolean)}.
*
* @return Whether this option is enabled.
*/
public boolean isConfirmationRequired() {
return mBundle.getBoolean(KEY_REQUIRE_CONFIRMATION);
}
/**
* See {@link Builder#setDeviceCredentialAllowed(boolean)}.
*
* @return Whether this option is enabled.
*/
public boolean isDeviceCredentialAllowed() {
return mBundle.getBoolean(KEY_ALLOW_DEVICE_CREDENTIAL);
}
}
/**
* The fragment manager that will be used to attach the prompt to the client activity.
*/
@Nullable private FragmentManager mClientFragmentManager;
/**
* Constructs a {@link BiometricPrompt}, which can be used to prompt the user to authenticate
* with a biometric such as fingerprint or face. The prompt can be shown to the user by calling
* {@code authenticate()} and persists across device configuration changes by default.
*
* <p>If authentication is in progress, calling this constructor to recreate the prompt will
* also update the {@link Executor} and {@link AuthenticationCallback} for the current session.
* Thus, this method should be called by the client activity each time the configuration changes
* (e.g. in {@code onCreate()}).
*
* @param activity The activity of the client application that will host the prompt.
* @param executor The executor that will be used to run {@link AuthenticationCallback} methods.
* @param callback The object that will receive and process authentication events.
*
* @see #BiometricPrompt(Fragment, Executor, AuthenticationCallback)
*/
@SuppressLint("LambdaLast")
@SuppressWarnings("ConstantConditions")
public BiometricPrompt(
@NonNull FragmentActivity activity,
@NonNull Executor executor,
@NonNull AuthenticationCallback callback) {
if (activity == null) {
throw new IllegalArgumentException("FragmentActivity must not be null");
}
if (executor == null) {
throw new IllegalArgumentException("Executor must not be null");
}
if (callback == null) {
throw new IllegalArgumentException("AuthenticationCallback must not be null");
}
init(activity, activity.getSupportFragmentManager(), executor, callback);
}
/**
* Constructs a {@link BiometricPrompt}, which can be used to prompt the user to authenticate
* with a biometric such as fingerprint or face. The prompt can be shown to the user by calling
* {@code authenticate()} and persists across device configuration changes by default.
*
* <p>If authentication is in progress, calling this constructor to recreate the prompt will
* also update the {@link Executor} and {@link AuthenticationCallback} for the current session.
* Thus, this method should be called by the client fragment each time the configuration changes
* (e.g. in {@code onCreate()}).
*
* @param fragment The fragment of the client application that will host the prompt.
* @param executor The executor that will be used to run {@link AuthenticationCallback} methods.
* @param callback The object that will receive and process authentication events.
*
* @see #BiometricPrompt(FragmentActivity, Executor, AuthenticationCallback)
*/
@SuppressLint("LambdaLast")
@SuppressWarnings("ConstantConditions")
public BiometricPrompt(
@NonNull Fragment fragment,
@NonNull Executor executor,
@NonNull AuthenticationCallback callback) {
if (fragment == null) {
throw new IllegalArgumentException("Fragment must not be null");
}
if (executor == null) {
throw new IllegalArgumentException("Executor must not be null");
}
if (callback == null) {
throw new IllegalArgumentException("AuthenticationCallback must not be null");
}
init(fragment.getActivity(), fragment.getChildFragmentManager(), executor, callback);
}
/**
* Initializes or updates the data needed by the prompt.
*
* @param activity The client activity that will host the prompt.
* @param fragmentManager The fragment manager that will be used to attach the prompt.
* @param executor The executor that will be used to run {@link AuthenticationCallback} methods.
* @param callback The object that will receive and process authentication events.
*/
private void init(
@Nullable FragmentActivity activity,
@Nullable FragmentManager fragmentManager,
@NonNull Executor executor,
@NonNull AuthenticationCallback callback) {
mClientFragmentManager = fragmentManager;
if (activity != null) {
final BiometricViewModel viewModel =
new ViewModelProvider(activity).get(BiometricViewModel.class);
viewModel.setClientExecutor(executor);
viewModel.setClientCallback(callback);
}
}
/**
* Shows the biometric prompt to the user. The prompt survives lifecycle changes by default. To
* cancel authentication and dismiss the prompt, use {@link #cancelAuthentication()}.
*
* @param info A {@link PromptInfo} object describing the appearance and behavior of the prompt.
* @param crypto A crypto object to be associated with this authentication.
*
* @see #authenticate(PromptInfo)
*/
@SuppressWarnings("ConstantConditions")
public void authenticate(@NonNull PromptInfo info, @NonNull CryptoObject crypto) {
if (info == null) {
throw new IllegalArgumentException("PromptInfo cannot be null.");
}
if (crypto == null) {
throw new IllegalArgumentException("CryptoObject cannot be null.");
}
if (info.getBundle().getBoolean(KEY_ALLOW_DEVICE_CREDENTIAL)) {
throw new IllegalArgumentException("Device credential not supported with crypto.");
}
authenticateInternal(info, crypto);
}
/**
* Shows the biometric prompt to the user. The prompt survives lifecycle changes by default. To
* cancel authentication and dismiss the prompt, use {@link #cancelAuthentication()}.
*
* @param info A {@link PromptInfo} object describing the appearance and behavior of the prompt.
*
* @see #authenticate(PromptInfo, CryptoObject)
*/
@SuppressWarnings("ConstantConditions")
public void authenticate(@NonNull PromptInfo info) {
if (info == null) {
throw new IllegalArgumentException("PromptInfo cannot be null.");
}
authenticateInternal(info, null /* crypto */);
}
/**
* Shows the biometric prompt to the user and begins authentication.
*
* @param info A {@link PromptInfo} object describing the appearance and behavior of the prompt.
* @param crypto A crypto object to be associated with this authentication.
*/
private void authenticateInternal(@NonNull PromptInfo info, @Nullable CryptoObject crypto) {
if (mClientFragmentManager == null) {
Log.e(TAG, "Unable to start authentication. Client fragment manager was null.");
return;
}
if (mClientFragmentManager.isStateSaved()) {
Log.e(TAG, "Unable to start authentication. Called after onSaveInstanceState().");
return;
}
final BiometricFragment biometricFragment =
findOrAddBiometricFragment(mClientFragmentManager);
biometricFragment.authenticate(info, crypto);
}
/**
* Cancels the ongoing authentication session and dismisses the prompt.
*
* <p>On versions prior to Android 10 (API 29), calling this method while the user is
* authenticating with their device credential will NOT work as expected. See
* {@link PromptInfo.Builder#setDeviceCredentialAllowed(boolean)} for more details.
*/
public void cancelAuthentication() {
if (mClientFragmentManager == null) {
Log.e(TAG, "Unable to start authentication. Client fragment manager was null.");
return;
}
final BiometricFragment biometricFragment = findBiometricFragment(mClientFragmentManager);
if (biometricFragment == null) {
Log.e(TAG, "Unable to cancel authentication. BiometricFragment not found.");
return;
}
biometricFragment.cancelAuthentication(BiometricFragment.CANCELED_FROM_NONE);
}
/**
* Searches for a {@link BiometricFragment} instance that has been added to an activity or
* fragment.
*
* @param fragmentManager The fragment manager that will be used to search for the fragment.
* @return An instance of {@link BiometricFragment} found by the fragment manager, or {@code
* null} if no such fragment is found.
*/
@Nullable
private static BiometricFragment findBiometricFragment(
@NonNull FragmentManager fragmentManager) {
return (BiometricFragment) fragmentManager.findFragmentByTag(
BiometricPrompt.BIOMETRIC_FRAGMENT_TAG);
}
/**
* Returns a {@link BiometricFragment} instance that has been added to an activity or fragment,
* adding one if necessary.
*
* @param fragmentManager The fragment manager used to search for and/or add the fragment.
* @return An instance of {@link BiometricFragment} associated with the fragment manager.
*/
@NonNull
private static BiometricFragment findOrAddBiometricFragment(
@NonNull FragmentManager fragmentManager) {
BiometricFragment biometricFragment = findBiometricFragment(fragmentManager);
// If the fragment hasn't been added before, add it.
if (biometricFragment == null) {
biometricFragment = BiometricFragment.newInstance();
fragmentManager.beginTransaction()
.add(biometricFragment, BiometricPrompt.BIOMETRIC_FRAGMENT_TAG)
.commitAllowingStateLoss();
// For the case when onResume() is being called right after authenticate,
// we need to make sure that all fragment transactions have been committed.
fragmentManager.executePendingTransactions();
}
return biometricFragment;
}
}