PIN Screen with Biometric Auth in Jetpack Compose

Learn how to add a Jetpack Compose biometric auth screen to your app, with a fallback to PIN

Introduction

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.

  • If the device has Biometric authentication available, users should be prompted to use biometric credentials.

  • Users can fallback to PIN authentication at any time.

  • If the PIN is incorrect we show an error.

  • If the PIN is correct we show the app’s main content.

  • When users return to the app from background they are prompted again to authenticate.

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.

  2. We call our stateless composable with the visibility toggle and a callback to act on changes to the toggle.

  3. Our stateless composable is basically a wrapper on OutlineText with some defaults specific to our use case.

  4. When tapping on the toggle, we forward that action to our parent — all state changes are handled in our stateful wrapper.

  5. When the toggle visibility changes we use a crossfade animation to switch icons, for a more pleasing visual effect.

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.

  2. We also provide the callbacks for the actions that can take place in our composable.

  3. Our composable is just a column as we want to display our 3 elements stacked vertically.

  4. The first of those elements is just a label that displays the app name.

  5. The second is out custom PIN input composable, note that we use the state to display the current PIN, whether we are in an error state and the callback to update the caller whenever the user changes the PIN. We also restrict input to just digits.

  6. Finally we add a button to unlock the app, again we use the state to determine if the button should be enabled (that logic will be handled by the ViewModel) and we forward clicks to the caller using the callback provided in the arguments.

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.

  2. We create a data class to represent out state. This builds on top of our PIN state class and adds the load state.

  3. We define the intents that our ViewModel will handle. Our ViewModel needs to know when the PIN is updated, the user attempts to unlock the app, a biometric authentication succeeded, or the app was backgrounded.

  4. Here we define the actions that create a new state based on the current state, these will trigger as a result of parsing Intents defined above.

  5. Our ViewModel implements the PinCallbacks interface we defined earlier.

  6. As this is a sample, the correct PIN is hardcoded, but this would usually come from some encrypted storage. We are sweeping under the rug any concerns about storing PINs or secure data in memory, those are out of scope for this article.

  7. Using our MVI framework we implement executeIntent that handles the intents (actions) the ViewModel is responsible for.

  8. When we receive the intent for PIN updated we check if the length is within our max range — if not, we do nothing and the composable won’t update, so the new digits will be ignored. Otherwise, we emit a new action to update the PIN and the button state, enabling it if the PIN is at least 4 digits long.

  9. When the user attempts to unlock we check if the PIN matches and based on this we either emit an Unlock or an Error action.

  10. When the user successfully authenticates via biometric prompt we emit the Unlock action.

  11. When the app is backgrounded we enter the Locked state.

  12. Also as part of our MVI framework we implement the reducer to generate new states.

  13. When the PIN has been updated our state reflects the new PIN and button state. We also clear any error state whenever the PIN updates.

  14. In case of PIN error (incorrect PIN) we set our error state to true. This will instruct our composable to show an error.

  15. When the user successfully authenticates we change our load state to SHOW_CONTENT which will trigger the display of our main screen.

  16. In case of a lock request we set our load state back to SHOW_PIN so we will ask for authentication again. We also clear any previous PIN fields to ensure the screen starts fresh.

  17. We implement the 2 PIN callbacks which translate those callbacks into intents in our MVI framework.

  18. The same applies when the user successfully authenticates via biometric prompt, this triggers a new intent.

  19. And finally the same happens when the ViewModel is notified of the app backgrounding, we push an intent to our MVI pipeline.

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.

  2. In our composable content we observe the state from the ViewModel, whenever the state changes the composable will be notified and child composables that are affected will be recomposed.

  3. We use a Crossfade animation to switch between our 2 states, showing the PIN screen and showing the main content.

  4. We determine what content to display based on the loadState parameter of our state.

  5. If the state indicates we should show the PIN screen, then we render the PIN screen.

  6. Otherwise we render the main content.

  7. When the activity is stopped, if we are not changing configurations (rotating, resizing) then we are backgrounding, so we notify the ViewModel of this event; this will trigger the reset of our load state back to showing PIN so when we return to the app we will show the PIN screen.

  8. Our main content is just a placeholder, but here is where you would have the app’s landing screen, possibly using the Navigation Component.

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.

  2. We create our prompt information, here you can specify a title and description that will be shown on the prompt.

  3. We create the biometric prompt.

  4. We provide a callback for authentication error — this callback fires for unrecoverable errors. If the error is not part of our ignored errors we show a Toast. Alternatively this error could be forwarded to the ViewModel if we need this information, but for simplicity sake in this sample we just show a toast.

  5. If the authentication is successful, we notify our ViewModel.

  6. If the authentication fails (like unrecognized fingerprint), this callback will fire. Again, for simplicity sake, we just show a toast, but in a more realistic scenario we could show some information and let the user retry.

  7. Finally we display the prompt.

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.