Skip to content
Open
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
21 changes: 21 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/AuthState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,19 @@ import com.google.firebase.auth.PhoneAuthProvider
*/
abstract class AuthState private constructor() {

/**
* Whether this is a one-off notification: something a screen shows once (a dialog, a "link
* sent" message) and must reset back to [Idle] immediately after consuming, so it doesn't
* leak to a screen/Activity created later. `abstract` so every new state must explicitly
* decide this rather than silently defaulting one way.
*/
abstract val isNotification: Boolean

/**
* Initial state before any authentication operation has been started.
*/
class Idle internal constructor() : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean = other is Idle
override fun hashCode(): Int = javaClass.hashCode()
override fun toString(): String = "AuthState.Idle"
Expand All @@ -49,6 +58,7 @@ abstract class AuthState private constructor() {
* @property message Optional message describing what is being loaded
*/
class Loading(val message: String? = null) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Loading) return false
Expand All @@ -72,6 +82,7 @@ abstract class AuthState private constructor() {
val user: FirebaseUser,
val isNewUser: Boolean = false
) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Success) return false
Expand Down Expand Up @@ -101,6 +112,7 @@ abstract class AuthState private constructor() {
val exception: Exception,
val isRecoverable: Boolean = true
) : AuthState() {
override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Error) return false
Expand All @@ -122,6 +134,7 @@ abstract class AuthState private constructor() {
* Authentication was cancelled by the user.
*/
class Cancelled internal constructor() : AuthState() {
override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean = other is Cancelled
override fun hashCode(): Int = javaClass.hashCode()
override fun toString(): String = "AuthState.Cancelled"
Expand All @@ -137,6 +150,7 @@ abstract class AuthState private constructor() {
val resolver: MultiFactorResolver,
val hint: String? = null
) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RequiresMfa) return false
Expand Down Expand Up @@ -164,6 +178,7 @@ abstract class AuthState private constructor() {
val user: FirebaseUser,
val email: String
) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RequiresEmailVerification) return false
Expand Down Expand Up @@ -191,6 +206,7 @@ abstract class AuthState private constructor() {
val user: FirebaseUser,
val missingFields: List<String> = emptyList()
) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RequiresProfileCompletion) return false
Expand Down Expand Up @@ -221,6 +237,7 @@ abstract class AuthState private constructor() {
// Not included in equals/hashCode — lambdas have no meaningful equality.
val retryOperation: (suspend (android.content.Context) -> Unit)? = null,
) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ReauthenticationRequired) return false
Expand All @@ -241,6 +258,7 @@ abstract class AuthState private constructor() {
* Password reset link has been sent to the user's email.
*/
class PasswordResetLinkSent : AuthState() {
override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean = other is PasswordResetLinkSent
override fun hashCode(): Int = javaClass.hashCode()
override fun toString(): String = "AuthState.PasswordResetLinkSent"
Expand All @@ -250,6 +268,7 @@ abstract class AuthState private constructor() {
* Email sign in link has been sent to the user's email.
*/
class EmailSignInLinkSent : AuthState() {
override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean = other is EmailSignInLinkSent
override fun hashCode(): Int = javaClass.hashCode()
override fun toString(): String = "AuthState.EmailSignInLinkSent"
Expand All @@ -267,6 +286,7 @@ abstract class AuthState private constructor() {
* @see PhoneNumberVerificationRequired for the manual verification flow
*/
class SMSAutoVerified(val credential: PhoneAuthCredential) : AuthState() {
override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SMSAutoVerified) return false
Expand Down Expand Up @@ -305,6 +325,7 @@ abstract class AuthState private constructor() {
val verificationId: String,
val forceResendingToken: PhoneAuthProvider.ForceResendingToken,
) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is PhoneNumberVerificationRequired) return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,18 @@ val LocalTopLevelDialogController = compositionLocalOf<TopLevelDialogController?
* **Usage:**
* ```kotlin
* // At the root of your auth flow (FirebaseAuthScreen):
* val dialogController = rememberTopLevelDialogController(stringProvider)
*
* val dialogController = rememberTopLevelDialogController(stringProvider) { authState }
*
* 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(
Expand All @@ -68,7 +68,7 @@ val LocalTopLevelDialogController = compositionLocalOf<TopLevelDialogController?
*/
class TopLevelDialogController(
private val stringProvider: AuthUIStringProvider,
private val authState: AuthState
private val currentAuthState: () -> AuthState
) {
private var dialogState by mutableStateOf<DialogState?>(null)
private val shownErrorStates = mutableSetOf<AuthState.Error>()
Expand All @@ -78,18 +78,22 @@ class TopLevelDialogController(
* Automatically prevents duplicate dialogs for the same AuthState.Error instance.
*
* @param exception The auth exception to display
* @param errorState The specific [AuthState.Error] instance this call is reacting to, used
* for de-duplication. Pass this explicitly when the caller might not be the only observer of
* the same error: by the time this runs, another observer may have already reset the live
* auth state to `Idle`, so falling back to [currentAuthState] alone would miss the dedup.
* @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,
errorState: AuthState.Error? = null,
onRetry: (AuthException) -> Unit = {},
onRecover: ((AuthException) -> Unit)? = null,
onDismiss: () -> Unit = {}
) {
// Get current error state
val currentErrorState = authState as? AuthState.Error
val currentErrorState = errorState ?: (currentAuthState() as? AuthState.Error)

// If this exact error state has already been shown, skip
if (currentErrorState != null && currentErrorState in shownErrorStates) {
Expand Down Expand Up @@ -162,13 +166,21 @@ class TopLevelDialogController(

/**
* Creates and remembers a [TopLevelDialogController].
*
* [authState] is a lambda rather than a snapshot value so the controller can read the
* live auth state on every [TopLevelDialogController.showErrorDialog] call without being
* recreated (and losing its de-duplication history) whenever the auth state changes.
*
* Keyed on [stringProvider] rather than left unkeyed: callers must pass a `remember`ed
* [stringProvider] (stable across recompositions), otherwise the controller — and its
* de-duplication history — would be recreated on every recomposition.
*/
@Composable
fun rememberTopLevelDialogController(
stringProvider: AuthUIStringProvider,
authState: AuthState
authState: () -> AuthState
): TopLevelDialogController {
return remember(stringProvider, authState) {
return remember(stringProvider) {
TopLevelDialogController(stringProvider, authState)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,11 @@ fun FirebaseAuthScreen(
val activity = LocalActivity.current
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val stringProvider = DefaultAuthUIStringProvider(context)
val stringProvider = remember(context) { DefaultAuthUIStringProvider(context) }
val navController = rememberNavController()

val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle)
val dialogController = rememberTopLevelDialogController(stringProvider, authState)
val dialogController = rememberTopLevelDialogController(stringProvider) { authState }
Comment thread
demolaf marked this conversation as resolved.
val lastSuccessfulUserId = remember { mutableStateOf<String?>(null) }
val pendingLinkingCredential = remember { mutableStateOf<AuthCredential?>(null) }
val pendingResolver = remember { mutableStateOf<MultiFactorResolver?>(null) }
Expand All @@ -158,6 +158,9 @@ fun FirebaseAuthScreen(
val emailLinkFromDifferentDevice = remember { mutableStateOf<String?>(null) }
val lastSignInPreference =
remember { mutableStateOf<SignInPreferenceManager.SignInPreference?>(null) }
// Last-processed AuthState, so the Idle branch below can tell a genuine reset apart from
// Idle-as-a-side-effect of consuming a notification (see AuthState.isNotification).
val previousAuthState = remember { mutableStateOf<AuthState>(AuthState.Idle) }
val startRoute = remember(configuration.providers, configuration.isProviderChoiceAlwaysShown) {
getStartRoute(configuration)
}
Expand Down Expand Up @@ -447,6 +450,8 @@ fun FirebaseAuthScreen(
// Synchronise auth state changes with navigation stack.
LaunchedEffect(authState) {
val state = authState
val previous = previousAuthState.value
previousAuthState.value = state
val currentRoute = navController.currentBackStackEntry?.destination?.route
when (state) {
is AuthState.Success -> {
Expand Down Expand Up @@ -556,6 +561,8 @@ fun FirebaseAuthScreen(
// Keep external cancellation reporting centralized here so child screens
// can handle local navigation without triggering duplicate callbacks.
onSignInCancelled()
// Consumed so this doesn't leak to a freshly created screen.
authUI.updateAuthState(AuthState.Idle)
}

is AuthState.Idle -> {
Expand All @@ -565,7 +572,9 @@ fun FirebaseAuthScreen(
pendingResolver.value = null
pendingLinkingCredential.value = null
lastSuccessfulUserId.value = null
if (currentRoute != startRoute.route) {
// A notification resets to Idle purely to avoid leaking to a freshly
// created screen — that's not a request to leave the current one.
if (!previous.isNotification && currentRoute != startRoute.route) {
navController.navigate(startRoute.route) {
popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
launchSingleTop = true
Expand All @@ -588,6 +597,7 @@ fun FirebaseAuthScreen(

dialogController.showErrorDialog(
exception = exception,
errorState = errorState,
onRetry = { _ ->
// Child screens handle their own retry logic
},
Expand Down Expand Up @@ -646,6 +656,8 @@ fun FirebaseAuthScreen(
// Dialog dismissed
}
)
// Consumed immediately so this doesn't leak to a freshly created screen.
authUI.updateAuthState(AuthState.Idle)
Comment thread
demolaf marked this conversation as resolved.
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
import com.firebase.ui.auth.FirebaseAuthUI
Expand Down Expand Up @@ -167,8 +168,11 @@ fun EmailAuthScreen(
val authCredentialForLinking = remember { credentialForLinking }
val errorMessage =
if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null
val resetLinkSent = authState is AuthState.PasswordResetLinkSent
val emailSignInLinkSent = authState is AuthState.EmailSignInLinkSent

// Latched locally since these get consumed (reset to Idle) below — deriving directly from
// authState would close ResetPasswordUI/SignInEmailLinkUI's dialogs as soon as it resets.
var resetLinkSentLocal by rememberSaveable { mutableStateOf(false) }
var emailSignInLinkSentLocal by rememberSaveable { mutableStateOf(false) }

// Track if credentials were retrieved from Credential Manager
val retrievedCredential = remember { mutableStateOf<Pair<String, String>?>(null) }
Expand All @@ -187,6 +191,7 @@ fun EmailAuthScreen(
onError(exception)
dialogController?.showErrorDialog(
exception = exception,
errorState = state,
onRetry = { ex ->
when (ex) {
is AuthException.UserNotFoundException -> {
Expand Down Expand Up @@ -229,10 +234,24 @@ fun EmailAuthScreen(
// Dialog dismissed
}
)
// Consumed immediately so this doesn't leak to a freshly created screen.
authUI.updateAuthState(AuthState.Idle)
}

is AuthState.Cancelled -> {
onCancel()
// Consumed so this doesn't leak to a freshly created screen.
authUI.updateAuthState(AuthState.Idle)
}

is AuthState.PasswordResetLinkSent -> {
resetLinkSentLocal = true
authUI.updateAuthState(AuthState.Idle)
}

is AuthState.EmailSignInLinkSent -> {
emailSignInLinkSentLocal = true
authUI.updateAuthState(AuthState.Idle)
}

else -> Unit
Expand All @@ -247,8 +266,8 @@ fun EmailAuthScreen(
confirmPassword = confirmPasswordTextValue.value,
isLoading = isLoading,
error = errorMessage,
resetLinkSent = resetLinkSent,
emailSignInLinkSent = emailSignInLinkSent,
resetLinkSent = resetLinkSentLocal,
emailSignInLinkSent = emailSignInLinkSentLocal,
onEmailChange = { email ->
emailTextValue.value = email
},
Expand Down Expand Up @@ -286,6 +305,7 @@ fun EmailAuthScreen(
}
},
onSignInEmailLinkClick = {
emailSignInLinkSentLocal = false
coroutineScope.launch {
try {
if (emailLinkFromDifferentDevice != null) {
Expand Down Expand Up @@ -327,6 +347,7 @@ fun EmailAuthScreen(
}
},
onSendResetLinkClick = {
resetLinkSentLocal = false
coroutineScope.launch {
try {
authUI.sendPasswordResetEmail(
Expand All @@ -346,14 +367,17 @@ fun EmailAuthScreen(
onGoToSignIn = {
textValues.forEach { it.value = "" }
mode.value = EmailAuthMode.SignIn
emailSignInLinkSentLocal = false
},
onGoToResetPassword = {
textValues.forEach { it.value = "" }
mode.value = EmailAuthMode.ResetPassword
resetLinkSentLocal = false
},
onGoToEmailLinkSignIn = {
textValues.forEach { it.value = "" }
mode.value = EmailAuthMode.EmailLinkSignIn
emailSignInLinkSentLocal = false
},
)

Expand Down
Loading