Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.credentials.CredentialManager
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.NoCredentialException
import com.firebase.ui.auth.AuthException
Expand Down Expand Up @@ -94,7 +95,9 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler(
* **Error Handling:**
* - [GoogleIdTokenParsingException]: Library version mismatch
* - [NoCredentialException]: No Google accounts on device
* - [GetCredentialException]: User cancellation, configuration errors, or no credentials
* - [GetCredentialCancellationException]: User dismissed the Credential Manager sheet -
* updates [AuthState.Cancelled] and does not throw
* - [GetCredentialException]: Configuration errors or no credentials
* - Configuration errors trigger detailed developer guidance logs
*
* @param context Android context for Credential Manager
Expand Down Expand Up @@ -214,6 +217,13 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle(
// Re-throw to let UI handle the account linking flow
updateAuthState(AuthState.Error(e))
throw e
} catch (e: GetCredentialCancellationException) {
// User dismissed the Credential Manager sheet - this is a normal user action,
// not an error, so it goes to AuthState.Cancelled instead of AuthState.Error.
// Swallow (don't rethrow) so rememberGoogleSignInHandler's catch block doesn't
// overwrite this state with AuthState.Error.
updateAuthState(AuthState.Cancelled)

} catch (e: CancellationException) {
val cancelledException = AuthException.AuthCancelledException(
message = "Sign in with google was cancelled",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ fun ErrorRecoveryDialog(
* @param stringProvider The [AuthUIStringProvider] for localized strings
* @return The localized recovery message
*/
private fun getRecoveryMessage(
internal fun getRecoveryMessage(
error: AuthException,
stringProvider: AuthUIStringProvider
): String {
Expand Down Expand Up @@ -202,12 +202,12 @@ private fun getRecoveryMessage(
* @param stringProvider The [AuthUIStringProvider] for localized strings
* @return The localized action text
*/
private fun getRecoveryActionText(
internal fun getRecoveryActionText(
error: AuthException,
stringProvider: AuthUIStringProvider
): String {
return when (error) {
is AuthException.AuthCancelledException -> error.message ?: stringProvider.continueText
is AuthException.AuthCancelledException -> stringProvider.continueText
is AuthException.EmailAlreadyInUseException -> stringProvider.signInDefault // Use existing "Sign in" text
is AuthException.AccountLinkingRequiredException -> stringProvider.signInDefault // User needs to sign in to link accounts
is AuthException.DifferentSignInMethodRequiredException ->
Expand Down Expand Up @@ -236,7 +236,7 @@ private fun getRecoveryActionText(
* @param error The [AuthException] to check
* @return `true` if the error is recoverable, `false` otherwise
*/
private fun isRecoverable(error: AuthException): Boolean {
internal fun isRecoverable(error: AuthException): Boolean {
return when (error) {
is AuthException.NetworkException -> true
is AuthException.InvalidCredentialsException -> true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package com.firebase.ui.auth.configuration.auth_provider
import android.content.Context
import androidx.core.net.toUri
import androidx.credentials.CredentialManager
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.test.core.app.ApplicationProvider
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
Expand Down Expand Up @@ -47,7 +48,9 @@ import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.eq
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

Expand Down Expand Up @@ -539,6 +542,46 @@ class GoogleAuthProviderFirebaseAuthUITest {
assertThat(errorState.exception).isInstanceOf(AuthException.AuthCancelledException::class.java)
}

@Test
fun `Sign in with Google when Credential Manager sheet is dismissed should update state to Cancelled without throwing`() = runTest {
// GetCredentialCancellationException is a checked exception, so it must be stubbed via
// doAnswer rather than thenThrow (which validates against the method's declared throws).
doAnswer { throw GetCredentialCancellationException("User cancelled the selector") }
.whenever(mockCredentialManagerProvider)
.getGoogleCredential(
context = eq(applicationContext),
credentialManager = any<CredentialManager>(),
serverClientId = eq("test-client-id"),
filterByAuthorizedAccounts = eq(true),
autoSelectEnabled = eq(false)
)

val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
val googleProvider = AuthProvider.Google(
serverClientId = "test-client-id",
scopes = emptyList()
)
val config = authUIConfiguration {
context = applicationContext
providers {
provider(googleProvider)
}
}

// Should not throw - user cancellation is not an error
instance.signInWithGoogle(
context = applicationContext,
config = config,
provider = googleProvider,
authorizationProvider = mockAuthorizationProvider,
credentialManagerProvider = mockCredentialManagerProvider
)

// Verify state is Cancelled, not Error
val finalState = instance.authStateFlow().first()
assertThat(finalState).isEqualTo(AuthState.Cancelled)
}

// =============================================================================================
// signInWithGoogle - Anonymous Upgrade
// =============================================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,10 @@ class ErrorRecoveryDialogLogicTest {
}

@Test
fun `getRecoveryActionText returns continue for AuthCancelledException`() {
// Arrange
val error = AuthException.AuthCancelledException("Auth cancelled")
fun `getRecoveryActionText returns continue for AuthCancelledException, ignoring the raw exception message`() {
// Arrange - message mirrors the raw GetCredentialCancellationException message from GH #2422;
// the action label must never surface this raw string to the user
val error = AuthException.AuthCancelledException("User cancelled the selector")

// Act
val actionText = getRecoveryActionText(error, mockStringProvider)
Expand All @@ -209,15 +210,15 @@ class ErrorRecoveryDialogLogicTest {
}

@Test
fun `getRecoveryActionText returns continue for AccountLinkingRequiredException`() {
// Arrange
fun `getRecoveryActionText returns sign in for AccountLinkingRequiredException`() {
// Arrange - user needs to sign in with the existing method to link accounts
val error = AuthException.AccountLinkingRequiredException("Account linking required")

// Act
val actionText = getRecoveryActionText(error, mockStringProvider)

// Assert
Truth.assertThat(actionText).isEqualTo("Continue")
Truth.assertThat(actionText).isEqualTo("Sign in")
}

@Test
Expand Down Expand Up @@ -308,75 +309,4 @@ class ErrorRecoveryDialogLogicTest {
// Act & Assert
Truth.assertThat(isRecoverable(error)).isTrue()
}

// Helper functions to test the private functions - we need to make them internal for testing
private fun getRecoveryMessage(error: AuthException, stringProvider: AuthUIStringProvider): String {
return when (error) {
is AuthException.NetworkException -> stringProvider.networkErrorRecoveryMessage
is AuthException.InvalidCredentialsException -> {
// Use the actual error message from Firebase if available, otherwise fallback to generic message
error.message?.takeIf { it.isNotBlank() && it != "Invalid credentials provided" }
?: stringProvider.invalidCredentialsRecoveryMessage
}
is AuthException.UserNotFoundException -> stringProvider.userNotFoundRecoveryMessage
is AuthException.WeakPasswordException -> {
val baseMessage = stringProvider.weakPasswordRecoveryMessage
error.reason?.let { reason ->
"$baseMessage\n\nReason: $reason"
} ?: baseMessage
}
is AuthException.EmailAlreadyInUseException -> {
val baseMessage = stringProvider.emailAlreadyInUseRecoveryMessage
error.email?.let { email ->
"$baseMessage ($email)"
} ?: baseMessage
}
is AuthException.TooManyRequestsException -> stringProvider.tooManyRequestsRecoveryMessage
is AuthException.MfaRequiredException -> stringProvider.mfaRequiredRecoveryMessage
is AuthException.AccountLinkingRequiredException -> stringProvider.accountLinkingRequiredRecoveryMessage
is AuthException.DifferentSignInMethodRequiredException ->
error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage
is AuthException.AuthCancelledException -> stringProvider.authCancelledRecoveryMessage
is AuthException.UnknownException -> stringProvider.unknownErrorRecoveryMessage
else -> stringProvider.unknownErrorRecoveryMessage
}
}

private fun getRecoveryActionText(error: AuthException, stringProvider: AuthUIStringProvider): String {
return when (error) {
is AuthException.AuthCancelledException -> stringProvider.continueText
is AuthException.EmailAlreadyInUseException -> stringProvider.signInDefault
is AuthException.AccountLinkingRequiredException -> stringProvider.continueText
is AuthException.DifferentSignInMethodRequiredException -> when (error.suggestedSignInMethod) {
GoogleAuthProvider.PROVIDER_ID -> stringProvider.continueWithGoogle
EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD -> stringProvider.signInWithEmailLink
else -> stringProvider.continueText
}
is AuthException.MfaRequiredException -> stringProvider.continueText
is AuthException.NetworkException,
is AuthException.InvalidCredentialsException,
is AuthException.UserNotFoundException,
is AuthException.WeakPasswordException,
is AuthException.TooManyRequestsException,
is AuthException.UnknownException -> stringProvider.retryAction
else -> stringProvider.retryAction
}
}

private fun isRecoverable(error: AuthException): Boolean {
return when (error) {
is AuthException.NetworkException -> true
is AuthException.InvalidCredentialsException -> true
is AuthException.UserNotFoundException -> true
is AuthException.WeakPasswordException -> true
is AuthException.EmailAlreadyInUseException -> true
is AuthException.TooManyRequestsException -> false
is AuthException.MfaRequiredException -> true
is AuthException.AccountLinkingRequiredException -> true
is AuthException.DifferentSignInMethodRequiredException -> true
is AuthException.AuthCancelledException -> true
is AuthException.UnknownException -> true
else -> true
}
}
}