Credential Manager

by | Oct 28, 2024

What Is Credential Manager?

Credential Manager is a Jetpack Compose API that isolates the intergration for credential management of our user’s into a single API and supports multiple sign-in options. These options include:

  1. Passkeys. Put simply, they are session tokens received from a backend that authenticates the user and can be stored in the credential manager and accessed with biometric authentication.
  2. Username and password pairs
  3. Federated sign-in solutions (like Google Sign-in)

Credential Manager is backwards compatible with Android 4.4 (API 19), which allows us to use username-and-password pairs and federated sign-in options. Passkeys are only supported by Android 9 (API 28).

This new Jetpack Compose API aims to unify the sign-in experience across all Android versions and simplify the login UX. Because these credentials are tied to the user’s Google Account, they will be available across devices, which means you can onboard users to your app quicker by supporting this API for account creation.

I’ll walk you through how to add this Jetpack Compose API to your app. I’ll also show you how I structure my code so that even my Grandma can understand what is happening.

Saving And Using User Credentials

What We Will Create

Below are a couple of GIFs of what we can achieve by using the Credential Manager in our app. They showcase that we can add new credentials and use existing ones to log our users. We can even show the credentials prompt when the user lands on the login screen; take a look:

Add new credential
Retrieve credential on demand
Retrieve credential when landing on login screen

Implementing the Credential Manager

Below are all the dependencies you will need to ensure you have included in your project before proceeding. These include the Credential Manager, Jetpack Navigation alongside the Kotlin Serialization plugin, and the standard lifecycle runtime compose dependency to construct ViewModels.

[versions]
kotlin = "2.0.10"
navigationCompose = "2.8.3"
credentials = "1.5.0-alpha06"
kotlinSerialization = "1.7.3"

[libraries]
androidx-compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref="navigationCompose"}
androidx-credentials-core = { group = "androidx.credentials", name = "credentials", version.ref = "credentials” }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinSerialization" }

# Optional - needed for credentials support from play services, for Android 13 and below.
androidx-credentials-compat = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentials" }

# Added for non-deprecated LocalLifecycleOwner
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }

[plugins]
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

This post won’t be covering the new Jetpack Compose Type Safe Navigation that will be used, but you can read about it in one of my other blog posts How to Implement Official Type-Safe Navigation With Jetpack Compose & Custom Types

After performing a Gradle sync, we can start by considering the different scenarios for registering and logging in as users and what can happen. This step allows us to easily define the expected results and return them when calling login or register. The ViewModel can then receive this result and update the UI accordingly. I like this approach because it communicates what the code is doing without writing a single comment.

sealed interface SignInResult {
    data class Success(val username: String): SignInResult
    data object Cancelled: SignInResult
    data object Failure: SignInResult
    data object NoCredentials: SignInResult
}

sealed interface SignUpResult {
    data class Success(val username: String): SignUpResult
    data object Cancelled: SignUpResult
    data object Failure: SignUpResult
}

Even though these two interfaces look very similar, they represent two very different things. Unique results can occur when signing in and when registering. Even though they have similarities like Cancelled, you may want to handle them differently when the user cancels a registration or login flow. For the registration flow, you may want to encourage them not to leave the app and register an account, whereas that would not make sense when the user cancels a login attempt.

Once that is defined, we can create the class that will encapsulate the Credentials Manager API and provide us with two simple functions: signIn and signUp.

class AccountManager(
    private val activity: Activity
) {
    private val credentialManager = CredentialManager.create(activity)

    suspend fun signUp(
        username: String, 
        password: String
    ): SignUpResult {
        // TODO
    }

    suspend fun signIn(): SignInResult {
        // TODO
    }
}

Here, we use our two result classes as the return types of each function. We also have an instance of Credential Manager that we create using an Activity we injected into this class’s constructor. With this credentialManager we can now implement the signUp function to add a new credential.

