| /* |
| * 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.app.Activity; |
| import android.app.KeyguardManager; |
| import android.content.Context; |
| import android.content.DialogInterface; |
| import android.content.Intent; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.text.TextUtils; |
| import android.util.Log; |
| |
| import androidx.annotation.IntDef; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.RequiresApi; |
| import androidx.annotation.RestrictTo; |
| import androidx.annotation.VisibleForTesting; |
| import androidx.biometric.BiometricManager.Authenticators; |
| import androidx.fragment.app.Fragment; |
| import androidx.fragment.app.FragmentActivity; |
| import androidx.fragment.app.FragmentManager; |
| import androidx.lifecycle.Observer; |
| import androidx.lifecycle.ViewModelProvider; |
| |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| import java.lang.ref.WeakReference; |
| import java.util.concurrent.Executor; |
| |
| /** |
| * A fragment that hosts the system-dependent UI for {@link BiometricPrompt} and coordinates logic |
| * for the ongoing authentication session across device configuration changes. |
| * |
| * @hide |
| */ |
| @RestrictTo(RestrictTo.Scope.LIBRARY) |
| public class BiometricFragment extends Fragment { |
| private static final String TAG = "BiometricFragment"; |
| |
| /** |
| * Authentication was canceled by the library or framework. |
| */ |
| static final int CANCELED_FROM_INTERNAL = 0; |
| |
| /** |
| * Authentication was canceled by the user (e.g. by pressing the system back button). |
| */ |
| static final int CANCELED_FROM_USER = 1; |
| |
| /** |
| * Authentication was canceled by the user by pressing the negative button on the prompt. |
| */ |
| static final int CANCELED_FROM_NEGATIVE_BUTTON = 2; |
| |
| /** |
| * Authentication was canceled by the client application via |
| * {@link BiometricPrompt#cancelAuthentication()}. |
| */ |
| static final int CANCELED_FROM_CLIENT = 3; |
| |
| /** |
| * Where authentication was canceled from. |
| */ |
| @IntDef({ |
| CANCELED_FROM_INTERNAL, |
| CANCELED_FROM_USER, |
| CANCELED_FROM_NEGATIVE_BUTTON, |
| CANCELED_FROM_CLIENT |
| }) |
| @Retention(RetentionPolicy.SOURCE) |
| @interface CanceledFrom {} |
| |
| /** |
| * Tag used to identify the {@link FingerprintDialogFragment} attached to the client |
| * activity/fragment. |
| */ |
| private static final String FINGERPRINT_DIALOG_FRAGMENT_TAG = |
| "androidx.biometric.FingerprintDialogFragment"; |
| |
| /** |
| * The amount of time (in milliseconds) before the flag indicating whether to dismiss the |
| * fingerprint dialog instantly can be changed. |
| */ |
| private static final int DISMISS_INSTANTLY_DELAY_MS = 500; |
| |
| /** |
| * The amount of time (in milliseconds) to wait before dismissing the fingerprint dialog after |
| * encountering an error. Ignored if |
| * {@link DeviceUtils#shouldHideFingerprintDialog(Context, String)} is {@code true}. |
| */ |
| private static final int HIDE_DIALOG_DELAY_MS = 2000; |
| |
| /** |
| * The amount of time (in milliseconds) to wait before showing the authentication UI if |
| * {@link BiometricViewModel#isDelayingPrompt()} is {@code true}. |
| */ |
| private static final int SHOW_PROMPT_DELAY_MS = 600; |
| |
| /** |
| * Request code used when launching the confirm device credential Settings activity. |
| */ |
| private static final int REQUEST_CONFIRM_CREDENTIAL = 1; |
| |
| /** |
| * An executor used by {@link android.hardware.biometrics.BiometricPrompt} to run framework |
| * code. |
| */ |
| private static class PromptExecutor implements Executor { |
| private final Handler mPromptHandler = new Handler(Looper.getMainLooper()); |
| |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| PromptExecutor() {} |
| |
| @Override |
| public void execute(@NonNull Runnable runnable) { |
| mPromptHandler.post(runnable); |
| } |
| } |
| |
| /** |
| * A runnable with a weak reference to this fragment that can be used to invoke |
| * {@link #showPromptForAuthentication()}. |
| */ |
| private static class ShowPromptForAuthenticationRunnable implements Runnable { |
| @NonNull private final WeakReference<BiometricFragment> mFragmentRef; |
| |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| ShowPromptForAuthenticationRunnable(@Nullable BiometricFragment fragment) { |
| mFragmentRef = new WeakReference<>(fragment); |
| } |
| |
| @Override |
| public void run() { |
| if (mFragmentRef.get() != null) { |
| mFragmentRef.get().showPromptForAuthentication(); |
| } |
| } |
| } |
| |
| /** |
| * A runnable with a weak reference to a {@link BiometricViewModel} that can be used to invoke |
| * {@link BiometricViewModel#setDelayingPrompt(boolean)} with a value of {@code false}. |
| */ |
| private static class StopDelayingPromptRunnable implements Runnable { |
| @NonNull private final WeakReference<BiometricViewModel> mViewModelRef; |
| |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| StopDelayingPromptRunnable(@Nullable BiometricViewModel viewModel) { |
| mViewModelRef = new WeakReference<>(viewModel); |
| } |
| |
| @Override |
| public void run() { |
| if (mViewModelRef.get() != null) { |
| mViewModelRef.get().setDelayingPrompt(false); |
| } |
| } |
| } |
| |
| /** |
| * A runnable with a weak reference to a {@link BiometricViewModel} that can be used to invoke |
| * {@link BiometricViewModel#setIgnoringCancel(boolean)} with a value of {@code false}. |
| */ |
| private static class StopIgnoringCancelRunnable implements Runnable { |
| @NonNull private final WeakReference<BiometricViewModel> mViewModelRef; |
| |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| StopIgnoringCancelRunnable(@Nullable BiometricViewModel viewModel) { |
| mViewModelRef = new WeakReference<>(viewModel); |
| } |
| |
| @Override |
| public void run() { |
| if (mViewModelRef.get() != null) { |
| mViewModelRef.get().setIgnoringCancel(false); |
| } |
| } |
| } |
| |
| /** |
| * A handler used to post delayed events. |
| */ |
| @VisibleForTesting Handler mHandler = new Handler(Looper.getMainLooper()); |
| |
| /** |
| * The view model for the ongoing authentication session. |
| */ |
| @VisibleForTesting BiometricViewModel mViewModel; |
| |
| /** |
| * Creates a new instance of {@link BiometricFragment}. |
| * |
| * @return A {@link BiometricFragment}. |
| */ |
| static BiometricFragment newInstance() { |
| return new BiometricFragment(); |
| } |
| |
| @Override |
| public void onCreate(@Nullable Bundle savedInstanceState) { |
| super.onCreate(savedInstanceState); |
| connectViewModel(); |
| } |
| |
| @Override |
| public void onStart() { |
| super.onStart(); |
| |
| // Some device credential implementations in API 29 cause the prompt to receive a cancel |
| // signal immediately after it's shown (b/162022588). |
| if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q |
| && AuthenticatorUtils.isDeviceCredentialAllowed( |
| mViewModel.getAllowedAuthenticators())) { |
| mViewModel.setIgnoringCancel(true); |
| mHandler.postDelayed(new StopIgnoringCancelRunnable(mViewModel), 250L); |
| } |
| } |
| |
| @Override |
| public void onStop() { |
| super.onStop(); |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q |
| && !mViewModel.isConfirmingDeviceCredential() |
| && !isChangingConfigurations()) { |
| cancelAuthentication(BiometricFragment.CANCELED_FROM_INTERNAL); |
| } |
| } |
| |
| @Override |
| public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { |
| super.onActivityResult(requestCode, resultCode, data); |
| if (requestCode == REQUEST_CONFIRM_CREDENTIAL) { |
| mViewModel.setConfirmingDeviceCredential(false); |
| handleConfirmCredentialResult(resultCode); |
| } |
| } |
| |
| /** |
| * Connects the {@link BiometricViewModel} for the ongoing authentication session to this |
| * fragment. |
| */ |
| private void connectViewModel() { |
| final FragmentActivity activity = getActivity(); |
| if (activity == null) { |
| return; |
| } |
| mViewModel = new ViewModelProvider(getActivity()).get(BiometricViewModel.class); |
| mViewModel.setClientActivity(activity); |
| mViewModel.getAuthenticationResult().observe( |
| this, |
| new Observer<BiometricPrompt.AuthenticationResult>() { |
| @Override |
| public void onChanged( |
| BiometricPrompt.AuthenticationResult authenticationResult) { |
| if (authenticationResult != null) { |
| onAuthenticationSucceeded(authenticationResult); |
| mViewModel.setAuthenticationResult(null); |
| } |
| } |
| }); |
| |
| mViewModel.getAuthenticationError().observe( |
| this, |
| new Observer<BiometricErrorData>() { |
| @Override |
| public void onChanged(BiometricErrorData authenticationError) { |
| if (authenticationError != null) { |
| onAuthenticationError( |
| authenticationError.getErrorCode(), |
| authenticationError.getErrorMessage()); |
| mViewModel.setAuthenticationError(null); |
| } |
| } |
| }); |
| |
| mViewModel.getAuthenticationHelpMessage().observe( |
| this, |
| new Observer<CharSequence>() { |
| @Override |
| public void onChanged(CharSequence authenticationHelpMessage) { |
| if (authenticationHelpMessage != null) { |
| onAuthenticationHelp(authenticationHelpMessage); |
| mViewModel.setAuthenticationError(null); |
| } |
| } |
| }); |
| |
| mViewModel.isAuthenticationFailurePending().observe( |
| this, |
| new Observer<Boolean>() { |
| @Override |
| public void onChanged(Boolean authenticationFailurePending) { |
| if (authenticationFailurePending) { |
| onAuthenticationFailed(); |
| mViewModel.setAuthenticationFailurePending(false); |
| } |
| } |
| }); |
| |
| mViewModel.isNegativeButtonPressPending().observe( |
| this, |
| new Observer<Boolean>() { |
| @Override |
| public void onChanged(Boolean negativeButtonPressPending) { |
| if (negativeButtonPressPending) { |
| if (isManagingDeviceCredentialButton()) { |
| onDeviceCredentialButtonPressed(); |
| } else { |
| onCancelButtonPressed(); |
| } |
| mViewModel.setNegativeButtonPressPending(false); |
| } |
| } |
| }); |
| |
| mViewModel.isFingerprintDialogCancelPending().observe( |
| this, |
| new Observer<Boolean>() { |
| @Override |
| public void onChanged(Boolean fingerprintDialogCancelPending) { |
| if (fingerprintDialogCancelPending) { |
| cancelAuthentication(BiometricFragment.CANCELED_FROM_USER); |
| dismiss(); |
| mViewModel.setFingerprintDialogCancelPending(false); |
| } |
| } |
| }); |
| } |
| |
| /** |
| * Shows the prompt UI to the user and begins an authentication session. |
| * |
| * @param info An object describing the appearance and behavior of the prompt. |
| * @param crypto A crypto object to be associated with this authentication. |
| */ |
| void authenticate( |
| @NonNull BiometricPrompt.PromptInfo info, |
| @Nullable BiometricPrompt.CryptoObject crypto) { |
| |
| final FragmentActivity activity = getActivity(); |
| if (activity == null) { |
| Log.e(TAG, "Not launching prompt. Client activity was null."); |
| return; |
| } |
| |
| mViewModel.setPromptInfo(info); |
| |
| // Use a fake crypto object to force Strong biometric auth prior to Android 11 (API 30). |
| @BiometricManager.AuthenticatorTypes final int authenticators = |
| AuthenticatorUtils.getConsolidatedAuthenticators(info, crypto); |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M |
| && Build.VERSION.SDK_INT < Build.VERSION_CODES.R |
| && authenticators == Authenticators.BIOMETRIC_STRONG |
| && crypto == null) { |
| mViewModel.setCryptoObject(CryptoObjectUtils.createFakeCryptoObject()); |
| } else { |
| mViewModel.setCryptoObject(crypto); |
| } |
| |
| if (isManagingDeviceCredentialButton()) { |
| mViewModel.setNegativeButtonTextOverride( |
| getString(R.string.confirm_device_credential_password)); |
| } else { |
| // Don't override the negative button text from the client. |
| mViewModel.setNegativeButtonTextOverride(null); |
| } |
| |
| // Fall back to device credential immediately if no known biometrics are available. |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP |
| && isManagingDeviceCredentialButton() |
| && BiometricManager.from(activity).canAuthenticate(Authenticators.BIOMETRIC_WEAK) |
| != BiometricManager.BIOMETRIC_SUCCESS) { |
| mViewModel.setAwaitingResult(true); |
| launchConfirmCredentialActivity(); |
| return; |
| } |
| |
| // Check if we should delay showing the authentication prompt. |
| if (mViewModel.isDelayingPrompt()) { |
| mHandler.postDelayed( |
| new ShowPromptForAuthenticationRunnable(this), SHOW_PROMPT_DELAY_MS); |
| } else { |
| showPromptForAuthentication(); |
| } |
| } |
| |
| /** |
| * Shows either the framework biometric prompt or fingerprint UI dialog to the user and begins |
| * authentication. |
| */ |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| void showPromptForAuthentication() { |
| if (!mViewModel.isPromptShowing()) { |
| if (getContext() == null) { |
| Log.w(TAG, "Not showing biometric prompt. Context is null."); |
| return; |
| } |
| |
| mViewModel.setPromptShowing(true); |
| mViewModel.setAwaitingResult(true); |
| if (isUsingFingerprintDialog()) { |
| showFingerprintDialogForAuthentication(); |
| } else { |
| showBiometricPromptForAuthentication(); |
| } |
| } |
| } |
| |
| /** |
| * Shows the fingerprint dialog UI to the user and begins authentication. |
| */ |
| @SuppressWarnings("deprecation") |
| private void showFingerprintDialogForAuthentication() { |
| final Context context = requireContext().getApplicationContext(); |
| androidx.core.hardware.fingerprint.FingerprintManagerCompat fingerprintManagerCompat = |
| androidx.core.hardware.fingerprint.FingerprintManagerCompat.from(context); |
| final int errorCode = checkForFingerprintPreAuthenticationErrors(fingerprintManagerCompat); |
| if (errorCode != BiometricPrompt.BIOMETRIC_SUCCESS) { |
| sendErrorAndDismiss( |
| errorCode, ErrorUtils.getFingerprintErrorString(context, errorCode)); |
| return; |
| } |
| |
| if (isAdded()) { |
| mViewModel.setFingerprintDialogDismissedInstantly(true); |
| if (!DeviceUtils.shouldHideFingerprintDialog(context, Build.MODEL)) { |
| mHandler.postDelayed( |
| new Runnable() { |
| @Override |
| public void run() { |
| mViewModel.setFingerprintDialogDismissedInstantly(false); |
| } |
| }, |
| DISMISS_INSTANTLY_DELAY_MS); |
| |
| final FingerprintDialogFragment dialog = FingerprintDialogFragment.newInstance(); |
| dialog.show(getParentFragmentManager(), FINGERPRINT_DIALOG_FRAGMENT_TAG); |
| } |
| |
| mViewModel.setCanceledFrom(CANCELED_FROM_INTERNAL); |
| |
| authenticateWithFingerprint(fingerprintManagerCompat, context); |
| } |
| } |
| |
| /** |
| * Shows the framework {@link android.hardware.biometrics.BiometricPrompt} UI to the user and |
| * begins authentication. |
| */ |
| @RequiresApi(Build.VERSION_CODES.P) |
| private void showBiometricPromptForAuthentication() { |
| final android.hardware.biometrics.BiometricPrompt.Builder builder = |
| Api28Impl.createPromptBuilder(requireContext().getApplicationContext()); |
| |
| final CharSequence title = mViewModel.getTitle(); |
| final CharSequence subtitle = mViewModel.getSubtitle(); |
| final CharSequence description = mViewModel.getDescription(); |
| if (title != null) { |
| Api28Impl.setTitle(builder, title); |
| } |
| if (subtitle != null) { |
| Api28Impl.setSubtitle(builder, subtitle); |
| } |
| if (description != null) { |
| Api28Impl.setDescription(builder, description); |
| } |
| |
| final CharSequence negativeButtonText = mViewModel.getNegativeButtonText(); |
| if (!TextUtils.isEmpty(negativeButtonText)) { |
| Api28Impl.setNegativeButton( |
| builder, |
| negativeButtonText, |
| mViewModel.getClientExecutor(), |
| mViewModel.getNegativeButtonListener()); |
| } |
| |
| // Set the confirmation required option introduced in Android 10 (API 29). |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { |
| Api29Impl.setConfirmationRequired(builder, mViewModel.isConfirmationRequired()); |
| } |
| |
| // Set or emulate the allowed authenticators option introduced in Android 11 (API 30). |
| @BiometricManager.AuthenticatorTypes final int authenticators = |
| mViewModel.getAllowedAuthenticators(); |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { |
| Api30Impl.setAllowedAuthenticators(builder, authenticators); |
| } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { |
| Api29Impl.setDeviceCredentialAllowed( |
| builder, AuthenticatorUtils.isDeviceCredentialAllowed(authenticators)); |
| } |
| |
| authenticateWithBiometricPrompt(Api28Impl.buildPrompt(builder), getContext()); |
| } |
| |
| /** |
| * Requests user authentication with the given fingerprint manager. |
| * |
| * @param fingerprintManager The fingerprint manager that will be used for authentication. |
| * @param context The application context. |
| */ |
| @SuppressWarnings("deprecation") |
| @VisibleForTesting |
| void authenticateWithFingerprint( |
| @NonNull androidx.core.hardware.fingerprint.FingerprintManagerCompat fingerprintManager, |
| @NonNull Context context) { |
| |
| final androidx.core.hardware.fingerprint.FingerprintManagerCompat.CryptoObject crypto = |
| CryptoObjectUtils.wrapForFingerprintManager(mViewModel.getCryptoObject()); |
| final androidx.core.os.CancellationSignal cancellationSignal = |
| mViewModel.getCancellationSignalProvider().getFingerprintCancellationSignal(); |
| final androidx.core.hardware.fingerprint.FingerprintManagerCompat.AuthenticationCallback |
| callback = mViewModel.getAuthenticationCallbackProvider() |
| .getFingerprintCallback(); |
| |
| try { |
| fingerprintManager.authenticate( |
| crypto, 0 /* flags */, cancellationSignal, callback, null /* handler */); |
| } catch (NullPointerException e) { |
| // Catch and handle NPE if thrown by framework call to authenticate() (b/151316421). |
| Log.e(TAG, "Got NPE while authenticating with fingerprint.", e); |
| final int errorCode = BiometricPrompt.ERROR_HW_UNAVAILABLE; |
| sendErrorAndDismiss( |
| errorCode, ErrorUtils.getFingerprintErrorString(context, errorCode)); |
| } |
| } |
| |
| /** |
| * Requests user authentication with the given framework biometric prompt. |
| * |
| * @param biometricPrompt The biometric prompt that will be used for authentication. |
| * @param context An application or activity context. |
| */ |
| @RequiresApi(Build.VERSION_CODES.P) |
| @VisibleForTesting |
| void authenticateWithBiometricPrompt( |
| @NonNull android.hardware.biometrics.BiometricPrompt biometricPrompt, |
| @Nullable Context context) { |
| |
| final android.hardware.biometrics.BiometricPrompt.CryptoObject cryptoObject = |
| CryptoObjectUtils.wrapForBiometricPrompt(mViewModel.getCryptoObject()); |
| final android.os.CancellationSignal cancellationSignal = |
| mViewModel.getCancellationSignalProvider().getBiometricCancellationSignal(); |
| final Executor executor = new PromptExecutor(); |
| final android.hardware.biometrics.BiometricPrompt.AuthenticationCallback callback = |
| mViewModel.getAuthenticationCallbackProvider().getBiometricCallback(); |
| |
| try { |
| if (cryptoObject == null) { |
| Api28Impl.authenticate(biometricPrompt, cancellationSignal, executor, callback); |
| } else { |
| Api28Impl.authenticate( |
| biometricPrompt, cryptoObject, cancellationSignal, executor, callback); |
| } |
| } catch (NullPointerException e) { |
| // Catch and handle NPE if thrown by framework call to authenticate() (b/151316421). |
| Log.e(TAG, "Got NPE while authenticating with biometric prompt.", e); |
| final int errorCode = BiometricPrompt.ERROR_HW_UNAVAILABLE; |
| final String errorString = context != null |
| ? context.getString(R.string.default_error_msg) |
| : ""; |
| sendErrorAndDismiss(errorCode, errorString); |
| } |
| } |
| |
| /** |
| * Cancels the ongoing authentication session and sends an error to the client callback. |
| * |
| * @param canceledFrom Where authentication was canceled from. |
| */ |
| void cancelAuthentication(@CanceledFrom int canceledFrom) { |
| if (canceledFrom != CANCELED_FROM_CLIENT && mViewModel.isIgnoringCancel()) { |
| return; |
| } |
| |
| if (isUsingFingerprintDialog()) { |
| mViewModel.setCanceledFrom(canceledFrom); |
| if (canceledFrom == CANCELED_FROM_USER) { |
| final int errorCode = BiometricPrompt.ERROR_USER_CANCELED; |
| sendErrorToClient( |
| errorCode, ErrorUtils.getFingerprintErrorString(getContext(), errorCode)); |
| } |
| } |
| |
| mViewModel.getCancellationSignalProvider().cancel(); |
| } |
| |
| /** |
| * Removes this fragment and any associated UI from the client activity/fragment. |
| */ |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| void dismiss() { |
| mViewModel.setPromptShowing(false); |
| dismissFingerprintDialog(); |
| if (!mViewModel.isConfirmingDeviceCredential() && isAdded()) { |
| getParentFragmentManager().beginTransaction().remove(this).commitAllowingStateLoss(); |
| } |
| |
| // Wait before showing again to work around a dismissal logic issue on API 29 (b/157783075). |
| final Context context = getContext(); |
| if (context != null && DeviceUtils.shouldDelayShowingPrompt(context, Build.MODEL)) { |
| mViewModel.setDelayingPrompt(true); |
| mHandler.postDelayed(new StopDelayingPromptRunnable(mViewModel), SHOW_PROMPT_DELAY_MS); |
| } |
| } |
| |
| /** |
| * Removes the fingerprint dialog UI from the client activity/fragment. |
| */ |
| private void dismissFingerprintDialog() { |
| mViewModel.setPromptShowing(false); |
| if (isAdded()) { |
| final FragmentManager fragmentManager = getParentFragmentManager(); |
| final FingerprintDialogFragment fingerprintDialog = |
| (FingerprintDialogFragment) fragmentManager.findFragmentByTag( |
| FINGERPRINT_DIALOG_FRAGMENT_TAG); |
| if (fingerprintDialog != null) { |
| if (fingerprintDialog.isAdded()) { |
| fingerprintDialog.dismissAllowingStateLoss(); |
| } else { |
| fragmentManager.beginTransaction().remove(fingerprintDialog) |
| .commitAllowingStateLoss(); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Callback that is run when the view model receives a successful authentication result. |
| * |
| * @param result An object containing authentication-related data. |
| */ |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| @VisibleForTesting |
| void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) { |
| sendSuccessAndDismiss(result); |
| } |
| |
| /** |
| * Callback that is run when the view model receives an unrecoverable error result. |
| * |
| * @param errorCode An integer ID associated with the error. |
| * @param errorMessage A human-readable string that describes the error. |
| */ |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| @VisibleForTesting |
| void onAuthenticationError(int errorCode, @Nullable CharSequence errorMessage) { |
| // Ensure we're only sending publicly defined errors. |
| final int knownErrorCode = ErrorUtils.isKnownError(errorCode) |
| ? errorCode |
| : BiometricPrompt.ERROR_VENDOR; |
| |
| final Context context = getContext(); |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP |
| && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q |
| && ErrorUtils.isLockoutError(knownErrorCode) |
| && context != null |
| && KeyguardUtils.isDeviceSecuredWithCredential(context) |
| && AuthenticatorUtils.isDeviceCredentialAllowed( |
| mViewModel.getAllowedAuthenticators())) { |
| launchConfirmCredentialActivity(); |
| return; |
| } |
| |
| if (isUsingFingerprintDialog()) { |
| // Avoid passing a null error string to the client callback. |
| final CharSequence errorString = errorMessage != null |
| ? errorMessage |
| : ErrorUtils.getFingerprintErrorString(getContext(), knownErrorCode); |
| |
| if (knownErrorCode == BiometricPrompt.ERROR_CANCELED) { |
| // User-initiated cancellation errors should already be handled. |
| @CanceledFrom final int canceledFrom = mViewModel.getCanceledFrom(); |
| if (canceledFrom == CANCELED_FROM_INTERNAL |
| || canceledFrom == CANCELED_FROM_CLIENT) { |
| sendErrorToClient(knownErrorCode, errorString); |
| } |
| |
| dismiss(); |
| } else { |
| if (mViewModel.isFingerprintDialogDismissedInstantly()) { |
| sendErrorAndDismiss(knownErrorCode, errorString); |
| } else { |
| showFingerprintErrorMessage(errorString); |
| mHandler.postDelayed( |
| new Runnable() { |
| @Override |
| public void run() { |
| sendErrorAndDismiss(knownErrorCode, errorString); |
| } |
| }, |
| getDismissDialogDelay()); |
| } |
| |
| // Always set this to true. In case the user tries to authenticate again |
| // the UI will not be shown. |
| mViewModel.setFingerprintDialogDismissedInstantly(true); |
| } |
| } else { |
| final CharSequence errorString = errorMessage != null |
| ? errorMessage |
| : getString(R.string.default_error_msg) + " " + knownErrorCode; |
| sendErrorAndDismiss(knownErrorCode, errorString); |
| } |
| } |
| |
| /** |
| * Callback that is run when the view model receives a recoverable error or help message. |
| * |
| * @param helpMessage A human-readable error/help message. |
| */ |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| void onAuthenticationHelp(@NonNull CharSequence helpMessage) { |
| if (isUsingFingerprintDialog()) { |
| showFingerprintErrorMessage(helpMessage); |
| } |
| } |
| |
| /** |
| * Callback that is run when the view model reports a failed authentication attempt. |
| */ |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| void onAuthenticationFailed() { |
| if (isUsingFingerprintDialog()) { |
| showFingerprintErrorMessage(getString(R.string.fingerprint_not_recognized)); |
| } |
| sendFailureToClient(); |
| } |
| |
| /** |
| * Callback that is run when the view model reports that the device credential fallback |
| * button has been pressed on the prompt. |
| */ |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| void onDeviceCredentialButtonPressed() { |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { |
| Log.e(TAG, "Failed to check device credential. Not supported prior to API 21."); |
| return; |
| } |
| launchConfirmCredentialActivity(); |
| } |
| |
| /** |
| * Callback that is run when the view model reports that the cancel button has been pressed on |
| * the prompt. |
| */ |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| void onCancelButtonPressed() { |
| final CharSequence negativeButtonText = mViewModel.getNegativeButtonText(); |
| sendErrorAndDismiss( |
| BiometricPrompt.ERROR_NEGATIVE_BUTTON, |
| negativeButtonText != null |
| ? negativeButtonText |
| : getString(R.string.default_error_msg)); |
| cancelAuthentication(BiometricFragment.CANCELED_FROM_NEGATIVE_BUTTON); |
| } |
| |
| /** |
| * Launches the confirm device credential Settings activity, where the user can authenticate |
| * using their PIN, pattern, or password. |
| */ |
| @RequiresApi(Build.VERSION_CODES.LOLLIPOP) |
| private void launchConfirmCredentialActivity() { |
| final FragmentActivity activity = getActivity(); |
| if (activity == null) { |
| Log.e(TAG, "Failed to check device credential. Client FragmentActivity not found."); |
| return; |
| } |
| |
| // Get the KeyguardManager service in whichever way the platform supports. |
| final KeyguardManager keyguardManager = KeyguardUtils.getKeyguardManager(activity); |
| if (keyguardManager == null) { |
| sendErrorAndDismiss( |
| BiometricPrompt.ERROR_HW_NOT_PRESENT, |
| getString(R.string.generic_error_no_keyguard)); |
| return; |
| } |
| |
| // Pass along the title and subtitle/description from the biometric prompt. |
| final CharSequence title = mViewModel.getTitle(); |
| final CharSequence subtitle = mViewModel.getSubtitle(); |
| final CharSequence description = mViewModel.getDescription(); |
| final CharSequence credentialDescription = subtitle != null ? subtitle : description; |
| |
| final Intent intent = Api21Impl.createConfirmDeviceCredentialIntent( |
| keyguardManager, title, credentialDescription); |
| |
| // A null intent from KeyguardManager means that the device is not secure. |
| if (intent == null) { |
| sendErrorAndDismiss( |
| BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL, |
| getString(R.string.generic_error_no_device_credential)); |
| return; |
| } |
| |
| mViewModel.setConfirmingDeviceCredential(true); |
| |
| // Dismiss the fingerprint dialog before launching the activity. |
| if (isUsingFingerprintDialog()) { |
| dismissFingerprintDialog(); |
| } |
| |
| // Launch a new instance of the confirm device credential Settings activity. |
| intent.setFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK | Intent.FLAG_ACTIVITY_NEW_DOCUMENT); |
| startActivityForResult(intent, REQUEST_CONFIRM_CREDENTIAL); |
| } |
| |
| /** |
| * Processes the result returned by the confirm device credential Settings activity. |
| * |
| * @param resultCode The result code from the Settings activity. |
| */ |
| private void handleConfirmCredentialResult(int resultCode) { |
| if (resultCode == Activity.RESULT_OK) { |
| // Device credential auth succeeded. This is incompatible with crypto for API <30. |
| sendSuccessAndDismiss( |
| new BiometricPrompt.AuthenticationResult( |
| null /* crypto */, |
| BiometricPrompt.AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL)); |
| } else { |
| // Device credential auth failed. Assume this is due to the user canceling. |
| sendErrorAndDismiss( |
| BiometricPrompt.ERROR_USER_CANCELED, |
| getString(R.string.generic_error_user_canceled)); |
| } |
| } |
| |
| /** |
| * Updates the fingerprint dialog to show an error message to the user. |
| * |
| * @param errorMessage The error message to show on the dialog. |
| */ |
| private void showFingerprintErrorMessage(@Nullable CharSequence errorMessage) { |
| final CharSequence helpMessage = errorMessage != null |
| ? errorMessage |
| : getString(R.string.default_error_msg); |
| mViewModel.setFingerprintDialogState(FingerprintDialogFragment.STATE_FINGERPRINT_ERROR); |
| mViewModel.setFingerprintDialogHelpMessage(helpMessage); |
| } |
| |
| /** |
| * Sends a successful authentication result to the client and dismisses the prompt. |
| * |
| * @param result An object containing authentication-related data. |
| * |
| * @see #sendSuccessToClient(BiometricPrompt.AuthenticationResult) |
| */ |
| private void sendSuccessAndDismiss(@NonNull BiometricPrompt.AuthenticationResult result) { |
| sendSuccessToClient(result); |
| dismiss(); |
| } |
| |
| /** |
| * Sends an unrecoverable error result to the client and dismisses the prompt. |
| * |
| * @param errorCode An integer ID associated with the error. |
| * @param errorString A human-readable string that describes the error. |
| * |
| * @see #sendErrorToClient(int, CharSequence) |
| */ |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| void sendErrorAndDismiss(int errorCode, @NonNull CharSequence errorString) { |
| sendErrorToClient(errorCode, errorString); |
| dismiss(); |
| } |
| |
| |
| /** |
| * Sends a successful authentication result to the client callback. |
| * |
| * @param result An object containing authentication-related data. |
| * |
| * @see #sendSuccessAndDismiss(BiometricPrompt.AuthenticationResult) |
| * @see BiometricPrompt.AuthenticationCallback#onAuthenticationSucceeded( |
| * BiometricPrompt.AuthenticationResult) |
| */ |
| private void sendSuccessToClient(@NonNull final BiometricPrompt.AuthenticationResult result) { |
| if (!mViewModel.isAwaitingResult()) { |
| Log.w(TAG, "Success not sent to client. Client is not awaiting a result."); |
| return; |
| } |
| |
| mViewModel.setAwaitingResult(false); |
| mViewModel.getClientExecutor().execute( |
| new Runnable() { |
| @Override |
| public void run() { |
| mViewModel.getClientCallback().onAuthenticationSucceeded(result); |
| } |
| }); |
| } |
| |
| /** |
| * Sends an unrecoverable error result to the client callback. |
| * |
| * @param errorCode An integer ID associated with the error. |
| * @param errorString A human-readable string that describes the error. |
| * |
| * @see #sendErrorAndDismiss(int, CharSequence) |
| * @see BiometricPrompt.AuthenticationCallback#onAuthenticationError(int, CharSequence) |
| */ |
| private void sendErrorToClient(final int errorCode, @NonNull final CharSequence errorString) { |
| if (mViewModel.isConfirmingDeviceCredential()) { |
| Log.v(TAG, "Error not sent to client. User is confirming their device credential."); |
| return; |
| } |
| |
| if (!mViewModel.isAwaitingResult()) { |
| Log.w(TAG, "Error not sent to client. Client is not awaiting a result."); |
| return; |
| } |
| |
| mViewModel.setAwaitingResult(false); |
| mViewModel.getClientExecutor().execute(new Runnable() { |
| @Override |
| public void run() { |
| mViewModel.getClientCallback().onAuthenticationError(errorCode, errorString); |
| } |
| }); |
| } |
| |
| /** |
| * Sends an authentication failure event to the client callback. |
| * |
| * @see BiometricPrompt.AuthenticationCallback#onAuthenticationFailed() |
| */ |
| private void sendFailureToClient() { |
| if (!mViewModel.isAwaitingResult()) { |
| Log.w(TAG, "Failure not sent to client. Client is not awaiting a result."); |
| return; |
| } |
| |
| mViewModel.getClientExecutor().execute(new Runnable() { |
| @Override |
| public void run() { |
| mViewModel.getClientCallback().onAuthenticationFailed(); |
| } |
| }); |
| } |
| |
| /** |
| * Checks for possible error conditions prior to starting fingerprint authentication. |
| * |
| * @return 0 if there is no error, or a nonzero integer identifying the specific error. |
| */ |
| @SuppressWarnings("deprecation") |
| private static int checkForFingerprintPreAuthenticationErrors( |
| androidx.core.hardware.fingerprint.FingerprintManagerCompat fingerprintManager) { |
| if (!fingerprintManager.isHardwareDetected()) { |
| return BiometricPrompt.ERROR_HW_NOT_PRESENT; |
| } else if (!fingerprintManager.hasEnrolledFingerprints()) { |
| return BiometricPrompt.ERROR_NO_BIOMETRICS; |
| } |
| return BiometricPrompt.BIOMETRIC_SUCCESS; |
| } |
| |
| /** |
| * Checks if this fragment is responsible for drawing and handling the result of a device |
| * credential fallback button on the prompt. |
| * |
| * @return Whether this fragment is managing a device credential button for the prompt. |
| */ |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| boolean isManagingDeviceCredentialButton() { |
| return Build.VERSION.SDK_INT <= Build.VERSION_CODES.P |
| && AuthenticatorUtils.isDeviceCredentialAllowed( |
| mViewModel.getAllowedAuthenticators()); |
| } |
| |
| /** |
| * Checks if this fragment should display the fingerprint dialog authentication UI to the user, |
| * rather than delegate to the framework {@link android.hardware.biometrics.BiometricPrompt}. |
| * |
| * @return Whether this fragment should display the fingerprint dialog UI. |
| */ |
| private boolean isUsingFingerprintDialog() { |
| return Build.VERSION.SDK_INT < Build.VERSION_CODES.P |
| || isFingerprintDialogNeededForCrypto() |
| || isFingerprintDialogNeededForErrorHandling(); |
| } |
| |
| /** |
| * Checks if this fragment should display the fingerprint dialog authentication UI for an |
| * ongoing crypto-based authentication attempt. |
| * |
| * @return Whether this fragment should display the fingerprint dialog UI. |
| * |
| * @see DeviceUtils#shouldUseFingerprintForCrypto(Context, String, String) |
| */ |
| private boolean isFingerprintDialogNeededForCrypto() { |
| final FragmentActivity activity = getActivity(); |
| return activity != null |
| && mViewModel.getCryptoObject() != null |
| && DeviceUtils.shouldUseFingerprintForCrypto( |
| activity, Build.MANUFACTURER, Build.MODEL); |
| } |
| |
| /** |
| * Checks if this fragment should invoke the fingerprint dialog, rather than the framework |
| * biometric prompt, to handle an authentication error. |
| * |
| * @return Whether this fragment should invoke the fingerprint dialog. |
| * |
| * @see DeviceUtils#shouldUseFingerprintForCrypto(Context, String, String) |
| */ |
| private boolean isFingerprintDialogNeededForErrorHandling() { |
| // On API 28, BiometricPrompt internally calls FingerprintManager#getErrorString(), which |
| // requires fingerprint hardware to be present (b/151443237). |
| return Build.VERSION.SDK_INT == Build.VERSION_CODES.P |
| && !PackageUtils.hasSystemFeatureFingerprint(getContext()); |
| } |
| |
| /** |
| * Checks if the client activity is currently changing configurations (e.g. rotating screen |
| * orientation). |
| * |
| * @return Whether the client activity is changing configurations. |
| */ |
| private boolean isChangingConfigurations() { |
| final FragmentActivity activity = getActivity(); |
| return activity != null && activity.isChangingConfigurations(); |
| } |
| |
| /** |
| * Gets the amount of time to wait after receiving an unrecoverable error before dismissing the |
| * fingerprint dialog and forwarding the error to the client. |
| * |
| * <p>This method respects the result of |
| * {@link DeviceUtils#shouldHideFingerprintDialog(Context, String)} and returns 0 if the latter |
| * is {@code true}. |
| * |
| * @return The delay (in milliseconds) to apply before hiding the fingerprint dialog. |
| */ |
| private int getDismissDialogDelay() { |
| Context context = getContext(); |
| return context != null && DeviceUtils.shouldHideFingerprintDialog(context, Build.MODEL) |
| ? 0 |
| : HIDE_DIALOG_DELAY_MS; |
| } |
| |
| /** |
| * Nested class to avoid verification errors for methods introduced in Android 11 (API 30). |
| */ |
| @RequiresApi(Build.VERSION_CODES.R) |
| private static class Api30Impl { |
| // Prevent instantiation. |
| private Api30Impl() {} |
| |
| /** |
| * Sets the allowed authenticator type(s) for the given framework prompt builder. |
| * |
| * @param builder An instance of |
| * {@link android.hardware.biometrics.BiometricPrompt.Builder}. |
| * @param allowedAuthenticators A bit field representing allowed authenticator types. |
| */ |
| static void setAllowedAuthenticators( |
| @NonNull android.hardware.biometrics.BiometricPrompt.Builder builder, |
| @BiometricManager.AuthenticatorTypes int allowedAuthenticators) { |
| builder.setAllowedAuthenticators(allowedAuthenticators); |
| } |
| } |
| |
| /** |
| * Nested class to avoid verification errors for methods introduced in Android 10 (API 29). |
| */ |
| @RequiresApi(Build.VERSION_CODES.Q) |
| private static class Api29Impl { |
| // Prevent instantiation. |
| private Api29Impl() {} |
| |
| /** |
| * Sets the "confirmation required" option for the given framework prompt builder. |
| * |
| * @param builder An instance of |
| * {@link android.hardware.biometrics.BiometricPrompt.Builder}. |
| * @param confirmationRequired The value for the "confirmation required" option. |
| */ |
| static void setConfirmationRequired( |
| @NonNull android.hardware.biometrics.BiometricPrompt.Builder builder, |
| boolean confirmationRequired) { |
| builder.setConfirmationRequired(confirmationRequired); |
| } |
| |
| /** |
| * Sets the "device credential allowed" option for the given framework prompt builder. |
| * |
| * @param builder An instance of {@link |
| * android.hardware.biometrics.BiometricPrompt.Builder}. |
| * @param deviceCredentialAllowed The value for the "device credential allowed" option. |
| */ |
| @SuppressWarnings("deprecation") |
| static void setDeviceCredentialAllowed( |
| @NonNull android.hardware.biometrics.BiometricPrompt.Builder builder, |
| boolean deviceCredentialAllowed) { |
| builder.setDeviceCredentialAllowed(deviceCredentialAllowed); |
| } |
| } |
| |
| /** |
| * Nested class to avoid verification errors for methods introduced in Android 9.0 (API 28). |
| */ |
| @RequiresApi(Build.VERSION_CODES.P) |
| private static class Api28Impl { |
| // Prevent instantiation. |
| private Api28Impl() {} |
| |
| /** |
| * Creates an instance of the framework class |
| * {@link android.hardware.biometrics.BiometricPrompt.Builder}. |
| * |
| * @param context The application or activity context. |
| * @return An instance of {@link android.hardware.biometrics.BiometricPrompt.Builder}. |
| */ |
| @NonNull |
| static android.hardware.biometrics.BiometricPrompt.Builder createPromptBuilder( |
| @NonNull Context context) { |
| return new android.hardware.biometrics.BiometricPrompt.Builder(context); |
| } |
| |
| /** |
| * Sets the title for the given framework prompt builder. |
| * |
| * @param builder An instance of |
| * {@link android.hardware.biometrics.BiometricPrompt.Builder}. |
| * @param title The title for the prompt. |
| */ |
| static void setTitle( |
| @NonNull android.hardware.biometrics.BiometricPrompt.Builder builder, |
| @NonNull CharSequence title) { |
| builder.setTitle(title); |
| } |
| |
| /** |
| * Sets the subtitle for the given framework prompt builder. |
| * |
| * @param builder An instance of |
| * {@link android.hardware.biometrics.BiometricPrompt.Builder}. |
| * @param subtitle The subtitle for the prompt. |
| */ |
| static void setSubtitle( |
| @NonNull android.hardware.biometrics.BiometricPrompt.Builder builder, |
| @NonNull CharSequence subtitle) { |
| builder.setSubtitle(subtitle); |
| } |
| |
| /** |
| * Sets the description for the given framework prompt builder. |
| * |
| * @param builder An instance of |
| * {@link android.hardware.biometrics.BiometricPrompt.Builder}. |
| * @param description The description for the prompt. |
| */ |
| static void setDescription( |
| @NonNull android.hardware.biometrics.BiometricPrompt.Builder builder, |
| @NonNull CharSequence description) { |
| builder.setDescription(description); |
| } |
| |
| /** |
| * Sets the negative button text and behavior for the given framework prompt builder. |
| * |
| * @param builder An instance of |
| * {@link android.hardware.biometrics.BiometricPrompt.Builder}. |
| * @param text The text for the negative button. |
| * @param executor An executor for the negative button callback. |
| * @param listener A listener for the negative button press event. |
| */ |
| static void setNegativeButton( |
| @NonNull android.hardware.biometrics.BiometricPrompt.Builder builder, |
| @NonNull CharSequence text, |
| @NonNull Executor executor, |
| @NonNull DialogInterface.OnClickListener listener) { |
| builder.setNegativeButton(text, executor, listener); |
| } |
| |
| /** |
| * Creates an instance of the framework class |
| * {@link android.hardware.biometrics.BiometricPrompt} from the given builder. |
| * |
| * @param builder The builder for the prompt. |
| * @return An instance of {@link android.hardware.biometrics.BiometricPrompt}. |
| */ |
| @NonNull |
| static android.hardware.biometrics.BiometricPrompt buildPrompt( |
| @NonNull android.hardware.biometrics.BiometricPrompt.Builder builder) { |
| return builder.build(); |
| } |
| |
| /** |
| * Starts (non-crypto) authentication for the given framework biometric prompt. |
| * |
| * @param biometricPrompt An instance of |
| * {@link android.hardware.biometrics.BiometricPrompt}. |
| * @param cancellationSignal A cancellation signal object for the prompt. |
| * @param executor An executor for authentication callbacks. |
| * @param callback An object that will receive authentication events. |
| */ |
| static void authenticate( |
| @NonNull android.hardware.biometrics.BiometricPrompt biometricPrompt, |
| @NonNull android.os.CancellationSignal cancellationSignal, |
| @NonNull Executor executor, |
| @NonNull android.hardware.biometrics.BiometricPrompt.AuthenticationCallback |
| callback) { |
| biometricPrompt.authenticate(cancellationSignal, executor, callback); |
| } |
| |
| /** |
| * Starts (crypto-based) authentication for the given framework biometric prompt. |
| * |
| * @param biometricPrompt An instance of |
| * {@link android.hardware.biometrics.BiometricPrompt}. |
| * @param crypto A crypto object associated with the given authentication. |
| * @param cancellationSignal A cancellation signal object for the prompt. |
| * @param executor An executor for authentication callbacks. |
| * @param callback An object that will receive authentication events. |
| */ |
| static void authenticate( |
| @NonNull android.hardware.biometrics.BiometricPrompt biometricPrompt, |
| @NonNull android.hardware.biometrics.BiometricPrompt.CryptoObject crypto, |
| @NonNull android.os.CancellationSignal cancellationSignal, |
| @NonNull Executor executor, |
| @NonNull android.hardware.biometrics.BiometricPrompt.AuthenticationCallback |
| callback) { |
| biometricPrompt.authenticate(crypto, cancellationSignal, executor, callback); |
| } |
| } |
| |
| /** |
| * Nested class to avoid verification errors for methods introduced in Android 5.0 (API 21). |
| */ |
| @RequiresApi(Build.VERSION_CODES.LOLLIPOP) |
| private static class Api21Impl { |
| // Prevent instantiation. |
| private Api21Impl() {} |
| |
| /** |
| * Calls |
| * {@link KeyguardManager#createConfirmDeviceCredentialIntent(CharSequence, CharSequence)} |
| * for the given keyguard manager. |
| * |
| * @param keyguardManager An instance of {@link KeyguardManager}. |
| * @param title The title for the confirm device credential activity. |
| * @param description The description for the confirm device credential activity. |
| * @return An intent that can be used to launch the confirm device credential activity. |
| */ |
| @SuppressWarnings("deprecation") |
| @Nullable |
| static Intent createConfirmDeviceCredentialIntent( |
| @NonNull KeyguardManager keyguardManager, |
| @Nullable CharSequence title, |
| @Nullable CharSequence description) { |
| return keyguardManager.createConfirmDeviceCredentialIntent(title, description); |
| } |
| } |
| } |