Skip to content

Commit 0182277

Browse files
committed
feat: implement credential manager for saving and retrieving user credentials
1 parent 30d5f19 commit 0182277

8 files changed

Lines changed: 508 additions & 18 deletions

File tree

auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,17 @@ package com.firebase.ui.auth.configuration.auth_provider
1616

1717
import android.content.Context
1818
import android.net.Uri
19+
import android.util.Log
1920
import com.firebase.ui.auth.R
2021
import com.firebase.ui.auth.AuthException
2122
import com.firebase.ui.auth.AuthState
2223
import com.firebase.ui.auth.FirebaseAuthUI
2324
import com.firebase.ui.auth.configuration.AuthUIConfiguration
2425
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous
2526
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.mergeProfile
27+
import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException
28+
import com.firebase.ui.auth.credentialmanager.PasswordCredentialException
29+
import com.firebase.ui.auth.credentialmanager.PasswordCredentialHandler
2630
import com.firebase.ui.auth.util.EmailLinkPersistenceManager
2731
import com.firebase.ui.auth.util.EmailLinkParser
2832
import com.firebase.ui.auth.util.PersistenceManager
@@ -38,6 +42,7 @@ import com.google.firebase.auth.FirebaseAuthUserCollisionException
3842
import kotlinx.coroutines.CancellationException
3943
import kotlinx.coroutines.tasks.await
4044

45+
private const val TAG = "EmailAuthProvider"
4146

4247
/**
4348
* Creates an email/password account or links the credential to an anonymous user.
@@ -160,6 +165,22 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
160165
mergeProfile(auth, name, null)
161166
}
162167
}
168+
169+
// Save credentials to Credential Manager if enabled
170+
if (config.isCredentialManagerEnabled) {
171+
try {
172+
val credentialHandler = PasswordCredentialHandler(context)
173+
credentialHandler.savePassword(email, password)
174+
Log.d(TAG, "Password credential saved successfully for: $email")
175+
} catch (e: PasswordCredentialCancelledException) {
176+
// User cancelled - this is fine, don't break the auth flow
177+
Log.d(TAG, "User cancelled credential save for: $email")
178+
} catch (e: PasswordCredentialException) {
179+
// Failed to save - log but don't break the auth flow
180+
Log.w(TAG, "Failed to save password credential for: $email", e)
181+
}
182+
}
183+
163184
updateAuthState(AuthState.Idle)
164185
return result
165186
} catch (e: FirebaseAuthUserCollisionException) {
@@ -281,6 +302,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
281302
email: String,
282303
password: String,
283304
credentialForLinking: AuthCredential? = null,
305+
skipCredentialSave: Boolean = false,
284306
): AuthResult? {
285307
try {
286308
updateAuthState(AuthState.Loading("Signing in..."))
@@ -361,7 +383,23 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
361383
result
362384
}
363385
}
364-
}.also {
386+
}.also { result ->
387+
// Save credentials to Credential Manager if enabled
388+
// Skip if user signed in with a retrieved credential (already saved)
389+
if (config.isCredentialManagerEnabled && result != null && !skipCredentialSave) {
390+
try {
391+
val credentialHandler = PasswordCredentialHandler(context)
392+
credentialHandler.savePassword(email, password)
393+
Log.d(TAG, "Password credential saved successfully for: $email")
394+
} catch (e: PasswordCredentialCancelledException) {
395+
// User cancelled - this is fine, don't break the auth flow
396+
Log.d(TAG, "User cancelled credential save for: $email")
397+
} catch (e: PasswordCredentialException) {
398+
// Failed to save - log but don't break the auth flow
399+
Log.w(TAG, "Failed to save password credential for: $email", e)
400+
}
401+
}
402+
365403
updateAuthState(AuthState.Idle)
366404
}
367405
} catch (e: FirebaseAuthMultiFactorException) {

auth/src/main/java/com/firebase/ui/auth/credentialmanager/PasswordCredentialHandler.kt

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,24 @@ import androidx.credentials.exceptions.CreateCredentialException
2525
import androidx.credentials.exceptions.GetCredentialCancellationException
2626
import androidx.credentials.exceptions.GetCredentialException
2727
import androidx.credentials.exceptions.NoCredentialException
28+
import com.firebase.ui.auth.util.CredentialPersistenceManager
29+
30+
/**
31+
* Provider interface for obtaining CredentialManager instances.
32+
* This allows test code to inject mock CredentialManager instances.
33+
*/
34+
interface CredentialManagerProvider {
35+
fun getCredentialManager(context: Context): CredentialManager
36+
}
37+
38+
/**
39+
* Default implementation that creates a real CredentialManager instance.
40+
*/
41+
class DefaultCredentialManagerProvider : CredentialManagerProvider {
42+
override fun getCredentialManager(context: Context): CredentialManager {
43+
return CredentialManager.create(context)
44+
}
45+
}
2846

