Skip to content
Merged
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 @@ -327,6 +327,9 @@ abstract class AuthException(
@JvmStatic
fun from(firebaseException: Exception): 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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
* 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.compose.ui.components

import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.firebase.ui.auth.compose.AuthException
import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider

/**
* CompositionLocal for accessing the top-level dialog controller from any composable.
*/
val LocalTopLevelDialogController = compositionLocalOf<TopLevelDialogController?> {
null
}

/**
* A top-level dialog controller that allows any child composable to show error recovery dialogs.
*
* It provides a single point of control for showing dialogs from anywhere in the composition tree,
* preventing duplicate dialogs when multiple screens observe the same error state.
*
* **Usage:**
* ```kotlin
* // At the root of your auth flow (FirebaseAuthScreen):
* val dialogController = rememberTopLevelDialogController(stringProvider)
*
* CompositionLocalProvider(LocalTopLevelDialogController provides dialogController) {
* // Your auth screens...
*
* // Show dialog at root level (only one instance)
* dialogController.CurrentDialog()
* }
*
* // In any child screen (EmailAuthScreen, PhoneAuthScreen, etc.):
* val dialogController = LocalTopLevelDialogController.current
*
* LaunchedEffect(error) {
* error?.let { exception ->
* dialogController?.showErrorDialog(
* exception = exception,
* onRetry = { ... },
* onRecover = { ... },
* onDismiss = { ... }
* )
* }
* }
* ```
*
* @since 10.0.0
*/
class TopLevelDialogController(
private val stringProvider: AuthUIStringProvider
) {
private var dialogState by mutableStateOf<DialogState?>(null)

/**
* Shows an error recovery dialog at the top level using [ErrorRecoveryDialog].
*
* @param exception The auth exception to display
* @param onRetry Callback when user clicks retry button
* @param onRecover Callback when user clicks recover button (e.g., navigate to different screen)
* @param onDismiss Callback when dialog is dismissed
*/
fun showErrorDialog(
exception: AuthException,
onRetry: (AuthException) -> Unit = {},
onRecover: (AuthException) -> Unit = {},
onDismiss: () -> Unit = {}
) {
dialogState = DialogState.ErrorDialog(
exception = exception,
onRetry = onRetry,
onRecover = onRecover,
onDismiss = {
dialogState = null
onDismiss()
}
)
}

/**
* Dismisses the currently shown dialog.
*/
fun dismissDialog() {
dialogState = null
}

/**
* Composable that renders the current dialog, if any.
* This should be called once at the root level of your auth flow.
*
* Uses the existing [ErrorRecoveryDialog] component.
*/
@Composable
fun CurrentDialog() {
val state = dialogState
when (state) {
is DialogState.ErrorDialog -> {
ErrorRecoveryDialog(
error = state.exception,
stringProvider = stringProvider,
onRetry = { exception ->
state.onRetry(exception)
state.onDismiss()
},
onRecover = { exception ->
state.onRecover(exception)
state.onDismiss()
},
onDismiss = state.onDismiss
)
}
null -> {
// No dialog to show
}
}
}

private sealed class DialogState {
data class ErrorDialog(
val exception: AuthException,
val onRetry: (AuthException) -> Unit,
val onRecover: (AuthException) -> Unit,
val onDismiss: () -> Unit
) : DialogState()
}
}

/**
* Creates and remembers a [TopLevelDialogController].
*/
@Composable
fun rememberTopLevelDialogController(
stringProvider: AuthUIStringProvider
): TopLevelDialogController {
return remember(stringProvider) {
TopLevelDialogController(stringProvider)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailL
import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider
import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider
import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider
import com.firebase.ui.auth.compose.ui.components.ErrorRecoveryDialog
import com.firebase.ui.auth.compose.ui.components.LocalTopLevelDialogController
import com.firebase.ui.auth.compose.ui.components.rememberTopLevelDialogController
import com.firebase.ui.auth.compose.ui.method_picker.AuthMethodPicker
import com.firebase.ui.auth.compose.ui.screens.phone.PhoneAuthScreen
import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager
Expand Down Expand Up @@ -96,9 +97,9 @@ fun FirebaseAuthScreen(
val coroutineScope = rememberCoroutineScope()
val stringProvider = DefaultAuthUIStringProvider(context)
val navController = rememberNavController()
val dialogController = rememberTopLevelDialogController(stringProvider)

val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
val isErrorDialogVisible = remember(authState) { mutableStateOf(authState is AuthState.Error) }
val lastSuccessfulUserId = remember { mutableStateOf<String?>(null) }
val pendingLinkingCredential = remember { mutableStateOf<AuthCredential?>(null) }
val pendingResolver = remember { mutableStateOf<MultiFactorResolver?>(null) }
Expand Down Expand Up @@ -192,7 +193,10 @@ fun FirebaseAuthScreen(
)
}

CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) {
CompositionLocalProvider(
LocalAuthUIStringProvider provides configuration.stringProvider,
LocalTopLevelDialogController provides dialogController
) {
Surface(
modifier = Modifier
.fillMaxSize()
Expand Down Expand Up @@ -393,7 +397,7 @@ fun FirebaseAuthScreen(
try {
// Try to retrieve saved email from DataStore (same-device flow)
val savedEmail = EmailLinkPersistenceManager.default.retrieveSessionRecord(context)?.email

if (savedEmail != null) {
// Same device - we have the email, sign in automatically
authUI.signInWithEmailLink(
Expand All @@ -418,7 +422,7 @@ fun FirebaseAuthScreen(
} catch (e: Exception) {
Log.e("FirebaseAuthScreen", "Failed to complete email link sign-in", e)
}

// Navigate to Email auth screen for cross-device error handling
if (navController.currentBackStackEntry?.destination?.route != AuthRoute.Email.route) {
navController.navigate(AuthRoute.Email.route)
Expand Down Expand Up @@ -501,31 +505,30 @@ fun FirebaseAuthScreen(
}
}

// Handle errors using top-level dialog controller
val errorState = authState as? AuthState.Error
if (isErrorDialogVisible.value && errorState != null) {
ErrorRecoveryDialog(
error = when (val throwable = errorState.exception) {
if (errorState != null) {
LaunchedEffect(errorState) {
val exception = when (val throwable = errorState.exception) {
is AuthException -> throwable
else -> AuthException.from(throwable)
},
stringProvider = stringProvider,
onRetry = { exception ->
when (exception) {
is AuthException.InvalidCredentialsException -> Unit
else -> Unit
}
isErrorDialogVisible.value = false
},
onRecover = { exception ->
when (exception) {
is AuthException.EmailAlreadyInUseException -> {
navController.navigate(AuthRoute.Email.route) {
launchSingleTop = true
}

dialogController.showErrorDialog(
exception = exception,
onRetry = { _ ->
// Child screens handle their own retry logic
},
onRecover = { exception ->
when (exception) {
is AuthException.EmailAlreadyInUseException -> {
navController.navigate(AuthRoute.Email.route) {
launchSingleTop = true
}
}
}

is AuthException.AccountLinkingRequiredException -> {
pendingLinkingCredential.value = exception.credential
is AuthException.AccountLinkingRequiredException -> {
pendingLinkingCredential.value = exception.credential
navController.navigate(AuthRoute.Email.route) {
launchSingleTop = true
}
Expand All @@ -547,16 +550,19 @@ fun FirebaseAuthScreen(
}
}

else -> Unit
else -> Unit
}
},
onDismiss = {
// Dialog dismissed
}
isErrorDialogVisible.value = false
},
onDismiss = {
isErrorDialogVisible.value = false
}
)
)
}
}

// Render the top-level dialog (only one instance)
dialogController.CurrentDialog()

val loadingState = authState as? AuthState.Loading
if (loadingState != null) {
LoadingDialog(loadingState.message ?: stringProvider.progressDialogLoading)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import com.firebase.ui.auth.compose.configuration.auth_provider.sendSignInLinkTo
import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailAndPassword
import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailLink
import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider
import com.firebase.ui.auth.compose.ui.components.ErrorRecoveryDialog
import com.firebase.ui.auth.compose.ui.components.LocalTopLevelDialogController
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -129,6 +129,7 @@ fun EmailAuthScreen(
) {
val provider = configuration.providers.filterIsInstance<AuthProvider.Email>().first()
val stringProvider = LocalAuthUIStringProvider.current
val dialogController = LocalTopLevelDialogController.current
val coroutineScope = rememberCoroutineScope()

val mode = rememberSaveable { mutableStateOf(EmailAuthMode.SignIn) }
Expand All @@ -153,9 +154,6 @@ fun EmailAuthScreen(
val resetLinkSent = authState is AuthState.PasswordResetLinkSent
val emailSignInLinkSent = authState is AuthState.EmailSignInLinkSent

val isErrorDialogVisible =
remember(authState) { mutableStateOf(authState is AuthState.Error) }

LaunchedEffect(authState) {
Log.d("EmailAuthScreen", "Current state: $authState")
when (val state = authState) {
Expand All @@ -166,7 +164,34 @@ fun EmailAuthScreen(
}

is AuthState.Error -> {
onError(AuthException.from(state.exception))
val exception = AuthException.from(state.exception)
onError(exception)

// Show dialog for screen-specific errors using top-level controller
// Navigation-related errors are handled by FirebaseAuthScreen
if (exception !is AuthException.AccountLinkingRequiredException &&
exception !is AuthException.EmailLinkPromptForEmailException &&
exception !is AuthException.EmailLinkCrossDeviceLinkingException
) {
dialogController?.showErrorDialog(
exception = exception,
onRetry = { ex ->
when (ex) {
is AuthException.InvalidCredentialsException -> {
// User can retry sign in with corrected credentials
}
is AuthException.EmailAlreadyInUseException -> {
// Switch to sign-in mode
mode.value = EmailAuthMode.SignIn
}
else -> Unit
}
},
onDismiss = {
// Dialog dismissed
}
)
}
}

is AuthState.Cancelled -> {
Expand Down Expand Up @@ -280,29 +305,6 @@ fun EmailAuthScreen(
}
)

if (isErrorDialogVisible.value &&
(authState as AuthState.Error).exception !is AuthException.AccountLinkingRequiredException
) {
ErrorRecoveryDialog(
error = when ((authState as AuthState.Error).exception) {
is AuthException -> (authState as AuthState.Error).exception as AuthException
else -> AuthException
.from((authState as AuthState.Error).exception)
},
stringProvider = stringProvider,
onRetry = { exception ->
when (exception) {
is AuthException.InvalidCredentialsException -> state.onSignInClick()
is AuthException.EmailAlreadyInUseException -> state.onGoToSignIn()
}
isErrorDialogVisible.value = false
},
onDismiss = {
isErrorDialogVisible.value = false
},
)
}

if (content != null) {
content(state)
} else {
Expand Down
Loading