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:
- 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.
- Username and password pairs
- 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:
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:
CreateCredentialCancellationException
– thrown when the user dismisses the dialog.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 forLoginRoute 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 ourLoginViewModel
. - 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 ourNavHost
where this screen will be called. - Create some variables:
scope
– to launch coroutines (we will use this scope to call oursuspend
functions from ourAccountManager
context
– to use when we create ourAccountManager
accountManager
– we use theremember
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.
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.