From 60edc2d88b10b37a411c75420283e9d2e154fab2 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:26:53 +0100 Subject: [PATCH 1/8] fix(auth): improve user identifier retrieval (#2314) * fix(auth): improve user identifier retrieval * updates --- .../android/demo/HighLevelApiDemoActivity.kt | 6 ++- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 6 ++- .../com/firebase/ui/auth/util/UserUtils.kt | 38 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/util/UserUtils.kt diff --git a/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt index fcae3ea9c..ff008abcf 100644 --- a/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt @@ -42,6 +42,8 @@ import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.ui.screens.AuthSuccessUiContext import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen import com.firebase.ui.auth.util.EmailLinkConstants +import com.firebase.ui.auth.util.displayIdentifier +import com.firebase.ui.auth.util.getDisplayEmail import com.google.firebase.auth.actionCodeSettings class HighLevelApiDemoActivity : ComponentActivity() { @@ -211,7 +213,7 @@ private fun AppAuthenticatedContent( when (state) { is AuthState.Success -> { val user = uiContext.authUI.getCurrentUser() - val identifier = user?.email ?: user?.phoneNumber ?: user?.uid.orEmpty() + val identifier = user.displayIdentifier() Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, @@ -263,7 +265,7 @@ private fun AppAuthenticatedContent( } is AuthState.RequiresEmailVerification -> { - val email = uiContext.authUI.getCurrentUser()?.email ?: stringProvider.emailProvider + val email = uiContext.authUI.getCurrentUser().getDisplayEmail(stringProvider.emailProvider) Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index 5a065400c..21bb3ee7e 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt @@ -78,6 +78,8 @@ import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen import com.firebase.ui.auth.util.EmailLinkPersistenceManager import com.firebase.ui.auth.util.SignInPreferenceManager +import com.firebase.ui.auth.util.displayIdentifier +import com.firebase.ui.auth.util.getDisplayEmail import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import com.google.firebase.auth.MultiFactorResolver @@ -733,7 +735,7 @@ private fun AuthSuccessContent( onManageMfa: () -> Unit, ) { val user = authUI.getCurrentUser() - val userIdentifier = user?.email ?: user?.phoneNumber ?: user?.uid.orEmpty() + val userIdentifier = user.displayIdentifier() Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, @@ -783,7 +785,7 @@ private fun EmailVerificationContent( onSignOut: () -> Unit, ) { val user = authUI.getCurrentUser() - val emailLabel = user?.email ?: stringProvider.emailProvider + val emailLabel = user.getDisplayEmail(stringProvider.emailProvider) Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, diff --git a/auth/src/main/java/com/firebase/ui/auth/util/UserUtils.kt b/auth/src/main/java/com/firebase/ui/auth/util/UserUtils.kt new file mode 100644 index 000000000..8e25766e0 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/util/UserUtils.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.util + +import com.google.firebase.auth.FirebaseUser + +/** + * Returns the best available display identifier for the user, trying each field in order: + * email → phoneNumber → displayName → uid. + * + * Each field is checked for blank (not just null) so that an empty string returned by the + * Firebase SDK falls through to the next candidate rather than being displayed as-is. + * Returns an empty string if the user is null. + */ +fun FirebaseUser?.displayIdentifier(): String = + this?.email?.takeIf { it.isNotBlank() } + ?: this?.phoneNumber?.takeIf { it.isNotBlank() } + ?: this?.displayName?.takeIf { it.isNotBlank() } + ?: this?.uid + ?: "" + +/** + * Returns the user's email if it is non-blank, otherwise returns the provided [fallback]. + */ +fun FirebaseUser?.getDisplayEmail(fallback: String): String = + this?.email?.takeIf { it.isNotBlank() } ?: fallback From d1466d14e236de056478472b0c5db525879b5747 Mon Sep 17 00:00:00 2001 From: Russell Wheatley Date: Fri, 17 Apr 2026 12:18:56 +0100 Subject: [PATCH 2/8] fix: ensure that when selecting phone or email, it routes straight to that screen (#2311) --- .../com/firebase/ui/auth/FirebaseAuthUI.kt | 44 +++---- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 43 +++++-- .../ui/screens/FirebaseAuthScreenRouteTest.kt | 118 ++++++++++++++++++ .../ui/screens/AnonymousAuthScreenTest.kt | 2 +- .../ui/auth/ui/screens/EmailAuthScreenTest.kt | 118 ++++++++---------- .../auth/ui/screens/GoogleAuthScreenTest.kt | 4 +- 6 files changed, 226 insertions(+), 103 deletions(-) create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenRouteTest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt index 9f829a37f..f01cde6a7 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -25,6 +25,7 @@ import com.google.firebase.Firebase import com.google.firebase.FirebaseApp import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth.AuthStateListener +import com.google.firebase.auth.FirebaseAuth.IdTokenListener import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.auth import kotlinx.coroutines.CancellationException @@ -255,29 +256,8 @@ class FirebaseAuthUI private constructor( fun authStateFlow(): Flow { // Create a flow from FirebaseAuth state listener val firebaseAuthFlow = callbackFlow { - // Set initial state based on current auth state - val initialState = auth.currentUser?.let { user -> - // Check if email verification is required - if (!user.isEmailVerified && - user.email != null && - user.providerData.any { it.providerId == "password" } - ) { - AuthState.RequiresEmailVerification( - user = user, - email = user.email!! - ) - } else { - AuthState.Success(result = null, user = user, isNewUser = false) - } - } ?: AuthState.Idle - - trySend(initialState) - - // Create auth state listener - val authStateListener = AuthStateListener { firebaseAuth -> - val currentUser = firebaseAuth.currentUser - val state = if (currentUser != null) { - // Check if email verification is required + fun buildState(currentUser: FirebaseUser?): AuthState { + return if (currentUser != null) { if (!currentUser.isEmailVerified && currentUser.email != null && currentUser.providerData.any { it.providerId == "password" } @@ -296,15 +276,31 @@ class FirebaseAuthUI private constructor( } else { AuthState.Idle } - trySend(state) + } + + // Set initial state based on current auth state + val initialState = buildState(auth.currentUser) + + trySend(initialState) + + // Create auth state listener + val authStateListener = AuthStateListener { firebaseAuth -> + trySend(buildState(firebaseAuth.currentUser)) + } + + // AuthStateListener does not reliably fire for account linking, but IdTokenListener does. + val idTokenListener = IdTokenListener { firebaseAuth -> + trySend(buildState(firebaseAuth.currentUser)) } // Add listener auth.addAuthStateListener(authStateListener) + auth.addIdTokenListener(idTokenListener) // Remove listener when flow collection is cancelled awaitClose { auth.removeAuthStateListener(authStateListener) + auth.removeIdTokenListener(idTokenListener) } } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index 21bb3ee7e..fbf0bed2b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -127,6 +128,10 @@ fun FirebaseAuthScreen( val emailLinkFromDifferentDevice = remember { mutableStateOf(null) } val lastSignInPreference = remember { mutableStateOf(null) } + val startRoute = remember(configuration.providers, configuration.isProviderChoiceAlwaysShown) { + getStartRoute(configuration) + } + val skipsMethodPicker = startRoute != AuthRoute.MethodPicker // Load last sign-in preference on launch LaunchedEffect(authState) { @@ -238,7 +243,7 @@ fun FirebaseAuthScreen( ) { NavHost( navController = navController, - startDestination = AuthRoute.MethodPicker.route, + startDestination = startRoute.route, enterTransition = configuration.transitions?.enterTransition ?: { fadeIn(animationSpec = tween(700)) }, @@ -321,7 +326,9 @@ fun FirebaseAuthScreen( }, onCancel = { pendingLinkingCredential.value = null - if (!navController.popBackStack()) { + if (skipsMethodPicker) { + onSignInCancelled() + } else if (!navController.popBackStack()) { navController.navigate(AuthRoute.MethodPicker.route) { popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } launchSingleTop = true @@ -341,7 +348,9 @@ fun FirebaseAuthScreen( onSignInFailure(exception) }, onCancel = { - if (!navController.popBackStack()) { + if (skipsMethodPicker) { + onSignInCancelled() + } else if (!navController.popBackStack()) { navController.navigate(AuthRoute.MethodPicker.route) { popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } launchSingleTop = true @@ -537,7 +546,7 @@ fun FirebaseAuthScreen( if (currentRoute != AuthRoute.Success.route) { navController.navigate(AuthRoute.Success.route) { - popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + popUpTo(navController.graph.findStartDestination().id) { inclusive = true } launchSingleTop = true } } @@ -550,7 +559,7 @@ fun FirebaseAuthScreen( pendingLinkingCredential.value = null if (currentRoute != AuthRoute.Success.route) { navController.navigate(AuthRoute.Success.route) { - popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + popUpTo(navController.graph.findStartDestination().id) { inclusive = true } launchSingleTop = true } } @@ -569,9 +578,9 @@ fun FirebaseAuthScreen( pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null - if (currentRoute != AuthRoute.MethodPicker.route) { - navController.navigate(AuthRoute.MethodPicker.route) { - popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + if (currentRoute != startRoute.route) { + navController.navigate(startRoute.route) { + popUpTo(navController.graph.findStartDestination().id) { inclusive = true } launchSingleTop = true } } @@ -582,9 +591,9 @@ fun FirebaseAuthScreen( pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null - if (currentRoute != AuthRoute.MethodPicker.route) { - navController.navigate(AuthRoute.MethodPicker.route) { - popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + if (currentRoute != startRoute.route) { + navController.navigate(startRoute.route) { + popUpTo(navController.graph.findStartDestination().id) { inclusive = true } launchSingleTop = true } } @@ -669,6 +678,18 @@ sealed class AuthRoute(val route: String) { object MfaChallenge : AuthRoute("auth_mfa_challenge") } +internal fun getStartRoute(configuration: AuthUIConfiguration): AuthRoute { + if (configuration.isProviderChoiceAlwaysShown || configuration.providers.size != 1) { + return AuthRoute.MethodPicker + } + + return when (configuration.providers.single()) { + is AuthProvider.Email -> AuthRoute.Email + is AuthProvider.Phone -> AuthRoute.Phone + else -> AuthRoute.MethodPicker + } +} + data class AuthSuccessUiContext( val authUI: FirebaseAuthUI, val stringProvider: AuthUIStringProvider, diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenRouteTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenRouteTest.kt new file mode 100644 index 000000000..4108004dd --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenRouteTest.kt @@ -0,0 +1,118 @@ +package com.firebase.ui.auth.ui.screens + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class FirebaseAuthScreenRouteTest { + + private lateinit var applicationContext: Context + + @Before + fun setUp() { + applicationContext = ApplicationProvider.getApplicationContext() + } + + @Test + fun `single email provider starts at email route`() { + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + } + + assertThat(getStartRoute(configuration)).isEqualTo(AuthRoute.Email) + } + + @Test + fun `single phone provider starts at phone route`() { + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + } + } + + assertThat(getStartRoute(configuration)).isEqualTo(AuthRoute.Phone) + } + + @Test + fun `single google provider starts at method picker`() { + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Google( + scopes = emptyList(), + serverClientId = "test-client-id" + ) + ) + } + } + + assertThat(getStartRoute(configuration)).isEqualTo(AuthRoute.MethodPicker) + } + + @Test + fun `single email provider shows picker when always shown is enabled`() { + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + isProviderChoiceAlwaysShown = true + } + + assertThat(getStartRoute(configuration)).isEqualTo(AuthRoute.MethodPicker) + } + + @Test + fun `multiple providers start at method picker`() { + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + } + } + + assertThat(getStartRoute(configuration)).isEqualTo(AuthRoute.MethodPicker) + } +} diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/AnonymousAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/AnonymousAuthScreenTest.kt index 59c5d829a..743a26db4 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/AnonymousAuthScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/AnonymousAuthScreenTest.kt @@ -167,7 +167,7 @@ class AnonymousAuthScreenTest { @Test fun `anonymous upgrade enabled links new user sign-up and emits RequiresEmailVerification auth state`() { val name = "Anonymous Upgrade User" - val email = "anonymousupgrade@example.com" + val email = "anonymous-upgrade-${System.currentTimeMillis()}@example.com" val password = "Test@123" val configuration = authUIConfiguration { context = applicationContext diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt index 423aa8d62..d438eb45b 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt @@ -149,7 +149,7 @@ class EmailAuthScreenTest { } @Test - fun `initial EmailAuthMode is SignIn`() { + fun `single email provider starts on email screen when provider choice always shown is false`() { val configuration = authUIConfiguration { context = applicationContext providers { @@ -167,15 +167,30 @@ class EmailAuthScreenTest { TestFirebaseAuthScreen(configuration = configuration, authUI = authUI) } - // Click on email provider in AuthMethodPicker - composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail) - .assertIsDisplayed() - .performClick() + assertDirectEmailStart() + } - composeAndroidTestRule.waitForIdle() + @Test + fun `single email provider shows method picker when provider choice always shown is true`() { + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + isCredentialManagerEnabled = false + isProviderChoiceAlwaysShown = true + } - composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault) - .assertIsDisplayed() + composeAndroidTestRule.setContent { + TestFirebaseAuthScreen(configuration = configuration, authUI = authUI) + } + + openEmailProviderFromMethodPicker() } @Test @@ -212,12 +227,7 @@ class EmailAuthScreenTest { currentAuthState = authState } - // Click on email provider in AuthMethodPicker - composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail) - .assertIsDisplayed() - .performClick() - - composeAndroidTestRule.waitForIdle() + assertDirectEmailStart() composeAndroidTestRule.onNodeWithText(stringProvider.emailHint) .performScrollTo() @@ -306,12 +316,7 @@ class EmailAuthScreenTest { currentAuthState = authState } - // Click on email provider in AuthMethodPicker - composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail) - .assertIsDisplayed() - .performClick() - - composeAndroidTestRule.waitForIdle() + assertDirectEmailStart() composeAndroidTestRule.onNodeWithText(stringProvider.emailHint) .performScrollTo() @@ -381,12 +386,7 @@ class EmailAuthScreenTest { currentAuthState = authState } - // Click on email provider in AuthMethodPicker - composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail) - .assertIsDisplayed() - .performClick() - - composeAndroidTestRule.waitForIdle() + assertDirectEmailStart() composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault) .assertIsDisplayed() @@ -471,12 +471,7 @@ class EmailAuthScreenTest { currentAuthState = authState } - // Click on email provider in AuthMethodPicker - composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail) - .assertIsDisplayed() - .performClick() - - composeAndroidTestRule.waitForIdle() + assertDirectEmailStart() composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault) .assertIsDisplayed() @@ -569,15 +564,7 @@ class EmailAuthScreenTest { currentAuthState = authState } - // Click on email provider in AuthMethodPicker - composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail) - .assertIsDisplayed() - .performClick() - - composeAndroidTestRule.waitForIdle() - - composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault) - .assertIsDisplayed() + assertDirectEmailStart() // Click "Sign in with email link" button to switch to email link mode composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmailLink.uppercase()) @@ -744,6 +731,7 @@ class EmailAuthScreenTest { ) ) } + isProviderChoiceAlwaysShown = true } // Track auth state changes @@ -758,12 +746,7 @@ class EmailAuthScreenTest { // STEP 1: Sign up and verify credential saved println("TEST: Starting sign-up flow...") - // Click on email provider - composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail) - .assertIsDisplayed() - .performClick() - - composeAndroidTestRule.waitForIdle() + openEmailProviderFromMethodPicker() // Click sign-up composeAndroidTestRule.onNodeWithText(stringProvider.signupPageTitle.uppercase()) @@ -816,13 +799,9 @@ class EmailAuthScreenTest { // STEP 3: Navigate to SignInUI screen to trigger credential retrieval println("TEST: Navigating to sign-in screen to trigger credential retrieval...") - // Click on email provider to show SignInUI, which will trigger auto-retrieval - composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail) - .assertIsDisplayed() - .performClick() - composeAndroidTestRule.waitForIdle() shadowOf(Looper.getMainLooper()).idle() + clickEmailProviderFromMethodPicker() // SignInUI's LaunchedEffect should now trigger credential retrieval and auto-sign-in println("TEST: Waiting for automatic credential retrieval and auto-sign-in...") @@ -877,6 +856,7 @@ class EmailAuthScreenTest { ) ) } + isProviderChoiceAlwaysShown = true } var currentAuthState: AuthState = AuthState.Idle @@ -890,11 +870,7 @@ class EmailAuthScreenTest { // STEP 1: Sign up and save credential println("TEST: Starting sign-up flow...") - composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail) - .assertIsDisplayed() - .performClick() - - composeAndroidTestRule.waitForIdle() + openEmailProviderFromMethodPicker() composeAndroidTestRule.onNodeWithText(stringProvider.signupPageTitle.uppercase()) .assertIsDisplayed() @@ -940,12 +916,9 @@ class EmailAuthScreenTest { // STEP 3: Navigate to SignInUI to trigger credential retrieval println("TEST: Navigating to sign-in screen...") - composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail) - .assertIsDisplayed() - .performClick() - composeAndroidTestRule.waitForIdle() shadowOf(Looper.getMainLooper()).idle() + clickEmailProviderFromMethodPicker() println("TEST: Waiting for automatic credential retrieval and auto-sign-in...") @@ -997,11 +970,7 @@ class EmailAuthScreenTest { } // Sign up - composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail) - .assertIsDisplayed() - .performClick() - - composeAndroidTestRule.waitForIdle() + assertDirectEmailStart() composeAndroidTestRule.onNodeWithText(stringProvider.signupPageTitle.uppercase()) .assertIsDisplayed() @@ -1078,4 +1047,21 @@ class EmailAuthScreenTest { } } } + + private fun assertDirectEmailStart() { + composeAndroidTestRule.waitForIdle() + composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault) + .assertIsDisplayed() + } + + private fun openEmailProviderFromMethodPicker() { + clickEmailProviderFromMethodPicker() + assertDirectEmailStart() + } + + private fun clickEmailProviderFromMethodPicker() { + composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail) + .assertIsDisplayed() + .performClick() + } } diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt index 64103ec32..0bbdc1372 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt @@ -149,13 +149,15 @@ class GoogleAuthScreenTest { @Test fun `anonymous upgrade with google links anonymous user and emits Success auth state`() = runTest { - val email = "anonymousupgrade@example.com" + val email = "anonymous-google-upgrade-${System.currentTimeMillis()}@example.com" + val sub = "anonymous-google-upgrade-${System.nanoTime()}" val name = "Anonymous Upgrade User" val photoUrl = "https://example.com/avatar.jpg" // Generate a JWT token for the Google account val mockIdToken = generateMockGoogleIdToken( email = email, + sub = sub, name = name, photoUrl = photoUrl ) From 4c9c5d396949605ab9cb612af8a3384c5d81b411 Mon Sep 17 00:00:00 2001 From: Russell Wheatley Date: Wed, 22 Apr 2026 16:43:15 +0100 Subject: [PATCH 3/8] fix: use secondary app if it is passed into FirebaseAuthUI (#2313) --- .../firebase/ui/auth/AuthFlowController.kt | 6 +- .../firebase/ui/auth/FirebaseAuthActivity.kt | 48 +++++++++-- .../ui/auth/FirebaseAuthActivityTest.kt | 84 ++++++++++++++++--- 3 files changed, 116 insertions(+), 22 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt b/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt index 44cdf45aa..917584219 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt @@ -158,7 +158,11 @@ class AuthFlowController internal constructor( */ fun createIntent(context: Context): Intent { checkNotDisposed() - return FirebaseAuthActivity.createIntent(context, configuration) + return FirebaseAuthActivity.createIntent( + context = context, + configuration = configuration, + authUI = authUI + ) } /** diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt index 168670da1..32e9eacd9 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt @@ -18,6 +18,7 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.annotation.RestrictTo import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -72,15 +73,16 @@ class FirebaseAuthActivity : ComponentActivity() { private lateinit var authUI: FirebaseAuthUI private lateinit var configuration: AuthUIConfiguration + private var launchKey: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - // Extract configuration from cache using UUID key - val configKey = intent.getStringExtra(EXTRA_CONFIGURATION_KEY) - configuration = if (configKey != null) { - configurationCache.remove(configKey) + // Extract configuration and auth instance from cache using UUID key + launchKey = intent.getStringExtra(EXTRA_CONFIGURATION_KEY) + configuration = if (launchKey != null) { + configurationCache[launchKey] } else { null } ?: run { @@ -90,7 +92,12 @@ class FirebaseAuthActivity : ComponentActivity() { return } - authUI = FirebaseAuthUI.getInstance() + authUI = launchKey?.let { authUICache[it] } ?: run { + // Missing auth instance, finish with error + setResult(RESULT_CANCELED) + finish() + return + } // Extract email link if present val emailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK) @@ -150,11 +157,17 @@ class FirebaseAuthActivity : ComponentActivity() { } override fun onDestroy() { - super.onDestroy() - // Reset auth state when activity is destroyed - if (!isFinishing) { + if (isFinishing) { + launchKey?.let { key -> + configurationCache.remove(key) + authUICache.remove(key) + } + } else { + // Preserve cached launch state so the recreated activity can recover it. authUI.updateAuthState(AuthState.Idle) } + + super.onDestroy() } companion object { @@ -191,14 +204,31 @@ class FirebaseAuthActivity : ComponentActivity() { */ internal fun createIntent( context: Context, - configuration: AuthUIConfiguration + configuration: AuthUIConfiguration, + authUI: FirebaseAuthUI = FirebaseAuthUI.getInstance() ): Intent { val configKey = UUID.randomUUID().toString() configurationCache[configKey] = configuration + authUICache[configKey] = authUI return Intent(context, FirebaseAuthActivity::class.java).apply { putExtra(EXTRA_CONFIGURATION_KEY, configKey) } } + + /** + * Clears cached launch state. This method is intended for testing purposes only. + * + * @suppress This is an internal API and should not be used in production code. + * @RestrictTo RestrictTo.Scope.TESTS + */ + @JvmStatic + @RestrictTo(RestrictTo.Scope.TESTS) + fun clearLaunchStateCache() { + configurationCache.clear() + authUICache.clear() + } + + private val authUICache = ConcurrentHashMap() } } diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt index 06e8c972a..a94999439 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt @@ -50,6 +50,7 @@ class FirebaseAuthActivityTest { private lateinit var applicationContext: Context private lateinit var authUI: FirebaseAuthUI + private lateinit var secondaryAuthUI: FirebaseAuthUI private lateinit var configuration: AuthUIConfiguration @Mock @@ -79,8 +80,20 @@ class FirebaseAuthActivityTest { .build() ) + val secondaryApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key-2") + .setApplicationId("fake-app-id-2") + .setProjectId("fake-project-id-2") + .build(), + "secondary" + ) + authUI = FirebaseAuthUI.getInstance() authUI.auth.useEmulator("127.0.0.1", 9099) + secondaryAuthUI = FirebaseAuthUI.getInstance(secondaryApp) + secondaryAuthUI.auth.useEmulator("127.0.0.1", 9099) configuration = AuthUIConfiguration( context = applicationContext, @@ -98,6 +111,7 @@ class FirebaseAuthActivityTest { @After fun tearDown() { + FirebaseAuthActivity.clearLaunchStateCache() FirebaseAuthUI.clearInstanceCache() FirebaseApp.getApps(applicationContext).forEach { app -> try { @@ -180,6 +194,46 @@ class FirebaseAuthActivityTest { assertThat(activity.isFinishing).isFalse() } + @Test + fun `activity launched from secondary auth flow observes supplied authUI instead of default app`() = + runTest { + val controller = secondaryAuthUI.createAuthFlow(configuration) + val intent = controller.createIntent(applicationContext) + val activity = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + .create() + .start() + .resume() + .get() + + `when`(mockFirebaseUser.uid).thenReturn("secondary-user-id") + + authUI.updateAuthState( + AuthState.Success( + result = null, + user = mockFirebaseUser, + isNewUser = false + ) + ) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(activity.isFinishing).isFalse() + + secondaryAuthUI.updateAuthState( + AuthState.Success( + result = null, + user = mockFirebaseUser, + isNewUser = false + ) + ) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(activity.isFinishing).isTrue() + val shadowActivity = shadowOf(activity) + assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_OK) + assertThat(shadowActivity.resultIntent.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID)) + .isEqualTo("secondary-user-id") + } + // ============================================================================================= // Auth State Success Tests // ============================================================================================= @@ -394,22 +448,28 @@ class FirebaseAuthActivityTest { // ============================================================================================= @Test - fun `configuration is removed from cache after onCreate`() { - val intent1 = FirebaseAuthActivity.createIntent(applicationContext, configuration) - val configKey1 = intent1.getStringExtra("com.firebase.ui.auth.CONFIGURATION_KEY") + fun `launch state survives recreation and is cleared when activity finishes`() { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) - assertThat(configKey1).isNotNull() + val firstController = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + val firstActivity = firstController.create().start().resume().get() + assertThat(firstActivity.isFinishing).isFalse() - // Create activity - this should consume the configuration from cache - val controller1 = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent1) - controller1.create().get() + // Simulate recreation: the first activity is destroyed without finishing. + firstController.pause().stop().destroy() + + val recreatedController = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + val recreatedActivity = recreatedController.create().start().resume().get() + assertThat(recreatedActivity.isFinishing).isFalse() - // Create another intent - val intent2 = FirebaseAuthActivity.createIntent(applicationContext, configuration) - val configKey2 = intent2.getStringExtra("com.firebase.ui.auth.CONFIGURATION_KEY") + // Once the recreated activity actually finishes, the cached launch state should be released. + recreatedActivity.finish() + recreatedController.pause().stop().destroy() - // Should be a different key - assertThat(configKey2).isNotEqualTo(configKey1) + val postFinishController = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + val postFinishActivity = postFinishController.create().get() + assertThat(postFinishActivity.isFinishing).isTrue() + assertThat(shadowOf(postFinishActivity).resultCode).isEqualTo(Activity.RESULT_CANCELED) } @Test From e5993652e6cc15bb3b3a76685e92a8755671c164 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 15 May 2026 10:01:24 +0100 Subject: [PATCH 4/8] fix(auth): make AuthException messages customisable via AuthUIStringProvider (#2320) --- .../com/firebase/ui/auth/AuthException.kt | 79 +++++++++++++------ .../com/firebase/ui/auth/FirebaseAuthUI.kt | 4 +- .../AnonymousAuthProvider+FirebaseAuthUI.kt | 3 +- .../EmailAuthProvider+FirebaseAuthUI.kt | 8 +- .../FacebookAuthProvider+FirebaseAuthUI.kt | 8 +- .../GoogleAuthProvider+FirebaseAuthUI.kt | 6 +- .../OAuthProvider+FirebaseAuthUI.kt | 4 +- .../PhoneAuthProvider+FirebaseAuthUI.kt | 4 +- .../string_provider/AuthUIStringProvider.kt | 49 ++++++++++++ .../DefaultAuthUIStringProvider.kt | 45 +++++++++++ .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 8 +- .../auth/ui/screens/email/EmailAuthScreen.kt | 6 +- .../auth/ui/screens/phone/PhoneAuthScreen.kt | 2 +- auth/src/main/res/values/strings.xml | 17 ++++ .../com/firebase/ui/auth/AuthExceptionTest.kt | 47 +++++++++++ 15 files changed, 239 insertions(+), 51 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthException.kt b/auth/src/main/java/com/firebase/ui/auth/AuthException.kt index 46d22f068..ae9d96e53 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthException.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthException.kt @@ -14,7 +14,10 @@ package com.firebase.ui.auth +import android.content.Context import com.firebase.ui.auth.AuthException.Companion.from +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.google.firebase.FirebaseException import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.FirebaseAuthException @@ -341,15 +344,22 @@ abstract class AuthException( * @return An appropriate [AuthException] subtype */ @JvmStatic - fun from(firebaseException: Exception): AuthException { + fun from(firebaseException: Exception, context: Context): AuthException = + from(firebaseException, DefaultAuthUIStringProvider(context)) + + @JvmStatic + @JvmOverloads + fun from(firebaseException: Exception, stringProvider: AuthUIStringProvider? = null): AuthException { return when (firebaseException) { // If already an AuthException, return it directly is AuthException -> firebaseException - + // Handle specific Firebase Auth exceptions first (before general FirebaseException) is FirebaseAuthInvalidCredentialsException -> { InvalidCredentialsException( - message = firebaseException.message ?: "Invalid credentials provided", + message = stringProvider?.errorInvalidCredentials.nonEmpty() + ?: firebaseException.message + ?: "Invalid credentials provided", cause = firebaseException ) } @@ -357,17 +367,23 @@ abstract class AuthException( is FirebaseAuthInvalidUserException -> { when (firebaseException.errorCode) { "ERROR_USER_NOT_FOUND" -> UserNotFoundException( - message = firebaseException.message ?: "User not found", + message = stringProvider?.errorUserNotFound.nonEmpty() + ?: firebaseException.message + ?: "User not found", cause = firebaseException ) "ERROR_USER_DISABLED" -> InvalidCredentialsException( - message = firebaseException.message ?: "User account has been disabled", + message = stringProvider?.errorUserDisabled.nonEmpty() + ?: firebaseException.message + ?: "User account has been disabled", cause = firebaseException ) else -> UserNotFoundException( - message = firebaseException.message ?: "User account error", + message = stringProvider?.errorUserAccountGeneric.nonEmpty() + ?: firebaseException.message + ?: "User account error", cause = firebaseException ) } @@ -375,7 +391,9 @@ abstract class AuthException( is FirebaseAuthWeakPasswordException -> { WeakPasswordException( - message = firebaseException.message ?: "Password is too weak", + message = stringProvider?.errorWeakPasswordGeneric.nonEmpty() + ?: firebaseException.message + ?: "Password is too weak", cause = firebaseException, reason = firebaseException.reason ) @@ -384,26 +402,31 @@ abstract class AuthException( is FirebaseAuthUserCollisionException -> { when (firebaseException.errorCode) { "ERROR_EMAIL_ALREADY_IN_USE" -> EmailAlreadyInUseException( - message = firebaseException.message + message = stringProvider?.errorEmailAlreadyInUse.nonEmpty() + ?: firebaseException.message ?: "Email address is already in use", cause = firebaseException, email = firebaseException.email ) "ERROR_ACCOUNT_EXISTS_WITH_DIFFERENT_CREDENTIAL" -> AccountLinkingRequiredException( - message = firebaseException.message + message = stringProvider?.errorAccountExistsDifferentCredential.nonEmpty() + ?: firebaseException.message ?: "Account already exists with different credentials", cause = firebaseException ) "ERROR_CREDENTIAL_ALREADY_IN_USE" -> AccountLinkingRequiredException( - message = firebaseException.message + message = stringProvider?.errorCredentialAlreadyInUse.nonEmpty() + ?: firebaseException.message ?: "Credential is already associated with a different user account", cause = firebaseException ) else -> AccountLinkingRequiredException( - message = firebaseException.message ?: "Account collision error", + message = stringProvider?.errorAccountCollisionGeneric.nonEmpty() + ?: firebaseException.message + ?: "Account collision error", cause = firebaseException ) } @@ -411,7 +434,8 @@ abstract class AuthException( is FirebaseAuthMultiFactorException -> { MfaRequiredException( - message = firebaseException.message + message = stringProvider?.errorMfaRequiredFallback.nonEmpty() + ?: firebaseException.message ?: "Multi-factor authentication required", cause = firebaseException ) @@ -419,23 +443,25 @@ abstract class AuthException( is FirebaseAuthRecentLoginRequiredException -> { InvalidCredentialsException( - message = firebaseException.message + message = stringProvider?.errorRecentLoginRequired.nonEmpty() + ?: firebaseException.message ?: "Recent login required for this operation", cause = firebaseException ) } is FirebaseAuthException -> { - // Handle FirebaseAuthException and check for specific error codes when (firebaseException.errorCode) { "ERROR_TOO_MANY_REQUESTS" -> TooManyRequestsException( - message = firebaseException.message + message = stringProvider?.errorTooManyRequests.nonEmpty() + ?: firebaseException.message ?: "Too many requests. Please try again later", cause = firebaseException ) else -> UnknownException( - message = firebaseException.message + message = stringProvider?.errorUnknownAuth.nonEmpty() + ?: firebaseException.message ?: "An unknown authentication error occurred", cause = firebaseException ) @@ -443,33 +469,36 @@ abstract class AuthException( } is FirebaseException -> { - // Handle general Firebase exceptions, which include network errors NetworkException( - message = firebaseException.message ?: "Network error occurred", + message = stringProvider?.errorNetworkGeneric.nonEmpty() + ?: firebaseException.message + ?: "Network error occurred", cause = firebaseException ) } else -> { - // Check for common cancellation patterns - if (firebaseException.message?.contains( - "cancelled", - ignoreCase = true - ) == true || + if (firebaseException.message?.contains("cancelled", ignoreCase = true) == true || firebaseException.message?.contains("canceled", ignoreCase = true) == true ) { AuthCancelledException( - message = firebaseException.message ?: "Authentication was cancelled", + message = stringProvider?.errorAuthCancelled.nonEmpty() + ?: firebaseException.message + ?: "Authentication was cancelled", cause = firebaseException ) } else { UnknownException( - message = firebaseException.message ?: "An unknown error occurred", + message = stringProvider?.errorUnknownAuth.nonEmpty() + ?: firebaseException.message + ?: "An unknown error occurred", cause = firebaseException ) } } } } + + private fun String?.nonEmpty(): String? = this?.ifEmpty { null } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt index f01cde6a7..af1daa3c0 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -387,7 +387,7 @@ class FirebaseAuthUI private constructor( throw e } catch (e: Exception) { // Map to appropriate AuthException - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) throw authException } @@ -453,7 +453,7 @@ class FirebaseAuthUI private constructor( throw e } catch (e: Exception) { // Map to appropriate AuthException - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) throw authException } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt index 009765727..746537390 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.tasks.await */ @Composable internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(): () -> Unit { + val context = androidx.compose.ui.platform.LocalContext.current val coroutineScope = rememberCoroutineScope() return remember(this) { { @@ -30,7 +31,7 @@ internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(): () -> Unit { // Already an AuthException, don't re-wrap it updateAuthState(AuthState.Error(e)) } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) } } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index 8d4bae6d1..61b50c613 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -225,7 +225,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( updateAuthState(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) throw authException } @@ -450,7 +450,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( updateAuthState(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) throw authException } @@ -766,7 +766,7 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( updateAuthState(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) throw authException } @@ -987,7 +987,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( updateAuthState(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) throw authException } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt index 28ef45636..c87748ea9 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt @@ -86,7 +86,7 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( // Already an AuthException, don't re-wrap it updateAuthState(AuthState.Error(e)) } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) } } @@ -98,7 +98,7 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( override fun onError(error: FacebookException) { Log.e("FacebookAuthProvider", "Error during Facebook sign in", error) - val authException = AuthException.from(error) + val authException = AuthException.from(error, context) updateAuthState( AuthState.Error( authException @@ -190,7 +190,7 @@ internal suspend fun FirebaseAuthUI.signInWithFacebook( updateAuthState(AuthState.Error(e)) throw e } catch (e: FacebookException) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) throw authException } catch (e: CancellationException) { @@ -204,7 +204,7 @@ internal suspend fun FirebaseAuthUI.signInWithFacebook( updateAuthState(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) throw authException } 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 4d18cb0a9..f8cbbdddf 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 @@ -67,7 +67,7 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler( } catch (e: AuthException) { updateAuthState(AuthState.Error(e)) } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) } } @@ -128,7 +128,7 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( authorizationProvider.authorize(context, requestedScopes) } catch (e: Exception) { // Continue with sign-in even if scope authorization fails - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) } } @@ -227,7 +227,7 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( throw e } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) throw authException } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt index 485065746..4053684d6 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt @@ -74,7 +74,7 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler( } catch (e: AuthException) { updateAuthState(AuthState.Error(e)) } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) } } @@ -231,7 +231,7 @@ internal suspend fun FirebaseAuthUI.signInWithProvider( throw e } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) throw authException } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt index 0be8ee8fa..dd8662064 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt @@ -224,7 +224,7 @@ internal suspend fun FirebaseAuthUI.submitVerificationCode( updateAuthState(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) throw authException } @@ -334,7 +334,7 @@ internal suspend fun FirebaseAuthUI.signInWithPhoneAuthCredential( updateAuthState(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, context) updateAuthState(AuthState.Error(authException)) throw authException } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt index a062debdd..bc7a8acdb 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt @@ -542,4 +542,53 @@ interface AuthUIStringProvider { /** Tooltip message shown when MFA is disabled */ val mfaDisabledTooltip: String + + // ============================================================================================= + // AuthException error messages + // ============================================================================================= + + /** Error when a user account has been disabled by an administrator. */ + val errorUserDisabled: String + + /** Error when provided credentials are invalid. Return empty to use the Firebase SDK message. */ + val errorInvalidCredentials: String + + /** Error when the user account does not exist. Return empty to use the Firebase SDK message. */ + val errorUserNotFound: String + + /** Generic error for unexpected user account issues. Return empty to use the Firebase SDK message. */ + val errorUserAccountGeneric: String + + /** Error when the password is too weak. Return empty to use the Firebase SDK message. */ + val errorWeakPasswordGeneric: String + + /** Error when the email address is already registered. Return empty to use the Firebase SDK message. */ + val errorEmailAlreadyInUse: String + + /** Error when an account already exists with a different sign-in method. Return empty to use the Firebase SDK message. */ + val errorAccountExistsDifferentCredential: String + + /** Error when a credential is already linked to another account. Return empty to use the Firebase SDK message. */ + val errorCredentialAlreadyInUse: String + + /** Generic error for account collision issues. Return empty to use the Firebase SDK message. */ + val errorAccountCollisionGeneric: String + + /** Error when multi-factor authentication is required. Return empty to use the Firebase SDK message. */ + val errorMfaRequiredFallback: String + + /** Error when the operation requires a recent sign-in. Return empty to use the Firebase SDK message. */ + val errorRecentLoginRequired: String + + /** Error when sign-in is blocked due to too many attempts. Return empty to use the Firebase SDK message. */ + val errorTooManyRequests: String + + /** Generic unknown authentication error. Return empty to use the Firebase SDK message. */ + val errorUnknownAuth: String + + /** Error for network failures during authentication. Return empty to use the Firebase SDK message. */ + val errorNetworkGeneric: String + + /** Error when authentication is cancelled. Return empty to use the Firebase SDK message. */ + val errorAuthCancelled: String } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt index 429d6d286..3d2b9772d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -494,4 +494,49 @@ class DefaultAuthUIStringProvider( override val mfaDisabledTooltip: String get() = localizedContext.getString(R.string.fui_mfa_disabled_tooltip) + + override val errorUserDisabled: String + get() = localizedContext.getString(R.string.fui_error_user_disabled) + + override val errorInvalidCredentials: String + get() = localizedContext.getString(R.string.fui_error_invalid_credentials) + + override val errorUserNotFound: String + get() = localizedContext.getString(R.string.fui_error_user_not_found) + + override val errorUserAccountGeneric: String + get() = localizedContext.getString(R.string.fui_error_user_account_generic) + + override val errorWeakPasswordGeneric: String + get() = localizedContext.getString(R.string.fui_error_weak_password_generic) + + override val errorEmailAlreadyInUse: String + get() = localizedContext.getString(R.string.fui_error_email_already_in_use) + + override val errorAccountExistsDifferentCredential: String + get() = localizedContext.getString(R.string.fui_error_account_exists_different_credential) + + override val errorCredentialAlreadyInUse: String + get() = localizedContext.getString(R.string.fui_error_credential_already_in_use) + + override val errorAccountCollisionGeneric: String + get() = localizedContext.getString(R.string.fui_error_account_collision_generic) + + override val errorMfaRequiredFallback: String + get() = localizedContext.getString(R.string.fui_error_mfa_required_fallback) + + override val errorRecentLoginRequired: String + get() = localizedContext.getString(R.string.fui_error_recent_login_required) + + override val errorTooManyRequests: String + get() = localizedContext.getString(R.string.fui_error_too_many_requests) + + override val errorUnknownAuth: String + get() = localizedContext.getString(R.string.fui_error_unknown_auth) + + override val errorNetworkGeneric: String + get() = localizedContext.getString(R.string.fui_error_network_generic) + + override val errorAuthCancelled: String + get() = localizedContext.getString(R.string.fui_error_auth_cancelled) } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index fbf0bed2b..7a67b977e 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt @@ -372,7 +372,7 @@ fun FirebaseAuthScreen( authUI.signOut(context) // Keep sign-in preference for "Continue as..." on next launch } catch (e: Exception) { - onSignInFailure(AuthException.from(e)) + onSignInFailure(AuthException.from(e, stringProvider)) } finally { pendingLinkingCredential.value = null pendingResolver.value = null @@ -453,7 +453,7 @@ fun FirebaseAuthScreen( onComplete = { navController.popBackStack() }, onSkip = { navController.popBackStack() }, onError = { exception -> - onSignInFailure(AuthException.from(exception)) + onSignInFailure(AuthException.from(exception, stringProvider)) } ) } else { @@ -478,7 +478,7 @@ fun FirebaseAuthScreen( navController.popBackStack() }, onError = { exception -> - onSignInFailure(AuthException.from(exception)) + onSignInFailure(AuthException.from(exception, stringProvider)) } ) } else { @@ -609,7 +609,7 @@ fun FirebaseAuthScreen( LaunchedEffect(errorState) { val exception = when (val throwable = errorState.exception) { is AuthException -> throwable - else -> AuthException.from(throwable) + else -> AuthException.from(throwable, stringProvider) } dialogController.showErrorDialog( diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt index 2ebc2542f..62972d18c 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt @@ -181,7 +181,7 @@ fun EmailAuthScreen( } is AuthState.Error -> { - val exception = AuthException.from(state.exception) + val exception = AuthException.from(state.exception, stringProvider) onError(exception) dialogController?.showErrorDialog( exception = exception, @@ -265,7 +265,7 @@ fun EmailAuthScreen( skipCredentialSave = isUsingRetrievedCredential ) } catch (e: Exception) { - onError(AuthException.from(e)) + onError(AuthException.from(e, stringProvider)) } } }, @@ -290,7 +290,7 @@ fun EmailAuthScreen( ) } } catch (e: Exception) { - onError(AuthException.from(e)) + onError(AuthException.from(e, stringProvider)) } } }, diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt index fa6278976..26161da78 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt @@ -210,7 +210,7 @@ fun PhoneAuthScreen( } is AuthState.Error -> { - val exception = AuthException.from(state.exception) + val exception = AuthException.from(state.exception, stringProvider) onError(exception) // Show dialog for phone-specific errors using top-level controller diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index bb4b4e813..cc5cfa6b3 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -222,6 +222,23 @@ Additional verification required. Please complete multi-factor authentication. Account needs to be linked. Please try a different sign-in method. Authentication was cancelled. Please try again when ready. + + User account has been disabled + + + + + + + + + + + + + + Choose Authentication Method diff --git a/auth/src/test/java/com/firebase/ui/auth/AuthExceptionTest.kt b/auth/src/test/java/com/firebase/ui/auth/AuthExceptionTest.kt index 0b7b5bbbf..caa382bb1 100644 --- a/auth/src/test/java/com/firebase/ui/auth/AuthExceptionTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/AuthExceptionTest.kt @@ -14,11 +14,15 @@ package com.firebase.ui.auth +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseException import com.google.firebase.auth.FirebaseAuthException +import com.google.firebase.auth.FirebaseAuthInvalidUserException import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -136,4 +140,47 @@ class AuthExceptionTest { // Assert assertThat(exception.email).isEqualTo(email) } + + // ============================================================================================= + // AuthUIStringProvider message customisation + // ============================================================================================= + + @Test + fun `from() uses string provider message when non-empty`() { + val firebaseException = mock(FirebaseAuthInvalidUserException::class.java) + whenever(firebaseException.errorCode).thenReturn("ERROR_USER_DISABLED") + whenever(firebaseException.message).thenReturn("Firebase: user disabled") + + val stringProvider = mock(AuthUIStringProvider::class.java) + whenever(stringProvider.errorUserDisabled).thenReturn("Custom: account disabled") + + val result = AuthException.from(firebaseException, stringProvider) + + assertThat(result.message).isEqualTo("Custom: account disabled") + } + + @Test + fun `from() falls back to Firebase message when string provider returns empty`() { + val firebaseException = mock(FirebaseAuthInvalidUserException::class.java) + whenever(firebaseException.errorCode).thenReturn("ERROR_USER_DISABLED") + whenever(firebaseException.message).thenReturn("Firebase: user disabled") + + val stringProvider = mock(AuthUIStringProvider::class.java) + whenever(stringProvider.errorUserDisabled).thenReturn("") + + val result = AuthException.from(firebaseException, stringProvider) + + assertThat(result.message).isEqualTo("Firebase: user disabled") + } + + @Test + fun `from() falls back to Firebase message when no string provider given`() { + val firebaseException = mock(FirebaseAuthInvalidUserException::class.java) + whenever(firebaseException.errorCode).thenReturn("ERROR_USER_DISABLED") + whenever(firebaseException.message).thenReturn("Firebase: user disabled") + + val result = AuthException.from(firebaseException) + + assertThat(result.message).isEqualTo("Firebase: user disabled") + } } \ No newline at end of file From 61e1b3d8943bf2f734d8b72e213373f488148bad Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 15 May 2026 10:40:59 +0100 Subject: [PATCH 5/8] fix(auth): preserve linkDomain in email link ActionCodeSettings (#2321) --- .../auth_provider/AuthProvider.kt | 1 + .../auth_provider/AuthProviderTest.kt | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt index 5cf392a8c..ffbe5242a 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt @@ -211,6 +211,7 @@ abstract class AuthProvider(open val providerId: String, open val providerName: return actionCodeSettings { url = continueUrl handleCodeInApp = emailLinkActionCodeSettings.canHandleCodeInApp() + linkDomain = emailLinkActionCodeSettings.linkDomain iosBundleId = emailLinkActionCodeSettings.iosBundle setAndroidPackageName( emailLinkActionCodeSettings.androidPackageName ?: "", diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt index 718d38ad3..126600e5d 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt @@ -32,6 +32,29 @@ class AuthProviderTest { // Email Provider Tests // ============================================================================================= + @Test + fun `addSessionInfoToActionCodeSettings preserves linkDomain`() { + val actionCodeSettings = actionCodeSettings { + url = "https://example.com" + handleCodeInApp = true + linkDomain = "myapp.page.link" + setAndroidPackageName("com.example", true, null) + } + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = actionCodeSettings, + passwordValidationRules = emptyList() + ) + + val result = provider.addSessionInfoToActionCodeSettings( + sessionId = "abc123", + anonymousUserId = "" + ) + + assertThat(result.linkDomain).isEqualTo("myapp.page.link") + } + @Test fun `email provider with valid configuration should succeed`() { val provider = AuthProvider.Email( From 28bbb09cbccb20140fa37e35726c3849dd5237a3 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 18 May 2026 14:59:54 +0100 Subject: [PATCH 6/8] fix(auth): log out before Facebook sign-in to clear stale cached token (#2322) Co-authored-by: russellwheatley --- .../FacebookAuthProvider+FirebaseAuthUI.kt | 7 ++ .../FacebookAuthProviderFirebaseAuthUI.kt | 89 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt index c87748ea9..10be33cb9 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt @@ -46,6 +46,7 @@ import kotlinx.coroutines.launch * @param context Android context for DataStore access when saving credentials for linking * @param config The [AuthUIConfiguration] containing authentication settings * @param provider The [AuthProvider.Facebook] configuration with scopes and credential provider + * @param loginManagerProvider Provides logout operations to clear stale Facebook sessions * * @return A launcher function that starts the Facebook sign-in flow when invoked * @@ -56,6 +57,7 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( context: Context, config: AuthUIConfiguration, provider: AuthProvider.Facebook, + loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(), ): () -> Unit { val coroutineScope = rememberCoroutineScope() val callbackManager = remember { CallbackManager.Factory.create() } @@ -114,6 +116,11 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( updateAuthState( AuthState.Loading("Signing in with facebook...") ) + try { + (testLoginManagerProvider ?: loginManagerProvider).logOut() + } catch (e: Exception) { + Log.w("FacebookAuthProvider", "Failed to clear Facebook session before sign in", e) + } launcher.launch(provider.scopes) } } diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt index 1e48bae90..fe10118e6 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt @@ -16,9 +16,11 @@ package com.firebase.ui.auth.configuration.auth_provider import android.content.Context import android.net.Uri +import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.core.app.ApplicationProvider import com.facebook.AccessToken import com.facebook.FacebookException +import com.facebook.FacebookSdk import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.FirebaseAuthUI @@ -40,6 +42,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock @@ -61,6 +64,9 @@ import org.robolectric.annotation.Config @Config(manifest = Config.NONE) class FacebookAuthProviderFirebaseAuthUITest { + @get:Rule + val composeTestRule = createComposeRule() + @Mock private lateinit var mockFirebaseAuth: FirebaseAuth @@ -78,6 +84,11 @@ class FacebookAuthProviderFirebaseAuthUITest { applicationContext = ApplicationProvider.getApplicationContext() + FacebookSdk.setApplicationId("fake-app-id") + FacebookSdk.setClientToken("fake-client-token") + @Suppress("DEPRECATION") + FacebookSdk.sdkInitialize(applicationContext) + FirebaseApp.getApps(applicationContext).forEach { app -> app.delete() } @@ -102,6 +113,84 @@ class FacebookAuthProviderFirebaseAuthUITest { } } + @Test + @Config(manifest = Config.NONE, qualifiers = "night") + fun `rememberSignInWithFacebookLauncher - calls logOut before launching to clear stale token`() { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val provider = AuthProvider.Facebook() + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + + var launcher: (() -> Unit)? = null + + composeTestRule.setContent { + launcher = instance.rememberSignInWithFacebookLauncher( + context = applicationContext, + config = config, + provider = provider, + loginManagerProvider = mockFBAuthCredentialProvider, + ) + } + + composeTestRule.runOnIdle { + try { + launcher?.invoke() + } catch (_: Exception) { + // launcher.launch() may throw in test environment — that's expected + } + } + + verify(mockFBAuthCredentialProvider).logOut() + } + + @Test + @Config(manifest = Config.NONE, qualifiers = "night") + fun `rememberSignInWithFacebookLauncher - does not propagate stale token logout failure`() { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val provider = AuthProvider.Facebook() + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + val logoutException = RuntimeException("logout failed") + doAnswer { + throw logoutException + }.whenever(mockFBAuthCredentialProvider).logOut() + + var launcher: (() -> Unit)? = null + var thrownException: Exception? = null + + composeTestRule.setContent { + launcher = instance.rememberSignInWithFacebookLauncher( + context = applicationContext, + config = config, + provider = provider, + loginManagerProvider = mockFBAuthCredentialProvider, + ) + } + + composeTestRule.runOnIdle { + try { + launcher?.invoke() + } catch (e: Exception) { + thrownException = e + } + } + + var exceptionInChain: Throwable? = thrownException + while (exceptionInChain != null) { + assertThat(exceptionInChain).isNotEqualTo(logoutException) + exceptionInChain = exceptionInChain.cause + } + verify(mockFBAuthCredentialProvider).logOut() + } + @Test @Config(manifest = Config.NONE, qualifiers = "night") fun `signInWithFacebook - successful sign in signs user in and emits Success authState`() = runTest { From eb4b70e01b1a9ca11ba2d038b9eb6122908d7e06 Mon Sep 17 00:00:00 2001 From: Nillan Sivarasa Date: Tue, 19 May 2026 16:37:18 +0200 Subject: [PATCH 7/8] Fix typo in README (#2325) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b34d88cc6..61a523aac 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ Then you can depend on snapshot versions: implementation 'com.firebaseui:firebase-ui-auth:$X.Y.Z-SNAPSHOT' ``` -You can see which `SNAPSHOT` builds are avaiable here: +You can see which `SNAPSHOT` builds are available here: https://oss.jfrog.org/webapp/#/artifacts/browse/tree/General/oss-snapshot-local/com/firebaseui Snapshot builds come with absolutely no guarantees and we will close any issues asking to troubleshoot From f002c6177e21148ae1e8c32bc2aabc10e4df9386 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 20 May 2026 14:31:14 +0100 Subject: [PATCH 8/8] fix(auth): emit AuthResult on sign-in success and fix MFA tooltip auto-open (#2326) * fix(auth): update AuthState to reflect success or idle based on user result * updates * updates * refactor: rename auth user state handler --- .../android/demo/HighLevelApiDemoActivity.kt | 2 +- .../com/firebase/ui/auth/FirebaseAuthUI.kt | 54 +++++-- .../AnonymousAuthProvider+FirebaseAuthUI.kt | 4 +- .../EmailAuthProvider+FirebaseAuthUI.kt | 8 +- .../OAuthProvider+FirebaseAuthUI.kt | 4 +- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 2 +- ...AnonymousAuthProviderFirebaseAuthUITest.kt | 4 +- .../EmailAuthProviderFirebaseAuthUITest.kt | 147 ++++++++++++++++++ .../GoogleAuthProviderFirebaseAuthUITest.kt | 12 +- .../OAuthProviderFirebaseAuthUITest.kt | 6 +- 10 files changed, 206 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt index ff008abcf..a4c708a6b 100644 --- a/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt @@ -247,7 +247,7 @@ private fun AppAuthenticatedContent( } }, state = rememberTooltipState( - initialIsVisible = !configuration.isMfaEnabled + initialIsVisible = false ) ) { Button( diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt index af1daa3c0..827c37abd 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -23,6 +23,7 @@ import com.firebase.ui.auth.configuration.auth_provider.signOutFromFacebook import com.firebase.ui.auth.configuration.auth_provider.signOutFromGoogle import com.google.firebase.Firebase import com.google.firebase.FirebaseApp +import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth.AuthStateListener import com.google.firebase.auth.FirebaseAuth.IdTokenListener @@ -258,21 +259,7 @@ class FirebaseAuthUI private constructor( val firebaseAuthFlow = callbackFlow { fun buildState(currentUser: FirebaseUser?): AuthState { return if (currentUser != null) { - if (!currentUser.isEmailVerified && - currentUser.email != null && - currentUser.providerData.any { it.providerId == "password" } - ) { - AuthState.RequiresEmailVerification( - user = currentUser, - email = currentUser.email!! - ) - } else { - AuthState.Success( - result = null, - user = currentUser, - isNewUser = false - ) - } + handleAuthUserState(currentUser, result = null, isNewUser = false) } else { AuthState.Idle } @@ -285,6 +272,17 @@ class FirebaseAuthUI private constructor( // Create auth state listener val authStateListener = AuthStateListener { firebaseAuth -> + // When user signs out, clear stale user-presence internal states so the combine + // doesn't return Success/RequiresEmailVerification after the user is gone. + if (firebaseAuth.currentUser == null) { + val current = _authStateFlow.value + if (current is AuthState.Success || + current is AuthState.RequiresEmailVerification || + current is AuthState.RequiresProfileCompletion + ) { + _authStateFlow.value = AuthState.Idle + } + } trySend(buildState(firebaseAuth.currentUser)) } @@ -325,6 +323,32 @@ class FirebaseAuthUI private constructor( _authStateFlow.value = state } + internal fun updateAuthStateWithResult(result: AuthResult?, defaultIsNewUser: Boolean = false) { + val user = result?.user + if (user != null) { + updateAuthState( + handleAuthUserState( + user = user, + result = result, + isNewUser = result.additionalUserInfo?.isNewUser ?: defaultIsNewUser + ) + ) + } else { + updateAuthState(AuthState.Idle) + } + } + + private fun handleAuthUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState { + return if (!user.isEmailVerified && + user.email != null && + user.providerData.any { it.providerId == "password" } + ) { + AuthState.RequiresEmailVerification(user = user, email = user.email!!) + } else { + AuthState.Success(result = result, user = user, isNewUser = isNewUser) + } + } + /** * Signs out the current user and clears authentication state. * diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt index 746537390..baf9cef82 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt @@ -110,8 +110,8 @@ internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(): () -> Unit { internal suspend fun FirebaseAuthUI.signInAnonymously() { try { updateAuthState(AuthState.Loading("Signing in anonymously...")) - auth.signInAnonymously().await() - updateAuthState(AuthState.Idle) + val result = auth.signInAnonymously().await() + updateAuthStateWithResult(result, defaultIsNewUser = true) } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in anonymously was cancelled", diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index 61b50c613..670b451bc 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -197,7 +197,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( } } - updateAuthState(AuthState.Idle) + updateAuthStateWithResult(result, defaultIsNewUser = true) return result } catch (e: FirebaseAuthUserCollisionException) { // Account collision: email already exists @@ -431,7 +431,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( } } - updateAuthState(AuthState.Idle) + updateAuthStateWithResult(result) } } catch (e: FirebaseAuthMultiFactorException) { // MFA required - extract resolver and update state @@ -557,7 +557,7 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( result?.user?.let { mergeProfile(auth, displayName, photoUrl) } - updateAuthState(AuthState.Idle) + updateAuthStateWithResult(result) } } catch (e: FirebaseAuthMultiFactorException) { // MFA required - extract resolver and update state @@ -974,7 +974,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( } // Clear DataStore after success persistenceManager.clear(context) - updateAuthState(AuthState.Idle) + updateAuthStateWithResult(result) return result } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt index 4053684d6..e2d141400 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt @@ -162,7 +162,6 @@ internal suspend fun FirebaseAuthUI.signInWithProvider( photoUrl = authResult.user?.photoUrl, ) } - updateAuthState(AuthState.Idle) return } @@ -195,8 +194,7 @@ internal suspend fun FirebaseAuthUI.signInWithProvider( android.util.Log.w("OAuthProvider", "Failed to save sign-in preference", e) } - // Just update state to Idle - updateAuthState(AuthState.Idle) + updateAuthStateWithResult(authResult) } else { throw AuthException.UnknownException( message = "OAuth sign-in did not return a valid credential" diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index 7a67b977e..9a7e3911a 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt @@ -780,7 +780,7 @@ private fun AuthSuccessContent( } }, state = rememberTooltipState( - initialIsVisible = !configuration.isMfaEnabled + initialIsVisible = false ) ) { Button( diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt index 53f465b9a..7ddfb3ac8 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt @@ -110,8 +110,8 @@ class AnonymousAuthProviderFirebaseAuthUITest { verify(mockFirebaseAuth).signInAnonymously() - val finalState = instance.authStateFlow().first { it is AuthState.Idle } - assertThat(finalState).isInstanceOf(AuthState.Idle::class.java) + val finalState = instance.authStateFlow().first { it is AuthState.Success } + assertThat(finalState).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = true)) } @Test diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt index dc027e3dc..d42bbab5f 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt @@ -1457,4 +1457,151 @@ class EmailAuthProviderFirebaseAuthUITest { assertThat(e).isNotNull() } } + + @Test + fun `signInWithEmailAndPassword - emits AuthState Success with non-null result`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider) } + } + + instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123" + ) + + val state = instance.authStateFlow().first { it !is AuthState.Loading } + assertThat(state).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false)) + } + + @Test + fun `signInAndLinkWithCredential - emits AuthState Success with non-null result`() = runTest { + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithCredential(credential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider) } + } + + instance.signInAndLinkWithCredential(config = config, credential = credential) + + val state = instance.authStateFlow().first { it !is AuthState.Loading } + assertThat(state).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false)) + } + + @Test + fun `createOrLinkUserWithEmailAndPassword - emits AuthState Success with non-null result`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.createUserWithEmailAndPassword("new@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider) } + } + + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "new@example.com", + password = "Pass@123" + ) + + val state = instance.authStateFlow().first { it !is AuthState.Loading } + assertThat(state).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = true)) + } + + @Test + fun `signInWithEmailLink - emits AuthState Success with non-null result`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.email).thenReturn("test@example.com") + `when`(mockUser.isAnonymous).thenReturn(false) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`(mockFirebaseAuth.isSignInWithEmailLink(anyString())).thenReturn(true) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithCredential(any())).thenReturn(taskCompletionSource.task) + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .build(), + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + val mockPersistence = MockPersistenceManager() + mockPersistence.setSessionRecord( + EmailLinkPersistenceManager.SessionRecord( + sessionId = "session123", + email = "test@example.com", + anonymousUserId = null, + credentialForLinking = null + ) + ) + + val emailLink = + "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code&continueUrl=https://example.com?ui_sid=session123" + + instance.signInWithEmailLink( + context = applicationContext, + config = config, + provider = provider, + email = "test@example.com", + emailLink = emailLink, + persistenceManager = mockPersistence + ) + + val state = instance.authStateFlow().first { it !is AuthState.Loading } + assertThat(state).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false)) + } } 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 2fd855c37..185483b9c 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 @@ -180,9 +180,9 @@ class GoogleAuthProviderFirebaseAuthUITest { // Verify Firebase sign-in was called verify(mockFirebaseAuth).signInWithCredential(mockCredential) - // Verify state is Idle after success - val finalState = instance.authStateFlow().first() - assertThat(finalState).isEqualTo(AuthState.Idle) + // Verify state is Success (with the real AuthResult) after sign-in + val finalState = instance.authStateFlow().first { it !is AuthState.Loading } + assertThat(finalState).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false)) } @Test @@ -853,8 +853,8 @@ class GoogleAuthProviderFirebaseAuthUITest { credentialManagerProvider = mockCredentialManagerProvider ) - // Verify final state - val finalState = instance.authStateFlow().first() - assertThat(finalState).isEqualTo(AuthState.Idle) + // Verify final state is Success (with the real AuthResult) + val finalState = instance.authStateFlow().first { it !is AuthState.Loading } + assertThat(finalState).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false)) } } diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt index 1d027d9ea..672e0c11d 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt @@ -143,9 +143,9 @@ class OAuthProviderFirebaseAuthUITest { any() ) - // Verify state is Idle after success - val finalState = instance.authStateFlow().first() - assertThat(finalState).isEqualTo(AuthState.Idle) + // Verify state is Success after sign-in + val finalState = instance.authStateFlow().first { it !is AuthState.Loading } + assertThat(finalState).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false)) } // =============================================================================================