diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt index 89837df3e..139eab163 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt @@ -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 @@ -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 @@ -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", diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt index d0d707bda..a3e216ebe 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt @@ -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 { @@ -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 -> @@ -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 diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt index 81f94b161..312de80cf 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt @@ -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 @@ -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 @@ -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(), + 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 // ============================================================================================= diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialogLogicTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialogLogicTest.kt index 05d86eecd..c095131a1 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialogLogicTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialogLogicTest.kt @@ -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) @@ -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 @@ -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 - } - } }