Modern Android devices support a wide range of authentication mechanisms, starting with a simple pin lock and all the way to using built-in sensors for biometric authentication. In this post, I’ll show you how to implement a basic biometric authentication in your Android application, and then demonstrate how to organize the resulting logic in a “clean” way.
Jetpack Biometric Library and BiometricPrompt
This tutorial will use BiometricPrompt component from Jetpack Biometric library. It makes the implementation of biometric authentication relatively straightforward, but works only on Android 6.0 (API level 23) or later. Therefore, if your need to support earlier versions of Android, you’ll need look for a different approach.
To use Biometric library in your project, please add either of the below lines to your Gradle dependencies configuration (make sure to check for the latest version when you read this guide):
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" // for Kotlin projects implementation "androidx.biometric:biometric:1.2.0-alpha05" // for Java projects
Checking the State of Biometric Authentication
Before you authenticate the user, you want to check the state of biometric authentication feature. This will allow you to know if the user hasn’t set up biometric credentials on their device yet, or if their device doesn’t support biometric authentication at all.
First, initialize an instance of androidx.biometric.BiometricManager
class:
val biometricManager = BiometricManager.from(context)
Then use BiometricManager to find out whether authentication is possible:
private fun handleBiometricAuthState() { when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) { BiometricManager.BIOMETRIC_SUCCESS -> authenticate() BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> launchBiometricEnrollment() else -> { /* handle biometric auth not possible */ } } }
Note that I pass BIOMETRIC_STRONG
as the argument. Depending on your use cases and constraints, you can also use BIOMETRIC_WEAK
or even DEVICE_CREDENTIAL
values.
Redirecting the User to Set Up Biometric Authentication
If the user doesn’t have any credentials set up on the device, you can redirect them to perform the initial setup in this manner:
private val activityResultHandler = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_CANCELED) { // handle biometric setup cancellation } else { handleBiometricAuthState() } } private fun launchBiometricEnrollment() { val intent: Intent = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { Intent(Settings.ACTION_BIOMETRIC_ENROLL).putExtra( Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED, BiometricManager.Authenticators.BIOMETRIC_STRONG ) } else -> Intent(Settings.ACTION_SECURITY_SETTINGS) } activityResultHandler.launch(intent) }
Note that on earlier Android versions you can’t redirect the user to the enrollment flow itself. The best you can do is to take them to security settings screen. Therefore, consider providing additional explanation before you open the settings app so that the user will understand what they need to do on that screen.
Performing Basic Biometric Authentication
Once BiometricManager.canAuthenticate()
call returns the result of BiometricManager.BIOMETRIC_SUCCESS
, you can proceed to the actual authentication flow.
However, due to unusual behavior in the context of Android lifecycles, you need to instantiate BiometricPrompt
in advance, preferably in onCreate()
method of the containing Activity or Fragment:
private var biometricPrompt: BiometricPrompt? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) initBiometricPrompt() } private fun initBiometricPrompt() { biometricPrompt = BiometricPrompt( requireActivity(), object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { super.onAuthenticationError(errorCode, errString) val cancelled = errorCode in arrayListOf<Int>( BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_USER_CANCELED, BiometricPrompt.ERROR_NEGATIVE_BUTTON ) if (cancelled) { // handle authentication cancelled } else { // handle authentication failed } } override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) // handle authentication succeeded } override fun onAuthenticationFailed() { super.onAuthenticationFailed() // this method will be invoked on unsuccessful intermediate attempts (e.g. unrecognized fingerprint) } } ) }
Then, to request biometric authentication, you simply do:
private fun authenticate() { val promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle("Biometric authentication") .setSubtitle("") .setDescription("Please confirm your credentials) .setNegativeButtonText("Cancel") .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) .build() biometricPrompt.authenticate(promptInfo) }
The result of the authentication flow will be delivered to the AuthenticationCallback
object that you provided while instantiating your BiometricPrompt. The naming of its methods can be a bit confusing, so let’s clarify their meaning:
onAuthenticationSucceeded
: called on successful authentication (no surprise here).onAuthenticationFailed
: called on every unsuccessful authentication attemp; note that this is not the final “failed” indication.onAuthenticationError
: this is the final “failed” indication; despite its name, this method will also be called if the user cancels the authentication flow (which isn’t an error per se).
Experienced developers looking at the above code can have concerns about the reliability and the safety of this approach in the context of Android lifecycles, and for the potential memory leaks. Such concerns are justified and, indeed, there were problems in the past. However, today, this component looks pretty robust to me and I haven’t experienced any issues with its latest versions so far.
Clean Design of Biometric Authentication
While the above implementation works, it’s not a “clean code”, due to (at least) two reasons:
- There is a considerable amount of a “boilerplate” code which pollutes your UI controllers (Activities, Fragments, etc.).
- You’ll need to duplicate all this code in every place in your app that requires biometric authentication.
We can tackle both of these problems by extracting a standalone use case class for biometric authentication:
class BiometricAuthUseCase( private val activity: FragmentActivity, private val biometricManager: BiometricManager, ): Observable<BiometricAuthUseCase.Listener>() { sealed class AuthResult { object NotEnrolled: AuthResult() object NotSupported: AuthResult() data class Failed(val errorCode: Int, val errorMessage: String): AuthResult() object Cancelled: AuthResult() object Success: AuthResult() } interface Listener { fun onBiometricAuthResult(result: AuthResult) } private val biometricPrompt = BiometricPrompt( activity, object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { super.onAuthenticationError(errorCode, errString) val cancelled = errorCode in arrayListOf<Int>( BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_USER_CANCELED, BiometricPrompt.ERROR_NEGATIVE_BUTTON ) if (cancelled) { listeners.map { it.onBiometricAuthResult(AuthResult.Cancelled) } } else { listeners.map { it.onBiometricAuthResult(AuthResult.Failed(errorCode, errString.toString())) } } } override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) listeners.map { it.onBiometricAuthResult(AuthResult.Success) } } override fun onAuthenticationFailed() { super.onAuthenticationFailed() } } ) fun authenticate(title: String, description: String, negativeButtonText: String) { when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) { BiometricManager.BIOMETRIC_SUCCESS -> { /* proceed */ } BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> { listeners.map { it.onBiometricAuthResult(AuthResult.NotEnrolled) } return } else -> { listeners.map { it.onBiometricAuthResult(AuthResult.NotSupported) } return } } val promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle(title) .setSubtitle("") .setDescription(description) .setNegativeButtonText(negativeButtonText) .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) .build() biometricPrompt.authenticate(promptInfo) } }
You can take the implementation of Observable base class from here, or simply remove this inheritance and implement a basic Observer design pattern right inside this class.
Once you have the above BiometricAuthUseCase
class in your project, implementing biometric authentication becomes very straightforward:
private lateinit var biometricAuthUseCase: BiometricAuthUseCase override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) biometricAuthUseCase = BiometricAuthUseCase(requireActivity(), BiometricManager.from(requireContext())) } override fun onStart() { super.onStart() biometricAuthUseCase.registerListener(this) } override fun onStop() { super.onStop() biometricAuthUseCase.unregisterListener(this) } private fun authenticate() { biometricAuthUseCase.authenticate( getString(R.string.biometric_auth_title), getString(R.string.biometric_auth_description), getString(R.string.cancel), ) } override fun onBiometricAuthResult(result: BiometricAuthUseCase.AuthResult) { when(result) { is BiometricAuthUseCase.AuthResult.NotEnrolled -> { launchBiometricEnrollment() } is BiometricAuthUseCase.AuthResult.NotSupported -> { // handle biometric auth not possible } is BiometricAuthUseCase.AuthResult.Success -> { // handle biometric auth succeeded } is BiometricAuthUseCase.AuthResult.Cancelled -> { // handle biometric auth cancelled } is BiometricAuthUseCase.AuthResult.Failed -> { // handle biometric auth failed (this is the "final" failed callback) } } }
That’s much less code inside UI controllers than we had before, so it’s alright to have it in multiple places inside your app.
Dependency Injection with Dagger and Hilt Course
Learn Dependency Injection in Android and master Dagger and Hilt dependency injection frameworks.
Go to CourseSummary
Now you know how to implement biometric authentication in your Android application while avoiding unneeded boilerplate and code duplication. The above code examples will get you started, but you’ll probably need to modify them a bit according to your specific requirements.
Thanks for reading. Please leave your comments and questions below.
Great article Vasiliy. Just one question. Should we dereference biometricAuthUseCase in onDestroy(), as it is holding the reference to the activity?
Hi,
There is no need to dereference this use case because it isn’t referenced by any object with a lifetime longer than the host Activity’s lifetime. Thus, both the host Activity and the use case will become eligible to GC at the same time.