Adding a PIN screen with biometric authentication in Jetpack Compose

In this article we’ll find out how to add biometric authentication to an Android app developed with Jetpack Compose. As not all devices offer biometric authentication, we will also add a PIN fallback in case no biometric options are available, or if the user prefers to use PIN.

What we want to achieve

Let’s first describe what we want our app to do:

  • When the user launches the app they should be presented with a PIN screen to access the app.

Let’s see how we can achieve this.

Creating the PIN screen

This is the screen we want to get to when launching the app:

The screen consists of a label, a PIN input field, with a visibility toggle, and a button to authenticate. The button will only be enabled if the PIN contains at least 4 digits. We will also limit the PIN length to 16 digits.

In Jetpack Compose it is common to write screens from the bottom up, we create small composables for the different elements we want to display on the screen and then assemble (compose) them into larger elements. For this particular screen, the label and the button will be the standard Jetpack Compose components, but for the PIN input field we will create a custom composable so that we can reuse it elsewhere; this will also keep our composables shorter and more manageable.

For our PIN input composable we want to offer a visibility toggle and show/hide the digits based on this state. The visibility toggle is internal state for this composable, so we could define this locally as no other parties will be interested in this value at this time. However, a better approach on Jetpack Compose for this kind of scenario is to create 2 versions of the composable, a stateless one (where state is hoisted to the caller), and a stateful one that wraps the stateless version. Let’s see how we can create these 2 PIN composables, the code is shown below:

Let’s analyze this:

  1. For our stateful composable, we create a local state to persist the value of the visibility toggle. We use remember to ensure this value is retained across recompositions, and in particular we want to use rememberSaveable so that the value is also persisted if we rotate or background the app.

Now that we have our password input field, we can put together our PIN screen. This will be fairly straightforward now that we have a composable that handles the PIN entry. But, before we write the PIN screen, let’s define the state that will drive the screen and the callbacks that we need to forward actions to the caller. Defining our inputs as a state and a set of callbacks, encapsulated in an interface, will allow us to decouple our composable from our ViewModel and make it more testable. If you have only 1 or 2 callbacks you may provide those as lambdas to your composable instead, but when the number of actions grows having them encapsulated in an interface makes this more manageable. I prefer to use an interface regardless of the number of callbacks, but use your best judgement here. Let’s see what state and callbacks we need:

For our state we need to provide the current PIN to display in the input field, whether the OK button is enabled and whether we have an error (incorrect PIN). For the callbacks, we need to update the caller every time the user changes the PIN, and also forward clicks on the button.

Now that we have our callbacks and state, we can write our PIN screen, the code is shown below:

We can see that

  1. We are providing our PIN state as an argument.

The main screen ViewModel

Now that we have the PIN screen let’s create the ViewModel that will drive it. We will use the MVI pattern for this; I have an article here that explains this pattern if you are interested in more details.

Our ViewModel will provide the state for our screen and implement the callbacks we defined earlier. Let’s see the code:

Let’s look at this in detail:

  1. We define an enum to represent our 2 possible states, showing the PIN screen or showing our main content.

Putting it all together

Now that we have our PIN screen and the ViewModel it’s time to put it all together. The code that ties all these pieces together we’ll be in our main Activity. For now we will only handle PIN entry, we will see how to add the biometric prompt next.

Let’s have a look at our main activity:

Let’s walk this code:

  1. We get an instance of our main ViewModel that will drive the state of the activity.

So with all this we have a functional PIN screen that prevents access to the app until the user has successfully authenticated with their PIN. When the app is backgrounded and the user later returns to the app they are prompted again to authenticate.

There is just one piece missing, adding biometric authentication, so let’s see how we can accomplish that.

Adding biometric authentication

To add biometric authentication to our app we need to include the gradle dependency, so we will add this to our build.gradle file:

implementation("androidx.biometric:biometric:1.1.0")

Next we need to trigger the display of the biometric prompt. Let’s see the code that show the prompt:

We will add this function in our main activity. Let’s analyze this code:

  1. Biometric prompt may fail for a variety of reasons. For some of those, for instance no biometric capabilities, we do not want to show an error and simply silently fallback to PIN authentication. Here we define the list of errors that will do just that.

An important thing to note is that when you create a new Compose project the main activity inherits from ComponentActivity. The biometric prompt requires as parameter a FragmentActivity instead, so we change our activity to inherit from FragmentActivity.

Now that we have our prompt ready, it’s time to launch it. To do so, we will leverage the Effects available in Compose. We want the prompt to be shown once per app launch, and only when we are in the state of SHOW_PIN, so we will use a LaunchedEffect that triggers after successful composition, and every time the key provided to the effect changes. The key we want to use will be our LoadState, so let’s add the necessary changes to our activity:

The only change we do is adding a LaunchedEffect keyed from our load state — if the state indicates we are showing the PIN, then we will display our biometric prompt using the function we described a bit earlier, provided the device has the necessary capabilities.

Now when we launch the app on a device with a fingerprint sensor we are presented with this screen:

If we authenticate successfully we land on the main screen. If we press the back button or the Cancel button we fallback to PIN entry.

Improvements

This solution here offers the basic elements for protecting an app with a PIN or biometric authentication. One possible improvement to this solution would be to add a grace period when the app is backgrounded so that if the user returns to the app within the grace period we do not show the PIN screen. This would be useful if users are momentarily leaving the app to check some other information, or are answering a call or message. We could also add a range of grace periods users could choose from in the settings section of the app so that they can configure it to their liking. This is left as an exercise to the reader.

The sample project is available on github. Happy coding!

Senior Android Developer