suspend fun signUp(username: String, password: String): SignUpResult {
    return try {
        credentialManager.createCredential(
            context = activity,
            request = CreatePasswordRequest(
                id = username,
                password = password
            )
        )
        SignUpResult.Success(username)
    } catch (e: CreateCredentialCancellationException) {
        e.printStackTrace()
        SignUpResult.Cancelled
    } catch (e: CreateCredentialException) {
        e.printStackTrace()
        SignUpResult.Failure
    }
}

At the root, we have a try-catch block. We will use this to catch the unique exceptions that the Credential Manager will throw under certain conditions. For example, when the user dismisses the dialog prompt (system prompt shown when we invoke functions on our credentialManager). We are specifically interested in:

  1. CreateCredentialCancellationException – thrown when the user dismisses the dialog.
  2. CreateCredentialsException – thrown when an issue occurs while creating a credential. You can see this being thrown when you try to run this app on a device or emulator without a signed-in user on Google Play.

When adding a new credential, the magic happens in the createCredential function. Here, we once again provide an Android Context and then pass in the type of credential we want to create. We want a username-and-password combination, so we use the CreatePasswordRequest object and pass in the user’s credentials.

If you wanted to use a passkey, you would use the CreatePublicKeyCredentialRequest object.

Similarly, we have the signIn function to retrieve the Credential Manager and prompt users with their credentials to use within our app.

suspend fun signIn(): SignInResult {
    return try {
        val credentialResponse = credentialManager.getCredential(
            context = activity,
            request = GetCredentialRequest(
                credentialOptions = listOf(GetPasswordOption())
            )
        )
        val credential = credentialResponse.credential as? PasswordCredential
            ?: return SignInResult.Failure
            
        // Make login API call here with credential.id and credential.password

        SignInResult.Success(credential.id)
    } catch (e: GetCredentialCancellationException) {
        e.printStackTrace()
        SignInResult.Cancelled
    } catch (e: NoCredentialException) {
        e.printStackTrace()
        SignInResult.NoCredentials
    } catch (e: GetCredentialException) {
        e.printStackTrace()
        SignInResult.Failure
    }
}

With the same structure as before, we encapsulate all the details related to the Credential Manager in this class and return one of our types (SignInResult) that the caller can use, thus encapsulating the Credential Manager in our AccountManager. The getCredential function specifies the types of credentials we want to fetch and display to the user via the GetCredentialRequest object and its credentialOptions parameter. We just want to show the username and password credentials, so we use GetPasswordOption().

The Jetpack Compose Credential Manager API only manages the storage and retrieval of credentials, NOT the actual validation of these credentials. You still need to validate the credentials somehow, like on a backend server via an API call.

With that in place, all we have left is to create the UI and use our AccountManager.

Using the AccountManager – The Presentaion Layer

Just like in the previous section, we start by defining some simple objects that represent the destinations of our app.

@Serializable
data object LoginRoute

@Serializable
data class LoggedInRoute(val username: String)

Then, we can create our navigation graph. Here we will define the folowing:

  • The NavController that we can use to navigate between our app’s destinations
  • A NavHost in which we can create navigation graphs and destinations
  • Two destinations using the composable {} function to define one for LoginRoute and one for LoggedInRoute
  • LoggedIn screen UI. It is a basic screen that takes the navigation argument of the username used to perform the login and displays it in a welcome message.

val navController = rememberNavController()
NavHost(
    navController = navController,
    startDestination = LoginRoute
) {
    composable<LoginRoute> {
        // TODO
    }
    composable<LoggedInRoute> {
        val username = it.toRoute<LoggedInRoute>().username
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Text(text = "Hello $username!")
        }
    }
}

Next, the place where we will hold our login screen’s state and process the results from the AccountManager, the ViewModel.

data class LoginState(
    val loggedInUser: String? = null,
    val username: String = "user",
    val password: String = "pass",
    val errorMessage: String? = null,
    val isRegister: Boolean = false
)

sealed interface LoginAction {
    data class OnSignIn(val result: SignInResult) : LoginAction
    data class OnSignUp(val result: SignUpResult) : LoginAction
    data class OnUsernameChange(val username: String) : LoginAction
    data class OnPasswordChange(val password: String) : LoginAction
}

class LoginViewModel: ViewModel() {

    var state by mutableStateOf(LoginState())
        private set

    fun onAction(action: LoginAction) {
        when(action) {
            is LoginAction.OnPasswordChange -> {
                state = state.copy(password = action.password)
            }
            is LoginAction.OnSignUp -> {
                when(action.result) {
                    SignUpResult.Cancelled -> {
                        state = state.copy(
                            errorMessage = "Sign up was cancelled."
                        )
                    }
                    SignUpResult.Failure -> {
                        state = state.copy(
                            errorMessage = "Sign up failed."
                        )
                    }
                    is SignUpResult.Success -> {
                        state = state.copy(
                            loggedInUser = action.result.username
                        )
                    }
                }
            }
            /* Handle other cases */
    }
}

And our LoginScreen where we will do the following:

  • Define a state parameter to tell the UI what to render.
  • Define an onAction lambda that we can call when the user interacts with the UI. This will be handled by our LoginViewModel.
  • Define an onLoggedIn lambda which we can call when the user is successfully logged in. We pass in the username used to the lambda and then trigger navigation from inside our NavHost where this screen will be called.
  • Create some variables:
    • scope – to launch coroutines (we will use this scope to call our suspend functions from our AccountManager
    • context – to use when we create our AccountManager
    • accountManager – we use the remember block to prevent this instance from being recreated each time Compose performs a recomposition.
  • We then have a button that changes text and the action performed when tapped depending on the state.isRegistering property.

Starting with Android 15, Credential Manager supports autofill for text fields for Views only.

@Composable
fun LoginScreen(
    state: LoginState,
    onAction: (LoginAction) -> Unit,
    onLoggedIn: (String) -> Unit
) {

    val scope = rememberCoroutineScope()
    val context = LocalContext.current
    val accountManager = remember {    
        AccountManager(context as ComponentActivity)
    }
    
    LaunchedEffect(key1 = true) {
        val result = accountManager.signIn()
        onAction(LoginAction.OnSignIn(result))
    }

    LaunchedEffect(key1 = state.loggedInUser) {
        if(state.loggedInUser != null) {
            onLoggedIn(state.loggedInUser)
        }
    }
    
    Column(/* Omitted for brevity */) {
        // Omitted some UI code to focus on the important parts
        // You can get the complete code on my GitHub linked below
        Button(onClick = {
            scope.launch {
                if (state.isRegister) {
                    val result = accountManager.signUp(
                        username = state.username,
                        password = state.password
                    )
                    onAction(LoginAction.OnSignUp(result))
                } else {
                    val result = accountManager.signIn()
                    onAction(LoginAction.OnSignIn(result))
                }
            }
        }) {
            Text(text = if (state.isRegister) "Register" else "Login")
        }
    }
}

To prompt the user with the Credential Manager dialog when they land on the LoginScreen, we use a LaunchedEffect block (which provides a coroutine scope) to call our signIn function. We also use LaunchedEffect to trigger navigation when the user is logged in.

Ideally, you handle the logic to validate the user and redirect them to the correct screen outside the actual LoginScreen, so you skip beginning to render the LoginScreen when the user is already logged in. I opted for this approach to simplify the app and focus on using AccountManager.

Conclusion

With Credential Manager, we centralise where we store and retrieve users´ credentials. By integrating this Jetpack Compose API into our app, we improve the UX for users by giving them access to all their existing credentials created on other devices. However, it is not an API to register new users on your app or validate a user’s login credentials. Your app and potentially a backend still need to do this.

The Essentials of Industry-Level Android App Development

Master Building Large-Scale Native Android Apps

Take this 23h online course to learn everything it takes to become an industry-ready native Android developer. Learn about multi-module architecture, Gradle, Jetpack Compose, authentication, offline-first development and more.

Read More