2947
/**
3048
* Handler for password credential operations using Android's Credential Manager.
@@ -33,11 +51,53 @@ import androidx.credentials.exceptions.NoCredentialException
3351
* the system credential manager, which displays native UI prompts to the user.
3452
*
3553
* @property context The Android context used for credential operations
54+
* @property provider Optional provider for testing purposes
3655
*/
3756
class PasswordCredentialHandler(
38-
private val context: Context
57+
private val context: Context,
58+
provider: CredentialManagerProvider? = null
3959
) {
40-
private val credentialManager: CredentialManager = CredentialManager.create(context)
60+
companion object {
61+
/**
62+
* Test-only provider for injecting mock CredentialManager instances.
63+
* Set this in your test setup to override the default CredentialManager.
64+
*
65+
* Example:
66+
* ```
67+
* PasswordCredentialHandler.testCredentialManagerProvider = object : CredentialManagerProvider {
68+
* override fun getCredentialManager(context: Context) = mockCredentialManager
69+
* }
70+
* ```
71+
*/
72+
@Volatile
73+
var testCredentialManagerProvider: CredentialManagerProvider? = null
74+
75+
/**
76+
* Checks if credentials have been saved at least once.
77+
* This prevents unnecessary credential retrieval attempts.
78+
*
79+
* @param context The Android context
80+
* @return true if credentials have been saved, false otherwise
81+
*/
82+
suspend fun hasSavedCredentials(context: Context): Boolean {
83+
return CredentialPersistenceManager.hasSavedCredentials(context)
84+
}
85+
86+
/**
87+
* Clears the saved credentials flag.
88+
* Useful for testing or when user signs out permanently.
89+
*
90+
* @param context The Android context
91+
*/
92+
suspend fun clearSavedCredentialsFlag(context: Context) {
93+
CredentialPersistenceManager.clearSavedCredentialsFlag(context)
94+
}
95+
}
96+
97+
private val credentialManager: CredentialManager =
98+
provider?.getCredentialManager(context)
99+
?: testCredentialManagerProvider?.getCredentialManager(context)
100+
?: CredentialManager.create(context)
41101

42102
/**
43103
* Saves a password credential to the system credential manager.
@@ -62,6 +122,8 @@ class PasswordCredentialHandler(
62122

63123
try {
64124
credentialManager.createCredential(context, request)
125+
// Mark that credentials have been saved successfully
126+
CredentialPersistenceManager.setCredentialsSaved(context)
65127
} catch (e: CreateCredentialCancellationException) {
66128
// User cancelled the save operation
67129
throw PasswordCredentialCancelledException("User cancelled password save operation", e)

auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ import com.firebase.ui.auth.configuration.auth_provider.sendSignInLinkToEmail
3535
import com.firebase.ui.auth.configuration.auth_provider.signInWithEmailAndPassword
3636
import com.firebase.ui.auth.configuration.auth_provider.signInWithEmailLink
3737
import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
38+
import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException
39+
import com.firebase.ui.auth.credentialmanager.PasswordCredentialException
40+
import com.firebase.ui.auth.credentialmanager.PasswordCredentialHandler
41+
import com.firebase.ui.auth.credentialmanager.PasswordCredentialNotFoundException
3842
import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController
3943
import com.google.firebase.auth.AuthCredential
4044
import com.google.firebase.auth.AuthResult
@@ -95,6 +99,7 @@ class EmailAuthContentState(
9599
val onConfirmPasswordChange: (String) -> Unit,
96100
val displayName: String,
97101
val onDisplayNameChange: (String) -> Unit,
102+
val onRetrievedCredential: (Pair<String, String>) -> Unit,
98103
val onSignInClick: () -> Unit,
99104
val onSignInEmailLinkClick: () -> Unit,
100105
val onSignUpClick: () -> Unit,
@@ -163,6 +168,9 @@ fun EmailAuthScreen(
163168
val resetLinkSent = authState is AuthState.PasswordResetLinkSent
164169
val emailSignInLinkSent = authState is AuthState.EmailSignInLinkSent
165170

171+
// Track if credentials were retrieved from Credential Manager
172+
val retrievedCredential = remember { mutableStateOf<Pair<String, String>?>(null) }
173+
166174
LaunchedEffect(authState) {
167175
Log.d("EmailAuthScreen", "Current state: $authState")
168176
when (val state = authState) {
@@ -237,15 +245,24 @@ fun EmailAuthScreen(
237245
onDisplayNameChange = { displayName ->
238246
displayNameValue.value = displayName
239247
},
248+
onRetrievedCredential = { credential ->
249+
retrievedCredential.value = credential
250+
},
240251
onSignInClick = {
241252
coroutineScope.launch {
242253
try {
254+
// Check if user is signing in with retrieved credentials
255+
val isUsingRetrievedCredential = retrievedCredential.value?.let { (email, password) ->
256+
email == emailTextValue.value && password == passwordTextValue.value
257+
} ?: false
258+
243259
authUI.signInWithEmailAndPassword(
244260
context = context,
245261
config = configuration,
246262
email = emailTextValue.value,
247263
password = passwordTextValue.value,
248264
credentialForLinking = authCredentialForLinking,
265+
skipCredentialSave = isUsingRetrievedCredential
249266
)
250267
} catch (e: Exception) {
251268
onError(AuthException.from(e))
@@ -350,6 +367,7 @@ private fun DefaultEmailAuthContent(
350367
password = state.password,
351368
onEmailChange = state.onEmailChange,
352369
onPasswordChange = state.onPasswordChange,
370+
onRetrievedCredential = state.onRetrievedCredential,
353371
onSignInClick = state.onSignInClick,
354372
onGoToSignUp = state.onGoToSignUp,
355373
onGoToResetPassword = state.onGoToResetPassword,

auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
package com.firebase.ui.auth.ui.screens.email
1616

17+
import android.util.Log
1718
import androidx.compose.foundation.layout.Column
1819
import androidx.compose.foundation.layout.PaddingValues
1920
import androidx.compose.foundation.layout.Row
@@ -46,7 +47,9 @@ import androidx.compose.material3.TopAppBar
4647
import androidx.compose.material3.rememberTooltipState
4748
import androidx.compose.runtime.Composable
4849
import androidx.compose.runtime.CompositionLocalProvider
50+
import androidx.compose.runtime.LaunchedEffect
4951
import androidx.compose.runtime.derivedStateOf
52+
import androidx.compose.runtime.mutableStateOf
5053
import androidx.compose.runtime.remember
5154
import androidx.compose.ui.Alignment
5255
import androidx.compose.ui.Modifier
@@ -65,6 +68,10 @@ import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvi
6568
import com.firebase.ui.auth.configuration.theme.AuthUITheme
6669
import com.firebase.ui.auth.configuration.validators.EmailValidator
6770
import com.firebase.ui.auth.configuration.validators.PasswordValidator
71+
import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException
72+
import com.firebase.ui.auth.credentialmanager.PasswordCredentialException
73+
import com.firebase.ui.auth.credentialmanager.PasswordCredentialHandler
74+
import com.firebase.ui.auth.credentialmanager.PasswordCredentialNotFoundException
6875
import com.firebase.ui.auth.ui.components.AuthTextField
6976
import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController
7077
import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm
@@ -80,12 +87,14 @@ fun SignInUI(
8087
password: String,
8188
onEmailChange: (String) -> Unit,
8289
onPasswordChange: (String) -> Unit,
90+
onRetrievedCredential: (Pair<String, String>) -> Unit,
8391
onSignInClick: () -> Unit,
8492
onGoToSignUp: () -> Unit,
8593
onGoToResetPassword: () -> Unit,
8694
onGoToEmailLinkSignIn: () -> Unit,
8795
onNavigateBack: (() -> Unit)? = null,
8896
) {
97+
val context = LocalContext.current
8998
val provider = configuration.providers.filterIsInstance<AuthProvider.Email>().first()
9099
val stringProvider = LocalAuthUIStringProvider.current
91100
val emailValidator = remember { EmailValidator(stringProvider) }
@@ -102,6 +111,45 @@ fun SignInUI(
102111
}
103112
}
104113

114+
// Retrieve saved credentials when in SignIn mode
115+
val credentialRetrievalAttempted = remember { mutableStateOf(false) }
116+
117+
LaunchedEffect(Unit) {
118+
if (configuration.isCredentialManagerEnabled &&
119+
!credentialRetrievalAttempted.value &&
120+
PasswordCredentialHandler.hasSavedCredentials(context)) {
121+
credentialRetrievalAttempted.value = true
122+
123+
try {
124+
val credentialHandler = PasswordCredentialHandler(context)
125+
val credential = credentialHandler.getPassword()
126+
127+
Log.d("EmailAuthScreen", "Retrieved credential for: ${credential.username}")
128+
129+
// Auto-fill the email and password fields
130+
onEmailChange(credential.username)
131+
onPasswordChange(credential.password)
132+
133+
emailValidator.validate(credential.username)
134+
passwordValidator.validate(credential.password)
135+
136+
// Store retrieved credential to compare later
137+
onRetrievedCredential(Pair(credential.username, credential.password))
138+
139+
onSignInClick()
140+
} catch (e: PasswordCredentialNotFoundException) {
141+
Log.d("EmailAuthScreen", "No saved credentials found")
142+
// No credentials saved - user will enter manually
143+
} catch (e: PasswordCredentialCancelledException) {
144+
Log.d("EmailAuthScreen", "User cancelled credential selection")
145+
// User cancelled - let them enter manually
146+
} catch (e: PasswordCredentialException) {
147+
Log.w("EmailAuthScreen", "Failed to retrieve credentials", e)
148+
// Failed to retrieve - let them enter manually
149+
}
150+
}
151+
}
152+
105153
Scaffold(
106154
modifier = modifier,
107155
topBar = {
@@ -289,6 +337,7 @@ fun PreviewSignInUI() {
289337
emailSignInLinkSent = false,
290338
onEmailChange = { email -> },
291339
onPasswordChange = { password -> },
340+
onRetrievedCredential = { credential -> },
292341
onSignInClick = {},
293342
onGoToSignUp = {},
294343
onGoToResetPassword = {},

0 commit comments

Comments
 (0)