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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since the state is already smart-cast to AuthState.Success in this block, you can use state.user directly instead of relying on the user variable retrieved from authUI.getCurrentUser(). This is more idiomatic as it ensures you are using the specific user instance that triggered the current state emission.

Suggested change
val identifier = user.displayIdentifier()
val identifier = state.user.displayIdentifier()

Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In the AuthState.RequiresEmailVerification block, you can use state.user directly instead of calling uiContext.authUI.getCurrentUser(). Using the data provided by the state object is safer and more consistent with Compose state management patterns.

Suggested change
val email = uiContext.authUI.getCurrentUser().getDisplayEmail(stringProvider.emailProvider)
val email = state.user.getDisplayEmail(stringProvider.emailProvider)

Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
Expand Down
44 changes: 20 additions & 24 deletions auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -255,29 +256,8 @@ class FirebaseAuthUI private constructor(
fun authStateFlow(): Flow<AuthState> {
// 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" }
Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -78,6 +79,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
Expand Down Expand Up @@ -125,6 +128,10 @@ fun FirebaseAuthScreen(
val emailLinkFromDifferentDevice = remember { mutableStateOf<String?>(null) }
val lastSignInPreference =
remember { mutableStateOf<SignInPreferenceManager.SignInPreference?>(null) }
val startRoute = remember(configuration.providers, configuration.isProviderChoiceAlwaysShown) {
getStartRoute(configuration)
}
val skipsMethodPicker = startRoute != AuthRoute.MethodPicker

// Load last sign-in preference on launch
LaunchedEffect(authState) {
Expand Down Expand Up @@ -236,7 +243,7 @@ fun FirebaseAuthScreen(
) {
NavHost(
navController = navController,
startDestination = AuthRoute.MethodPicker.route,
startDestination = startRoute.route,
enterTransition = configuration.transitions?.enterTransition ?: {
fadeIn(animationSpec = tween(700))
},
Expand Down Expand Up @@ -319,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
Expand All @@ -339,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
Expand Down Expand Up @@ -535,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
}
}
Expand All @@ -548,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
}
}
Expand All @@ -567,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
}
}
Expand All @@ -580,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
}
}
Expand Down Expand Up @@ -667,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,
Expand Down Expand Up @@ -733,7 +756,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,
Expand Down Expand Up @@ -783,7 +806,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,
Expand Down
38 changes: 38 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/util/UserUtils.kt
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading