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 01/14] 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 02/14] 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 03/14] 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 04/14] 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 05/14] 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 06/14] 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 07/14] 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 08/14] 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)) } // ============================================================================================= From 50de8bbffec9445200911966e9fbf3e99209b70e Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 22 May 2026 09:49:08 +0100 Subject: [PATCH 09/14] fix(auth): expose slot parameters through FirebaseAuthScreen (#2328) * fix(auth): add configurable params for custom UI - AuthMethodPicker - EmailAuth - PhoneAuth - MFA Enrollment & Challenge * fix(auth): add demo activities for email and phone authentication with customizable UI * updates * updates --- app/src/main/AndroidManifest.xml | 24 + .../demo/CustomMethodPickerDemoActivity.kt | 318 +++++ .../demo/CustomSlotsThemingDemoActivity.kt | 1107 +---------------- .../android/demo/EmailAuthSlotDemoActivity.kt | 437 +++++++ .../android/demo/PhoneAuthSlotDemoActivity.kt | 338 +++++ .../demo/ShapeCustomizationDemoActivity.kt | 263 ++++ .../auth/ui/method_picker/AuthMethodPicker.kt | 2 +- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 14 + .../ui/screens/FirebaseAuthScreenSlotsTest.kt | 199 +++ 9 files changed, 1662 insertions(+), 1040 deletions(-) create mode 100644 app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt create mode 100644 app/src/main/java/com/firebaseui/android/demo/EmailAuthSlotDemoActivity.kt create mode 100644 app/src/main/java/com/firebaseui/android/demo/PhoneAuthSlotDemoActivity.kt create mode 100644 app/src/main/java/com/firebaseui/android/demo/ShapeCustomizationDemoActivity.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7403aaf50..72f1b8025 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -55,6 +55,30 @@ android:label="Custom Slots & Theming Demo" android:exported="false" android:theme="@style/Theme.FirebaseUIAndroid" /> + + + + + + + + diff --git a/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt new file mode 100644 index 000000000..cd73ce212 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt @@ -0,0 +1,318 @@ +package com.firebaseui.android.demo + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.configuration.theme.AuthUITheme +import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults +import com.firebase.ui.auth.ui.components.AuthProviderButton +import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen + +class CustomMethodPickerDemoActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val authUI = FirebaseAuthUI.getInstance() + + val configuration = authUIConfiguration { + context = applicationContext + logo = AuthUIAsset.Resource(R.drawable.firebase_auth) + tosUrl = "https://policies.google.com/terms" + privacyPolicyUrl = "https://policies.google.com/privacy" + providers { + provider( + AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "406099696497-a12gakvts4epfk5pkio7dphc1anjiggc.apps.googleusercontent.com", + ) + ) + provider(AuthProvider.Apple(customParameters = emptyMap(), locale = null)) + provider(AuthProvider.Facebook()) + provider(AuthProvider.Twitter(customParameters = emptyMap())) + provider(AuthProvider.Github(customParameters = emptyMap())) + provider(AuthProvider.Microsoft(tenant = null, customParameters = emptyMap())) + provider(AuthProvider.Yahoo(customParameters = emptyMap())) + provider( + AuthProvider.GenericOAuth( + providerName = "Discord", + providerId = "oidc.discord", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = "Sign in with Discord", + buttonIcon = AuthUIAsset.Resource(R.drawable.ic_discord_24dp), + buttonColor = Color(0xFF5865F2), + contentColor = Color.White + ) + ) + provider( + AuthProvider.GenericOAuth( + providerName = "LINE", + providerId = "oidc.line", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = "Sign in with LINE", + buttonIcon = AuthUIAsset.Resource(R.drawable.ic_line_logo_24dp), + buttonColor = Color(0xFF06C755), + contentColor = Color.White + ) + ) + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + provider(AuthProvider.Anonymous) + } + } + + setContent { + AuthUITheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = { result -> + Log.d("CustomMethodPickerDemo", "Auth success: ${result.user?.uid}") + }, + onSignInFailure = { exception: AuthException -> + Log.e("CustomMethodPickerDemo", "Auth failed", exception) + }, + onSignInCancelled = { + Log.d("CustomMethodPickerDemo", "Auth cancelled") + }, + customMethodPickerLayout = { providers, onProviderSelected -> + SpotlightMethodPicker( + providers = providers, + onProviderSelected = onProviderSelected + ) + } + ) + } + } + } + } +} + +@Composable +fun SpotlightMethodPicker( + providers: List, + onProviderSelected: (AuthProvider) -> Unit, +) { + val stringProvider = LocalAuthUIStringProvider.current + + val groups = providers.groupBy { + when (it) { + is AuthProvider.Google, is AuthProvider.Apple -> "featured" + is AuthProvider.Email, is AuthProvider.Phone -> "credential" + is AuthProvider.Anonymous -> "anonymous" + else -> "social" + } + } + val featured = groups.getOrElse("featured") { emptyList() } + val social = groups.getOrElse("social") { emptyList() } + val credential = groups.getOrElse("credential") { emptyList() } + val anonymous = groups["anonymous"]?.firstOrNull() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 48.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + Text( + text = "Sign in", + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 32.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Choose how you'd like to continue", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 32.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + } + + items(featured) { provider -> + AuthProviderButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + provider = provider, + onClick = { onProviderSelected(provider) }, + stringProvider = stringProvider + ) + } + + if (social.isNotEmpty()) { + item { + Spacer(modifier = Modifier.height(4.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(horizontal = 32.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + } + item { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + items(social) { provider -> + val style = styleForProvider(provider) + ProviderIconButton( + style = style, + contentDescription = provider.providerId, + onClick = { onProviderSelected(provider) } + ) + } + } + } + } + + if (credential.isNotEmpty()) { + item { + Spacer(modifier = Modifier.height(4.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(horizontal = 32.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + } + items(credential) { provider -> + AuthProviderButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + provider = provider, + onClick = { onProviderSelected(provider) }, + stringProvider = stringProvider + ) + } + } + + anonymous?.let { + item { + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = { onProviderSelected(it) }) { + Text("Continue as guest") + } + } + } + } +} + +@Composable +private fun ProviderIconButton( + style: AuthUITheme.ProviderStyle, + contentDescription: String, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + modifier = Modifier.size(52.dp), + shape = CircleShape, + colors = ButtonDefaults.buttonColors(containerColor = style.backgroundColor), + contentPadding = PaddingValues(0.dp), + elevation = ButtonDefaults.buttonElevation(defaultElevation = style.elevation) + ) { + style.icon?.let { asset -> + val painter = asset.asPainter() + val tint = style.iconTint + if (tint != null) { + Icon( + painter = painter, + contentDescription = contentDescription, + tint = tint, + modifier = Modifier.size(22.dp) + ) + } else { + Image( + painter = painter, + contentDescription = contentDescription, + modifier = Modifier.size(22.dp) + ) + } + } + } +} + +@Composable +private fun AuthUIAsset.asPainter(): Painter = when (this) { + is AuthUIAsset.Resource -> painterResource(resId) + is AuthUIAsset.Vector -> rememberVectorPainter(image) +} + +private fun styleForProvider(provider: AuthProvider): AuthUITheme.ProviderStyle = when (provider) { + is AuthProvider.Facebook -> ProviderStyleDefaults.Facebook + is AuthProvider.Twitter -> ProviderStyleDefaults.Twitter + is AuthProvider.Github -> ProviderStyleDefaults.Github + is AuthProvider.Microsoft -> ProviderStyleDefaults.Microsoft + is AuthProvider.Yahoo -> ProviderStyleDefaults.Yahoo + is AuthProvider.GenericOAuth -> AuthUITheme.ProviderStyle( + icon = provider.buttonIcon, + backgroundColor = provider.buttonColor ?: Color(0xFF666666), + contentColor = provider.contentColor ?: Color.White + ) + else -> AuthUITheme.ProviderStyle( + icon = null, + backgroundColor = Color(0xFF666666), + contentColor = Color.White + ) +} diff --git a/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt index 00b0054f0..4aa50b05f 100644 --- a/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt @@ -1,1105 +1,134 @@ package com.firebaseui.android.demo +import android.content.Intent import android.os.Bundle -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.AuthException -import com.firebase.ui.auth.FirebaseAuthUI -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.PasswordRule -import com.firebase.ui.auth.configuration.authUIConfiguration -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.configuration.theme.AuthUITheme -import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults -import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider -import com.firebase.ui.auth.ui.components.AuthProviderButton -import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState -import com.firebase.ui.auth.ui.screens.email.EmailAuthMode -import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen -import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState -import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen -import com.firebase.ui.auth.ui.screens.phone.PhoneAuthStep -import com.google.firebase.auth.AuthResult -/** - * Demo activity showcasing custom slots and theming capabilities: - * - EmailAuthScreen with custom slot UI - * - PhoneAuthScreen with custom slot UI - * - Provider button shape customization with global and per-provider overrides - * - AuthUITheme.fromMaterialTheme() with custom ProviderStyle overrides - */ class CustomSlotsThemingDemoActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - val authUI = FirebaseAuthUI.getInstance() - val appContext = applicationContext - - // Configuration for email authentication - val emailConfiguration = authUIConfiguration { - context = appContext - providers { - provider( - AuthProvider.Email( - isDisplayNameRequired = true, - isNewAccountsAllowed = true, - isEmailLinkSignInEnabled = false, - emailLinkActionCodeSettings = null, - isEmailLinkForceSameDeviceEnabled = false, - minimumPasswordLength = 8, - passwordValidationRules = listOf( - PasswordRule.MinimumLength(8), - PasswordRule.RequireLowercase, - PasswordRule.RequireUppercase, - PasswordRule.RequireDigit - ) - ) - ) - } - tosUrl = "https://policies.google.com/terms" - privacyPolicyUrl = "https://policies.google.com/privacy" - } - - // Configuration for phone authentication - val phoneConfiguration = authUIConfiguration { - context = appContext - providers { - provider( - AuthProvider.Phone( - defaultNumber = null, - defaultCountryCode = "US", - allowedCountries = emptyList(), - smsCodeLength = 6, - timeout = 60L, - isInstantVerificationEnabled = true - ) - ) - } - } - setContent { - // Custom theme using fromMaterialTheme() with custom provider styles - CustomAuthUITheme { + MaterialTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - var selectedDemo by remember { mutableStateOf(DemoType.Email) } - - Column( - modifier = Modifier - .fillMaxSize() - .systemBarsPadding() - ) { - // Demo selector tabs - DemoSelector( - selectedDemo = selectedDemo, - onDemoSelected = { selectedDemo = it } - ) - - // Show selected demo - when (selectedDemo) { - DemoType.Email -> EmailAuthDemo( - authUI = authUI, - configuration = emailConfiguration, - context = appContext - ) - DemoType.Phone -> PhoneAuthDemo( - authUI = authUI, - configuration = phoneConfiguration, - context = appContext - ) - DemoType.ShapeCustomization -> ShapeCustomizationDemo() + CustomSlotsDemoChooser( + onEmailAuthSlotClick = { + startActivity(Intent(this, EmailAuthSlotDemoActivity::class.java)) + }, + onPhoneAuthSlotClick = { + startActivity(Intent(this, PhoneAuthSlotDemoActivity::class.java)) + }, + onShapeCustomizationClick = { + startActivity(Intent(this, ShapeCustomizationDemoActivity::class.java)) + }, + onCustomMethodPickerClick = { + startActivity(Intent(this, CustomMethodPickerDemoActivity::class.java)) } - } - } - } - } - } -} - -enum class DemoType { - Email, - Phone, - ShapeCustomization -} - -@Composable -fun CustomAuthUITheme(content: @Composable () -> Unit) { - // Use Material Theme colors - MaterialTheme { - // UPDATED: Now uses ProviderStyleDefaults and the new providerButtonShape API - // Apply custom theme using fromMaterialTheme with global button shape - val authTheme = AuthUITheme.fromMaterialTheme( - providerButtonShape = RoundedCornerShape(12.dp) // Global shape for all buttons - ) - - AuthUITheme(theme = authTheme) { - content() - } - } -} - -@Composable -fun DemoSelector( - selectedDemo: DemoType, - onDemoSelected: (DemoType) -> Unit -) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = "Custom Slots & Theming Demo", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = "Select a demo to see custom UI implementations using slot APIs", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilterChip( - selected = selectedDemo == DemoType.Email, - onClick = { onDemoSelected(DemoType.Email) }, - label = { Text("Email Auth") }, - modifier = Modifier.weight(1f) - ) - FilterChip( - selected = selectedDemo == DemoType.Phone, - onClick = { onDemoSelected(DemoType.Phone) }, - label = { Text("Phone Auth") }, - modifier = Modifier.weight(1f) ) } - FilterChip( - selected = selectedDemo == DemoType.ShapeCustomization, - onClick = { onDemoSelected(DemoType.ShapeCustomization) }, - label = { Text("Shape Customization") }, - modifier = Modifier.fillMaxWidth() - ) } } } } @Composable -fun EmailAuthDemo( - authUI: FirebaseAuthUI, - configuration: AuthUIConfiguration, - context: android.content.Context +fun CustomSlotsDemoChooser( + onEmailAuthSlotClick: () -> Unit, + onPhoneAuthSlotClick: () -> Unit, + onShapeCustomizationClick: () -> Unit, + onCustomMethodPickerClick: () -> Unit, ) { - var currentUser by remember { mutableStateOf(authUI.getCurrentUser()) } - - // Monitor auth state changes - LaunchedEffect(Unit) { - authUI.authStateFlow().collect { _ -> - currentUser = authUI.getCurrentUser() - } - } - - if (currentUser != null) { - // Show success screen - val successScrollState = rememberScrollState() - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(successScrollState) - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = "✓", - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Successfully Authenticated!", - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = currentUser?.email ?: "Signed in", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(32.dp)) - Button(onClick = { - authUI.auth.signOut() - }) { - Text("Sign Out") - } - } - } else { - // Show custom email auth UI using slot API - // Provide the string provider required by EmailAuthScreen - CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) { - EmailAuthScreen( - context = context, - configuration = configuration, - authUI = authUI, - onSuccess = { result: AuthResult -> - Log.d("CustomSlotsDemo", "Email auth success: ${result.user?.uid}") - }, - onError = { exception: AuthException -> - Log.e("CustomSlotsDemo", "Email auth error", exception) - }, - onCancel = { - Log.d("CustomSlotsDemo", "Email auth cancelled") - } - ) { state: EmailAuthContentState -> - // Custom UI using the slot API - CustomEmailAuthUI(state) - } - } - } -} - -@Composable -fun CustomEmailAuthUI(state: EmailAuthContentState) { - val scrollState = rememberScrollState() - Column( modifier = Modifier .fillMaxSize() - .verticalScroll(scrollState) + .verticalScroll(rememberScrollState()) + .systemBarsPadding() .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Spacer(modifier = Modifier.height(16.dp)) - - // Title based on mode - Text( - text = when (state.mode) { - EmailAuthMode.SignIn, EmailAuthMode.EmailLinkSignIn -> "📧 Welcome Back" - EmailAuthMode.SignUp -> "📧 Create Account" - EmailAuthMode.ResetPassword -> "📧 Reset Password" - }, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // Error display - state.error?.let { errorMessage -> - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = errorMessage, - modifier = Modifier.padding(12.dp), - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.bodySmall - ) - } - } - - // Render UI based on mode - when (state.mode) { - EmailAuthMode.SignIn, EmailAuthMode.EmailLinkSignIn -> SignInUI(state) - EmailAuthMode.SignUp -> SignUpUI(state) - EmailAuthMode.ResetPassword -> ResetPasswordUI(state) - } - } -} - -@Composable -fun SignInUI(state: EmailAuthContentState) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedTextField( - value = state.email, - onValueChange = state.onEmailChange, - label = { Text("Email") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - enabled = !state.isLoading - ) - - OutlinedTextField( - value = state.password, - onValueChange = state.onPasswordChange, - label = { Text("Password") }, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), - singleLine = true, - enabled = !state.isLoading - ) - - if (state.emailSignInLinkSent) { - Text( - text = "✓ Sign-in link sent! Check your email.", - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth() - ) - } - Spacer(modifier = Modifier.height(8.dp)) - Button( - onClick = state.onSignInClick, - modifier = Modifier.fillMaxWidth(), - enabled = !state.isLoading - ) { - if (state.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onPrimary - ) - } else { - Text("Sign In") - } - } - - TextButton( - onClick = state.onGoToResetPassword, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) { - Text("Forgot Password?") - } - - HorizontalDivider() - - TextButton( - onClick = state.onGoToSignUp, - modifier = Modifier.fillMaxWidth() - ) { - Text("Don't have an account? Sign Up") - } - } -} - -@Composable -fun SignUpUI(state: EmailAuthContentState) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedTextField( - value = state.displayName, - onValueChange = state.onDisplayNameChange, - label = { Text("Display Name") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - enabled = !state.isLoading - ) - - OutlinedTextField( - value = state.email, - onValueChange = state.onEmailChange, - label = { Text("Email") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - enabled = !state.isLoading - ) - - OutlinedTextField( - value = state.password, - onValueChange = state.onPasswordChange, - label = { Text("Password") }, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), - singleLine = true, - enabled = !state.isLoading - ) - - OutlinedTextField( - value = state.confirmPassword, - onValueChange = state.onConfirmPasswordChange, - label = { Text("Confirm Password") }, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), - singleLine = true, - enabled = !state.isLoading + Text( + text = "Custom Slots & Theming", + style = MaterialTheme.typography.headlineMedium ) - - Spacer(modifier = Modifier.height(8.dp)) - - Button( - onClick = state.onSignUpClick, - modifier = Modifier.fillMaxWidth(), - enabled = !state.isLoading - ) { - if (state.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onPrimary - ) - } else { - Text("Create Account") - } - } - - HorizontalDivider() - - TextButton( - onClick = state.onGoToSignIn, - modifier = Modifier.fillMaxWidth() - ) { - Text("Already have an account? Sign In") - } - } -} - -@Composable -fun ResetPasswordUI(state: EmailAuthContentState) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { Text( - text = "Enter your email address and we'll send you a link to reset your password.", + text = "Select a demo to explore slot APIs and theme customization", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = state.email, - onValueChange = state.onEmailChange, - label = { Text("Email") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - enabled = !state.isLoading + DemoCard( + title = "Email Auth — Custom Slot", + description = "Replace the default email sign-in UI with a fully custom composable using the content slot.", + onClick = onEmailAuthSlotClick ) - if (state.resetLinkSent) { - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = "✓ Password reset link sent! Check your email.", - modifier = Modifier.padding(12.dp), - color = MaterialTheme.colorScheme.onPrimaryContainer, - style = MaterialTheme.typography.bodyMedium - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - Button( - onClick = state.onSendResetLinkClick, - modifier = Modifier.fillMaxWidth(), - enabled = !state.isLoading && !state.resetLinkSent - ) { - if (state.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onPrimary - ) - } else { - Text("Send Reset Link") - } - } - - HorizontalDivider() - - TextButton( - onClick = state.onGoToSignIn, - modifier = Modifier.fillMaxWidth() - ) { - Text("Back to Sign In") - } - } -} - -@Composable -fun PhoneAuthDemo( - authUI: FirebaseAuthUI, - configuration: AuthUIConfiguration, - context: android.content.Context -) { - var currentUser by remember { mutableStateOf(authUI.getCurrentUser()) } - - // Monitor auth state changes - LaunchedEffect(Unit) { - authUI.authStateFlow().collect { _ -> - currentUser = authUI.getCurrentUser() - } - } - - if (currentUser != null) { - // Show success screen - val successScrollState = rememberScrollState() - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(successScrollState) - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = "📱", - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Phone Verified!", - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = currentUser?.phoneNumber ?: "Signed in", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(32.dp)) - Button(onClick = { - authUI.auth.signOut() - }) { - Text("Sign Out") - } - } - } else { - // Show custom phone auth UI using slot API - // Provide the string provider required by PhoneAuthScreen - CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) { - PhoneAuthScreen( - context = context, - configuration = configuration, - authUI = authUI, - onSuccess = { result: AuthResult -> - Log.d("CustomSlotsDemo", "Phone auth success: ${result.user?.uid}") - }, - onError = { exception: AuthException -> - Log.e("CustomSlotsDemo", "Phone auth error", exception) - }, - onCancel = { - Log.d("CustomSlotsDemo", "Phone auth cancelled") - } - ) { state: PhoneAuthContentState -> - // Custom UI using the slot API - CustomPhoneAuthUI(state) - } - } - } -} - -@Composable -fun CustomPhoneAuthUI(state: PhoneAuthContentState) { - val scrollState = rememberScrollState() - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(modifier = Modifier.height(16.dp)) - - // Title based on step - Text( - text = when (state.step) { - PhoneAuthStep.EnterPhoneNumber -> "📱 Phone Verification" - PhoneAuthStep.EnterVerificationCode -> "📱 Enter Code" - }, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface + DemoCard( + title = "Phone Auth — Custom Slot", + description = "Replace the default phone auth UI with a fully custom composable using the content slot.", + onClick = onPhoneAuthSlotClick ) - Spacer(modifier = Modifier.height(8.dp)) - - // Error display - state.error?.let { errorMessage -> - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = errorMessage, - modifier = Modifier.padding(12.dp), - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.bodySmall - ) - } - } - - // Render UI based on step - when (state.step) { - PhoneAuthStep.EnterPhoneNumber -> EnterPhoneNumberUI(state) - PhoneAuthStep.EnterVerificationCode -> EnterVerificationCodeUI(state) - } - } -} - -@Composable -fun EnterPhoneNumberUI(state: PhoneAuthContentState) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = "Enter your phone number to receive a verification code", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + DemoCard( + title = "Shape Customization", + description = "Preview provider button shapes using global and per-provider overrides via AuthUITheme.", + onClick = onShapeCustomizationClick ) - Spacer(modifier = Modifier.height(8.dp)) - - // Country selector (simplified for demo) - OutlinedCard( - onClick = { /* In real app, open country selector */ }, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "${state.selectedCountry.flagEmoji} ${state.selectedCountry.dialCode}", - style = MaterialTheme.typography.bodyLarge - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - text = state.selectedCountry.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - OutlinedTextField( - value = state.phoneNumber, - onValueChange = state.onPhoneNumberChange, - label = { Text("Phone Number") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - enabled = !state.isLoading + DemoCard( + title = "Custom Method Picker Layout", + description = "Replace the default vertical provider list with a 2-column grid using customMethodPickerLayout on FirebaseAuthScreen.", + onClick = onCustomMethodPickerClick ) - - Spacer(modifier = Modifier.height(16.dp)) - - Button( - onClick = state.onSendCodeClick, - modifier = Modifier.fillMaxWidth(), - enabled = !state.isLoading && state.phoneNumber.isNotBlank() - ) { - if (state.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onPrimary - ) - } else { - Text("Send Code") - } - } } } @Composable -fun EnterVerificationCodeUI(state: PhoneAuthContentState) { - Column( +private fun DemoCard( + title: String, + description: String, + onClick: () -> Unit, +) { + Card( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) + onClick = onClick ) { - Text( - text = "We sent a verification code to:", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - Text( - text = state.fullPhoneNumber, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(8.dp)) - - OutlinedTextField( - value = state.verificationCode, - onValueChange = state.onVerificationCodeChange, - label = { Text("6-Digit Code") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - enabled = !state.isLoading - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Button( - onClick = state.onVerifyCodeClick, - modifier = Modifier.fillMaxWidth(), - enabled = !state.isLoading && state.verificationCode.length == 6 - ) { - if (state.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onPrimary - ) - } else { - Text("Verify Code") - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextButton(onClick = state.onChangeNumberClick) { - Text("Change Number") - } - - TextButton( - onClick = state.onResendCodeClick, - enabled = state.resendTimer == 0 - ) { - Text( - if (state.resendTimer > 0) - "Resend (${state.resendTimer}s)" - else - "Resend Code" - ) - } - } - } -} - -/** - * Demo showcasing provider button shape customization capabilities. - * Demonstrates: - * - Global shape configuration for all buttons - * - Per-provider shape overrides - * - Using ProviderStyleDefaults with .copy() - */ -@Composable -fun ShapeCustomizationDemo() { - val context = androidx.compose.ui.platform.LocalContext.current - val stringProvider = DefaultAuthUIStringProvider(context) - var selectedPreset by remember { mutableStateOf(ShapePreset.DEFAULT) } - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Title and description - Text( - text = "Provider Button Shape Customization", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.primary - ) - - Text( - text = "This demo showcases the new shape customization API for provider buttons. " + - "You can set a global shape for all buttons or customize individual providers.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - HorizontalDivider() - - // Preset selector - Text( - text = "Select Shape Preset:", - style = MaterialTheme.typography.titleMedium - ) - - ShapePreset.entries.forEach { preset -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = selectedPreset == preset, - onClick = { selectedPreset = preset } - ) - Spacer(modifier = Modifier.width(8.dp)) - Column { - Text( - text = preset.displayName, - style = MaterialTheme.typography.bodyLarge - ) - Text( - text = preset.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - HorizontalDivider() - - // Preview section - Text( - text = "Preview:", - style = MaterialTheme.typography.titleMedium - ) - - // Render buttons with the selected preset - when (selectedPreset) { - ShapePreset.DEFAULT -> DefaultShapeButtons(stringProvider) - ShapePreset.DEFAULT_COPY -> DefaultCopyShapeButtons(stringProvider) - ShapePreset.DARK_COPY -> DarkCopyShapeButtons(stringProvider) - ShapePreset.FROM_MATERIAL -> FromMaterialThemeButtons(stringProvider) - ShapePreset.PILL -> PillShapeButtons(stringProvider) - ShapePreset.MIXED -> MixedShapeButtons(stringProvider) - } - - // Code example - HorizontalDivider() - - Text( - text = "Code Example:", - style = MaterialTheme.typography.titleMedium - ) - - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(8.dp) + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { + Text(text = title, style = MaterialTheme.typography.titleMedium) Text( - text = selectedPreset.codeExample, - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace - ), - modifier = Modifier.padding(12.dp) + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } - -enum class ShapePreset( - val displayName: String, - val description: String, - val codeExample: String -) { - DEFAULT( - "Default Shapes", - "Uses the standard 4dp rounded corners", - """ -// No customization needed -val theme = AuthUITheme.Default - """.trimIndent() - ), - DEFAULT_COPY( - "Default.copy()", - "Customize default light theme with .copy()", - """ -val theme = AuthUITheme.Default.copy( - providerButtonShape = RoundedCornerShape(12.dp) -) - """.trimIndent() - ), - DARK_COPY( - "DefaultDark.copy()", - "Customize default dark theme with .copy()", - """ -val theme = AuthUITheme.DefaultDark.copy( - providerButtonShape = RoundedCornerShape(16.dp) -) - """.trimIndent() - ), - FROM_MATERIAL( - "fromMaterialTheme()", - "Inherit from Material Theme", - """ -val theme = AuthUITheme.fromMaterialTheme( - providerButtonShape = RoundedCornerShape(12.dp) -) - """.trimIndent() - ), - PILL( - "Pill Shape", - "Creates pill-shaped buttons (Default.copy)", - """ -val theme = AuthUITheme.Default.copy( - providerButtonShape = RoundedCornerShape(28.dp) -) - """.trimIndent() - ), - MIXED( - "Mixed Shapes", - "Different shapes per provider (Default.copy)", - """ -val customStyles = mapOf( - "google.com" to ProviderStyleDefaults.Google.copy( - shape = RoundedCornerShape(24.dp) - ), - "facebook.com" to ProviderStyleDefaults.Facebook.copy( - shape = RoundedCornerShape(8.dp) - ) -) - -val theme = AuthUITheme.Default.copy( - providerButtonShape = RoundedCornerShape(12.dp), - providerStyles = customStyles -) - """.trimIndent() - ) -} - -@Composable -fun DefaultShapeButtons(stringProvider: DefaultAuthUIStringProvider) { - // Default theme - no customization - AuthUITheme { - ButtonPreviewColumn(stringProvider) - } -} - -@Composable -fun DefaultCopyShapeButtons(stringProvider: DefaultAuthUIStringProvider) { - // Using AuthUITheme.Default.copy() to customize the light theme - val theme = AuthUITheme.Default.copy( - providerButtonShape = RoundedCornerShape(12.dp) - ) - AuthUITheme(theme = theme) { - ButtonPreviewColumn(stringProvider) - } -} - -@Composable -fun DarkCopyShapeButtons(stringProvider: DefaultAuthUIStringProvider) { - // Using AuthUITheme.DefaultDark.copy() to customize the dark theme - val theme = AuthUITheme.DefaultDark.copy( - providerButtonShape = RoundedCornerShape(16.dp) - ) - AuthUITheme(theme = theme) { - ButtonPreviewColumn(stringProvider) - } -} - -@Composable -fun FromMaterialThemeButtons(stringProvider: DefaultAuthUIStringProvider) { - // Using AuthUITheme.fromMaterialTheme() to inherit from Material Theme - val theme = AuthUITheme.fromMaterialTheme( - providerButtonShape = RoundedCornerShape(12.dp) - ) - AuthUITheme(theme = theme) { - ButtonPreviewColumn(stringProvider) - } -} - -@Composable -fun PillShapeButtons(stringProvider: DefaultAuthUIStringProvider) { - // Pill-shaped buttons using Default.copy() - val theme = AuthUITheme.Default.copy( - providerButtonShape = RoundedCornerShape(28.dp) - ) - AuthUITheme(theme = theme) { - ButtonPreviewColumn(stringProvider) - } -} - -@Composable -fun MixedShapeButtons(stringProvider: DefaultAuthUIStringProvider) { - // Mixed shapes per provider using Default.copy() - val customStyles = mapOf( - "google.com" to ProviderStyleDefaults.Google.copy( - shape = RoundedCornerShape(24.dp) // Pill shape for Google - ), - "facebook.com" to ProviderStyleDefaults.Facebook.copy( - shape = RoundedCornerShape(8.dp) // Medium rounded for Facebook - ) - // Email uses global default (12dp) - ) - - val theme = AuthUITheme.Default.copy( - providerButtonShape = RoundedCornerShape(12.dp), - providerStyles = customStyles - ) - - AuthUITheme(theme = theme) { - ButtonPreviewColumn(stringProvider) - } -} - -@Composable -fun ButtonPreviewColumn(stringProvider: DefaultAuthUIStringProvider) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - AuthProviderButton( - provider = AuthProvider.Google(scopes = emptyList(), serverClientId = null), - onClick = { }, - stringProvider = stringProvider, - modifier = Modifier.fillMaxWidth() - ) - - AuthProviderButton( - provider = AuthProvider.Facebook(), - onClick = { }, - stringProvider = stringProvider, - modifier = Modifier.fillMaxWidth() - ) - - AuthProviderButton( - provider = AuthProvider.Email( - emailLinkActionCodeSettings = null, - passwordValidationRules = emptyList() - ), - onClick = { }, - stringProvider = stringProvider, - modifier = Modifier.fillMaxWidth() - ) - } -} diff --git a/app/src/main/java/com/firebaseui/android/demo/EmailAuthSlotDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/EmailAuthSlotDemoActivity.kt new file mode 100644 index 000000000..cb9605621 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/EmailAuthSlotDemoActivity.kt @@ -0,0 +1,437 @@ +package com.firebaseui.android.demo + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.PasswordRule +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.configuration.theme.AuthUITheme +import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState +import com.firebase.ui.auth.ui.screens.email.EmailAuthMode +import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen +import com.google.firebase.auth.AuthResult + +class EmailAuthSlotDemoActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val authUI = FirebaseAuthUI.getInstance() + val appContext = applicationContext + + val configuration = authUIConfiguration { + context = appContext + providers { + provider( + AuthProvider.Email( + isDisplayNameRequired = true, + isNewAccountsAllowed = true, + isEmailLinkSignInEnabled = false, + emailLinkActionCodeSettings = null, + isEmailLinkForceSameDeviceEnabled = false, + minimumPasswordLength = 8, + passwordValidationRules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireLowercase, + PasswordRule.RequireUppercase, + PasswordRule.RequireDigit + ) + ) + ) + } + tosUrl = "https://policies.google.com/terms" + privacyPolicyUrl = "https://policies.google.com/privacy" + } + + setContent { + CustomAuthUITheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + EmailAuthDemo( + authUI = authUI, + configuration = configuration, + context = appContext + ) + } + } + } + } + } +} + +@Composable +fun CustomAuthUITheme(content: @Composable () -> Unit) { + MaterialTheme { + val authTheme = AuthUITheme.fromMaterialTheme( + providerButtonShape = RoundedCornerShape(12.dp) + ) + AuthUITheme(theme = authTheme) { + content() + } + } +} + +@Composable +fun EmailAuthDemo( + authUI: FirebaseAuthUI, + configuration: AuthUIConfiguration, + context: android.content.Context +) { + var currentUser by remember { mutableStateOf(authUI.getCurrentUser()) } + + LaunchedEffect(Unit) { + authUI.authStateFlow().collect { _ -> + currentUser = authUI.getCurrentUser() + } + } + + if (currentUser != null) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Successfully Authenticated!", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = currentUser?.email ?: "Signed in", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(32.dp)) + Button(onClick = { authUI.auth.signOut() }) { + Text("Sign Out") + } + } + } else { + CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) { + EmailAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + onSuccess = { result: AuthResult -> + Log.d("EmailAuthSlotDemo", "Auth success: ${result.user?.uid}") + }, + onError = { exception: AuthException -> + Log.e("EmailAuthSlotDemo", "Auth error", exception) + }, + onCancel = { + Log.d("EmailAuthSlotDemo", "Auth cancelled") + } + ) { state: EmailAuthContentState -> + CustomEmailAuthUI(state) + } + } + } +} + +@Composable +fun CustomEmailAuthUI(state: EmailAuthContentState) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = when (state.mode) { + EmailAuthMode.SignIn, EmailAuthMode.EmailLinkSignIn -> "Welcome Back" + EmailAuthMode.SignUp -> "Create Account" + EmailAuthMode.ResetPassword -> "Reset Password" + }, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + state.error?.let { errorMessage -> + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = errorMessage, + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall + ) + } + } + + when (state.mode) { + EmailAuthMode.SignIn, EmailAuthMode.EmailLinkSignIn -> SignInUI(state) + EmailAuthMode.SignUp -> SignUpUI(state) + EmailAuthMode.ResetPassword -> ResetPasswordUI(state) + } + } +} + +@Composable +fun SignInUI(state: EmailAuthContentState) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = state.email, + onValueChange = state.onEmailChange, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + OutlinedTextField( + value = state.password, + onValueChange = state.onPasswordChange, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + if (state.emailSignInLinkSent) { + Text( + text = "Sign-in link sent! Check your email.", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = state.onSignInClick, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Sign In") + } + } + + TextButton( + onClick = state.onGoToResetPassword, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Text("Forgot Password?") + } + + HorizontalDivider() + + TextButton( + onClick = state.onGoToSignUp, + modifier = Modifier.fillMaxWidth() + ) { + Text("Don't have an account? Sign Up") + } + } +} + +@Composable +fun SignUpUI(state: EmailAuthContentState) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = state.displayName, + onValueChange = state.onDisplayNameChange, + label = { Text("Display Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + OutlinedTextField( + value = state.email, + onValueChange = state.onEmailChange, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + OutlinedTextField( + value = state.password, + onValueChange = state.onPasswordChange, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + OutlinedTextField( + value = state.confirmPassword, + onValueChange = state.onConfirmPasswordChange, + label = { Text("Confirm Password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = state.onSignUpClick, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Create Account") + } + } + + HorizontalDivider() + + TextButton( + onClick = state.onGoToSignIn, + modifier = Modifier.fillMaxWidth() + ) { + Text("Already have an account? Sign In") + } + } +} + +@Composable +fun ResetPasswordUI(state: EmailAuthContentState) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Enter your email address and we'll send you a link to reset your password.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = state.email, + onValueChange = state.onEmailChange, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + if (state.resetLinkSent) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Password reset link sent! Check your email.", + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.bodyMedium + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = state.onSendResetLinkClick, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading && !state.resetLinkSent + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Send Reset Link") + } + } + + HorizontalDivider() + + TextButton( + onClick = state.onGoToSignIn, + modifier = Modifier.fillMaxWidth() + ) { + Text("Back to Sign In") + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/PhoneAuthSlotDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/PhoneAuthSlotDemoActivity.kt new file mode 100644 index 000000000..9639beefb --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/PhoneAuthSlotDemoActivity.kt @@ -0,0 +1,338 @@ +package com.firebaseui.android.demo + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthStep +import com.google.firebase.auth.AuthResult + +class PhoneAuthSlotDemoActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val authUI = FirebaseAuthUI.getInstance() + val appContext = applicationContext + + val configuration = authUIConfiguration { + context = appContext + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = "US", + allowedCountries = emptyList(), + smsCodeLength = 6, + timeout = 60L, + isInstantVerificationEnabled = true + ) + ) + } + } + + setContent { + CustomAuthUITheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + PhoneAuthDemo( + authUI = authUI, + configuration = configuration, + context = appContext + ) + } + } + } + } + } +} + +@Composable +fun PhoneAuthDemo( + authUI: FirebaseAuthUI, + configuration: AuthUIConfiguration, + context: android.content.Context +) { + var currentUser by remember { mutableStateOf(authUI.getCurrentUser()) } + + LaunchedEffect(Unit) { + authUI.authStateFlow().collect { _ -> + currentUser = authUI.getCurrentUser() + } + } + + if (currentUser != null) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Phone Verified!", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = currentUser?.phoneNumber ?: "Signed in", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(32.dp)) + Button(onClick = { authUI.auth.signOut() }) { + Text("Sign Out") + } + } + } else { + CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) { + PhoneAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + onSuccess = { result: AuthResult -> + Log.d("PhoneAuthSlotDemo", "Auth success: ${result.user?.uid}") + }, + onError = { exception: AuthException -> + Log.e("PhoneAuthSlotDemo", "Auth error", exception) + }, + onCancel = { + Log.d("PhoneAuthSlotDemo", "Auth cancelled") + } + ) { state: PhoneAuthContentState -> + CustomPhoneAuthUI(state) + } + } + } +} + +@Composable +fun CustomPhoneAuthUI(state: PhoneAuthContentState) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = when (state.step) { + PhoneAuthStep.EnterPhoneNumber -> "Phone Verification" + PhoneAuthStep.EnterVerificationCode -> "Enter Code" + }, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + state.error?.let { errorMessage -> + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = errorMessage, + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall + ) + } + } + + when (state.step) { + PhoneAuthStep.EnterPhoneNumber -> EnterPhoneNumberUI(state) + PhoneAuthStep.EnterVerificationCode -> EnterVerificationCodeUI(state) + } + } +} + +@Composable +fun EnterPhoneNumberUI(state: PhoneAuthContentState) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Enter your phone number to receive a verification code", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedCard( + onClick = { }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${state.selectedCountry.flagEmoji} ${state.selectedCountry.dialCode}", + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = state.selectedCountry.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + OutlinedTextField( + value = state.phoneNumber, + onValueChange = state.onPhoneNumberChange, + label = { Text("Phone Number") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = state.onSendCodeClick, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading && state.phoneNumber.isNotBlank() + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Send Code") + } + } + } +} + +@Composable +fun EnterVerificationCodeUI(state: PhoneAuthContentState) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "We sent a verification code to:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Text( + text = state.fullPhoneNumber, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = state.verificationCode, + onValueChange = state.onVerificationCodeChange, + label = { Text("6-Digit Code") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = state.onVerifyCodeClick, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading && state.verificationCode.length == 6 + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Verify Code") + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = state.onChangeNumberClick) { + Text("Change Number") + } + + TextButton( + onClick = state.onResendCodeClick, + enabled = state.resendTimer == 0 + ) { + Text( + if (state.resendTimer > 0) "Resend (${state.resendTimer}s)" + else "Resend Code" + ) + } + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/ShapeCustomizationDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/ShapeCustomizationDemoActivity.kt new file mode 100644 index 000000000..5faba7336 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/ShapeCustomizationDemoActivity.kt @@ -0,0 +1,263 @@ +package com.firebaseui.android.demo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.theme.AuthUITheme +import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults +import com.firebase.ui.auth.ui.components.AuthProviderButton + +class ShapeCustomizationDemoActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + ShapeCustomizationDemo() + } + } + } + } + } +} + +@Composable +fun ShapeCustomizationDemo() { + val context = LocalContext.current + val stringProvider = DefaultAuthUIStringProvider(context) + var selectedPreset by remember { mutableStateOf(ShapePreset.DEFAULT) } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Provider Button Shape Customization", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary + ) + + Text( + text = "Showcases the shape customization API for provider buttons. " + + "Set a global shape for all buttons or customize individual providers.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + HorizontalDivider() + + Text(text = "Select Shape Preset:", style = MaterialTheme.typography.titleMedium) + + ShapePreset.entries.forEach { preset -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selectedPreset == preset, + onClick = { selectedPreset = preset } + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text(text = preset.displayName, style = MaterialTheme.typography.bodyLarge) + Text( + text = preset.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + HorizontalDivider() + + Text(text = "Preview:", style = MaterialTheme.typography.titleMedium) + + when (selectedPreset) { + ShapePreset.DEFAULT -> DefaultShapeButtons(stringProvider) + ShapePreset.DEFAULT_COPY -> DefaultCopyShapeButtons(stringProvider) + ShapePreset.DARK_COPY -> DarkCopyShapeButtons(stringProvider) + ShapePreset.FROM_MATERIAL -> FromMaterialThemeButtons(stringProvider) + ShapePreset.PILL -> PillShapeButtons(stringProvider) + ShapePreset.MIXED -> MixedShapeButtons(stringProvider) + } + + HorizontalDivider() + + Text(text = "Code Example:", style = MaterialTheme.typography.titleMedium) + + androidx.compose.material3.Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = selectedPreset.codeExample, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ), + modifier = Modifier.padding(12.dp) + ) + } + } +} + +enum class ShapePreset( + val displayName: String, + val description: String, + val codeExample: String +) { + DEFAULT( + "Default Shapes", + "Uses the standard 4dp rounded corners", + "// No customization needed\nval theme = AuthUITheme.Default" + ), + DEFAULT_COPY( + "Default.copy()", + "Customize default light theme with .copy()", + "val theme = AuthUITheme.Default.copy(\n providerButtonShape = RoundedCornerShape(12.dp)\n)" + ), + DARK_COPY( + "DefaultDark.copy()", + "Customize default dark theme with .copy()", + "val theme = AuthUITheme.DefaultDark.copy(\n providerButtonShape = RoundedCornerShape(16.dp)\n)" + ), + FROM_MATERIAL( + "fromMaterialTheme()", + "Inherit from Material Theme", + "val theme = AuthUITheme.fromMaterialTheme(\n providerButtonShape = RoundedCornerShape(12.dp)\n)" + ), + PILL( + "Pill Shape", + "Creates pill-shaped buttons (Default.copy)", + "val theme = AuthUITheme.Default.copy(\n providerButtonShape = RoundedCornerShape(28.dp)\n)" + ), + MIXED( + "Mixed Shapes", + "Different shapes per provider (Default.copy)", + "val customStyles = mapOf(\n \"google.com\" to ProviderStyleDefaults.Google.copy(\n shape = RoundedCornerShape(24.dp)\n ),\n \"facebook.com\" to ProviderStyleDefaults.Facebook.copy(\n shape = RoundedCornerShape(8.dp)\n )\n)\n\nval theme = AuthUITheme.Default.copy(\n providerButtonShape = RoundedCornerShape(12.dp),\n providerStyles = customStyles\n)" + ) +} + +@Composable +fun DefaultShapeButtons(stringProvider: DefaultAuthUIStringProvider) { + AuthUITheme { ButtonPreviewColumn(stringProvider) } +} + +@Composable +fun DefaultCopyShapeButtons(stringProvider: DefaultAuthUIStringProvider) { + AuthUITheme(theme = AuthUITheme.Default.copy(providerButtonShape = RoundedCornerShape(12.dp))) { + ButtonPreviewColumn(stringProvider) + } +} + +@Composable +fun DarkCopyShapeButtons(stringProvider: DefaultAuthUIStringProvider) { + AuthUITheme(theme = AuthUITheme.DefaultDark.copy(providerButtonShape = RoundedCornerShape(16.dp))) { + ButtonPreviewColumn(stringProvider) + } +} + +@Composable +fun FromMaterialThemeButtons(stringProvider: DefaultAuthUIStringProvider) { + AuthUITheme(theme = AuthUITheme.fromMaterialTheme(providerButtonShape = RoundedCornerShape(12.dp))) { + ButtonPreviewColumn(stringProvider) + } +} + +@Composable +fun PillShapeButtons(stringProvider: DefaultAuthUIStringProvider) { + AuthUITheme(theme = AuthUITheme.Default.copy(providerButtonShape = RoundedCornerShape(28.dp))) { + ButtonPreviewColumn(stringProvider) + } +} + +@Composable +fun MixedShapeButtons(stringProvider: DefaultAuthUIStringProvider) { + val customStyles = mapOf( + "google.com" to ProviderStyleDefaults.Google.copy(shape = RoundedCornerShape(24.dp)), + "facebook.com" to ProviderStyleDefaults.Facebook.copy(shape = RoundedCornerShape(8.dp)) + ) + AuthUITheme( + theme = AuthUITheme.Default.copy( + providerButtonShape = RoundedCornerShape(12.dp), + providerStyles = customStyles + ) + ) { + ButtonPreviewColumn(stringProvider) + } +} + +@Composable +fun ButtonPreviewColumn(stringProvider: DefaultAuthUIStringProvider) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + AuthProviderButton( + provider = AuthProvider.Google(scopes = emptyList(), serverClientId = null), + onClick = { }, + stringProvider = stringProvider, + modifier = Modifier.fillMaxWidth() + ) + AuthProviderButton( + provider = AuthProvider.Facebook(), + onClick = { }, + stringProvider = stringProvider, + modifier = Modifier.fillMaxWidth() + ) + AuthProviderButton( + provider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ), + onClick = { }, + stringProvider = stringProvider, + modifier = Modifier.fillMaxWidth() + ) + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt index bf4c3b6a5..083148016 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt @@ -77,10 +77,10 @@ fun AuthMethodPicker( providers: List, logo: AuthUIAsset? = null, onProviderSelected: (AuthProvider) -> Unit, - customLayout: @Composable ((List, (AuthProvider) -> Unit) -> Unit)? = null, termsOfServiceUrl: String? = null, privacyPolicyUrl: String? = null, lastSignInPreference: SignInPreferenceManager.SignInPreference? = null, + customLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null, ) { val context = LocalContext.current val inPreview = LocalInspectionMode.current 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 9a7e3911a..a919223ad 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 @@ -74,8 +74,12 @@ import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvi import com.firebase.ui.auth.configuration.theme.LocalAuthUITheme import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController import com.firebase.ui.auth.ui.components.rememberTopLevelDialogController +import com.firebase.ui.auth.mfa.MfaChallengeContentState +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker +import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen import com.firebase.ui.auth.util.EmailLinkPersistenceManager import com.firebase.ui.auth.util.SignInPreferenceManager @@ -107,6 +111,11 @@ fun FirebaseAuthScreen( authUI: FirebaseAuthUI = FirebaseAuthUI.getInstance(), emailLink: String? = null, mfaConfiguration: MfaConfiguration = MfaConfiguration(), + customMethodPickerLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null, + emailContent: (@Composable (EmailAuthContentState) -> Unit)? = null, + phoneContent: (@Composable (PhoneAuthContentState) -> Unit)? = null, + mfaEnrollmentContent: (@Composable (MfaEnrollmentContentState) -> Unit)? = null, + mfaChallengeContent: (@Composable (MfaChallengeContentState) -> Unit)? = null, authenticatedContent: (@Composable (state: AuthState, uiContext: AuthSuccessUiContext) -> Unit)? = null, ) { // Set FirebaseUI version @@ -267,6 +276,7 @@ fun FirebaseAuthScreen( termsOfServiceUrl = configuration.tosUrl, privacyPolicyUrl = configuration.privacyPolicyUrl, lastSignInPreference = lastSignInPreference.value, + customLayout = customMethodPickerLayout, onProviderSelected = { provider -> when (provider) { is AuthProvider.Anonymous -> onSignInAnonymously?.invoke() @@ -318,6 +328,7 @@ fun FirebaseAuthScreen( authUI = authUI, credentialForLinking = pendingLinkingCredential.value, emailLinkFromDifferentDevice = emailLinkFromDifferentDevice.value, + content = emailContent, onSuccess = { pendingLinkingCredential.value = null }, @@ -343,6 +354,7 @@ fun FirebaseAuthScreen( context = context, configuration = configuration, authUI = authUI, + content = phoneContent, onSuccess = {}, onError = { exception -> onSignInFailure(exception) @@ -450,6 +462,7 @@ fun FirebaseAuthScreen( auth = authUI.auth, configuration = mfaConfiguration, authConfiguration = configuration, + content = mfaEnrollmentContent, onComplete = { navController.popBackStack() }, onSkip = { navController.popBackStack() }, onError = { exception -> @@ -467,6 +480,7 @@ fun FirebaseAuthScreen( MfaChallengeScreen( resolver = resolver, auth = authUI.auth, + content = mfaChallengeContent, onSuccess = { pendingResolver.value = null // Reset auth state to Idle so the firebaseAuthFlow Success state takes over diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt new file mode 100644 index 000000000..0c3836ace --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt @@ -0,0 +1,199 @@ +/* + * 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.ui.screens + +import android.content.Context +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Tests that [FirebaseAuthScreen] correctly forwards each customization slot to the + * appropriate sub-screen. + * + * These tests cover the fix for the API gap where slots such as [customMethodPickerLayout], + * [emailContent], and [phoneContent] were accepted by sub-screens but never reachable through + * the high-level [FirebaseAuthScreen] composable. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class FirebaseAuthScreenSlotsTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var context: Context + private lateinit var authUI: FirebaseAuthUI + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(context).forEach { it.delete() } + FirebaseApp.initializeApp( + context, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + authUI = FirebaseAuthUI.getInstance() + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(context).forEach { + try { it.delete() } catch (_: Exception) {} + } + } + + // ============================================================================================= + // customMethodPickerLayout slot tests + // ============================================================================================= + + @Test + fun `customMethodPickerLayout is rendered when provided`() { + val configuration = authUIConfiguration { + context = this@FirebaseAuthScreenSlotsTest.context + providers { + provider(AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList())) + provider(AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null)) + } + } + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + customMethodPickerLayout = { _, _ -> + Text( + text = "Custom Picker", + modifier = Modifier.testTag("custom_method_picker") + ) + } + ) + } + + composeTestRule.onNodeWithTag("custom_method_picker").assertIsDisplayed() + } + + @Test + fun `default method picker renders when customMethodPickerLayout is null`() { + val configuration = authUIConfiguration { + context = this@FirebaseAuthScreenSlotsTest.context + providers { + provider(AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList())) + provider(AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null)) + } + } + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {} + ) + } + + composeTestRule.onNodeWithTag("AuthMethodPicker LazyColumn").assertIsDisplayed() + } + + // ============================================================================================= + // emailContent slot tests + // ============================================================================================= + + @Test + fun `emailContent slot is rendered when provided`() { + val configuration = authUIConfiguration { + context = this@FirebaseAuthScreenSlotsTest.context + providers { + provider(AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList())) + } + } + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + emailContent = { _ -> + Text( + text = "Custom Email UI", + modifier = Modifier.testTag("custom_email_slot") + ) + } + ) + } + + composeTestRule.onNodeWithTag("custom_email_slot").assertIsDisplayed() + } + + // ============================================================================================= + // phoneContent slot tests + // ============================================================================================= + + @Test + fun `phoneContent slot is rendered when provided`() { + val configuration = authUIConfiguration { + context = this@FirebaseAuthScreenSlotsTest.context + providers { + provider(AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null)) + } + } + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + phoneContent = { _ -> + Text( + text = "Custom Phone UI", + modifier = Modifier.testTag("custom_phone_slot") + ) + } + ) + } + + composeTestRule.onNodeWithTag("custom_phone_slot").assertIsDisplayed() + } +} From 000e208519d47d4afea91ca0d334a65494bd36a7 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:47:20 +0100 Subject: [PATCH 10/14] feat(auth): add customMethodPickerTermsContent slot to FirebaseAuthScreen (#2330) * feat(auth): add termsContent slot to AuthMethodPicker for custom ToS UI * feat(auth): add termsAccepted parameter to AuthMethodPicker for consent handling * update example * updates * updates * updates --- app/src/main/AndroidManifest.xml | 1 + .../demo/CustomMethodPickerDemoActivity.kt | 42 ++++- .../demo/CustomSlotsThemingDemoActivity.kt | 4 +- .../auth/ui/method_picker/AuthMethodPicker.kt | 62 ++++++-- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 3 + .../ui/method_picker/AuthMethodPickerTest.kt | 143 ++++++++++++++++++ 6 files changed, 236 insertions(+), 19 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 72f1b8025..991e55b60 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -79,6 +79,7 @@ android:label="Custom Method Picker Layout" android:exported="false" android:theme="@style/Theme.FirebaseUIAndroid" /> + diff --git a/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt index cd73ce212..54eadccc3 100644 --- a/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt @@ -8,6 +8,7 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.fillMaxSize @@ -20,6 +21,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -27,6 +29,10 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -45,6 +51,7 @@ import com.firebase.ui.auth.configuration.theme.AuthUIAsset import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults import com.firebase.ui.auth.ui.components.AuthProviderButton +import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen class CustomMethodPickerDemoActivity : ComponentActivity() { @@ -119,6 +126,8 @@ class CustomMethodPickerDemoActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { + var termsAccepted by remember { mutableStateOf(false) } + FirebaseAuthScreen( configuration = configuration, authUI = authUI, @@ -134,9 +143,30 @@ class CustomMethodPickerDemoActivity : ComponentActivity() { customMethodPickerLayout = { providers, onProviderSelected -> SpotlightMethodPicker( providers = providers, - onProviderSelected = onProviderSelected + onProviderSelected = onProviderSelected, + enabled = termsAccepted ) - } + }, + customMethodPickerTermsConfiguration = MethodPickerTermsConfiguration( + content = { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = termsAccepted, + onCheckedChange = { termsAccepted = it } + ) + Text( + text = "I have read and accept the Terms of Service and Privacy Policy", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 8.dp) + ) + } + }, + accepted = termsAccepted, + disableProvidersUntilAccepted = true, + ), ) } } @@ -148,6 +178,7 @@ class CustomMethodPickerDemoActivity : ComponentActivity() { fun SpotlightMethodPicker( providers: List, onProviderSelected: (AuthProvider) -> Unit, + enabled: Boolean = true, ) { val stringProvider = LocalAuthUIStringProvider.current @@ -195,6 +226,7 @@ fun SpotlightMethodPicker( .padding(horizontal = 32.dp), provider = provider, onClick = { onProviderSelected(provider) }, + enabled = enabled, stringProvider = stringProvider ) } @@ -219,6 +251,7 @@ fun SpotlightMethodPicker( ProviderIconButton( style = style, contentDescription = provider.providerId, + enabled = enabled, onClick = { onProviderSelected(provider) } ) } @@ -242,6 +275,7 @@ fun SpotlightMethodPicker( .padding(horizontal = 32.dp), provider = provider, onClick = { onProviderSelected(provider) }, + enabled = enabled, stringProvider = stringProvider ) } @@ -250,7 +284,7 @@ fun SpotlightMethodPicker( anonymous?.let { item { Spacer(modifier = Modifier.height(8.dp)) - TextButton(onClick = { onProviderSelected(it) }) { + TextButton(onClick = { onProviderSelected(it) }, enabled = enabled) { Text("Continue as guest") } } @@ -263,9 +297,11 @@ private fun ProviderIconButton( style: AuthUITheme.ProviderStyle, contentDescription: String, onClick: () -> Unit, + enabled: Boolean = true, ) { Button( onClick = onClick, + enabled = enabled, modifier = Modifier.size(52.dp), shape = CircleShape, colors = ButtonDefaults.buttonColors(containerColor = style.backgroundColor), diff --git a/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt index 4aa50b05f..4b824eed5 100644 --- a/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt @@ -102,8 +102,8 @@ fun CustomSlotsDemoChooser( ) DemoCard( - title = "Custom Method Picker Layout", - description = "Replace the default vertical provider list with a 2-column grid using customMethodPickerLayout on FirebaseAuthScreen.", + title = "Custom Method Picker Layout & Terms", + description = "Replace the default provider list with a custom layout, and swap the 'By continuing...' footer with a checkbox using customMethodPickerLayout and customMethodPickerTermsConfiguration on FirebaseAuthScreen.", onClick = onCustomMethodPickerClick ) } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt index 083148016..feb04fb7c 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt @@ -46,6 +46,24 @@ import com.firebase.ui.auth.configuration.theme.AuthUIAsset import com.firebase.ui.auth.ui.components.AuthProviderButton import com.firebase.ui.auth.util.SignInPreferenceManager +/** + * Configuration for a custom Terms of Service/Privacy Policy footer in [AuthMethodPicker]. + * + * @param content A composable that replaces the default "By continuing..." footer. Use this to + * supply a checkbox or any custom consent UI. + * @param accepted The current acceptance state. Only used when [disableProvidersUntilAccepted] + * is true. + * @param disableProvidersUntilAccepted When true, provider buttons are disabled until [accepted] + * is true. Defaults to false — buttons remain enabled unless explicitly opted in. + * + * @since 10.0.0 + */ +class MethodPickerTermsConfiguration( + val content: @Composable () -> Unit, + val accepted: Boolean = true, + val disableProvidersUntilAccepted: Boolean = false, +) + /** * Renders the provider selection screen. * @@ -68,6 +86,8 @@ import com.firebase.ui.auth.util.SignInPreferenceManager * @param termsOfServiceUrl The URL for the Terms of Service. * @param privacyPolicyUrl The URL for the Privacy Policy. * @param lastSignInPreference The last sign-in preference to show a "Continue as..." button. + * @param termsConfiguration Optional configuration for a custom ToS/Privacy Policy footer. + * When provided, replaces the default "By continuing..." text. See [MethodPickerTermsConfiguration]. * * @since 10.0.0 */ @@ -81,10 +101,14 @@ fun AuthMethodPicker( privacyPolicyUrl: String? = null, lastSignInPreference: SignInPreferenceManager.SignInPreference? = null, customLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null, + termsConfiguration: MethodPickerTermsConfiguration? = null, ) { val context = LocalContext.current val inPreview = LocalInspectionMode.current val stringProvider = LocalAuthUIStringProvider.current + val providerButtonsEnabled = termsConfiguration == null || + !termsConfiguration.disableProvidersUntilAccepted || + termsConfiguration.accepted Column( modifier = modifier @@ -100,7 +124,9 @@ fun AuthMethodPicker( ) } if (customLayout != null) { - customLayout(providers, onProviderSelected) + Box(modifier = Modifier.weight(1f)) { + customLayout(providers, onProviderSelected) + } } else { BoxWithConstraints( modifier = Modifier @@ -121,6 +147,7 @@ fun AuthMethodPicker( ContinueAsButton( provider = lastProvider, identifier = preference.identifier, + enabled = providerButtonsEnabled, onClick = { onProviderSelected(lastProvider) } ) Spacer(modifier = Modifier.height(24.dp)) @@ -155,6 +182,7 @@ fun AuthMethodPicker( onClick = { onProviderSelected(provider) }, + enabled = providerButtonsEnabled, provider = provider, stringProvider = LocalAuthUIStringProvider.current ) @@ -163,20 +191,24 @@ fun AuthMethodPicker( } } } - AnnotatedStringResource( - modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp), - context = context, - inPreview = inPreview, - previewText = "By continuing, you accept our Terms of Service and Privacy Policy.", - text = stringProvider.tosAndPrivacyPolicy( - termsOfServiceLabel = stringProvider.termsOfService, - privacyPolicyLabel = stringProvider.privacyPolicy - ), - links = arrayOf( - stringProvider.termsOfService to (termsOfServiceUrl ?: ""), - stringProvider.privacyPolicy to (privacyPolicyUrl ?: "") + if (termsConfiguration != null) { + termsConfiguration.content() + } else { + AnnotatedStringResource( + modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp), + context = context, + inPreview = inPreview, + previewText = "By continuing, you accept our Terms of Service and Privacy Policy.", + text = stringProvider.tosAndPrivacyPolicy( + termsOfServiceLabel = stringProvider.termsOfService, + privacyPolicyLabel = stringProvider.privacyPolicy + ), + links = arrayOf( + stringProvider.termsOfService to (termsOfServiceUrl ?: ""), + stringProvider.privacyPolicy to (privacyPolicyUrl ?: "") + ) ) - ) + } } } @@ -191,6 +223,7 @@ fun AuthMethodPicker( private fun ContinueAsButton( provider: AuthProvider, identifier: String?, + enabled: Boolean = true, onClick: () -> Unit ) { val stringProvider = LocalAuthUIStringProvider.current @@ -200,6 +233,7 @@ private fun ContinueAsButton( .fillMaxWidth() .testTag("ContinueAsButton"), onClick = onClick, + enabled = enabled, provider = provider, stringProvider = stringProvider, subtitle = identifier, 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 a919223ad..e82caf88b 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 @@ -77,6 +77,7 @@ import com.firebase.ui.auth.ui.components.rememberTopLevelDialogController import com.firebase.ui.auth.mfa.MfaChallengeContentState import com.firebase.ui.auth.mfa.MfaEnrollmentContentState import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker +import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState @@ -112,6 +113,7 @@ fun FirebaseAuthScreen( emailLink: String? = null, mfaConfiguration: MfaConfiguration = MfaConfiguration(), customMethodPickerLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null, + customMethodPickerTermsConfiguration: MethodPickerTermsConfiguration? = null, emailContent: (@Composable (EmailAuthContentState) -> Unit)? = null, phoneContent: (@Composable (PhoneAuthContentState) -> Unit)? = null, mfaEnrollmentContent: (@Composable (MfaEnrollmentContentState) -> Unit)? = null, @@ -277,6 +279,7 @@ fun FirebaseAuthScreen( privacyPolicyUrl = configuration.privacyPolicyUrl, lastSignInPreference = lastSignInPreference.value, customLayout = customMethodPickerLayout, + termsConfiguration = customMethodPickerTermsConfiguration, onProviderSelected = { provider -> when (provider) { is AuthProvider.Anonymous -> onSignInAnonymously?.invoke() diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt index b40917d5b..c4029b198 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt @@ -7,7 +7,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription @@ -18,6 +20,7 @@ import androidx.compose.ui.test.performScrollToNode import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.configuration.theme.AuthUIAsset @@ -280,6 +283,146 @@ class AuthMethodPickerTest { Truth.assertThat(selectedProvider).isEqualTo(googleProvider) } + @Test + fun `AuthMethodPicker still renders default ToS text when customLayout is provided`() { + val links = arrayOf("Terms of Service" to "", "Privacy Policy" to "") + val labels = links.map { it.first }.toTypedArray() + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + + setContentWithStringProvider { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it }, + customLayout = { _, _ -> Text("Custom Layout") } + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_tos_and_pp, *labels)) + .assertIsDisplayed() + } + + // ============================================================================================= + // Custom Terms Content Tests + // ============================================================================================= + + @Test + fun `AuthMethodPicker renders termsConfiguration content instead of default ToS when provided`() { + val links = arrayOf("Terms of Service" to "", "Privacy Policy" to "") + val labels = links.map { it.first }.toTypedArray() + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + + setContentWithStringProvider { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it }, + termsConfiguration = MethodPickerTermsConfiguration( + content = { Text("Custom ToS checkbox") } + ) + ) + } + + composeTestRule + .onNodeWithText("Custom ToS checkbox") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_tos_and_pp, *labels)) + .assertDoesNotExist() + } + + @Test + fun `AuthMethodPicker still renders providers when termsConfiguration is provided`() { + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + + setContentWithStringProvider { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it }, + termsConfiguration = MethodPickerTermsConfiguration( + content = { Text("Custom ToS checkbox") } + ) + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsDisplayed() + } + + // ============================================================================================= + // Terms Accepted / Gating Tests + // ============================================================================================= + + @Test + fun `AuthMethodPicker disables provider buttons when disableProvidersUntilAccepted is true and accepted is false`() { + val googleProvider = AuthProvider.Google(scopes = emptyList(), serverClientId = null) + + setContentWithStringProvider { + AuthMethodPicker( + providers = listOf(googleProvider), + onProviderSelected = { selectedProvider = it }, + termsConfiguration = MethodPickerTermsConfiguration( + content = { Text("Checkbox") }, + accepted = false, + disableProvidersUntilAccepted = true + ) + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsNotEnabled() + } + + @Test + fun `AuthMethodPicker enables provider buttons when disableProvidersUntilAccepted is true and accepted is true`() { + val googleProvider = AuthProvider.Google(scopes = emptyList(), serverClientId = null) + + setContentWithStringProvider { + AuthMethodPicker( + providers = listOf(googleProvider), + onProviderSelected = { selectedProvider = it }, + termsConfiguration = MethodPickerTermsConfiguration( + content = { Text("Checkbox") }, + accepted = true, + disableProvidersUntilAccepted = true + ) + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsEnabled() + } + + @Test + fun `AuthMethodPicker ignores accepted when disableProvidersUntilAccepted is false`() { + val googleProvider = AuthProvider.Google(scopes = emptyList(), serverClientId = null) + + setContentWithStringProvider { + AuthMethodPicker( + providers = listOf(googleProvider), + onProviderSelected = { selectedProvider = it }, + termsConfiguration = MethodPickerTermsConfiguration( + content = { Text("Checkbox") }, + accepted = false, + disableProvidersUntilAccepted = false + ) + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsEnabled() + } + // ============================================================================================= // Scrolling Tests // ============================================================================================= From bebc29027d558b2fc0aae6e6ed1038f1fd937ab0 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 8 Jun 2026 07:59:01 +0100 Subject: [PATCH 11/14] feat(auth): add isCredentialLinkingEnabled for authenticated users (#2319) * feat(auth): add isCredentialLinkingEnabled for authenticated non-anonymous users * t feat(app): add CredentialLinkingDemoActivity to sample app # Conflicts: # app/src/main/AndroidManifest.xml * updates --- app/src/main/AndroidManifest.xml | 6 + .../demo/CredentialLinkingDemoActivity.kt | 186 ++++++++ .../firebaseui/android/demo/MainActivity.kt | 30 ++ .../auth/configuration/AuthUIConfiguration.kt | 9 + .../auth_provider/AuthProvider.kt | 7 + .../EmailAuthProvider+FirebaseAuthUI.kt | 21 +- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 20 +- .../configuration/AuthUIConfigurationTest.kt | 1 + ...AnonymousAuthProviderFirebaseAuthUITest.kt | 50 ++ .../EmailAuthProviderFirebaseAuthUITest.kt | 44 +- .../GoogleAuthProviderFirebaseAuthUITest.kt | 63 +++ .../firebase/ui/auth/testutil/TestHelpers.kt | 25 + .../ui/screens/CredentialLinkingScreenTest.kt | 436 ++++++++++++++++++ .../auth/ui/screens/GoogleAuthScreenTest.kt | 43 +- 14 files changed, 874 insertions(+), 67 deletions(-) create mode 100644 app/src/main/java/com/firebaseui/android/demo/CredentialLinkingDemoActivity.kt create mode 100644 e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/CredentialLinkingScreenTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 991e55b60..ca5cda2d8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -56,6 +56,12 @@ android:exported="false" android:theme="@style/Theme.FirebaseUIAndroid" /> + + }, + onSignInCancelled = {}, + authenticatedContent = { state, uiContext -> + CredentialLinkingAuthenticatedContent(state, uiContext) + } + ) + } + } + } + } +} + +@Composable +private fun CredentialLinkingAuthenticatedContent( + state: AuthState, + uiContext: AuthSuccessUiContext, +) { + when (state) { + is AuthState.Success -> { + val user = state.user + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Signed in", + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(modifier = Modifier.height(16.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("UID: ${user.uid}", style = MaterialTheme.typography.bodySmall) + Text("Email: ${user.email ?: "—"}") + Text("Phone: ${user.phoneNumber ?: "—"}") + Text( + "Providers: ${user.providerData.map { it.providerId }}", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Start + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { uiContext.onNavigate(AuthRoute.MethodPicker) } + ) { + Text("Add sign-in method") + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = uiContext.onSignOut + ) { + Text("Sign out") + } + } + } + + is AuthState.RequiresEmailVerification -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Verify your email", + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "A verification link was sent to ${state.email}. Once verified, tap the button below.", + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = uiContext.onReloadUser + ) { + Text("I've verified my email") + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = uiContext.onSignOut + ) { + Text("Sign out") + } + } + } + + else -> {} + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt index 300014174..b1fbd486e 100644 --- a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt @@ -94,6 +94,9 @@ class MainActivity : ComponentActivity() { onCustomSlotsClick = { startActivity(Intent(this, CustomSlotsThemingDemoActivity::class.java)) }, + onCredentialLinkingClick = { + startActivity(Intent(this, CredentialLinkingDemoActivity::class.java)) + }, isEmulatorMode = USE_AUTH_EMULATOR ) } @@ -107,6 +110,7 @@ fun ChooserScreen( onHighLevelApiClick: () -> Unit, onLowLevelApiClick: () -> Unit, onCustomSlotsClick: () -> Unit, + onCredentialLinkingClick: () -> Unit = {}, isEmulatorMode: Boolean = false ) { val scrollState = rememberScrollState() @@ -272,6 +276,32 @@ fun ChooserScreen( } } + // Credential Linking Card + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onCredentialLinkingClick + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "🔗 Credential Linking", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "isCredentialLinkingEnabled", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Sign in with one provider, then add another to the same account without losing your UID.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) // Info card diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt index 3fa7f394b..bff7c72f9 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt @@ -42,6 +42,7 @@ class AuthUIConfigurationBuilder { var isCredentialManagerEnabled: Boolean = true var isMfaEnabled: Boolean = true var isAnonymousUpgradeEnabled: Boolean = false + var isCredentialLinkingEnabled: Boolean = false var tosUrl: String? = null var privacyPolicyUrl: String? = null var logo: AuthUIAsset? = null @@ -107,6 +108,7 @@ class AuthUIConfigurationBuilder { isCredentialManagerEnabled = isCredentialManagerEnabled, isMfaEnabled = isMfaEnabled, isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled, + isCredentialLinkingEnabled = isCredentialLinkingEnabled, tosUrl = tosUrl, privacyPolicyUrl = privacyPolicyUrl, logo = logo, @@ -164,6 +166,13 @@ class AuthUIConfiguration( */ val isAnonymousUpgradeEnabled: Boolean = false, + /** + * Allows linking a new credential to an already authenticated (non-anonymous) user. + * When enabled, signing in via FirebaseUI while a user is already signed in will link + * the new credential to the existing account instead of creating a new one. + */ + val isCredentialLinkingEnabled: Boolean = false, + /** * The URL for the terms of service. */ 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 ffbe5242a..8d2aaafa4 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 @@ -991,6 +991,13 @@ abstract class AuthProvider(open val providerId: String, open val providerName: && currentUser.isAnonymous } + internal fun canLinkCredential(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean { + val currentUser = auth.currentUser + return config.isCredentialLinkingEnabled + && currentUser != null + && !currentUser.isAnonymous + } + /** * Merges profile information (display name and photo URL) with the current user's profile. * 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 670b451bc..2f1e14e4c 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 @@ -22,6 +22,7 @@ import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canLinkCredential import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.mergeProfile import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException @@ -126,12 +127,14 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( credentialProvider: AuthProvider.Email.CredentialProvider = AuthProvider.Email.DefaultCredentialProvider(), ): AuthResult? { val canUpgrade = canUpgradeAnonymous(config, auth) + val canLink = canLinkCredential(config, auth) + val shouldLinkCredential = canUpgrade || canLink val pendingCredential = - if (canUpgrade) credentialProvider.getCredential(email, password) else null + if (shouldLinkCredential) credentialProvider.getCredential(email, password) else null try { - // Check if new accounts are allowed (only for non-upgrade flows) - if (!canUpgrade && !provider.isNewAccountsAllowed) { + // Check if new accounts are allowed (only for non-upgrade/non-linking flows) + if (!shouldLinkCredential && !provider.isNewAccountsAllowed) { throw AuthException.UserNotFoundException( message = context.getString(R.string.fui_error_email_does_not_exist) ) @@ -156,7 +159,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( } updateAuthState(AuthState.Loading("Creating user...")) - val result = if (canUpgrade) { + val result = if (shouldLinkCredential) { auth.currentUser?.linkWithCredential(requireNotNull(pendingCredential))?.await() } else { auth.createUserWithEmailAndPassword(email, password).await() @@ -205,10 +208,10 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( message = "An account already exists with this email. " + "Please sign in with your existing account.", email = e.email ?: email, - credential = if (canUpgrade) { - e.updatedCredential ?: pendingCredential - } else { - null + credential = when { + canUpgrade -> e.updatedCredential ?: pendingCredential + canLink -> pendingCredential + else -> null }, cause = e ) @@ -548,7 +551,7 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( ): AuthResult? { try { updateAuthState(AuthState.Loading("Signing in user...")) - return if (canUpgradeAnonymous(config, auth)) { + return if (canUpgradeAnonymous(config, auth) || canLinkCredential(config, auth)) { auth.currentUser?.linkWithCredential(credential)?.await() } else { auth.signInWithCredential(credential).await() 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 e82caf88b..fd59695d6 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 @@ -90,6 +90,7 @@ import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import com.google.firebase.auth.MultiFactorResolver import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await /** * High-level authentication screen that wires together provider selection, individual provider @@ -409,27 +410,22 @@ fun FirebaseAuthScreen( coroutineScope.launch { try { // Reload user to get fresh data from server - authUI.getCurrentUser()?.reload() - authUI.getCurrentUser()?.getIdToken(true) - - // Check the user's email verification status after reload - val user = authUI.getCurrentUser() - if (user != null) { - // If email is now verified, transition to Success state - if (user.isEmailVerified) { + authUI.getCurrentUser()?.let { + it.reload().await() + it.getIdToken(true).await() + if (it.isEmailVerified) { authUI.updateAuthState( AuthState.Success( result = null, - user = user, + user = it, isNewUser = false ) ) } else { - // Email still not verified, keep showing verification screen authUI.updateAuthState( AuthState.RequiresEmailVerification( - user = user, - email = user.email ?: "" + user = it, + email = it.email ?: "" ) ) } diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt index 4afcfa84b..5d9efdb75 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt @@ -458,6 +458,7 @@ class AuthUIConfigurationTest { "isCredentialManagerEnabled", "isMfaEnabled", "isAnonymousUpgradeEnabled", + "isCredentialLinkingEnabled", "tosUrl", "privacyPolicyUrl", "logo", 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 7ddfb3ac8..df4f6bcde 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 @@ -41,6 +41,7 @@ import org.junit.runner.RunWith import org.mockito.ArgumentMatchers import org.mockito.Mock import org.mockito.Mockito.mock +import org.mockito.Mockito.never import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations @@ -313,4 +314,53 @@ class AnonymousAuthProviderFirebaseAuthUITest { assertThat(result).isNotNull() verify(mockAnonymousUser).linkWithCredential(credential) } + + // ============================================================================================= + // Credential Linking for Authenticated (Non-Anonymous) Users Tests + // ============================================================================================= + + @Test + fun `createOrLinkUserWithEmailAndPassword - links email credential to authenticated non-anonymous user when isCredentialLinkingEnabled`() = runTest { + val authenticatedUser = mock(FirebaseUser::class.java) + `when`(authenticatedUser.isAnonymous).thenReturn(false) + `when`(mockFirebaseAuth.currentUser).thenReturn(authenticatedUser) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mock(AuthResult::class.java)) + `when`(authenticatedUser.linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java))) + .thenReturn(taskCompletionSource.task) + // Stub createUserWithEmailAndPassword so the test fails at verify, not with NPE + `when`(mockFirebaseAuth.createUserWithEmailAndPassword( + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString() + )).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) + } + isCredentialLinkingEnabled = true + } + + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + + verify(authenticatedUser).linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java)) + verify(mockFirebaseAuth, never()).createUserWithEmailAndPassword( + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString() + ) + } } 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 d42bbab5f..b592f23e7 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 @@ -608,11 +608,47 @@ class EmailAuthProviderFirebaseAuthUITest { assertThat(e.credential).isEqualTo(updatedCredential) assertThat(e.cause).isEqualTo(collisionException) } + } + + @Test + fun `signInAndLinkWithCredential - links credential to authenticated non-anonymous user when isCredentialLinkingEnabled`() = runTest { + val authenticatedUser = mock(FirebaseUser::class.java) + `when`(authenticatedUser.isAnonymous).thenReturn(false) + `when`(mockFirebaseAuth.currentUser).thenReturn(authenticatedUser) + + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(authenticatedUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(authenticatedUser.linkWithCredential(credential)) + .thenReturn(taskCompletionSource.task) + // Also stub signInWithCredential so the test fails at the verify assertion, + // not with a NPE from an unmocked call + `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) + } + isCredentialLinkingEnabled = true + } - val currentState = instance.authStateFlow().first { it is AuthState.Error } - assertThat(currentState).isInstanceOf(AuthState.Error::class.java) - val errorState = currentState as AuthState.Error - assertThat(errorState.exception).isInstanceOf(AuthException.AccountLinkingRequiredException::class.java) + val result = instance.signInAndLinkWithCredential( + config = config, + credential = credential + ) + + assertThat(result).isNotNull() + verify(authenticatedUser).linkWithCredential(credential) + verify(mockFirebaseAuth, never()).signInWithCredential(credential) } // ============================================================================================= 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 185483b9c..81f94b161 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 @@ -602,6 +602,69 @@ class GoogleAuthProviderFirebaseAuthUITest { verify(mockFirebaseAuth, never()).signInWithCredential(any()) } + // ============================================================================================= + // signInWithGoogle - Credential Linking for Authenticated (Non-Anonymous) Users + // ============================================================================================= + + @Test + fun `Sign in with Google with authenticated non-anonymous user and isCredentialLinkingEnabled should link credentials`() = runTest { + val mockCredential = mock(AuthCredential::class.java) + val mockAuthenticatedUser = mock(FirebaseUser::class.java) + `when`(mockAuthenticatedUser.isAnonymous).thenReturn(false) + `when`(mockAuthenticatedUser.uid).thenReturn("authenticated-uid") + + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockAuthenticatedUser) + + val googleSignInResult = AuthProvider.Google.GoogleSignInResult( + credential = mockCredential, + idToken = "test-id-token", + displayName = "Test User", + photoUrl = null + ) + + `when`( + mockCredentialManagerProvider.getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + ).thenReturn(googleSignInResult) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAuthenticatedUser) + `when`(mockAuthenticatedUser.linkWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + isCredentialLinkingEnabled = true + providers { + provider(googleProvider) + } + } + + instance.signInWithGoogle( + context = applicationContext, + config = config, + provider = googleProvider, + authorizationProvider = mockAuthorizationProvider, + credentialManagerProvider = mockCredentialManagerProvider + ) + + // Verify link was called instead of sign-in + verify(mockAuthenticatedUser).linkWithCredential(mockCredential) + verify(mockFirebaseAuth, never()).signInWithCredential(any()) + } + // ============================================================================================= // signInWithGoogle - Configuration Properties // ============================================================================================= diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/testutil/TestHelpers.kt b/e2eTest/src/test/java/com/firebase/ui/auth/testutil/TestHelpers.kt index 958df6f53..98b570008 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/testutil/TestHelpers.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/testutil/TestHelpers.kt @@ -1,6 +1,7 @@ package com.firebase.ui.auth.testutil import android.os.Looper +import android.util.Base64 import com.firebase.ui.auth.FirebaseAuthUI import com.google.firebase.auth.FirebaseUser import org.robolectric.Shadows.shadowOf @@ -99,3 +100,27 @@ fun verifyEmailInEmulator(authUI: FirebaseAuthUI, emulatorApi: EmulatorAuthApi, println("TEST: Email verified successfully for user ${user.uid}") println("TEST: User isEmailVerified: ${authUI.auth.currentUser?.isEmailVerified}") } + +fun generateMockGoogleIdToken( + email: String, + sub: String = "test-user-id", + name: String? = null, + photoUrl: String? = null, +): String { + val header = """{"alg":"RS256","kid":"test"}""" + val payload = buildString { + append("{") + append("\"iss\":\"https://accounts.google.com\",") + append("\"aud\":\"test-client-id\",") + append("\"sub\":\"$sub\",") + append("\"email\":\"$email\",") + append("\"email_verified\":true") + name?.let { append(",\"name\":\"$it\"") } + photoUrl?.let { append(",\"picture\":\"$it\"") } + append(",\"iat\":1689600000,\"exp\":1689603600") + append("}") + } + val encodedHeader = Base64.encodeToString(header.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + val encodedPayload = Base64.encodeToString(payload.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + return "$encodedHeader.$encodedPayload.mock-signature" +} diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/CredentialLinkingScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/CredentialLinkingScreenTest.kt new file mode 100644 index 000000000..a862a4e57 --- /dev/null +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/CredentialLinkingScreenTest.kt @@ -0,0 +1,436 @@ +package com.firebase.ui.auth.ui.screens + +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.os.Looper +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.testutil.AUTH_STATE_WAIT_TIMEOUT_MS +import com.firebase.ui.auth.testutil.EmulatorAuthApi +import com.firebase.ui.auth.testutil.awaitWithLooper +import com.firebase.ui.auth.testutil.ensureFreshUser +import com.firebase.ui.auth.testutil.generateMockGoogleIdToken +import com.firebase.ui.auth.testutil.verifyEmailInEmulator +import com.firebase.ui.auth.util.CountryUtils +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) +class CredentialLinkingScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Mock + private lateinit var mockCredentialManager: CredentialManager + + private lateinit var applicationContext: Context + private lateinit var stringProvider: AuthUIStringProvider + private lateinit var authUI: FirebaseAuthUI + private lateinit var emulatorApi: EmulatorAuthApi + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + applicationContext = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(applicationContext) + + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + val firebaseApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + + authUI = FirebaseAuthUI.getInstance() + authUI.auth.useEmulator("127.0.0.1", 9099) + + authUI.testCredentialManagerProvider = object : AuthProvider.Google.CredentialManagerProvider { + override suspend fun getGoogleCredential( + context: Context, + credentialManager: CredentialManager, + serverClientId: String, + filterByAuthorizedAccounts: Boolean, + autoSelectEnabled: Boolean, + ): AuthProvider.Google.GoogleSignInResult { + return AuthProvider.Google.DefaultCredentialManagerProvider().getGoogleCredential( + context = context, + credentialManager = mockCredentialManager, + serverClientId = serverClientId, + filterByAuthorizedAccounts = filterByAuthorizedAccounts, + autoSelectEnabled = autoSelectEnabled, + ) + } + + override suspend fun clearCredentialState(context: Context, credentialManager: CredentialManager) {} + } + + emulatorApi = EmulatorAuthApi( + projectId = firebaseApp.options.projectId + ?: throw IllegalStateException("Project ID is required for emulator interactions"), + emulatorHost = "127.0.0.1", + emulatorPort = 9099 + ) + + emulatorApi.clearEmulatorData() + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + emulatorApi.clearEmulatorData() + } + + @Test + fun `isCredentialLinkingEnabled links phone to existing email user preserving UID`() { + val email = "credentiallink@example.com" + val password = "Test@123" + val phone = "2025550123" + val country = CountryUtils.findByCountryCode("US")!! + + // Step 1: Create an email/password user, verify their email, and sign in + println("TEST: Creating email/password user...") + val createdUser = ensureFreshUser(authUI, email, password) + requireNotNull(createdUser) { "Failed to create user" } + + println("TEST: Verifying email in emulator...") + verifyEmailInEmulator(authUI, emulatorApi, createdUser) + + val signInResult = authUI.auth.signInWithEmailAndPassword(email, password).awaitWithLooper() + val originalUID = signInResult.user!!.uid + println("TEST: Signed in as $email, UID: $originalUID") + + assertThat(authUI.auth.currentUser).isNotNull() + assertThat(authUI.auth.currentUser!!.isAnonymous).isFalse() + assertThat(authUI.auth.currentUser!!.isEmailVerified).isTrue() + + // Step 2: Set up auth screen with isCredentialLinkingEnabled + phone provider + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = country.countryCode, + allowedCountries = null, + timeout = 60L, + ) + ) + } + isCredentialLinkingEnabled = true + isCredentialManagerEnabled = false + } + + var currentAuthState: AuthState = AuthState.Idle + + composeTestRule.setContent { + TestAuthScreen(configuration = configuration) + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + currentAuthState = authState + } + + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Wait for the authenticated content to render + composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + currentAuthState is AuthState.Success + } + + // Step 3: Navigate to phone auth from the authenticated content slot + composeTestRule.onNodeWithText("Link Phone") + .assertIsDisplayed() + .performClick() + + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Step 4: Enter phone number and request verification code + println("TEST: Entering phone number...") + composeTestRule.onNodeWithText(stringProvider.phoneNumberHint) + .assertIsDisplayed() + .performTextInput(phone) + + composeTestRule.onNodeWithText(stringProvider.sendVerificationCode.uppercase()) + .performScrollTo() + .assertIsEnabled() + .performClick() + + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Step 5: Fetch verification code from emulator + println("TEST: Fetching phone verification code...") + var phoneCode: String? = null + var retries = 0 + val maxRetries = 5 + while (phoneCode == null && retries < maxRetries) { + Thread.sleep(if (retries == 0) 200L else 500L * retries) + shadowOf(Looper.getMainLooper()).idle() + try { + phoneCode = emulatorApi.fetchVerifyPhoneCode(phone) + println("TEST: Found phone code after ${retries + 1} attempts") + } catch (e: Exception) { + retries++ + if (retries >= maxRetries) { + Assume.assumeTrue( + "Skipping test: Firebase Auth Emulator not available. Error: ${e.message}", + false + ) + } + println("TEST: Phone code not found yet, retrying... (attempt $retries/$maxRetries)") + } + } + requireNotNull(phoneCode) { "Phone code should not be null at this point" } + + // Step 6: Enter verification code + println("TEST: Entering verification code: $phoneCode") + val textFields = composeTestRule.onAllNodes(hasSetTextAction()) + phoneCode.forEachIndexed { index, digit -> + composeTestRule.waitForIdle() + textFields[index].performTextInput(digit.toString()) + } + + composeTestRule.onNodeWithText(stringProvider.verifyPhoneNumber.uppercase()) + .performScrollTo() + .assertIsEnabled() + .performClick() + + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Step 7: Wait for success + println("TEST: Waiting for auth state change after phone verification...") + composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + println("TEST: Auth state: $currentAuthState") + currentAuthState is AuthState.Success + } + + // Step 8: Verify the UID is preserved (linking happened, not a new account) + val linkedUser = authUI.auth.currentUser!! + println("TEST: Original UID: $originalUID, Linked UID: ${linkedUser.uid}") + assertThat(linkedUser.uid).isEqualTo(originalUID) + assertThat(linkedUser.email).isEqualTo(email) + assertThat(linkedUser.phoneNumber).isEqualTo( + CountryUtils.formatPhoneNumber(country.dialCode, phone) + ) + val providerIds = linkedUser.providerData.map { it.providerId } + assertThat(providerIds).contains("password") + assertThat(providerIds).contains("phone") + } + + @Test + fun `isCredentialLinkingEnabled links Google to existing email user preserving UID`() = runTest { + val email = "googlelinktest@example.com" + val password = "Test@123" + val googleEmail = "googlelinktest@gmail.com" + val googleName = "Google Link Test User" + val googlePhotoUrl = "https://example.com/avatar.jpg" + + // Step 1: Create an email/password user, verify their email, and sign in + println("TEST: Creating email/password user...") + val createdUser = ensureFreshUser(authUI, email, password) + requireNotNull(createdUser) { "Failed to create user" } + + println("TEST: Verifying email in emulator...") + verifyEmailInEmulator(authUI, emulatorApi, createdUser) + + val signInResult = authUI.auth.signInWithEmailAndPassword(email, password).awaitWithLooper() + val originalUID = signInResult.user!!.uid + println("TEST: Signed in as $email, UID: $originalUID") + + assertThat(authUI.auth.currentUser).isNotNull() + assertThat(authUI.auth.currentUser!!.isAnonymous).isFalse() + assertThat(authUI.auth.currentUser!!.isEmailVerified).isTrue() + + // Step 2: Configure mock Google credential + val mockIdToken = generateMockGoogleIdToken( + email = googleEmail, + name = googleName, + photoUrl = googlePhotoUrl, + ) + val mockCredential = mock { + on { type } doReturn GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL + on { data } doReturn Bundle().apply { + putString("com.google.android.libraries.identity.googleid.BUNDLE_KEY_ID_TOKEN", mockIdToken) + putString("com.google.android.libraries.identity.googleid.BUNDLE_KEY_ID", googleEmail) + putString("com.google.android.libraries.identity.googleid.BUNDLE_KEY_DISPLAY_NAME", googleName) + putParcelable("com.google.android.libraries.identity.googleid.BUNDLE_KEY_PROFILE_PICTURE_URI", Uri.parse(googlePhotoUrl)) + } + on { displayName } doReturn googleName + on { profilePictureUri } doReturn Uri.parse(googlePhotoUrl) + } + val mockResult = mock { + on { credential } doReturn mockCredential + } + whenever(mockCredentialManager.getCredential(any(), any())) + .thenReturn(mockResult) + + // Step 3: Set up auth screen with isCredentialLinkingEnabled + Google provider + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "test-server-client-id", + ) + ) + } + isCredentialLinkingEnabled = true + isCredentialManagerEnabled = false + } + + var currentAuthState: AuthState = AuthState.Idle + + composeTestRule.setContent { + TestAuthScreen(configuration = configuration) + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + currentAuthState = authState + } + + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Wait for authenticated content to render + composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + currentAuthState is AuthState.Success + } + + // Step 4: Click "Link Google" from authenticated content + composeTestRule.onNodeWithText("Link Google") + .assertIsDisplayed() + .performClick() + + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Step 5: Click the Google sign-in button on the method picker + println("TEST: Clicking Google sign-in button...") + composeTestRule + .onNodeWithTag("AuthMethodPicker LazyColumn") + .performScrollToNode(hasText(stringProvider.signInWithGoogle)) + composeTestRule + .onNode(hasText(stringProvider.signInWithGoogle)) + .assertIsDisplayed() + .performClick() + + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Step 6: Wait for linking to complete + println("TEST: Waiting for Google linking to complete...") + composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + println("TEST: Auth state: $currentAuthState") + currentAuthState is AuthState.Success + } + + // Step 7: Verify the UID is preserved and Google provider is added + val linkedUser = authUI.auth.currentUser!! + println("TEST: Original UID: $originalUID, Linked UID: ${linkedUser.uid}") + assertThat(linkedUser.uid).isEqualTo(originalUID) + assertThat(linkedUser.email).isEqualTo(email) + val providerIds = linkedUser.providerData.map { it.providerId } + assertThat(providerIds).contains("password") + assertThat(providerIds).contains("google.com") + } + + @Composable + private fun TestAuthScreen(configuration: AuthUIConfiguration) { + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = { _: AuthException -> }, + onSignInCancelled = {}, + authenticatedContent = { state, uiContext -> + when (state) { + is AuthState.Success -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("UID - ${state.user.uid}") + Text("Email - ${state.user.email}") + Text("Phone - ${state.user.phoneNumber}") + Button(onClick = { uiContext.onNavigate(AuthRoute.Phone) }) { + Text("Link Phone") + } + Button(onClick = { uiContext.onNavigate(AuthRoute.MethodPicker) }) { + Text("Link Google") + } + } + } + } + } + ) + } +} 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 0bbdc1372..942d70678 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 @@ -4,7 +4,6 @@ import android.content.Context import android.net.Uri import android.os.Bundle import android.os.Looper -import android.util.Base64 import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -40,6 +39,7 @@ import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.testutil.AUTH_STATE_WAIT_TIMEOUT_MS import com.firebase.ui.auth.testutil.EmulatorAuthApi +import com.firebase.ui.auth.testutil.generateMockGoogleIdToken import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp @@ -449,45 +449,4 @@ class GoogleAuthScreenTest { ) } - /** - * Generates a mock Google ID token (JWT) with the specified email. - * This is useful for testing so that the token payload matches the test data. - */ - private fun generateMockGoogleIdToken( - email: String, - sub: String = "test-user-id", - name: String? = null, - photoUrl: String? = null - ): String { - // JWT Header - val header = """{"alg":"RS256","kid":"test"}""" - - // JWT Payload with dynamic email - val payload = buildString { - append("{") - append("\"iss\":\"https://accounts.google.com\",") - append("\"aud\":\"test-client-id\",") - append("\"sub\":\"$sub\",") - append("\"email\":\"$email\",") - append("\"email_verified\":true") - name?.let { append(",\"name\":\"$it\"") } - photoUrl?.let { append(",\"picture\":\"$it\"") } - append(",\"iat\":1689600000,\"exp\":1689603600") - append("}") - } - - // Base64 encode header and payload (URL-safe, no padding, no wrap) - val encodedHeader = Base64.encodeToString( - header.toByteArray(), - Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP - ) - val encodedPayload = Base64.encodeToString( - payload.toByteArray(), - Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP - ) - - // Return JWT format: header.payload.signature - // Signature doesn't need to be valid for testing - return "$encodedHeader.$encodedPayload.mock-signature" - } } \ No newline at end of file From 8479299b6db7698e3b853f747670174b2be85716 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:49:27 +0100 Subject: [PATCH 12/14] feat(auth): surface GIdP password policy violations with actionable error messages (#2333) * feat(auth): handle GIdP password policy violations with specific error messages * updates * updates * cleanup --- .../com/firebase/ui/auth/AuthException.kt | 96 +++++++++++++--- .../auth/ui/components/ErrorRecoveryDialog.kt | 7 ++ .../auth/ui/screens/email/EmailAuthScreen.kt | 4 +- .../com/firebase/ui/auth/AuthExceptionTest.kt | 106 ++++++++++++++++++ 4 files changed, 194 insertions(+), 19 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 ae9d96e53..779b93a8d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthException.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthException.kt @@ -123,6 +123,25 @@ abstract class AuthException( val reason: String? = null ) : AuthException(message, cause) + /** + * The password violates one or more Google Identity Platform password policy requirements. + * + * This exception is thrown when GIdP password policy enforcement is enabled and the supplied + * password fails one or more configured constraints (e.g. minimum length, missing uppercase). + * + * [message] is a newline-separated, human-readable description of each failing constraint + * as returned by the server, suitable for direct display in the UI. + * + * @property message Human-readable description of the failing constraints + * @property failingRequirements The individual constraint strings from the server + * @property cause The underlying [Throwable] that caused this exception + */ + class PasswordPolicyViolationException( + message: String, + val failingRequirements: List, + cause: Throwable? = null + ) : AuthException(message, cause) + /** * An account with the given email already exists. * @@ -354,7 +373,32 @@ abstract class AuthException( // If already an AuthException, return it directly is AuthException -> firebaseException - // Handle specific Firebase Auth exceptions first (before general FirebaseException) + // Handle specific Firebase Auth exceptions first (before general FirebaseException). + // FirebaseAuthWeakPasswordException extends FirebaseAuthInvalidCredentialsException, + // so it must be checked before the parent type. + is FirebaseAuthWeakPasswordException -> { + val sourceText = firebaseException.reason ?: firebaseException.message ?: "" + if (sourceText.contains("PASSWORD_DOES_NOT_MEET_REQUIREMENTS", ignoreCase = true)) { + val requirements = parsePasswordPolicyRequirements(sourceText) + PasswordPolicyViolationException( + message = requirements.joinToString("\n").ifEmpty { + stringProvider?.errorWeakPasswordGeneric.nonEmpty() + ?: "Password does not meet policy requirements" + }, + failingRequirements = requirements, + cause = firebaseException + ) + } else { + WeakPasswordException( + message = stringProvider?.errorWeakPasswordGeneric.nonEmpty() + ?: firebaseException.message + ?: "Password is too weak", + cause = firebaseException, + reason = firebaseException.reason + ) + } + } + is FirebaseAuthInvalidCredentialsException -> { InvalidCredentialsException( message = stringProvider?.errorInvalidCredentials.nonEmpty() @@ -389,16 +433,6 @@ abstract class AuthException( } } - is FirebaseAuthWeakPasswordException -> { - WeakPasswordException( - message = stringProvider?.errorWeakPasswordGeneric.nonEmpty() - ?: firebaseException.message - ?: "Password is too weak", - cause = firebaseException, - reason = firebaseException.reason - ) - } - is FirebaseAuthUserCollisionException -> { when (firebaseException.errorCode) { "ERROR_EMAIL_ALREADY_IN_USE" -> EmailAlreadyInUseException( @@ -469,12 +503,24 @@ abstract class AuthException( } is FirebaseException -> { - NetworkException( - message = stringProvider?.errorNetworkGeneric.nonEmpty() - ?: firebaseException.message - ?: "Network error occurred", - cause = firebaseException - ) + val msg = firebaseException.message ?: "" + if (msg.contains("PASSWORD_DOES_NOT_MEET_REQUIREMENTS", ignoreCase = true)) { + val requirements = parsePasswordPolicyRequirements(msg) + PasswordPolicyViolationException( + message = requirements.joinToString("\n").ifEmpty { + stringProvider?.errorWeakPasswordGeneric.nonEmpty() + ?: "Password does not meet policy requirements" + }, + failingRequirements = requirements, + cause = firebaseException + ) + } else { + NetworkException( + message = stringProvider?.errorNetworkGeneric.nonEmpty() + ?: msg.ifEmpty { "Network error occurred" }, + cause = firebaseException + ) + } } else -> { @@ -500,5 +546,21 @@ abstract class AuthException( } private fun String?.nonEmpty(): String? = this?.ifEmpty { null } + + // Finds the [...] content that immediately follows PASSWORD_DOES_NOT_MEET_REQUIREMENTS + // in both FirebaseException and FirebaseAuthWeakPasswordException messages. + // GIdP returns human-readable requirement strings inside those brackets, e.g. + // "...PASSWORD_DOES_NOT_MEET_REQUIREMENTS:Missing password requirements: [Password must contain at least 10 characters]" + private fun parsePasswordPolicyRequirements(message: String): List { + val policyIndex = message.indexOf("PASSWORD_DOES_NOT_MEET_REQUIREMENTS", ignoreCase = true) + if (policyIndex == -1) return emptyList() + val start = message.indexOf('[', policyIndex) + val end = message.indexOf(']', policyIndex) + if (start == -1 || end == -1 || end <= start) return emptyList() + return message.substring(start + 1, end) + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + } } } 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 dff4daa60..230bb835a 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 @@ -140,6 +140,11 @@ private fun getRecoveryMessage( } ?: baseMessage } + is AuthException.PasswordPolicyViolationException -> { + error.message?.takeIf { it.isNotBlank() } + ?: stringProvider.weakPasswordRecoveryMessage + } + is AuthException.EmailAlreadyInUseException -> { // Include email if available val baseMessage = stringProvider.emailAlreadyInUseRecoveryMessage @@ -201,6 +206,7 @@ private fun getRecoveryActionText( is AuthException.NetworkException, is AuthException.InvalidCredentialsException, is AuthException.WeakPasswordException, + is AuthException.PasswordPolicyViolationException, is AuthException.TooManyRequestsException, is AuthException.PhoneVerificationCooldownException -> stringProvider.retryAction is AuthException.UnknownException -> stringProvider.retryAction @@ -221,6 +227,7 @@ private fun isRecoverable(error: AuthException): Boolean { is AuthException.InvalidCredentialsException -> true is AuthException.UserNotFoundException -> true is AuthException.WeakPasswordException -> true + is AuthException.PasswordPolicyViolationException -> true is AuthException.EmailAlreadyInUseException -> true is AuthException.TooManyRequestsException -> false // User must wait is AuthException.PhoneVerificationCooldownException -> false // User must wait for cooldown 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 62972d18c..1f566f5cf 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 @@ -306,7 +306,7 @@ fun EmailAuthScreen( password = passwordTextValue.value, ) } catch (e: Exception) { - + onError(AuthException.from(e, stringProvider)) } } }, @@ -318,7 +318,7 @@ fun EmailAuthScreen( actionCodeSettings = configuration.passwordResetActionCodeSettings, ) } catch (e: Exception) { - + onError(AuthException.from(e, stringProvider)) } } }, 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 caa382bb1..ea8ec7ecd 100644 --- a/auth/src/test/java/com/firebase/ui/auth/AuthExceptionTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/AuthExceptionTest.kt @@ -19,6 +19,7 @@ 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 com.google.firebase.auth.FirebaseAuthWeakPasswordException import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.mock @@ -113,6 +114,7 @@ class AuthExceptionTest { assertThat(AuthException.InvalidCredentialsException("Test")).isInstanceOf(AuthException::class.java) assertThat(AuthException.UserNotFoundException("Test")).isInstanceOf(AuthException::class.java) assertThat(AuthException.WeakPasswordException("Test")).isInstanceOf(AuthException::class.java) + assertThat(AuthException.PasswordPolicyViolationException("Test", emptyList())).isInstanceOf(AuthException::class.java) assertThat(AuthException.EmailAlreadyInUseException("Test")).isInstanceOf(AuthException::class.java) assertThat(AuthException.TooManyRequestsException("Test")).isInstanceOf(AuthException::class.java) assertThat(AuthException.MfaRequiredException("Test")).isInstanceOf(AuthException::class.java) @@ -183,4 +185,108 @@ class AuthExceptionTest { assertThat(result.message).isEqualTo("Firebase: user disabled") } + + // ============================================================================================= + // GIdP password policy + // ============================================================================================= + + @Test + fun `from() maps GIdP policy violation FirebaseException to PasswordPolicyViolationException`() { + val msg = "An internal error has occurred. [ PASSWORD_DOES_NOT_MEET_REQUIREMENTS:" + + "Missing password requirements: [Password must contain at least 10 characters] ]" + val firebaseException = object : com.google.firebase.FirebaseException(msg) {} + + val result = AuthException.from(firebaseException) + + assertThat(result).isInstanceOf(AuthException.PasswordPolicyViolationException::class.java) + val policyEx = result as AuthException.PasswordPolicyViolationException + assertThat(policyEx.failingRequirements).containsExactly( + "Password must contain at least 10 characters" + ) + assertThat(policyEx.message).isEqualTo("Password must contain at least 10 characters") + assertThat(policyEx.cause).isEqualTo(firebaseException) + } + + @Test + fun `from() maps GIdP policy violation with multiple requirements`() { + val msg = "An internal error has occurred. [ PASSWORD_DOES_NOT_MEET_REQUIREMENTS:" + + "Missing password requirements: [Password must contain at least 10 characters, " + + "Password must contain at least one uppercase letter] ]" + val firebaseException = object : com.google.firebase.FirebaseException(msg) {} + + val result = AuthException.from(firebaseException) + + assertThat(result).isInstanceOf(AuthException.PasswordPolicyViolationException::class.java) + val policyEx = result as AuthException.PasswordPolicyViolationException + assertThat(policyEx.failingRequirements).containsExactly( + "Password must contain at least 10 characters", + "Password must contain at least one uppercase letter" + ).inOrder() + assertThat(policyEx.message).isEqualTo( + "Password must contain at least 10 characters\nPassword must contain at least one uppercase letter" + ) + } + + @Test + fun `from() maps GIdP policy violation in FirebaseAuthWeakPasswordException reason`() { + val firebaseException = FirebaseAuthWeakPasswordException( + "ERROR_WEAK_PASSWORD", + "weak", + "PASSWORD_DOES_NOT_MEET_REQUIREMENTS : [Password must contain uppercase, Password must contain a number]" + ) + + val result = AuthException.from(firebaseException) + + assertThat(result).isInstanceOf(AuthException.PasswordPolicyViolationException::class.java) + val policyEx = result as AuthException.PasswordPolicyViolationException + assertThat(policyEx.failingRequirements).containsExactly( + "Password must contain uppercase", + "Password must contain a number" + ).inOrder() + assertThat(policyEx.message).isEqualTo("Password must contain uppercase\nPassword must contain a number") + } + + @Test + fun `from() passes through unknown requirement strings as-is`() { + val msg = "An internal error has occurred. [ PASSWORD_DOES_NOT_MEET_REQUIREMENTS:" + + "Missing password requirements: [Some future requirement] ]" + val firebaseException = object : com.google.firebase.FirebaseException(msg) {} + + val result = AuthException.from(firebaseException) + + assertThat(result).isInstanceOf(AuthException.PasswordPolicyViolationException::class.java) + val policyEx = result as AuthException.PasswordPolicyViolationException + assertThat(policyEx.failingRequirements).containsExactly("Some future requirement") + assertThat(policyEx.message).isEqualTo("Some future requirement") + } + + @Test + fun `from() maps plain weak password (no policy) to WeakPasswordException`() { + val firebaseException = FirebaseAuthWeakPasswordException( + "ERROR_WEAK_PASSWORD", + "The given password is invalid.", + "Password should be at least 6 characters" + ) + + val result = AuthException.from(firebaseException) + + assertThat(result).isInstanceOf(AuthException.WeakPasswordException::class.java) + } + + @Test + fun `from() maps plain FirebaseException without policy to NetworkException`() { + val firebaseException = object : com.google.firebase.FirebaseException("Network timeout") {} + + val result = AuthException.from(firebaseException) + + assertThat(result).isInstanceOf(AuthException.NetworkException::class.java) + } + + @Test + fun `PasswordPolicyViolationException stores failingRequirements correctly`() { + val requirements = listOf("MISSING_UPPERCASE_CHARACTER", "MISSING_NUMERIC_CHARACTER") + val exception = AuthException.PasswordPolicyViolationException("msg", requirements) + + assertThat(exception.failingRequirements).isEqualTo(requirements) + } } \ No newline at end of file From 683257c4534820b1b858a4040b49a0963d545321 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:55:57 +0100 Subject: [PATCH 13/14] fix(auth): allow SAML providers in AuthUIConfiguration (#2331) * fix(auth): allow saml. prefixed provider IDs in AuthUIConfiguration validation * updates --- .../auth/configuration/AuthUIConfiguration.kt | 6 ++++-- .../configuration/AuthUIConfigurationTest.kt | 17 +++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt index bff7c72f9..babb3525d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt @@ -64,10 +64,12 @@ class AuthUIConfigurationBuilder { "At least one provider must be configured" } - // No unsupported providers (allow predefined providers and custom OIDC providers starting with "oidc.") + // No unsupported providers (allow predefined providers and custom OIDC/SAML providers) val supportedProviderIds = Provider.entries.map { it.id }.toSet() + val customPrefixes = listOf("oidc.", "saml.") val unknownProviders = providers.filter { provider -> - provider.providerId !in supportedProviderIds && !provider.providerId.startsWith("oidc.") + provider.providerId !in supportedProviderIds && + customPrefixes.none { provider.providerId.startsWith(it) } } require(unknownProviders.isEmpty()) { "Unknown providers: ${unknownProviders.joinToString { it.providerId }}" diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt index 5d9efdb75..970a5e09b 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt @@ -325,7 +325,7 @@ class AuthUIConfigurationTest { } @Test - fun `validation accepts custom OIDC providers`() { + fun `validation accepts custom OIDC and SAML providers`() { val linkedInProvider = AuthProvider.GenericOAuth( providerName = "LinkedIn", providerId = "oidc.linkedin", @@ -348,17 +348,30 @@ class AuthUIConfigurationTest { contentColor = null, ) + val samlProvider = AuthProvider.GenericOAuth( + providerName = "Corp SSO", + providerId = "saml.corp-sso", + scopes = listOf(), + customParameters = mapOf(), + buttonLabel = "Sign in with Corp SSO", + buttonIcon = null, + buttonColor = null, + contentColor = null, + ) + val config = authUIConfiguration { context = applicationContext providers { provider(linkedInProvider) provider(oktaProvider) + provider(samlProvider) } } - assertThat(config.providers).hasSize(2) + assertThat(config.providers).hasSize(3) assertThat(config.providers[0].providerId).isEqualTo("oidc.linkedin") assertThat(config.providers[1].providerId).isEqualTo("oidc.okta") + assertThat(config.providers[2].providerId).isEqualTo("saml.corp-sso") } @Test From 8eb50fee3cd7a499624806ee6a9f57b7f58f0239 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:30:07 +0100 Subject: [PATCH 14/14] feat(auth): add reauthentication flow with automatic operation retry (#2332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip # Conflicts: # auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt # auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt * feat(auth): add reauthentication flow with automatic operation retry Adds AuthState.ReauthenticationRequired, withReauth(), and createReauthFlow() to support sensitive operations that require recent sign-in. FirebaseAuthUI.delete() and withReauth() automatically catch FirebaseAuthRecentLoginRequiredException, emit the new state carrying the original operation as retryOperation, and FirebaseAuthScreen presents a ModalBottomSheet overlay scoped to the user's linked providers — no navigation away from the authenticated screen. On successful reauthentication the original operation is retried automatically. * test(auth): add e2e tests for reauthentication flow UI * updates * fix reviews * update README --- .../android/demo/HighLevelApiDemoActivity.kt | 257 +++++++++++++ auth/README.md | 314 ++++++++++----- .../firebase/ui/auth/AuthFlowController.kt | 2 +- .../java/com/firebase/ui/auth/AuthState.kt | 29 ++ .../com/firebase/ui/auth/FirebaseAuthUI.kt | 113 ++++++ .../auth/configuration/AuthUIConfiguration.kt | 36 +- .../ui/auth/configuration/PasswordRule.kt | 4 +- .../auth_provider/AuthProvider.kt | 14 + .../EmailAuthProvider+FirebaseAuthUI.kt | 46 ++- .../FacebookAuthProvider+FirebaseAuthUI.kt | 2 +- .../GoogleAuthProvider+FirebaseAuthUI.kt | 2 +- .../OAuthProvider+FirebaseAuthUI.kt | 18 +- .../validators/PasswordValidator.kt | 2 +- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 362 +++++++++++------- .../ui/auth/FirebaseAuthUIAuthStateTest.kt | 195 ++++++++++ .../firebase/ui/auth/FirebaseAuthUITest.kt | 91 +++++ .../configuration/AuthUIConfigurationTest.kt | 3 +- .../auth_provider/AuthProviderTest.kt | 75 ++++ .../ui/auth/ui/screens/ReauthFlowTest.kt | 311 +++++++++++++++ 19 files changed, 1630 insertions(+), 246 deletions(-) create mode 100644 e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.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 a4c708a6b..145be42eb 100644 --- a/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt @@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -20,16 +22,27 @@ import androidx.compose.material3.PlainTooltip import androidx.compose.material3.ShapeDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.lifecycleScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.FirebaseAuthUI @@ -192,6 +205,13 @@ class HighLevelApiDemoActivity : ComponentActivity() { onSignInCancelled = { Log.d("HighLevelApiDemoActivity", "Authentication cancelled") }, + reauthContent = { state, onDismiss -> + ReauthDialog( + authUI = authUI, + state = state, + onDismiss = onDismiss, + ) + }, authenticatedContent = { state, uiContext -> AppAuthenticatedContent(state, uiContext) } @@ -212,8 +232,24 @@ private fun AppAuthenticatedContent( val configuration = uiContext.configuration when (state) { is AuthState.Success -> { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var isDeletingAccount by remember { mutableStateOf(false) } val user = uiContext.authUI.getCurrentUser() val identifier = user.displayIdentifier() + var showChangePasswordDialog by remember { mutableStateOf(false) } + + if (showChangePasswordDialog) { + ChangePasswordDialog( + authUI = uiContext.authUI, + configuration = uiContext.configuration, + stringProvider = uiContext.stringProvider, + context = context, + lifecycleOwner = lifecycleOwner, + onDismiss = { showChangePasswordDialog = false }, + ) + } + Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, @@ -261,6 +297,32 @@ private fun AppAuthenticatedContent( Button(onClick = uiContext.onSignOut) { Text(stringProvider.signOutAction) } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { showChangePasswordDialog = true }) { + Text("Change password (withReauth)") + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + lifecycleOwner.lifecycleScope.launch { + isDeletingAccount = true + try { + uiContext.authUI.delete(context) + } catch (e: AuthException.InvalidCredentialsException) { + // ReauthenticationRequired state was emitted — + // FirebaseAuthScreen navigates to the reauth flow automatically. + Log.d("HighLevelApiDemoActivity", "Reauth required before delete") + } catch (e: AuthException) { + Log.e("HighLevelApiDemoActivity", "Delete failed", e) + } finally { + isDeletingAccount = false + } + } + }, + enabled = !isDeletingAccount + ) { + if (isDeletingAccount) CircularProgressIndicator() else Text("Delete account") + } } } @@ -326,3 +388,198 @@ private fun AppAuthenticatedContent( } } } + +@Composable +private fun ReauthDialog( + authUI: FirebaseAuthUI, + state: AuthState.ReauthenticationRequired, + onDismiss: () -> Unit, +) { + var password by remember { mutableStateOf("") } + var isVerifying by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + val email = state.user.email.orEmpty() + + AlertDialog( + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + title = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Verify your identity") + state.reason?.let { reason -> + Text( + reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Signing in as $email", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + com.firebase.ui.auth.ui.components.AuthTextField( + value = password, + onValueChange = { + password = it + errorMessage = null + }, + label = { Text("Password") }, + isSecureTextField = true, + isError = errorMessage != null, + errorMessage = errorMessage, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + confirmButton = { + Button( + onClick = { + coroutineScope.launch { + isVerifying = true + errorMessage = null + try { + val result = authUI.auth + .signInWithEmailAndPassword(email, password) + .await() + result.user?.let { user -> + authUI.updateAuthState(AuthState.Success(result, user)) + } + } catch (e: Exception) { + errorMessage = "Incorrect password. Please try again." + } finally { + isVerifying = false + } + } + }, + enabled = password.isNotBlank() && !isVerifying, + ) { + if (isVerifying) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } else { + Text("Verify") + } + } + }, + ) +} + +@Composable +private fun ChangePasswordDialog( + authUI: FirebaseAuthUI, + configuration: com.firebase.ui.auth.configuration.AuthUIConfiguration, + stringProvider: com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider, + context: android.content.Context, + lifecycleOwner: androidx.lifecycle.LifecycleOwner, + onDismiss: () -> Unit, +) { + var newPassword by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + var isUpdating by remember { mutableStateOf(false) } + var updateError by remember { mutableStateOf(null) } + + val emailProvider = remember(configuration) { + configuration.providers.filterIsInstance().firstOrNull() + } + val passwordValidator = remember(emailProvider, stringProvider) { + com.firebase.ui.auth.configuration.validators.PasswordValidator( + stringProvider = stringProvider, + rules = emailProvider?.passwordValidationRules ?: emptyList(), + ) + } + val confirmValidator = remember(stringProvider) { + com.firebase.ui.auth.configuration.validators.PasswordValidator( + stringProvider = stringProvider, + rules = emptyList(), + ) + } + + val passwordsMatch = newPassword == confirmPassword + val isValid = !passwordValidator.hasError && newPassword.isNotBlank() && + passwordsMatch && confirmPassword.isNotBlank() + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Change password") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + com.firebase.ui.auth.ui.components.AuthTextField( + value = newPassword, + onValueChange = { + newPassword = it + updateError = null + }, + label = { Text("New password") }, + isSecureTextField = true, + validator = passwordValidator, + ) + com.firebase.ui.auth.ui.components.AuthTextField( + value = confirmPassword, + onValueChange = { + confirmPassword = it + updateError = null + }, + label = { Text("Confirm password") }, + isSecureTextField = true, + isError = confirmPassword.isNotEmpty() && !passwordsMatch, + errorMessage = if (confirmPassword.isNotEmpty() && !passwordsMatch) "Passwords do not match" else null, + validator = confirmValidator, + ) + if (updateError != null) { + Text( + updateError!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + confirmButton = { + Button( + onClick = { + lifecycleOwner.lifecycleScope.launch { + isUpdating = true + updateError = null + try { + authUI.withReauth( + context, + reason = "Verify your identity to change your password", + ) { + authUI.getCurrentUser()?.updatePassword(newPassword)?.await() + Log.d("HighLevelApiDemoActivity", "Password changed successfully") + onDismiss() + } + } catch (e: Exception) { + updateError = "Failed to update password. Please try again." + } finally { + isUpdating = false + } + } + }, + enabled = isValid && !isUpdating, + ) { + if (isUpdating) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } else { + Text("Update") + } + } + }, + ) +} diff --git a/auth/README.md b/auth/README.md index 04c6a77b2..564691b2c 100644 --- a/auth/README.md +++ b/auth/README.md @@ -52,6 +52,7 @@ Equivalent FirebaseUI libraries are available for [iOS](https://github.com/fireb - [High-Level API (Recommended)](#high-level-api-recommended) - [Low-Level API (Advanced)](#low-level-api-advanced) - [Custom UI with Slots](#custom-ui-with-slots) + - [Reauthentication](#reauthentication) 8. [Theming & Customization](#theming--customization) - [Using Default Themes](#using-default-themes) - [Using Adaptive Theme](#using-adaptive-theme-recommended) @@ -794,131 +795,258 @@ class AuthActivity : ComponentActivity() { ### Custom UI with Slots -For complete UI control while keeping authentication logic, use content slots: +`FirebaseAuthScreen` accepts optional slot parameters that let you replace individual screens with your own UI while keeping all authentication logic intact. Each slot receives a state object with the data and callbacks needed to drive your UI. ```kotlin -@Composable -fun CustomEmailAuth() { - val emailConfig = AuthProvider.Email( - passwordValidationRules = listOf( - PasswordRule.MinimumLength(8), - PasswordRule.RequireDigit - ) - ) +FirebaseAuthScreen( + configuration = configuration, + onSignInSuccess = { /* ... */ }, + onSignInFailure = { /* ... */ }, + onSignInCancelled = { /* ... */ }, + customMethodPickerLayout = { providers, onProviderSelected -> /* ... */ }, + customMethodPickerTermsConfiguration = MethodPickerTermsConfiguration( + content = { Text("By continuing you agree to our Terms") }, + accepted = termsAccepted, + disableProvidersUntilAccepted = true, + ), + emailContent = { state -> /* ... */ }, + phoneContent = { state -> /* ... */ }, + mfaEnrollmentContent = { state -> /* ... */ }, + mfaChallengeContent = { state -> /* ... */ }, + reauthContent = { state, onDismiss -> /* ... */ }, +) { authState, uiContext -> + // authenticated content +} +``` - EmailAuthScreen( - configuration = emailConfig, - onSuccess = { /* ... */ }, - onError = { /* ... */ }, - onCancel = { /* ... */ } - ) { state -> - // Custom UI with full control - when (state.mode) { - EmailAuthMode.SignIn -> { - CustomSignInUI(state) - } - EmailAuthMode.SignUp -> { - CustomSignUpUI(state) - } - EmailAuthMode.ResetPassword -> { - CustomResetPasswordUI(state) +#### Method picker (`customMethodPickerLayout`) + +Replaces the default provider selection screen. Receives the configured providers and a callback to invoke when the user selects one. + +```kotlin +customMethodPickerLayout = { providers, onProviderSelected -> + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + providers.forEach { provider -> + OutlinedButton( + onClick = { onProviderSelected(provider) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Text("Continue with ${provider.providerName}") } } } } +``` -@Composable -fun CustomSignInUI(state: EmailAuthContentState) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Welcome Back!", - style = MaterialTheme.typography.headlineLarge - ) - - Spacer(modifier = Modifier.height(32.dp)) +Use `customMethodPickerTermsConfiguration` alongside it to add a terms-of-service checkbox that can optionally gate provider selection until accepted. - OutlinedTextField( - value = state.email, - onValueChange = state.onEmailChange, - label = { Text("Email") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), - modifier = Modifier.fillMaxWidth() - ) +```kotlin +customMethodPickerTermsConfiguration = MethodPickerTermsConfiguration( + content = { Text("I agree to the Terms of Service") }, + accepted = termsAccepted, + disableProvidersUntilAccepted = true, +) +``` - Spacer(modifier = Modifier.height(16.dp)) +#### Email (`emailContent`) - OutlinedTextField( - value = state.password, - onValueChange = state.onPasswordChange, - label = { Text("Password") }, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - modifier = Modifier.fillMaxWidth() - ) +Replaces the default email sign-in / sign-up / password reset screens. The `EmailAuthContentState` carries the current `mode` (`SignIn`, `SignUp`, `ResetPassword`, `EmailLinkSignIn`), field values, and callbacks for every action. - if (state.error != null) { - Text( - text = state.error!!, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 8.dp) - ) +```kotlin +emailContent = { state -> + when (state.mode) { + EmailAuthMode.SignIn -> { + Column { + OutlinedTextField( + value = state.email, + onValueChange = state.onEmailChange, + label = { Text("Email") }, + ) + OutlinedTextField( + value = state.password, + onValueChange = state.onPasswordChange, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + ) + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Button(onClick = state.onSignInClick, enabled = !state.isLoading) { + Text("Sign in") + } + TextButton(onClick = state.onGoToSignUp) { Text("Create account") } + TextButton(onClick = state.onGoToResetPassword) { Text("Forgot password?") } + } } + EmailAuthMode.SignUp -> { /* ... */ } + EmailAuthMode.ResetPassword -> { /* ... */ } + EmailAuthMode.EmailLinkSignIn -> { /* ... */ } + } +} +``` - Spacer(modifier = Modifier.height(24.dp)) +#### Phone (`phoneContent`) - Button( - onClick = state.onSignInClick, - enabled = !state.isLoading, - modifier = Modifier.fillMaxWidth() - ) { - if (state.isLoading) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } else { - Text("Sign In") +Replaces the default phone number entry and SMS code verification screens. The `PhoneAuthContentState` carries the current `step` (`EnterPhoneNumber`, `EnterVerificationCode`), field values, and callbacks. + +```kotlin +phoneContent = { state -> + when (state.step) { + PhoneAuthStep.EnterPhoneNumber -> { + Column { + OutlinedTextField( + value = state.phoneNumber, + onValueChange = state.onPhoneNumberChange, + label = { Text("Phone number") }, + ) + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Button(onClick = state.onSendCodeClick, enabled = !state.isLoading) { + Text("Send code") + } } } - - TextButton(onClick = state.onGoToResetPassword) { - Text("Forgot Password?") + PhoneAuthStep.EnterVerificationCode -> { + Column { + OutlinedTextField( + value = state.verificationCode, + onValueChange = state.onVerificationCodeChange, + label = { Text("Verification code") }, + ) + Button(onClick = state.onVerifyCodeClick, enabled = !state.isLoading) { + Text("Verify") + } + if (state.resendTimer == 0) { + TextButton(onClick = state.onResendCodeClick) { Text("Resend code") } + } + } } + } +} +``` + +#### MFA enrollment (`mfaEnrollmentContent`) + +Replaces the default MFA enrollment screens. The `MfaEnrollmentContentState` carries the current `step`, `availableFactors`, `enrolledFactors`, and callbacks for factor selection, unenrollment, and navigation. - TextButton(onClick = state.onGoToSignUp) { - Text("Create Account") +```kotlin +mfaEnrollmentContent = { state -> + when (state.step) { + MfaEnrollmentStep.SelectFactor -> { + Column { + state.availableFactors.forEach { factor -> + Button(onClick = { state.onFactorSelected(factor) }) { + Text("Enroll ${factor.name}") + } + } + state.onSkipClick?.let { skip -> + TextButton(onClick = skip) { Text("Skip") } + } + } } + // Handle other steps... + else -> { /* ... */ } } } ``` -Similarly, create custom phone authentication UI: +#### MFA challenge (`mfaChallengeContent`) + +Replaces the default MFA verification screen shown during sign-in. The `MfaChallengeContentState` carries `factorType`, `verificationCode`, `resendTimer`, and callbacks to verify or resend. ```kotlin -@Composable -fun CustomPhoneAuth() { - val phoneConfig = AuthProvider.Phone(defaultCountryCode = "US") - - PhoneAuthScreen( - configuration = phoneConfig, - onSuccess = { /* ... */ }, - onError = { /* ... */ }, - onCancel = { /* ... */ } - ) { state -> - when (state.step) { - PhoneAuthStep.EnterPhoneNumber -> { - CustomPhoneNumberInput(state) - } - PhoneAuthStep.EnterVerificationCode -> { - CustomVerificationCodeInput(state) +mfaChallengeContent = { state -> + Column { + state.maskedPhoneNumber?.let { Text("Code sent to $it") } + OutlinedTextField( + value = state.verificationCode, + onValueChange = state.onVerificationCodeChange, + label = { Text("Verification code") }, + ) + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Button(onClick = state.onVerifyClick, enabled = !state.isLoading) { + Text("Verify") + } + if (state.resendTimer == 0) { + state.onResendCodeClick?.let { resend -> + TextButton(onClick = resend) { Text("Resend code") } } + } else { + Text("Resend available in ${state.resendTimer}s") } + TextButton(onClick = state.onCancelClick) { Text("Cancel") } + } +} +``` + +#### Reauthentication (`reauthContent`) + +Replaces the default reauthentication bottom sheet shown when a sensitive operation requires the user to re-verify their identity. Receives the `AuthState.ReauthenticationRequired` state (including an optional `reason` string and the signed-in `user`) and an `onDismiss` callback that resets auth state to `Idle`. + +```kotlin +reauthContent = { state, onDismiss -> + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Verify your identity") }, + text = { + Column { + state.reason?.let { Text(it) } + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + ) + } + }, + confirmButton = { + Button(onClick = { + // Re-authenticate then update auth state on success + }) { Text("Confirm") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} +``` + +For most cases, use [`withReauth`](#reauthentication) instead — it handles the full reauth cycle automatically and only shows the default bottom sheet. Use `reauthContent` when you need a custom design for the reauth UI. + +### Reauthentication + +Firebase requires the user to have signed in recently before performing sensitive operations like deleting their account or changing their password. If the session is too old, Firebase throws `FirebaseAuthRecentLoginRequiredException`. + +`withReauth` wraps any sensitive operation. If the exception is thrown, it automatically emits `AuthState.ReauthenticationRequired` and — once the user reauthenticates via the default bottom sheet or your `reauthContent` slot — retries the original operation. + +```kotlin +lifecycleScope.launch { + authUI.withReauth( + context = context, + reason = "Verify your identity to delete your account", + ) { + auth.currentUser?.delete()?.await() } } ``` +`withReauth` handles the full cycle: + +1. Runs the operation. +2. If `FirebaseAuthRecentLoginRequiredException` is thrown, emits `AuthState.ReauthenticationRequired` with the retry attached. +3. `FirebaseAuthScreen` shows the reauth UI scoped to the user's linked providers. +4. On successful reauthentication, retries the operation automatically and emits `AuthState.Success` or `AuthState.Error`. + +**Activity-based alternative:** use `createReauthFlow` to start a standalone reauthentication activity scoped to the current user's linked providers, returning an `AuthFlowController`. + +```kotlin +val reauth = authUI.createReauthFlow( + context = context, + configuration = authUIConfiguration { + // Providers are automatically filtered to those linked to the current user + }, +) +val intent = reauth.createIntent(context) +launcher.launch(intent) +``` + ## Multi-Factor Authentication ### MFA Configuration 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 917584219..93974a174 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt @@ -103,7 +103,7 @@ import java.util.concurrent.atomic.AtomicBoolean */ class AuthFlowController internal constructor( private val authUI: FirebaseAuthUI, - private val configuration: AuthUIConfiguration + internal val configuration: AuthUIConfiguration ) { private val coroutineScope = CoroutineScope(Dispatchers.Main + Job()) diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt index 061b33a45..697480213 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt @@ -208,6 +208,35 @@ abstract class AuthState private constructor() { "AuthState.RequiresProfileCompletion(user=$user, missingFields=$missingFields)" } + /** + * Reauthentication is required before a sensitive operation (e.g. delete account, change email) + * can proceed. Use [FirebaseAuthUI.createReauthFlow] to launch the reauthentication flow. + * + * @property user The [FirebaseUser] that needs to reauthenticate + * @property reason Optional human-readable reason to show the user + */ + class ReauthenticationRequired( + val user: FirebaseUser, + val reason: String? = null, + // Not included in equals/hashCode — lambdas have no meaningful equality. + val retryOperation: (suspend (android.content.Context) -> Unit)? = null, + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ReauthenticationRequired) return false + return user == other.user && reason == other.reason + } + + override fun hashCode(): Int { + var result = user.hashCode() + result = 31 * result + (reason?.hashCode() ?: 0) + return result + } + + override fun toString(): String = + "AuthState.ReauthenticationRequired(user=$user, reason=$reason)" + } + /** * Password reset link has been sent to the user's email. */ 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 827c37abd..45300cf23 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -19,6 +19,8 @@ import android.content.Intent import androidx.annotation.RestrictTo import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.auth_provider.filterToLinkedProviders +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException import com.firebase.ui.auth.configuration.auth_provider.signOutFromFacebook import com.firebase.ui.auth.configuration.auth_provider.signOutFromGoogle import com.google.firebase.Firebase @@ -211,6 +213,45 @@ class FirebaseAuthUI private constructor( return AuthFlowController(this, configuration) } + /** + * Creates a reauthentication flow scoped to the current user's linked providers. + * + * This method builds a sign-in flow where: + * - Only providers already linked to the current [FirebaseUser] are offered + * - Account creation is disabled + * - The credential path calls [FirebaseUser.reauthenticateWithCredential] instead of + * [FirebaseAuth.signInWithCredential] + * + * Use this before sensitive operations (delete account, change email, etc.) that require + * a recent sign-in. + * + * @param configuration Base [AuthUIConfiguration] whose provider list is filtered to + * the user's linked providers. All other settings are preserved. + * @param reason Optional human-readable string shown to the user explaining why + * reauthentication is needed (e.g. "To delete your account we need to verify it's you"). + * @return An [AuthFlowController] configured for reauthentication + * @throws AuthException.UserNotFoundException if no user is currently signed in + * @throws IllegalStateException if none of the configured providers are linked to the + * current user + * @since 10.0.0 + */ + fun createReauthFlow(configuration: AuthUIConfiguration): AuthFlowController { + val currentUser = auth.currentUser + ?: throw AuthException.UserNotFoundException( + message = "No user is currently signed in" + ) + val linked = configuration.providers.filterToLinkedProviders(currentUser) + check(linked.isNotEmpty()) { + "No configured providers are linked to the current user" + } + val reauthConfig = configuration.copy( + providers = linked, + isNewEmailAccountsAllowed = false, + isReauthenticationMode = true, + ) + return AuthFlowController(this, reauthConfig) + } + /** * Returns a [Flow] that emits [AuthState] changes. * @@ -447,6 +488,65 @@ class FirebaseAuthUI private constructor( * @throws AuthException.UnknownException for other errors * @since 10.0.0 */ + /** + * Executes a sensitive operation, automatically handling reauthentication if required. + * + * If the [operation] throws [FirebaseAuthRecentLoginRequiredException], this method emits + * [AuthState.ReauthenticationRequired] with the operation attached as [AuthState.ReauthenticationRequired.retryOperation]. + * [FirebaseAuthScreen] observes this state and presents a reauthentication sheet; on success + * the operation is retried automatically without any further action from the caller. + * + * All other exceptions propagate normally. + * + * **Example:** + * ```kotlin + * lifecycleScope.launch { + * authUI.withReauth(context, reason = "Verify your identity to change email") { + * user.updateEmail(newEmail).await() + * } + * } + * ``` + * + * @param context Android [Context] + * @param reason Optional message shown to the user explaining why reauthentication is needed + * @param operation The sensitive operation to attempt + * @since 10.0.0 + */ + suspend fun withReauth( + context: Context, + reason: String? = null, + operation: suspend () -> Unit, + ) { + try { + operation() + } catch (e: FirebaseAuthRecentLoginRequiredException) { + val user = auth.currentUser + ?: throw AuthException.UserNotFoundException(message = "No user is currently signed in") + updateAuthState( + AuthState.ReauthenticationRequired( + user = user, + reason = reason, + retryOperation = { + try { + operation() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + updateAuthState(AuthState.Error(e)) + return@ReauthenticationRequired + } + val currentUser = auth.currentUser + if (currentUser != null) { + updateAuthState(AuthState.Success(result = null, user = currentUser)) + } else { + updateAuthState(AuthState.Idle) + } + }, + ) + ) + } + } + suspend fun delete(context: Context) { try { val currentUser = auth.currentUser @@ -463,6 +563,19 @@ class FirebaseAuthUI private constructor( // Update state to idle (user deleted and signed out) updateAuthState(AuthState.Idle) + } catch (e: FirebaseAuthRecentLoginRequiredException) { + auth.currentUser?.let { + updateAuthState( + AuthState.ReauthenticationRequired( + user = it, + retryOperation = { ctx -> delete(ctx) }, + ) + ) + } + throw AuthException.InvalidCredentialsException( + message = e.message ?: "Recent login required for this operation", + cause = e + ) } catch (e: CancellationException) { // Handle coroutine cancellation val cancelledException = AuthException.AuthCancelledException( diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt index babb3525d..adc8878c5 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt @@ -51,6 +51,7 @@ class AuthUIConfigurationBuilder { var isDisplayNameRequired: Boolean = true var isProviderChoiceAlwaysShown: Boolean = false var transitions: AuthUITransitions? = null + internal var isReauthenticationMode: Boolean = false fun providers(block: AuthProvidersBuilder.() -> Unit) = providers.addAll(AuthProvidersBuilder().apply(block).build()) @@ -118,7 +119,8 @@ class AuthUIConfigurationBuilder { isNewEmailAccountsAllowed = isNewEmailAccountsAllowed, isDisplayNameRequired = isDisplayNameRequired, isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown, - transitions = transitions + transitions = transitions, + isReauthenticationMode = isReauthenticationMode, ) } } @@ -215,4 +217,34 @@ class AuthUIConfiguration( * If null, uses default fade in/out transitions. */ val transitions: AuthUITransitions? = null, -) + + /** + * When true, the flow operates as a reauthentication flow: account creation is disabled and + * only providers already linked to the current user are shown. Set by [FirebaseAuthUI.createReauthFlow]. + */ + internal val isReauthenticationMode: Boolean = false, +) { + internal fun copy( + providers: List = this.providers, + isNewEmailAccountsAllowed: Boolean = this.isNewEmailAccountsAllowed, + isReauthenticationMode: Boolean = this.isReauthenticationMode, + ): AuthUIConfiguration = AuthUIConfiguration( + context = this.context, + providers = providers, + theme = this.theme, + locale = this.locale, + stringProvider = this.stringProvider, + isCredentialManagerEnabled = this.isCredentialManagerEnabled, + isMfaEnabled = this.isMfaEnabled, + isAnonymousUpgradeEnabled = this.isAnonymousUpgradeEnabled, + tosUrl = this.tosUrl, + privacyPolicyUrl = this.privacyPolicyUrl, + logo = this.logo, + passwordResetActionCodeSettings = this.passwordResetActionCodeSettings, + isNewEmailAccountsAllowed = isNewEmailAccountsAllowed, + isDisplayNameRequired = this.isDisplayNameRequired, + isProviderChoiceAlwaysShown = this.isProviderChoiceAlwaysShown, + transitions = this.transitions, + isReauthenticationMode = isReauthenticationMode, + ) +} diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/PasswordRule.kt index d10f1c811..fef83a4ec 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/PasswordRule.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/PasswordRule.kt @@ -111,7 +111,7 @@ abstract class PasswordRule { * @param password The password to validate * @return true if the password meets this rule's requirements, false otherwise */ - internal abstract fun isValid(password: String): Boolean + abstract fun isValid(password: String): Boolean /** * Returns the appropriate error message for this rule when validation fails. @@ -119,5 +119,5 @@ abstract class PasswordRule { * @param stringProvider The string provider for localized error messages * @return The localized error message for this rule */ - internal abstract fun getErrorMessage(stringProvider: AuthUIStringProvider): String + abstract fun getErrorMessage(stringProvider: AuthUIStringProvider): String } \ No newline at end of file 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 8d2aaafa4..59bff5d73 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 @@ -1052,3 +1052,17 @@ abstract class AuthProvider(open val providerId: String, open val providerName: } } } + +/** + * Filters this provider list to only those whose [AuthProvider.providerId] matches a provider + * already linked to [user], as reported by [com.google.firebase.auth.FirebaseUser.providerData]. + * + * Used by [com.firebase.ui.auth.FirebaseAuthUI.createReauthFlow] to restrict the reauthentication + * UI to methods the user has actually registered. + */ +internal fun List.filterToLinkedProviders( + user: com.google.firebase.auth.FirebaseUser, +): List { + val linkedIds = user.providerData.map { it.providerId }.toSet() + return filter { it.providerId in linkedIds } +} 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 2f1e14e4c..c888e8d39 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 @@ -46,6 +46,25 @@ import kotlinx.coroutines.tasks.await private const val TAG = "EmailAuthProvider" +/** + * Signs in or reauthenticates with [credential] depending on [AuthUIConfiguration.isReauthenticationMode]. + * + * - Normal mode: [com.google.firebase.auth.FirebaseAuth.signInWithCredential], returns [AuthResult]. + * - Reauth mode: [com.google.firebase.auth.FirebaseUser.reauthenticate] (Task), returns null. + * Callers must reconstruct auth state from [com.google.firebase.auth.FirebaseAuth.currentUser]. + */ +internal suspend fun FirebaseAuthUI.signInOrReauth( + credential: AuthCredential, + config: AuthUIConfiguration, +): AuthResult? = if (config.isReauthenticationMode) { + val currentUser = auth.currentUser + ?: throw AuthException.UserNotFoundException(message = "No user is currently signed in for reauthentication") + currentUser.reauthenticate(credential).await() + null +} else { + auth.signInWithCredential(credential).await() +} + /** * Creates an email/password account or links the credential to an anonymous user. * @@ -325,6 +344,14 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( ): AuthResult? { try { updateAuthState(AuthState.Loading("Signing in...")) + // In reauth mode build a credential and go through signInAndLinkWithCredential so + // signInOrReauth routes to FirebaseUser.reauthenticate() instead of signInWithCredential(). + if (config.isReauthenticationMode) { + return signInAndLinkWithCredential( + config = config, + credential = EmailAuthProvider.getCredential(email, password), + ) + } return if (canUpgradeAnonymous(config, auth)) { // Anonymous upgrade flow: validate credential in scratch auth val credentialToValidate = EmailAuthProvider.getCredential(email, password) @@ -551,17 +578,22 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( ): AuthResult? { try { updateAuthState(AuthState.Loading("Signing in user...")) - return if (canUpgradeAnonymous(config, auth) || canLinkCredential(config, auth)) { + val result = if (canUpgradeAnonymous(config, auth) || canLinkCredential(config, auth)) { auth.currentUser?.linkWithCredential(credential)?.await() } else { - auth.signInWithCredential(credential).await() - }.also { result -> - // Merge profile information from the provider - result?.user?.let { - mergeProfile(auth, displayName, photoUrl) + signInOrReauth(credential, config) + } + // signInOrReauth returns null in reauth mode (Task has no AuthResult). + // Reconstruct success state from the now-reauthenticated current user. + if (result == null && config.isReauthenticationMode) { + auth.currentUser?.let { + updateAuthState(AuthState.Success(result = null, user = it, isNewUser = false)) } - updateAuthStateWithResult(result) + return null } + result?.user?.let { mergeProfile(auth, displayName, photoUrl) } + updateAuthStateWithResult(result) + return result } catch (e: FirebaseAuthMultiFactorException) { // MFA required - extract resolver and update state val resolver = e.resolver 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 10be33cb9..674f02d33 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 @@ -71,7 +71,7 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( onResult = {}, ) - DisposableEffect(Unit) { + DisposableEffect(config) { loginManager.registerCallback( callbackManager, object : FacebookCallback { 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 f8cbbdddf..496e1cd44 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 @@ -59,7 +59,7 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler( provider: AuthProvider.Google, ): () -> Unit { val coroutineScope = rememberCoroutineScope() - return remember(this) { + return remember(this, config) { { coroutineScope.launch { try { 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 e2d141400..ef974785f 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 @@ -61,7 +61,7 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler( "Ensure FirebaseAuthScreen is used within an Activity." ) - return remember(this, provider.providerId) { + return remember(this, provider.providerId, config) { { coroutineScope.launch { try { @@ -165,11 +165,17 @@ internal suspend fun FirebaseAuthUI.signInWithProvider( return } - // Determine if we should upgrade anonymous user or do normal sign-in - val authResult = if (canUpgradeAnonymous(config, auth)) { - auth.currentUser?.startActivityForLinkWithProvider(activity, oauthProvider)?.await() - } else { - auth.startActivityForSignInWithProvider(activity, oauthProvider).await() + // Determine if we should upgrade anonymous user, reauthenticate, or do normal sign-in + val authResult = when { + canUpgradeAnonymous(config, auth) -> + auth.currentUser?.startActivityForLinkWithProvider(activity, oauthProvider)?.await() + config.isReauthenticationMode -> { + val currentUser = auth.currentUser + ?: throw AuthException.UserNotFoundException(message = "No user is currently signed in for reauthentication") + currentUser.startActivityForReauthenticateWithProvider(activity, oauthProvider).await() + } + else -> + auth.startActivityForSignInWithProvider(activity, oauthProvider).await() } // Extract OAuth credential and complete sign-in diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PasswordValidator.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PasswordValidator.kt index b7a8a70eb..a7d7e698e 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PasswordValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PasswordValidator.kt @@ -17,7 +17,7 @@ package com.firebase.ui.auth.configuration.validators import com.firebase.ui.auth.configuration.PasswordRule import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider -internal class PasswordValidator( +class PasswordValidator( override val stringProvider: AuthUIStringProvider, private val rules: List ) : FieldValidator { 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 fd59695d6..9691d101d 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 @@ -30,6 +30,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -37,6 +38,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -63,6 +65,7 @@ import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.MfaConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.auth_provider.filterToLinkedProviders import com.firebase.ui.auth.configuration.auth_provider.rememberAnonymousSignInHandler import com.firebase.ui.auth.configuration.auth_provider.rememberGoogleSignInHandler import com.firebase.ui.auth.configuration.auth_provider.rememberOAuthSignInHandler @@ -103,6 +106,7 @@ import kotlinx.coroutines.tasks.await * * @since 10.0.0 */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun FirebaseAuthScreen( configuration: AuthUIConfiguration, @@ -119,6 +123,7 @@ fun FirebaseAuthScreen( phoneContent: (@Composable (PhoneAuthContentState) -> Unit)? = null, mfaEnrollmentContent: (@Composable (MfaEnrollmentContentState) -> Unit)? = null, mfaChallengeContent: (@Composable (MfaChallengeContentState) -> Unit)? = null, + reauthContent: (@Composable (state: AuthState.ReauthenticationRequired, onDismiss: () -> Unit) -> Unit)? = null, authenticatedContent: (@Composable (state: AuthState, uiContext: AuthSuccessUiContext) -> Unit)? = null, ) { // Set FirebaseUI version @@ -137,6 +142,9 @@ fun FirebaseAuthScreen( val lastSuccessfulUserId = remember { mutableStateOf(null) } val pendingLinkingCredential = remember { mutableStateOf(null) } val pendingResolver = remember { mutableStateOf(null) } + val pendingReauthConfig = remember { mutableStateOf(null) } + val pendingReauthState = remember { mutableStateOf(null) } + val pendingReauthOperation = remember { mutableStateOf<(suspend (android.content.Context) -> Unit)?>(null) } val emailLinkFromDifferentDevice = remember { mutableStateOf(null) } val lastSignInPreference = remember { mutableStateOf(null) } @@ -150,99 +158,24 @@ fun FirebaseAuthScreen( lastSignInPreference.value = SignInPreferenceManager.getLastSignIn(context) } - val anonymousProvider = - configuration.providers.filterIsInstance().firstOrNull() - val googleProvider = - configuration.providers.filterIsInstance().firstOrNull() val emailProvider = configuration.providers.filterIsInstance().firstOrNull() - val facebookProvider = - configuration.providers.filterIsInstance().firstOrNull() - val appleProvider = configuration.providers.filterIsInstance().firstOrNull() - val githubProvider = - configuration.providers.filterIsInstance().firstOrNull() - val microsoftProvider = - configuration.providers.filterIsInstance().firstOrNull() - val yahooProvider = configuration.providers.filterIsInstance().firstOrNull() - val twitterProvider = - configuration.providers.filterIsInstance().firstOrNull() - val genericOAuthProviders = - configuration.providers.filterIsInstance() - val logoAsset = configuration.logo - - val onSignInAnonymously = anonymousProvider?.let { - authUI.rememberAnonymousSignInHandler() - } - - val onSignInWithGoogle = googleProvider?.let { - authUI.rememberGoogleSignInHandler( - context = context, - config = configuration, - provider = it - ) - } - - val onSignInWithFacebook = facebookProvider?.let { - authUI.rememberSignInWithFacebookLauncher( - context = context, - config = configuration, - provider = it - ) - } - - val onSignInWithApple = appleProvider?.let { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } - - val onSignInWithGithub = githubProvider?.let { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } - - val onSignInWithMicrosoft = microsoftProvider?.let { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } - - val onSignInWithYahoo = yahooProvider?.let { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } - - val onSignInWithTwitter = twitterProvider?.let { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } - - val genericOAuthHandlers = genericOAuthProviders.associateWith { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } + val onProviderSelected = authUI.rememberOnProviderSelected( + context = context, + activity = activity, + config = configuration, + onNavigate = { route -> navController.navigate(route.route) }, + onUnknownProvider = { provider -> + onSignInFailure( + AuthException.UnknownException( + message = "Provider ${provider.providerId} is not supported in FirebaseAuthScreen", + cause = IllegalArgumentException( + "Provider ${provider.providerId} is not supported in FirebaseAuthScreen" + ) + ) + ) + }, + ) CompositionLocalProvider( LocalAuthUIStringProvider provides configuration.stringProvider, @@ -281,46 +214,7 @@ fun FirebaseAuthScreen( lastSignInPreference = lastSignInPreference.value, customLayout = customMethodPickerLayout, termsConfiguration = customMethodPickerTermsConfiguration, - onProviderSelected = { provider -> - when (provider) { - is AuthProvider.Anonymous -> onSignInAnonymously?.invoke() - - is AuthProvider.Email -> { - navController.navigate(AuthRoute.Email.route) - } - - is AuthProvider.Phone -> { - navController.navigate(AuthRoute.Phone.route) - } - - is AuthProvider.Google -> onSignInWithGoogle?.invoke() - - is AuthProvider.Facebook -> onSignInWithFacebook?.invoke() - - is AuthProvider.Apple -> onSignInWithApple?.invoke() - - is AuthProvider.Github -> onSignInWithGithub?.invoke() - - is AuthProvider.Microsoft -> onSignInWithMicrosoft?.invoke() - - is AuthProvider.Yahoo -> onSignInWithYahoo?.invoke() - - is AuthProvider.Twitter -> onSignInWithTwitter?.invoke() - - is AuthProvider.GenericOAuth -> genericOAuthHandlers[provider]?.invoke() - - else -> { - onSignInFailure( - AuthException.UnknownException( - message = "Provider ${provider.providerId} is not supported in FirebaseAuthScreen", - cause = IllegalArgumentException( - "Provider ${provider.providerId} is not supported in FirebaseAuthScreen" - ) - ) - ) - } - } - } + onProviderSelected = onProviderSelected, ) } } @@ -544,6 +438,26 @@ fun FirebaseAuthScreen( pendingResolver.value = null pendingLinkingCredential.value = null + // If reauth just completed, execute the pending retry and skip normal success handling + pendingReauthOperation.value?.let { retry -> + pendingReauthOperation.value = null + pendingReauthConfig.value = null + pendingReauthState.value = null + // Lock the state to Loading before launching the retry so no + // intermediate Success emission can navigate to AuthRoute.Success. + authUI.updateAuthState(AuthState.Loading()) + coroutineScope.launch { + try { + retry(context) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + authUI.updateAuthState(AuthState.Error(e)) + } + } + return@LaunchedEffect + } + state.result?.let { result -> if (state.user.uid != lastSuccessfulUserId.value) { onSignInSuccess(result) @@ -565,6 +479,30 @@ fun FirebaseAuthScreen( } } + is AuthState.ReauthenticationRequired -> { + pendingReauthOperation.value = state.retryOperation + val linked = configuration.providers.filterToLinkedProviders(state.user) + if (linked.isEmpty()) { + authUI.updateAuthState( + AuthState.Error( + AuthException.UnknownException( + "No configured providers are linked to the current user" + ) + ) + ) + return@LaunchedEffect + } + if (reauthContent != null) { + pendingReauthState.value = state + } else { + pendingReauthConfig.value = configuration.copy( + providers = linked, + isNewEmailAccountsAllowed = false, + isReauthenticationMode = true, + ) + } + } + is AuthState.RequiresEmailVerification, is AuthState.RequiresProfileCompletion, -> { @@ -588,6 +526,9 @@ fun FirebaseAuthScreen( } is AuthState.Cancelled -> { + pendingReauthOperation.value = null + pendingReauthConfig.value = null + pendingReauthState.value = null pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -601,6 +542,9 @@ fun FirebaseAuthScreen( } is AuthState.Idle -> { + pendingReauthOperation.value = null + pendingReauthConfig.value = null + pendingReauthState.value = null pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -678,6 +622,44 @@ fun FirebaseAuthScreen( if (loadingState != null) { LoadingDialog(loadingState.message ?: stringProvider.progressDialogLoading) } + + // Custom reauth UI — rendered when the caller provides reauthContent. + val pendingReauth = pendingReauthState.value + if (pendingReauth != null && reauthContent != null) { + reauthContent(pendingReauth) { + pendingReauthOperation.value = null + pendingReauthState.value = null + authUI.updateAuthState(AuthState.Idle) + } + } + + // Default reauth bottom sheet — used when reauthContent is not provided. + val reauthConfig = pendingReauthConfig.value + if (reauthConfig != null) { + ModalBottomSheet( + onDismissRequest = { + pendingReauthOperation.value = null + pendingReauthConfig.value = null + authUI.updateAuthState(AuthState.Idle) + }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + ReauthSheetContent( + authUI = authUI, + reauthConfig = reauthConfig, + activity = activity, + context = context, + emailContent = emailContent, + phoneContent = phoneContent, + customMethodPickerLayout = customMethodPickerLayout, + onDismiss = { + pendingReauthOperation.value = null + pendingReauthConfig.value = null + authUI.updateAuthState(AuthState.Idle) + }, + ) + } + } } } } @@ -895,3 +877,121 @@ private fun LoadingDialog(message: String) { } ) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ReauthSheetContent( + authUI: FirebaseAuthUI, + reauthConfig: AuthUIConfiguration, + activity: android.app.Activity?, + context: android.content.Context, + emailContent: (@Composable (EmailAuthContentState) -> Unit)?, + phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?, + customMethodPickerLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)?, + onDismiss: () -> Unit, +) { + val sheetNavController = rememberNavController() + val startRoute = remember(reauthConfig) { getStartRoute(reauthConfig) } + val skipsMethodPicker = startRoute != AuthRoute.MethodPicker + val onProviderSelected = authUI.rememberOnProviderSelected( + context = context, + activity = activity, + config = reauthConfig, + onNavigate = { route -> sheetNavController.navigate(route.route) }, + ) + + NavHost( + navController = sheetNavController, + startDestination = startRoute.route, + enterTransition = { fadeIn(animationSpec = tween(700)) }, + exitTransition = { fadeOut(animationSpec = tween(700)) }, + popEnterTransition = { fadeIn(animationSpec = tween(700)) }, + popExitTransition = { fadeOut(animationSpec = tween(700)) }, + ) { + composable(AuthRoute.MethodPicker.route) { + Scaffold { innerPadding -> + AuthMethodPicker( + modifier = Modifier.padding(innerPadding), + providers = reauthConfig.providers, + customLayout = customMethodPickerLayout, + onProviderSelected = onProviderSelected, + ) + } + } + + composable(AuthRoute.Email.route) { + com.firebase.ui.auth.ui.screens.email.EmailAuthScreen( + context = context, + configuration = reauthConfig, + authUI = authUI, + content = emailContent, + onSuccess = {}, + onError = {}, + onCancel = { + if (skipsMethodPicker || !sheetNavController.popBackStack()) onDismiss() + } + ) + } + + composable(AuthRoute.Phone.route) { + com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen( + context = context, + configuration = reauthConfig, + authUI = authUI, + content = phoneContent, + onSuccess = {}, + onError = {}, + onCancel = { + if (skipsMethodPicker || !sheetNavController.popBackStack()) onDismiss() + } + ) + } + } +} + +@Composable +private fun FirebaseAuthUI.rememberOnProviderSelected( + context: android.content.Context, + activity: android.app.Activity?, + config: AuthUIConfiguration, + onNavigate: (AuthRoute) -> Unit, + onUnknownProvider: ((AuthProvider) -> Unit)? = null, +): (AuthProvider) -> Unit { + val anonymousProvider = config.providers.filterIsInstance().firstOrNull() + val googleProvider = config.providers.filterIsInstance().firstOrNull() + val facebookProvider = config.providers.filterIsInstance().firstOrNull() + val appleProvider = config.providers.filterIsInstance().firstOrNull() + val githubProvider = config.providers.filterIsInstance().firstOrNull() + val microsoftProvider = config.providers.filterIsInstance().firstOrNull() + val yahooProvider = config.providers.filterIsInstance().firstOrNull() + val twitterProvider = config.providers.filterIsInstance().firstOrNull() + val genericOAuthProviders = config.providers.filterIsInstance() + + val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler() } + val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, config, it) } + val onSignInWithFacebook = facebookProvider?.let { rememberSignInWithFacebookLauncher(context, config, it) } + val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } + val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } + val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } + val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } + val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } + val genericOAuthHandlers = genericOAuthProviders.associateWith { + rememberOAuthSignInHandler(context, activity, config, it) + } + + return { provider -> + when (provider) { + is AuthProvider.Anonymous -> onSignInAnonymously?.invoke() + is AuthProvider.Email -> onNavigate(AuthRoute.Email) + is AuthProvider.Phone -> onNavigate(AuthRoute.Phone) + is AuthProvider.Google -> onSignInWithGoogle?.invoke() + is AuthProvider.Facebook -> onSignInWithFacebook?.invoke() + is AuthProvider.Apple -> onSignInWithApple?.invoke() + is AuthProvider.Github -> onSignInWithGithub?.invoke() + is AuthProvider.Microsoft -> onSignInWithMicrosoft?.invoke() + is AuthProvider.Yahoo -> onSignInWithYahoo?.invoke() + is AuthProvider.Twitter -> onSignInWithTwitter?.invoke() + is AuthProvider.GenericOAuth -> genericOAuthHandlers[provider]?.invoke() + else -> onUnknownProvider?.invoke(provider) + } + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt index d2558a8a8..8a4715c97 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt @@ -18,12 +18,16 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions +import android.content.Context +import com.google.android.gms.tasks.TaskCompletionSource import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth.AuthStateListener +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactorResolver import com.google.firebase.auth.UserInfo +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.take @@ -37,6 +41,7 @@ import org.junit.runner.RunWith import org.mockito.ArgumentCaptor import org.mockito.Mock import org.mockito.Mockito.mock +import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations @@ -381,4 +386,194 @@ class FirebaseAuthUIAuthStateTest { assertThat(state.user).isEqualTo(mockFirebaseUser) assertThat(state.missingFields).containsExactly("displayName", "photoUrl") } + + // ============================================================================================= + // delete() ReauthenticationRequired state Tests + // ============================================================================================= + + @Test + fun `delete() emits ReauthenticationRequired state when recent login required`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + val tcs = TaskCompletionSource() + tcs.setException( + FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + ) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.delete()).thenReturn(tcs.task) + + val context = ApplicationProvider.getApplicationContext() + + try { + authUI.delete(context) + } catch (_: AuthException.InvalidCredentialsException) { + // expected — existing contract preserved + } + + assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.ReauthenticationRequired::class.java) + val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + assertThat(state.user).isEqualTo(mockUser) + } + + @Test + fun `delete() attaches retryOperation to ReauthenticationRequired state`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + val tcs = TaskCompletionSource() + tcs.setException( + FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + ) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.delete()).thenReturn(tcs.task) + + val context = ApplicationProvider.getApplicationContext() + try { authUI.delete(context) } catch (_: AuthException.InvalidCredentialsException) {} + + val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + // Fails until delete() passes retryOperation into the state + assertThat(state.retryOperation).isNotNull() + } + + // ============================================================================================= + // withReauth() Tests + // ============================================================================================= + + @Test + fun `withReauth() executes operation normally when no reauth needed`() = runTest { + val context = ApplicationProvider.getApplicationContext() + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + var callCount = 0 + + authUI.withReauth(context) { callCount++ } + + assertThat(callCount).isEqualTo(1) + } + + @Test + fun `withReauth() emits ReauthenticationRequired when FirebaseAuthRecentLoginRequiredException thrown`() = runTest { + val context = ApplicationProvider.getApplicationContext() + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + + authUI.withReauth(context) { + throw FirebaseAuthRecentLoginRequiredException("ERROR_REQUIRES_RECENT_LOGIN", "Recent login required") + } + + assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.ReauthenticationRequired::class.java) + val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + assertThat(state.user).isEqualTo(mockFirebaseUser) + } + + @Test + fun `withReauth() forwards reason to ReauthenticationRequired state`() = runTest { + val context = ApplicationProvider.getApplicationContext() + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + + authUI.withReauth(context, reason = "Verify identity to change email") { + throw FirebaseAuthRecentLoginRequiredException("ERROR_REQUIRES_RECENT_LOGIN", "Recent login required") + } + + val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + assertThat(state.reason).isEqualTo("Verify identity to change email") + } + + @Test + fun `withReauth() attaches retryOperation that re-invokes the original operation`() = runTest { + val context = ApplicationProvider.getApplicationContext() + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + var callCount = 0 + + authUI.withReauth(context) { + callCount++ + if (callCount == 1) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + } + + val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + assertThat(state.retryOperation).isNotNull() + state.retryOperation!!(context) + assertThat(callCount).isEqualTo(2) + } + + @Test + fun `withReauth() retryOperation restores auth state after successful retry`() = runTest { + val context = ApplicationProvider.getApplicationContext() + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + var callCount = 0 + + authUI.withReauth(context) { + callCount++ + if (callCount == 1) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + } + + val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + + // Simulate FirebaseAuthScreen: set Loading, then invoke the retry + authUI.updateAuthState(AuthState.Loading()) + state.retryOperation!!(context) + + // Auth state must not be stuck on Loading — withReauth owns the state lifecycle + val authState = authUI.authStateFlow().first() + assertThat(authState).isNotInstanceOf(AuthState.Loading::class.java) + assertThat(authState).isInstanceOf(AuthState.Success::class.java) + } + + @Test + fun `withReauth() does not throw when reauth is needed`() = runTest { + val context = ApplicationProvider.getApplicationContext() + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + + // Should complete without throwing + authUI.withReauth(context) { + throw FirebaseAuthRecentLoginRequiredException("ERROR_REQUIRES_RECENT_LOGIN", "Recent login required") + } + } + + @Test + fun `withReauth() propagates non-reauth exceptions`() = runTest { + val context = ApplicationProvider.getApplicationContext() + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + val cause = RuntimeException("Network error") + var thrown: Exception? = null + + try { + authUI.withReauth(context) { throw cause } + } catch (e: Exception) { + thrown = e + } + + assertThat(thrown).isEqualTo(cause) + } + + @Test + fun `delete() retryOperation re-invokes delete on execution`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + + val failTcs = TaskCompletionSource() + failTcs.setException( + FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + ) + val successTcs = TaskCompletionSource() + successTcs.setResult(null) + + `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.delete()) + .thenReturn(failTcs.task) + .thenReturn(successTcs.task) + + val context = ApplicationProvider.getApplicationContext() + try { authUI.delete(context) } catch (_: AuthException.InvalidCredentialsException) {} + + val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + // Fails until delete() passes retryOperation into the state + state.retryOperation!!(context) + + verify(mockUser, times(2)).delete() + } } \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt index 05f61c538..7d5a75e3d 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt @@ -19,6 +19,7 @@ import android.content.Intent import android.net.Uri import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.authUIConfiguration import com.google.android.gms.tasks.TaskCompletionSource import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp @@ -679,6 +680,96 @@ class FirebaseAuthUITest { } } + // ============================================================================================= + // createReauthFlow Tests + // ============================================================================================= + + private fun baseConfig(vararg providers: AuthProvider): com.firebase.ui.auth.configuration.AuthUIConfiguration { + val context = ApplicationProvider.getApplicationContext() + return authUIConfiguration { + this.context = context + providers.forEach { p -> this.providers { provider(p) } } + } + } + + @Test + fun `createReauthFlow throws UserNotFoundException when no user is signed in`() { + val mockAuth = mock(FirebaseAuth::class.java) + `when`(mockAuth.currentUser).thenReturn(null) + val authUI = FirebaseAuthUI.create(defaultApp, mockAuth) + + try { + authUI.createReauthFlow(baseConfig(AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()))) + assertThat(false).isTrue() + } catch (e: AuthException.UserNotFoundException) { + assertThat(e.message).contains("No user is currently signed in") + } + } + + @Test + fun `createReauthFlow throws when no configured provider is linked to the current user`() { + val mockUser = mock(FirebaseUser::class.java) + val info = mock(UserInfo::class.java) + `when`(info.providerId).thenReturn("password") + `when`(mockUser.providerData).thenReturn(listOf(info)) + val mockAuth = mock(FirebaseAuth::class.java) + `when`(mockAuth.currentUser).thenReturn(mockUser) + val authUI = FirebaseAuthUI.create(defaultApp, mockAuth) + + // Config only has Google; user only has email linked + val config = baseConfig(AuthProvider.Google(scopes = emptyList(), serverClientId = "id")) + + try { + authUI.createReauthFlow(config) + assertThat(false).isTrue() + } catch (e: IllegalStateException) { + assertThat(e.message).contains("No configured providers are linked") + } + } + + @Test + fun `createReauthFlow returns a controller whose config has only linked providers`() { + val mockUser = mock(FirebaseUser::class.java) + val emailInfo = mock(UserInfo::class.java) + val googleInfo = mock(UserInfo::class.java) + `when`(emailInfo.providerId).thenReturn("password") + `when`(googleInfo.providerId).thenReturn("google.com") + `when`(mockUser.providerData).thenReturn(listOf(emailInfo, googleInfo)) + val mockAuth = mock(FirebaseAuth::class.java) + `when`(mockAuth.currentUser).thenReturn(mockUser) + val authUI = FirebaseAuthUI.create(defaultApp, mockAuth) + + // Three providers configured; only Email and Google are linked — Phone should be stripped + val config = baseConfig( + AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()), + AuthProvider.Google(scopes = emptyList(), serverClientId = "id"), + AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null), + ) + + val controller = authUI.createReauthFlow(config) + + assertThat(controller.configuration.providers.map { it.providerId }) + .containsExactly("password", "google.com") + } + + @Test + fun `createReauthFlow resulting config disables new account creation and enables reauth mode`() { + val mockUser = mock(FirebaseUser::class.java) + val info = mock(UserInfo::class.java) + `when`(info.providerId).thenReturn("password") + `when`(mockUser.providerData).thenReturn(listOf(info)) + val mockAuth = mock(FirebaseAuth::class.java) + `when`(mockAuth.currentUser).thenReturn(mockUser) + val authUI = FirebaseAuthUI.create(defaultApp, mockAuth) + + val config = baseConfig(AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList())) + val controller = authUI.createReauthFlow(config) + + assertThat(controller.configuration.isNewEmailAccountsAllowed).isFalse() + assertThat(controller.configuration.isReauthenticationMode).isTrue() + } + + @Test fun `canHandleIntent returns true when auth validates email link`() { val emailLink = "https://example.com/__/auth/action?mode=signIn" diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt index 970a5e09b..d017005d4 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt @@ -479,7 +479,8 @@ class AuthUIConfigurationTest { "isNewEmailAccountsAllowed", "isDisplayNameRequired", "isProviderChoiceAlwaysShown", - "transitions" + "transitions", + "isReauthenticationMode" ) val actualProperties = allProperties.map { it.name }.toSet() 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 126600e5d..747e03ff2 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 @@ -4,10 +4,14 @@ import android.content.Context import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R import com.google.common.truth.Truth.assertThat +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.UserInfo import com.google.firebase.auth.actionCodeSettings import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -405,6 +409,77 @@ class AuthProviderTest { } } + // ============================================================================================= + // filterToLinkedProviders Tests + // ============================================================================================= + + private fun mockUser(vararg providerIds: String): FirebaseUser { + val user = mock(FirebaseUser::class.java) + val infos = providerIds.map { id -> + mock(UserInfo::class.java).also { `when`(it.providerId).thenReturn(id) } + } + `when`(user.providerData).thenReturn(infos) + return user + } + + @Test + fun `filterToLinkedProviders keeps only providers matching user providerData`() { + val user = mockUser("password", "google.com") + val providers = listOf( + AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()), + AuthProvider.Google(scopes = emptyList(), serverClientId = "id"), + AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null), + ) + + val result = providers.filterToLinkedProviders(user) + + assertThat(result.map { it.providerId }).containsExactly("password", "google.com") + } + + @Test + fun `filterToLinkedProviders returns empty list when no providers match`() { + val user = mockUser("password") + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = "id"), + ) + + val result = providers.filterToLinkedProviders(user) + + assertThat(result).isEmpty() + } + + @Test + fun `filterToLinkedProviders returns all providers when all are linked`() { + val user = mockUser("password", "phone") + val email = AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()) + val phone = AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null) + + val result = listOf(email, phone).filterToLinkedProviders(user) + + assertThat(result).containsExactly(email, phone) + } + + @Test + fun `filterToLinkedProviders on empty list returns empty list`() { + val user = mockUser("password") + + val result = emptyList().filterToLinkedProviders(user) + + assertThat(result).isEmpty() + } + + @Test + fun `filterToLinkedProviders ignores providers linked to user but absent from list`() { + val user = mockUser("password", "google.com", "facebook.com") + val providers = listOf( + AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()), + ) + + val result = providers.filterToLinkedProviders(user) + + assertThat(result.map { it.providerId }).containsExactly("password") + } + @Test fun `generic oauth provider with blank button label should throw`() { val provider = AuthProvider.GenericOAuth( diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt new file mode 100644 index 000000000..2939527ac --- /dev/null +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt @@ -0,0 +1,311 @@ +package com.firebase.ui.auth.ui.screens + +import android.content.Context +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.testutil.AUTH_STATE_WAIT_TIMEOUT_MS +import com.firebase.ui.auth.testutil.EmulatorAuthApi +import com.firebase.ui.auth.testutil.ensureFreshUser +import com.firebase.ui.auth.testutil.verifyEmailInEmulator +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import org.junit.After +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class ReauthFlowTest { + + @get:Rule + val composeAndroidTestRule = createAndroidComposeRule() + + private lateinit var applicationContext: Context + private lateinit var stringProvider: DefaultAuthUIStringProvider + private lateinit var authUI: FirebaseAuthUI + private lateinit var emulatorApi: EmulatorAuthApi + + @Before + fun setUp() { + applicationContext = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(applicationContext) + + FirebaseApp.getApps(applicationContext).forEach { it.delete() } + + val firebaseApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + + authUI = FirebaseAuthUI.getInstance() + authUI.auth.useEmulator("127.0.0.1", 9099) + + emulatorApi = EmulatorAuthApi( + projectId = firebaseApp.options.projectId + ?: throw IllegalStateException("Project ID is required"), + emulatorHost = "127.0.0.1", + emulatorPort = 9099, + ) + + emulatorApi.clearEmulatorData() + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + emulatorApi.clearEmulatorData() + } + + /** + * Full cycle: sign in via the main flow, then emit ReauthenticationRequired to simulate a + * sensitive operation. Verifies the default ModalBottomSheet reauth UI appears, completing + * reauthentication triggers the pending retry operation. + * + * The initial sign-in must complete first so the main screen shows the authenticated view — + * this avoids having two simultaneous email input forms (one in the main sign-in screen and + * one in the reauth bottom sheet). + */ + @Test + fun `reauth bottom sheet appears and triggers retry operation on successful reauthentication`() { + val email = "reauth-test-${System.currentTimeMillis()}@example.com" + val password = "test123" + + val user = ensureFreshUser(authUI, email, password) + requireNotNull(user) { "Failed to create user" } + + // Email must be verified so sign-in (both initial and reauth) resolves to Success. + try { + verifyEmailInEmulator(authUI, emulatorApi, user) + } catch (e: Exception) { + Assume.assumeTrue( + "Skipping: Firebase Auth Emulator OOB codes not available. Error: ${e.message}", + false + ) + } + + // Sign out so the screen starts on the sign-in form. + authUI.auth.signOut() + shadowOf(Looper.getMainLooper()).idle() + + var currentAuthState: AuthState = AuthState.Idle + var retryOperationCalled = false + + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + isCredentialManagerEnabled = false + } + + composeAndroidTestRule.setContent { + CompositionLocalProvider( + LocalAuthUIStringProvider provides DefaultAuthUIStringProvider(applicationContext) + ) { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + ) { state, _ -> + if (state is AuthState.Success) Text("AUTHENTICATED") else Text("NOT AUTHENTICATED") + } + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + currentAuthState = authState + } + } + + shadowOf(Looper.getMainLooper()).idle() + + // Step 1: Complete initial sign-in via the main screen form. + composeAndroidTestRule.onNodeWithText(stringProvider.emailHint) + .performScrollTo() + .performTextInput(email) + composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint) + .performScrollTo() + .performTextInput(password) + composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault.uppercase()) + .performScrollTo() + .performClick() + + shadowOf(Looper.getMainLooper()).idle() + + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + currentAuthState is AuthState.Success + } + + // Main screen now shows authenticated content — no email form visible. + composeAndroidTestRule.onNodeWithText("AUTHENTICATED").assertIsDisplayed() + + val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" } + + // Step 2: Emit ReauthenticationRequired to simulate a sensitive operation requiring reauth. + authUI.updateAuthState( + AuthState.ReauthenticationRequired( + user = signedInUser, + reason = "Please verify your identity to continue", + retryOperation = { retryOperationCalled = true }, + ) + ) + + shadowOf(Looper.getMainLooper()).idle() + + // Wait for the reauth bottom sheet email form to appear (now the only email form visible). + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.onAllNodesWithText(stringProvider.emailHint) + .fetchSemanticsNodes().isNotEmpty() + } + + // Step 3: Enter credentials in the reauth bottom sheet. + composeAndroidTestRule.onNodeWithText(stringProvider.emailHint) + .performScrollTo() + .performTextInput(email) + composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint) + .performScrollTo() + .performTextInput(password) + composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault.uppercase()) + .performScrollTo() + .performClick() + + shadowOf(Looper.getMainLooper()).idle() + + // Verify the retry operation fires after successful reauthentication. + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + retryOperationCalled + } + + assertThat(retryOperationCalled).isTrue() + } + + /** + * Verifies that when reauthContent is provided, it receives the ReauthenticationRequired state + * and calling onDismiss resets the auth state to Idle. + */ + @Test + fun `custom reauthContent receives ReauthenticationRequired state and dismisses to Idle`() { + val email = "reauth-custom-${System.currentTimeMillis()}@example.com" + val password = "test123" + + val user = ensureFreshUser(authUI, email, password) + requireNotNull(user) { "Failed to create user" } + + val capturedUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in after creation" } + authUI.auth.signOut() + shadowOf(Looper.getMainLooper()).idle() + + var currentAuthState: AuthState = AuthState.Idle + val expectedReason = "Sensitive operation requires sign-in" + + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + isCredentialManagerEnabled = false + } + + composeAndroidTestRule.setContent { + CompositionLocalProvider( + LocalAuthUIStringProvider provides DefaultAuthUIStringProvider(applicationContext) + ) { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { reauthState, onDismiss -> + Column { + Text("REAUTH REQUIRED - ${reauthState.reason}") + Button(onClick = onDismiss) { Text("DISMISS REAUTH") } + } + }, + ) { _, _ -> + Text("CONTENT") + } + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + currentAuthState = authState + } + } + + shadowOf(Looper.getMainLooper()).idle() + + // Emit ReauthenticationRequired to trigger the custom reauthContent slot. + authUI.updateAuthState( + AuthState.ReauthenticationRequired( + user = capturedUser, + reason = expectedReason, + ) + ) + + shadowOf(Looper.getMainLooper()).idle() + + // Verify the custom reauth content is displayed with the correct reason. + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.onAllNodesWithText("REAUTH REQUIRED - $expectedReason") + .fetchSemanticsNodes().isNotEmpty() + } + + composeAndroidTestRule.onNodeWithText("REAUTH REQUIRED - $expectedReason") + .assertIsDisplayed() + + // Dismiss the custom reauth UI via the onDismiss callback. + composeAndroidTestRule.onNodeWithText("DISMISS REAUTH").performClick() + + shadowOf(Looper.getMainLooper()).idle() + + // Verify that dismissing resets auth state to Idle. + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + currentAuthState is AuthState.Idle + } + + assertThat(currentAuthState).isInstanceOf(AuthState.Idle::class.java) + } +}