Skip to content
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 Down Expand Up @@ -125,6 +126,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 +241,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 +324,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 +346,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 +544,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 +557,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 +576,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 +589,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 +676,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
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading