diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt index 2ad02ce28..cdcd00338 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt @@ -45,6 +45,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 kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import com.firebase.ui.auth.AuthException @@ -334,9 +335,13 @@ private fun AppAuthenticatedContent( lifecycleOwner.lifecycleScope.launch { isDeletingAccount = true try { + // Reauthentication, if it is needed, happens inside this call: + // the progress indicator below covers it, and the deletion is + // retried here rather than needing anything from this caller. uiContext.authUI.delete(context) - } catch (e: AuthException.InvalidCredentialsException) { - Log.d("HighLevelApiDemoActivity", "Reauth required before delete") + } catch (e: AuthException.AuthCancelledException) { + // Declined at the identity check; the account is untouched. + Log.d("HighLevelApiDemoActivity", "Delete cancelled", e) } catch (e: AuthException) { Log.e("HighLevelApiDemoActivity", "Delete failed", e) } finally { @@ -567,6 +572,15 @@ private fun ChangePasswordDialog( Log.d("HighLevelApiDemoActivity", "Password changed successfully") onDismiss() } + } catch (e: CancellationException) { + // withReauth suspends across the reauthentication sheet, so this + // scope really can be cancelled mid-call. Never report that as a + // failure the user can retry. + throw e + } catch (e: AuthException.AuthCancelledException) { + // The user backed out of confirming their identity. Nothing failed, + // and the password was not changed — so say neither. + Log.d("HighLevelApiDemoActivity", "Reauthentication declined", e) } catch (e: Exception) { updateError = "Failed to update password. Please try again." } finally { diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt b/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt new file mode 100644 index 000000000..3a3564a60 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt @@ -0,0 +1,117 @@ +/* + * 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 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser + +/** Where one auth flow's states go. */ +internal fun interface AuthStateSink { + fun emit(state: AuthState) +} + +/** + * One auth flow's collaborators, and where its states go. Provider code is written against this, + * not [FirebaseAuthUI], so it reaches the public state channel only through [sink]. + * + * @since 10.0.0 + */ +internal class AuthFlowScope( + val auth: FirebaseAuth, + val config: AuthUIConfiguration, + val credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null, + val loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null, + /** + * What this flow is currently doing, for the screens rendering it. Under a reauthentication + * request's scope this is that request's phase rather than the host's state. + */ + val state: State, + private val sink: AuthStateSink, +) { + fun emit(state: AuthState) = sink.emit(state) + + /** + * Publishes what [result] means for this flow: a password user who still owes email + * verification is not signed in yet, however successful the credential exchange was. + */ + fun emitResult(result: AuthResult?, defaultIsNewUser: Boolean = false) { + val user = result?.user + if (user != null) { + val isNewUser = result.additionalUserInfo?.isNewUser ?: defaultIsNewUser + emit(authUserState(user, result, isNewUser)) + } else { + emit(AuthState.Idle) + } + } +} + +/** + * What a signed-in [user] means as an [AuthState]: the single source of truth for whether they + * still owe email verification. Callers must not re-derive it — only password users with an email + * can satisfy that screen. + */ +internal fun authUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState { + val email = user.email + return if (!user.isEmailVerified && + email != null && + user.providerData.any { it.providerId == "password" } + ) { + AuthState.RequiresEmailVerification(user = user, email = email) + } else { + AuthState.Success(result = result, user = user, isNewUser = isNewUser) + } +} + +/** The auth flow the current composition belongs to, or null outside one. */ +internal val LocalAuthFlowScope = staticCompositionLocalOf { null } + +/** + * The ambient flow when composed inside one, otherwise a fresh flow over [authUI]'s public state — + * which is what a consumer composing `EmailAuthScreen` or `PhoneAuthScreen` on its own gets. + */ +@Composable +internal fun rememberAuthFlowScope( + authUI: FirebaseAuthUI, + configuration: AuthUIConfiguration, +): AuthFlowScope { + val ambient = LocalAuthFlowScope.current + val hostState = remember(authUI) { authUI.authStateFlow() } + .collectAsState(AuthState.Idle) + return remember(ambient, authUI, configuration, hostState) { + ambient ?: hostAuthFlowScope(authUI, configuration, hostState) + } +} + +/** An [AuthFlowScope] over [authUI]'s public flow, in both directions. */ +internal fun hostAuthFlowScope( + authUI: FirebaseAuthUI, + configuration: AuthUIConfiguration, + state: State, +): AuthFlowScope = AuthFlowScope( + auth = authUI.auth, + config = configuration, + credentialManagerProvider = authUI.testCredentialManagerProvider, + loginManagerProvider = authUI.testLoginManagerProvider, + state = state, + sink = { authUI.updateAuthState(it) }, +) 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 cfb6f4634..a59a88bf4 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt @@ -21,6 +21,7 @@ import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactorResolver import com.google.firebase.auth.PhoneAuthCredential import com.google.firebase.auth.PhoneAuthProvider +import kotlinx.coroutines.CompletableDeferred import java.util.UUID /** @@ -256,11 +257,8 @@ abstract class AuthState private constructor() { } /** - * A state in the lifecycle of one reauthentication request. - * - * Every state carries a stable [requestId], so Activity recreation can distinguish a - * continuation of the same sensitive operation from a new operation for the same user. The - * request itself is process-local because its retry callback cannot be serialized. + * A state in the lifecycle of one reauthentication request. Every state carries a stable + * [requestId], so recreation can tell a continuation from a new operation for the same user. */ sealed class Reauthentication : AuthState() { abstract val requestId: String @@ -273,21 +271,31 @@ abstract class AuthState private constructor() { val requestId: String, val user: FirebaseUser, val reason: String?, - retryOperation: (suspend (android.content.Context) -> Unit)?, + /** + * Where the caller awaiting this request is parked, or null when nobody is — a + * standalone flow from [FirebaseAuthUI.createReauthFlow] has no operation behind it. + */ + val resolver: CompletableDeferred? = null, ) { - /** Null once [claimRetryOperation] consumed it, so no recreation can re-run it. */ - var retryOperation: (suspend (android.content.Context) -> Unit)? = retryOperation - private set + /** Whether a caller is waiting on this request to decide a pending operation. */ + val hasPendingOperation: Boolean get() = resolver != null + + /** Whether the awaiting caller is still there to resume. */ + val isResumable: Boolean get() = resolver?.isActive != false - /** Whether this request ever carried an operation, even after it was claimed. */ - val hasRetryOperation: Boolean = retryOperation != null + /** Credentials were accepted: the caller resumes and retries. Idempotent. */ + fun resolve() { + resolver?.complete(true) + } /** - * Hands the operation out exactly once. A second claim means the first run was lost, - * which must be reported rather than retried: the operation may have committed already. + * The request ended without proof. Completed with a value, not an exception: failing a + * parented Deferred would cancel the caller's scope, so [FirebaseAuthUI.withReauth] + * throws in its own frame instead. */ - fun claimRetryOperation(): (suspend (android.content.Context) -> Unit)? = - retryOperation.also { retryOperation = null } + fun decline() { + resolver?.complete(false) + } } /** @@ -302,16 +310,15 @@ abstract class AuthState private constructor() { class Required internal constructor( override val request: Request, ) : Reauthentication() { - constructor( + /** A request with nobody waiting on it, as a standalone reauthentication flow has. */ + internal constructor( user: FirebaseUser, reason: String? = null, - retryOperation: (suspend (android.content.Context) -> Unit)? = null, ) : this( Request( requestId = UUID.randomUUID().toString(), user = user, reason = reason, - retryOperation = retryOperation, ) ) @@ -319,9 +326,11 @@ abstract class AuthState private constructor() { override val userUid: String get() = request.user.uid val user: FirebaseUser get() = request.user val reason: String? get() = request.reason - val retryOperation: (suspend (android.content.Context) -> Unit)? - get() = request.retryOperation + /** + * Identity is the request. Snapshot state and [FirebaseAuthUI.pendingReauth] both + * conflate equal values, so a transition that must be observed changes the phase type. + */ override fun equals(other: Any?): Boolean = other is Required && requestId == other.requestId @@ -341,7 +350,7 @@ abstract class AuthState private constructor() { override val userUid: String get() = request.user.uid } - /** The most recent credential attempt failed, but the request remains armed. */ + /** The most recent credential attempt failed, but the request remains outstanding. */ internal class AttemptFailed( override val request: Request, val exception: Exception, @@ -395,7 +404,7 @@ abstract class AuthState private constructor() { override val userUid: String get() = request.user.uid } - /** Credentials were accepted for the request's user. */ + /** Credentials were accepted for the request's user. Terminal for the exchange. */ internal class Succeeded( override val request: Request, val success: Success, @@ -404,43 +413,9 @@ abstract class AuthState private constructor() { override val userUid: String get() = request.user.uid } - /** The sensitive operation is being retried after credentials were accepted. */ - internal class RetryingOperation( - override val request: Request, - ) : Reauthentication() { - override val requestId: String get() = request.requestId - override val userUid: String get() = request.user.uid - } - - /** The retry completed and [outcome] is ready to become the ordinary auth state. */ - internal class OperationFinished( - override val request: Request, - val outcome: AuthState, - ) : Reauthentication() { - override val requestId: String get() = request.requestId - override val userUid: String get() = request.user.uid - } - - /** - * Saved UI state proved a request existed, but its process-local retry callback was lost. - */ - internal class Interrupted( - override val requestId: String, - override val userUid: String, - ) : Reauthentication() { - override val request: Request? = null - } - - /** - * Whether this request's reauthentication already succeeded. A sign-out must not clear such - * a phase, because the pending operation succeeding can be what signed the user out. - */ - internal val isReauthenticated: Boolean - get() = this is Succeeded || this is RetryingOperation || this is OperationFinished - /** * A provider attempt is about to run, clearing any previously surfaced failure. Null once - * credentials were accepted, so a late attempt cannot rewind a running operation. + * credentials were accepted, so a late attempt cannot rewind a finished request. */ internal fun attemptStarted(): AuthState? = when (this) { is Required, @@ -484,11 +459,6 @@ abstract class AuthState private constructor() { else -> null } - - /** The retried sensitive operation produced [outcome]. Null unless a retry is in flight. */ - internal fun operationFinished(outcome: AuthState): AuthState? = - (this as? RetryingOperation) - ?.let { OperationFinished(it.request, outcome) } } /** 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 d296ed838..2b1625641 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -20,10 +20,10 @@ import androidx.annotation.MainThread 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.firebase.ui.auth.ui.screens.reauth.toReauthConfiguration import com.google.firebase.Firebase import com.google.firebase.FirebaseApp import com.google.firebase.auth.AuthResult @@ -33,13 +33,18 @@ import com.google.firebase.auth.FirebaseAuth.IdTokenListener import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.auth import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.tasks.await +import java.util.UUID +import kotlin.coroutines.coroutineContext import java.util.concurrent.ConcurrentHashMap /** @@ -80,8 +85,13 @@ class FirebaseAuthUI private constructor( private val _authStateFlow = MutableStateFlow(AuthState.Idle) + /** + * The reauthentication request waiting for a screen to take it on, or null. Process-scoped + * like the caller awaiting it, so a recreated screen picks up the same request. + */ + internal val pendingReauth = MutableStateFlow(null) + /** How many composed [FirebaseAuthScreen]s can currently drive a reauthentication request. */ - private var reauthenticationDrainers = 0 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) var testCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null @@ -231,8 +241,6 @@ class FirebaseAuthUI private constructor( * * @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 @@ -244,19 +252,10 @@ class FirebaseAuthUI private constructor( ?: throw AuthException.UserNotFoundException( message = "No user is currently signed in" ) - val linked = configuration.providers.filterToLinkedProviders(currentUser) - check(linked.isNotEmpty()) { + val reauthConfig = configuration.toReauthConfiguration(currentUser) + checkNotNull(reauthConfig) { "No configured providers are linked to the current user" } - val reauthConfig = configuration.copy( - providers = linked, - // Belt and braces with the canLinkCredential/canUpgradeAnonymous guards: a linked - // credential is not a proof of identity, so a reauth config never enables either. - isAnonymousUpgradeEnabled = false, - isCredentialLinkingEnabled = false, - isNewEmailAccountsAllowed = false, - isReauthenticationMode = true, - ) return AuthFlowController(this, reauthConfig) } @@ -310,7 +309,7 @@ class FirebaseAuthUI private constructor( val firebaseAuthFlow = callbackFlow { fun buildState(currentUser: FirebaseUser?): AuthState { return if (currentUser != null) { - handleAuthUserState(currentUser, result = null, isNewUser = false) + authUserState(currentUser, result = null, isNewUser = false) } else { AuthState.Idle } @@ -332,14 +331,11 @@ class FirebaseAuthUI private constructor( is AuthState.RequiresEmailVerification, is AuthState.RequiresProfileCompletion, -> true - // A sensitive operation such as delete() signs the user out as its own - // success condition, so phases owning that operation must survive this. - is AuthState.Reauthentication -> !current.isReauthenticated else -> false } - // Via the session helper so a cleared request leaves the state machine outright - // instead of being written past contextualizeReauthenticationState(). - if (isStale) finishReauthentication(AuthState.Idle) + if (isStale) updateAuthState(AuthState.Idle) + // A signed-out user cannot reauthenticate; the caller is told, not dropped. + pendingReauth.getAndUpdate { null }?.request?.decline() } trySend(buildState(firebaseAuth.currentUser)) } @@ -379,162 +375,9 @@ class FirebaseAuthUI private constructor( */ @MainThread fun updateAuthState(state: AuthState) { - _authStateFlow.value = contextualizeReauthenticationState(state) - } - - /** Ends the current reauthentication session without preserving its request context. */ - @MainThread - internal fun finishReauthentication(state: AuthState) { _authStateFlow.value = state } - /** - * Registers a screen that can drive an armed reauthentication request to completion. - * Call [removeReauthenticationDrainer] when it leaves the composition. - */ - @MainThread - internal fun addReauthenticationDrainer() { - reauthenticationDrainers++ - } - - /** Unregisters a drainer added by [addReauthenticationDrainer]. */ - @MainThread - internal fun removeReauthenticationDrainer() { - if (reauthenticationDrainers > 0) reauthenticationDrainers-- - } - - /** - * Applies a reauthentication [transition] only while [requestId] is still the armed request. - * A null transition result is a no-op, which is how phases reject a transition they disallow. - */ - @MainThread - internal fun updateReauthentication( - requestId: String, - transition: (AuthState.Reauthentication) -> AuthState?, - ) { - val current = _authStateFlow.value as? AuthState.Reauthentication ?: return - if (current.requestId != requestId) return - transition(current)?.let { updateAuthState(it) } - } - - /** - * Publishes the [AuthState.Success] that proves a genuine reauthentication of the signed-in - * user, for the one exchange no provider owns: a resolved second factor. Call only on success. - */ - @MainThread - internal fun publishReauthenticationSuccess() { - // Matches the provider stamp sites: no current user means nothing was re-proved, so the - // attempt is reported as a failure rather than published as an unstamped Success. - val reauthenticatedUser = auth.currentUser - if (reauthenticatedUser == null) { - updateAuthState( - AuthState.Error( - AuthException.UserNotFoundException( - message = "No user is currently signed in for reauthentication" - ) - ) - ) - return - } - updateAuthState( - AuthState.Success( - result = null, - user = reauthenticatedUser, - reauthenticatedUid = reauthenticatedUser.uid, - ) - ) - } - - /** - * Keeps one reauthentication request attached while provider code publishes ordinary auth - * states. Provider implementations therefore do not need their own parallel session storage. - * - * Scoped to a registered drainer: with no screen to end a request, an arming from public API - * alone stays inert rather than swallowing every later state and capturing [authStateFlow]. - */ - private fun contextualizeReauthenticationState(state: AuthState): AuthState { - if (state is AuthState.Reauthentication) return state - if (reauthenticationDrainers == 0) return state - - val current = _authStateFlow.value as? AuthState.Reauthentication ?: return state - val request = current.request ?: return state - - if (current is AuthState.Reauthentication.RetryingOperation) { - return when (state) { - // Sensitive operations such as delete() publish their own Loading before the - // final result. Keep the retry phase and its callback attached in the meantime. - is AuthState.Loading -> current - else -> AuthState.Reauthentication.OperationFinished(request, state) - } - } - - return when (state) { - is AuthState.Loading -> - AuthState.Reauthentication.Authenticating(request, state.message) - - is AuthState.Error -> { - if (state.exception is AuthException.AuthCancelledException) { - AuthState.Reauthentication.Required(request) - } else { - AuthState.Reauthentication.AttemptFailed(request, state.exception) - } - } - - is AuthState.Cancelled -> AuthState.Reauthentication.Required(request) - - is AuthState.RequiresMfa -> - AuthState.Reauthentication.RequiresMfa(request, state.resolver, state.hint) - - is AuthState.PhoneNumberVerificationRequired -> - AuthState.Reauthentication.PhoneNumberVerificationRequired( - request = request, - verificationId = state.verificationId, - forceResendingToken = state.forceResendingToken, - ) - - is AuthState.SMSAutoVerified -> - AuthState.Reauthentication.SmsAutoVerified(request, state.credential) - - is AuthState.PasswordResetLinkSent -> - AuthState.Reauthentication.PasswordResetLinkSent(request) - - is AuthState.EmailSignInLinkSent -> - AuthState.Reauthentication.EmailSignInLinkSent(request) - - is AuthState.Success -> { - if (state.reauthenticatedUid != null) { - AuthState.Reauthentication.Succeeded(request, state) - } else { - current - } - } - - // These states can be ambient FirebaseAuth emissions or notification cleanup while a - // request is armed. They must not detach the process-local retry callback. - is AuthState.Idle, - is AuthState.RequiresEmailVerification, - is AuthState.RequiresProfileCompletion, - -> current - - else -> 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) - } - } - /** * Re-reads the signed-in user from the server and republishes the resulting auth state. * No-op when nobody is signed in. @@ -546,22 +389,7 @@ class FirebaseAuthUI private constructor( // Signing out (or switching account) mid-reload must win: publishing here would pin the // combine in authStateFlow() to a Success for a user who is already gone. if (auth.currentUser?.uid != user.uid) return - updateAuthState(handleAuthUserState(user, result = null, isNewUser = false)) - } - - /** - * Single source of truth for whether a signed-in user still owes email verification. - * Callers must not re-derive it: only password users with an email can satisfy that screen. - */ - 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) - } + updateAuthState(authUserState(user, result = null, isNewUser = false)) } /** @@ -605,8 +433,17 @@ class FirebaseAuthUI private constructor( // Sign out from Firebase Auth auth.signOut() .also { - signOutFromGoogle(context) - signOutFromFacebook() + signOutFromGoogle( + auth = auth, + context = context, + credentialManagerProvider = testCredentialManagerProvider + ?: AuthProvider.Google.DefaultCredentialManagerProvider(), + ) + signOutFromFacebook( + auth = auth, + loginManagerProvider = testLoginManagerProvider + ?: AuthProvider.Facebook.DefaultLoginManagerProvider(), + ) } // Update state to idle (user signed out) @@ -632,44 +469,16 @@ class FirebaseAuthUI private constructor( } } - /** - * Deletes the current user account and clears authentication state. - * - * This method deletes the current user's account from Firebase Auth. If the user - * hasn't signed in recently, it will throw an exception requiring reauthentication. - * The operation is performed asynchronously and will emit appropriate states during - * the process. - * - * **Example:** - * ```kotlin - * val authUI = FirebaseAuthUI.getInstance() - * - * try { - * authUI.delete(context) - * // User account is now deleted - * } catch (e: AuthException.InvalidCredentialsException) { - * // Recent login required - show reauthentication UI - * handleReauthentication() - * } catch (e: AuthException) { - * // Handle other errors - * } - * ``` - * - * @param context The Android [Context] for any required UI operations - * @throws AuthException.InvalidCredentialsException if reauthentication is required - * @throws AuthException.AuthCancelledException if the operation is cancelled - * @throws AuthException.NetworkException if a network error occurs - * @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.Reauthentication.Required] with the operation attached as its - * [AuthState.Reauthentication.Required.retryOperation]. - * [FirebaseAuthScreen] observes this state and presents a reauthentication sheet; on success - * the operation is retried automatically without any further action from the caller. + * If the [operation] throws [FirebaseAuthRecentLoginRequiredException], this raises a + * reauthentication request and suspends. [FirebaseAuthScreen] presents a sheet for it; once + * credentials are accepted the [operation] runs again on this same coroutine. + * + * If the user backs out, this throws [AuthException.AuthCancelledException] and the operation + * is not retried. A caller that must survive Activity recreation should launch from a scope + * that does too. * * All other exceptions propagate normally. * @@ -685,6 +494,7 @@ class FirebaseAuthUI private constructor( * @param context Android [Context] * @param reason Optional message shown to the user explaining why reauthentication is needed * @param operation The sensitive operation to attempt + * @throws AuthException.AuthCancelledException if the user declines reauthentication * @since 10.0.0 */ suspend fun withReauth( @@ -697,60 +507,68 @@ class FirebaseAuthUI private constructor( } catch (e: FirebaseAuthRecentLoginRequiredException) { val user = auth.currentUser ?: throw AuthException.UserNotFoundException(message = "No user is currently signed in") - updateAuthState( - AuthState.Reauthentication.Required( + // Parented to the caller's job, so a dying scope makes this unresumable. + val resolver = CompletableDeferred(parent = coroutineContext[Job]) + val required = AuthState.Reauthentication.Required( + AuthState.Reauthentication.Request( + requestId = UUID.randomUUID().toString(), user = user, reason = reason, - retryOperation = { - try { - operation() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - updateAuthState(AuthState.Error(e)) - return@Required - } - val currentUser = auth.currentUser - if (currentUser != null) { - updateAuthState(AuthState.Success(result = null, user = currentUser)) - } else { - updateAuthState(AuthState.Idle) - } - }, + resolver = resolver, ) ) + // One at a time; the caller this displaces is told rather than left waiting. + pendingReauth.getAndUpdate { required }?.request?.decline() + val retry = try { + resolver.await() + } finally { + pendingReauth.compareAndSet(required, null) + } + // Not through the resolver: failing a parented Deferred cancels the caller's scope. + if (!retry) { + throw AuthException.AuthCancelledException( + message = "Reauthentication was cancelled" + ) + } + // The screen handed over on a loading state, so it goes however the retry ends. + try { + operation() + } finally { + updateAuthState( + auth.currentUser?.let { authUserState(it, result = null, isNewUser = false) } + ?: AuthState.Idle + ) + } } } + /** + * Deletes the signed-in user's account, reauthenticating first if Firebase requires it. + * + * @param context The Android [Context] for any required UI operations + * @throws AuthException.UserNotFoundException if no user is currently signed in + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.UnknownException for other errors + * @since 10.0.0 + */ suspend fun delete(context: Context) { try { - val currentUser = auth.currentUser - ?: throw AuthException.UserNotFoundException( - message = "No user is currently signed in" - ) - - // Update state to loading - updateAuthState(AuthState.Loading(context.getString(R.string.fui_loading_deleting_account))) - - // Delete the user account - currentUser.delete().await() - - // Update state to idle (user deleted and signed out) - updateAuthState(AuthState.Idle) - - } catch (e: FirebaseAuthRecentLoginRequiredException) { - auth.currentUser?.let { - updateAuthState( - AuthState.Reauthentication.Required( - user = it, - retryOperation = { ctx -> delete(ctx) }, + withReauth(context) { + val currentUser = auth.currentUser + ?: throw AuthException.UserNotFoundException( + message = "No user is currently signed in" ) + updateAuthState( + AuthState.Loading(context.getString(R.string.fui_loading_deleting_account)) ) + currentUser.delete().await() + // The user is deleted and therefore signed out. + updateAuthState(AuthState.Idle) } - throw AuthException.InvalidCredentialsException( - message = e.message ?: "Recent login required for this operation", - cause = e - ) + } catch (e: AuthException.AuthCancelledException) { + // Declined, not failed: the screen already published the terminal state. + throw e } catch (e: CancellationException) { // Handle coroutine cancellation val cancelledException = AuthException.AuthCancelledException( 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 1027b9cab..a073786da 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 @@ -2,9 +2,9 @@ package com.firebase.ui.auth.configuration.auth_provider import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope +import com.firebase.ui.auth.AuthFlowScope 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 kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch @@ -13,7 +13,6 @@ import kotlinx.coroutines.tasks.await /** * Creates a remembered launcher function for anonymous sign-in. * - * @param config Authentication UI configuration * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure * @return A launcher function that starts the anonymous sign-in flow when invoked * @@ -21,8 +20,7 @@ import kotlinx.coroutines.tasks.await * @see createOrLinkUserWithEmailAndPassword for upgrading anonymous accounts */ @Composable -internal fun FirebaseAuthUI.rememberAnonymousSignInHandler( - config: AuthUIConfiguration, +internal fun AuthFlowScope.rememberAnonymousSignInHandler( onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { val context = androidx.compose.ui.platform.LocalContext.current @@ -30,14 +28,14 @@ internal fun FirebaseAuthUI.rememberAnonymousSignInHandler( return { coroutineScope.launch { try { - signInAnonymously(config) + signInAnonymously() } catch (e: AuthException) { // Already an AuthException, don't re-wrap it - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) if (e !is AuthException.AuthCancelledException) onSignInFailure(e) } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } } @@ -112,24 +110,24 @@ internal fun FirebaseAuthUI.rememberAnonymousSignInHandler( * @see createOrLinkUserWithEmailAndPassword for email/password upgrade * @see signInWithPhoneAuthCredential for phone authentication upgrade */ -internal suspend fun FirebaseAuthUI.signInAnonymously(config: AuthUIConfiguration) { +internal suspend fun AuthFlowScope.signInAnonymously() { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningInAnonymously)) + emit(AuthState.Loading(config.stringProvider.loadingSigningInAnonymously)) val result = auth.signInAnonymously().await() - updateAuthStateWithResult(result, defaultIsNewUser = true) + emitResult(result, defaultIsNewUser = true) } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in anonymously was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw 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 8fab40ede..057c5eddc 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 @@ -18,9 +18,9 @@ import android.content.Context import android.net.Uri import android.util.Log import com.firebase.ui.auth.R +import com.firebase.ui.auth.AuthFlowScope 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 @@ -54,9 +54,8 @@ private const val TAG = "EmailAuthProvider" * - 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( +internal suspend fun AuthFlowScope.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") @@ -137,9 +136,8 @@ internal suspend fun FirebaseAuthUI.signInOrReauth( * } * ``` */ -internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( +internal suspend fun AuthFlowScope.createOrLinkUserWithEmailAndPassword( context: Context, - config: AuthUIConfiguration, provider: AuthProvider.Email, name: String?, email: String, @@ -184,7 +182,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( } } - updateAuthState(AuthState.Loading(config.stringProvider.loadingCreatingUser)) + emit(AuthState.Loading(config.stringProvider.loadingCreatingUser)) val result = if (shouldLinkCredential) { auth.currentUser?.linkWithCredential(requireNotNull(pendingCredential))?.await() } else { @@ -226,7 +224,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( } } - updateAuthStateWithResult(result, defaultIsNewUser = true) + emitResult(result, defaultIsNewUser = true) return result } catch (e: FirebaseAuthUserCollisionException) { // Account collision: email already exists @@ -241,21 +239,21 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( }, cause = e ) - updateAuthState(AuthState.Error(accountLinkingException)) + emit(AuthState.Error(accountLinkingException)) throw accountLinkingException } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Create or link user with email and password was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } } @@ -341,21 +339,19 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( * } * ``` */ -internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( +internal suspend fun AuthFlowScope.signInWithEmailAndPassword( context: Context, - config: AuthUIConfiguration, email: String, password: String, credentialForLinking: AuthCredential? = null, skipCredentialSave: Boolean = false, ): AuthResult? { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningIn)) + emit(AuthState.Loading(config.stringProvider.loadingSigningIn)) // 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), ) } @@ -390,7 +386,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( credential = credentialToValidate, cause = null ) - updateAuthState(AuthState.Error(accountLinkingException)) + emit(AuthState.Error(accountLinkingException)) throw accountLinkingException } } else { @@ -408,7 +404,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( credential = credentialToValidate, cause = null ) - updateAuthState(AuthState.Error(accountLinkingException)) + emit(AuthState.Error(accountLinkingException)) throw accountLinkingException } } @@ -468,34 +464,33 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( } } - updateAuthStateWithResult(result) + emitResult(result) } } catch (e: FirebaseAuthMultiFactorException) { // MFA required - extract resolver and update state val resolver = e.resolver val hint = resolver.hints.firstOrNull()?.displayName - updateAuthState(AuthState.RequiresMfa(resolver, hint)) + emit(AuthState.RequiresMfa(resolver, hint)) return null } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in with email and password was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = recoverLegacyDifferentSignInMethod(config, email, e) + val authException = recoverLegacyDifferentSignInMethod(email, e) ?: AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } } -private suspend fun FirebaseAuthUI.recoverLegacyDifferentSignInMethod( - config: AuthUIConfiguration, +private suspend fun AuthFlowScope.recoverLegacyDifferentSignInMethod( email: String, cause: Exception, ): AuthException.DifferentSignInMethodRequiredException? { @@ -545,7 +540,7 @@ private fun selectSuggestedLegacySignInMethod( } } -private suspend fun FirebaseAuthUI.fetchLegacySignInMethods(email: String): List { +private suspend fun AuthFlowScope.fetchLegacySignInMethods(email: String): List { return try { @Suppress("DEPRECATION") auth.fetchSignInMethodsForEmail(email) @@ -643,19 +638,18 @@ private fun SignInMethodQueryResult?.toSignInMethods(): List = * // User signed in with email link (passwordless) * ``` */ -internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( - config: AuthUIConfiguration, +internal suspend fun AuthFlowScope.signInAndLinkWithCredential( credential: AuthCredential, provider: AuthProvider? = null, displayName: String? = null, photoUrl: Uri? = null, ): AuthResult? { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingLinkingCredential)) + emit(AuthState.Loading(config.stringProvider.loadingLinkingCredential)) val result = if (canUpgradeAnonymous(config, auth) || canLinkCredential(config, auth)) { auth.currentUser?.linkWithCredential(credential)?.await() } else { - signInOrReauth(credential, config) + signInOrReauth(credential) } // signInOrReauth returns null in reauth mode (Task has no AuthResult). // Reconstruct success state from the now-reauthenticated current user. @@ -664,7 +658,7 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( ?: throw AuthException.UserNotFoundException( message = "No user is currently signed in for reauthentication" ) - updateAuthState( + emit( AuthState.Success( result = null, user = reauthenticatedUser, @@ -674,13 +668,13 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( return null } result?.user?.let { mergeProfile(auth, displayName, photoUrl) } - updateAuthStateWithResult(result) + emitResult(result) return result } catch (e: FirebaseAuthMultiFactorException) { // MFA required - extract resolver and update state val resolver = e.resolver val hint = resolver.hints.firstOrNull()?.displayName - updateAuthState(AuthState.RequiresMfa(resolver, hint)) + emit(AuthState.RequiresMfa(resolver, hint)) return null } catch (e: FirebaseAuthUserCollisionException) { // Account collision: account already exists with different sign-in method @@ -702,21 +696,21 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( credential = credentialForException, cause = e ) - updateAuthState(AuthState.Error(accountLinkingException)) + emit(AuthState.Error(accountLinkingException)) throw accountLinkingException } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in and link with credential was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } } @@ -835,16 +829,15 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( * @see EmailLinkPersistenceManager * @see com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail */ -internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( +internal suspend fun AuthFlowScope.sendSignInLinkToEmail( context: Context, - config: AuthUIConfiguration, provider: AuthProvider.Email, email: String, credentialForLinking: AuthCredential?, persistenceManager: PersistenceManager = EmailLinkPersistenceManager.default, ) { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSendingEmailLink)) + emit(AuthState.Loading(config.stringProvider.loadingSendingEmailLink)) // Get anonymousUserId if can upgrade anonymously else default to empty string. // NOTE: check for empty string instead of null to validate anonymous user ID matches @@ -871,20 +864,20 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( // Save Email to dataStore for use in signInWithEmailLink persistenceManager.saveEmail(context, email, sessionId, anonymousUserId) - updateAuthState(AuthState.EmailSignInLinkSent()) + emit(AuthState.EmailSignInLinkSent()) } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Send sign in link to email was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } } @@ -993,16 +986,15 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( * @see sendSignInLinkToEmail for sending the initial email link * @see EmailLinkPersistenceManager for session data management */ -internal suspend fun FirebaseAuthUI.signInWithEmailLink( +internal suspend fun AuthFlowScope.signInWithEmailLink( context: Context, - config: AuthUIConfiguration, provider: AuthProvider.Email, email: String, emailLink: String, persistenceManager: PersistenceManager = EmailLinkPersistenceManager.default, ): AuthResult? { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningInWithEmailLink)) + emit(AuthState.Loading(config.stringProvider.loadingSigningInWithEmailLink)) // Validate link format if (!auth.isSignInWithEmailLink(emailLink)) { @@ -1032,14 +1024,14 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( // Session ID must always be present in the link if (sessionIdFromLink.isNullOrEmpty()) { val exception = AuthException.InvalidEmailLinkException() - updateAuthState(AuthState.Error(exception)) + emit(AuthState.Error(exception)) throw exception } // These scenarios require same-device flow if (isEmailLinkForceSameDeviceEnabled || !anonymousUserIdFromLink.isNullOrEmpty()) { val exception = AuthException.EmailLinkWrongDeviceException() - updateAuthState(AuthState.Error(exception)) + emit(AuthState.Error(exception)) throw exception } @@ -1067,7 +1059,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( || currentUser.uid != anonymousUserIdFromLink ) { val exception = AuthException.EmailLinkDifferentAnonymousUserException() - updateAuthState(AuthState.Error(exception)) + emit(AuthState.Error(exception)) throw exception } } @@ -1078,12 +1070,11 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( val result = if (storedCredentialForLink == null) { // Normal Flow: Just sign in with email link - handleEmailLinkNormalFlow(config, emailLinkCredential) + handleEmailLinkNormalFlow(emailLinkCredential) } else { // Linking Flow: Sign in with email link, then link the social credential handleEmailLinkCredentialLinkingFlow( context = context, - config = config, email = email, emailLinkCredential = emailLinkCredential, storedCredentialForLink = storedCredentialForLink, @@ -1092,30 +1083,30 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( // Clear DataStore after success persistenceManager.clear(context) // In reauth mode the stamped Success is already published and there is no AuthResult, so - // updateAuthStateWithResult would overwrite the stamp with Idle and orphan the operation. + // emitResult would overwrite the stamp with Idle and orphan the operation. if (result == null && config.isReauthenticationMode) { return null } - updateAuthStateWithResult(result) + emitResult(result) return result } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in with email link was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } } -private suspend fun FirebaseAuthUI.handleDifferentDeviceErrorFlow( +private suspend fun AuthFlowScope.handleDifferentDeviceErrorFlow( oobCode: String, providerIdFromLink: String?, emailLink: String @@ -1126,7 +1117,7 @@ private suspend fun FirebaseAuthUI.handleDifferentDeviceErrorFlow( } catch (e: Exception) { // Invalid action code val exception = AuthException.InvalidEmailLinkException(cause = e) - updateAuthState(AuthState.Error(exception)) + emit(AuthState.Error(exception)) throw exception } @@ -1138,7 +1129,7 @@ private suspend fun FirebaseAuthUI.handleDifferentDeviceErrorFlow( providerName = providerNameForMessage, emailLink = emailLink ) - updateAuthState(AuthState.Error(exception)) + emit(AuthState.Error(exception)) throw exception } @@ -1147,20 +1138,18 @@ private suspend fun FirebaseAuthUI.handleDifferentDeviceErrorFlow( cause = null, emailLink = emailLink ) - updateAuthState(AuthState.Error(exception)) + emit(AuthState.Error(exception)) throw exception } -private suspend fun FirebaseAuthUI.handleEmailLinkNormalFlow( - config: AuthUIConfiguration, +private suspend fun AuthFlowScope.handleEmailLinkNormalFlow( emailLinkCredential: AuthCredential, ): AuthResult? { - return signInAndLinkWithCredential(config, emailLinkCredential) + return signInAndLinkWithCredential(emailLinkCredential) } -private suspend fun FirebaseAuthUI.handleEmailLinkCredentialLinkingFlow( +private suspend fun AuthFlowScope.handleEmailLinkCredentialLinkingFlow( context: Context, - config: AuthUIConfiguration, email: String, emailLinkCredential: AuthCredential, storedCredentialForLink: AuthCredential, @@ -1188,7 +1177,7 @@ private suspend fun FirebaseAuthUI.handleEmailLinkCredentialLinkingFlow( credential = storedCredentialForLink, cause = null ) - updateAuthState(AuthState.Error(accountLinkingException)) + emit(AuthState.Error(accountLinkingException)) throw accountLinkingException } } else { @@ -1273,28 +1262,27 @@ private suspend fun FirebaseAuthUI.handleEmailLinkCredentialLinkingFlow( * * @see com.google.firebase.auth.ActionCodeSettings */ -internal suspend fun FirebaseAuthUI.sendPasswordResetEmail( +internal suspend fun AuthFlowScope.sendPasswordResetEmail( email: String, - config: AuthUIConfiguration, actionCodeSettings: ActionCodeSettings? = null, ) { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSendingPasswordResetEmail)) + emit(AuthState.Loading(config.stringProvider.loadingSendingPasswordResetEmail)) auth.sendPasswordResetEmail(email, actionCodeSettings).await() - updateAuthState(AuthState.PasswordResetLinkSent()) + emit(AuthState.PasswordResetLinkSent()) } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Send password reset email was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) + emit(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 3cd51c1d3..3af460f4c 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 @@ -14,6 +14,7 @@ package com.firebase.ui.auth.configuration.auth_provider +import com.google.firebase.auth.FirebaseAuth import android.content.Context import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult @@ -29,9 +30,9 @@ import com.facebook.FacebookCallback import com.facebook.FacebookException import com.facebook.login.LoginManager import com.facebook.login.LoginResult +import com.firebase.ui.auth.AuthFlowScope 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.util.EmailLinkPersistenceManager import com.firebase.ui.auth.util.SignInPreferenceManager @@ -56,9 +57,8 @@ import kotlinx.coroutines.launch * @see signInWithFacebook */ @Composable -internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( +internal fun AuthFlowScope.rememberSignInWithFacebookLauncher( context: Context, - config: AuthUIConfiguration, provider: AuthProvider.Facebook, loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(), onSignInFailure: (AuthException) -> Unit = {}, @@ -67,7 +67,8 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( val callbackManager = remember { CallbackManager.Factory.create() } val loginManager = LoginManager.getInstance() val currentContext by rememberUpdatedState(context) - val currentConfig by rememberUpdatedState(config) + // Registered once under DisposableEffect(Unit), so it must not close over a stale scope. + val currentScope by rememberUpdatedState(this) val currentProvider by rememberUpdatedState(provider) val currentOnSignInFailure by rememberUpdatedState(onSignInFailure) @@ -86,32 +87,31 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( override fun onSuccess(result: LoginResult) { coroutineScope.launch { try { - signInWithFacebook( + currentScope.signInWithFacebook( context = currentContext, - config = currentConfig, provider = currentProvider, accessToken = result.accessToken, ) } catch (e: AuthException) { // Already an AuthException, don't re-wrap it - updateAuthState(AuthState.Error(e)) + currentScope.emit(AuthState.Error(e)) if (e !is AuthException.AuthCancelledException) currentOnSignInFailure(e) } catch (e: Exception) { val authException = AuthException.from(e, currentContext) - updateAuthState(AuthState.Error(authException)) + currentScope.emit(AuthState.Error(authException)) if (authException !is AuthException.AuthCancelledException) currentOnSignInFailure(authException) } } } override fun onCancel() { - updateAuthState(AuthState.Idle) + currentScope.emit(AuthState.Idle) } override fun onError(error: FacebookException) { Log.e("FacebookAuthProvider", "Error during Facebook sign in", error) val authException = AuthException.from(error, currentContext) - updateAuthState( + currentScope.emit( AuthState.Error( authException ) @@ -124,11 +124,11 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( } return { - updateAuthState( + emit( AuthState.Loading(config.stringProvider.loadingSigningInWithFacebook) ) try { - (testLoginManagerProvider ?: loginManagerProvider).logOut() + (this.loginManagerProvider ?: loginManagerProvider).logOut() } catch (e: Exception) { Log.w("FacebookAuthProvider", "Failed to clear Facebook session before sign in", e) } @@ -157,21 +157,19 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( * @see rememberSignInWithFacebookLauncher * @see signInAndLinkWithCredential */ -internal suspend fun FirebaseAuthUI.signInWithFacebook( +internal suspend fun AuthFlowScope.signInWithFacebook( context: Context, - config: AuthUIConfiguration, provider: AuthProvider.Facebook, accessToken: AccessToken, credentialProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(), ) { try { - updateAuthState( + emit( AuthState.Loading(config.stringProvider.loadingSigningInWithFacebook) ) val profileData = provider.fetchFacebookProfile(accessToken) val credential = credentialProvider.getCredential(accessToken.token) signInAndLinkWithCredential( - config = config, credential = credential, provider = provider, displayName = profileData?.displayName, @@ -205,25 +203,25 @@ internal suspend fun FirebaseAuthUI.signInWithFacebook( ) // Re-throw to let UI handle the account linking flow - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: FacebookException) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in with facebook was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } } @@ -238,12 +236,13 @@ internal suspend fun FirebaseAuthUI.signInWithFacebook( * This is typically called as part of the overall sign-out flow when a user signs out * from Firebase Authentication. */ -internal fun FirebaseAuthUI.signOutFromFacebook( +internal fun signOutFromFacebook( + auth: FirebaseAuth, loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(), ) { try { - if (Provider.fromId(getCurrentUser()?.providerId) != Provider.FACEBOOK) return - (testLoginManagerProvider ?: loginManagerProvider).logOut() + if (Provider.fromId(auth.currentUser?.providerId) != Provider.FACEBOOK) return + loginManagerProvider.logOut() } catch (e: Exception) { Log.e("FacebookAuthProvider", "Error during Facebook sign out", e) } 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 ebaace91b..055a91e8a 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 @@ -1,5 +1,6 @@ package com.firebase.ui.auth.configuration.auth_provider +import com.google.firebase.auth.FirebaseAuth import android.content.Context import android.util.Log import androidx.compose.runtime.Composable @@ -8,9 +9,9 @@ import androidx.credentials.CredentialManager import androidx.credentials.exceptions.GetCredentialCancellationException import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.NoCredentialException +import com.firebase.ui.auth.AuthFlowScope 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.util.EmailLinkPersistenceManager import com.firebase.ui.auth.util.SignInPreferenceManager @@ -54,9 +55,8 @@ import kotlinx.coroutines.launch * @see AuthProvider.Google */ @Composable -internal fun FirebaseAuthUI.rememberGoogleSignInHandler( +internal fun AuthFlowScope.rememberGoogleSignInHandler( context: Context, - config: AuthUIConfiguration, provider: AuthProvider.Google, onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { @@ -64,13 +64,13 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler( return { coroutineScope.launch { try { - signInWithGoogle(context, config, provider) + signInWithGoogle(context, provider) } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) if (e !is AuthException.AuthCancelledException) onSignInFailure(e) } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } } @@ -114,16 +114,15 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler( * @see AuthProvider.Google * @see signInAndLinkWithCredential */ -internal suspend fun FirebaseAuthUI.signInWithGoogle( +internal suspend fun AuthFlowScope.signInWithGoogle( context: Context, - config: AuthUIConfiguration, provider: AuthProvider.Google, authorizationProvider: AuthProvider.Google.AuthorizationProvider = AuthProvider.Google.DefaultAuthorizationProvider(), credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider = AuthProvider.Google.DefaultCredentialManagerProvider(), ) { var idTokenFromResult: String? = null try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningInWithGoogle)) + emit(AuthState.Loading(config.stringProvider.loadingSigningInWithGoogle)) // Request OAuth scopes if specified (before sign-in) if (provider.scopes.isNotEmpty()) { @@ -133,7 +132,7 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( } catch (e: Exception) { // Continue with sign-in even if scope authorization fails val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) } } @@ -143,7 +142,7 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( val result = if (provider.filterByAuthorizedAccounts) { // Default behavior: Try authorized accounts first, fallback to all accounts try { - (testCredentialManagerProvider ?: credentialManagerProvider).getGoogleCredential( + (this.credentialManagerProvider ?: credentialManagerProvider).getGoogleCredential( context = context, credentialManager = CredentialManager.create(context), serverClientId = provider.serverClientId!!, @@ -154,7 +153,7 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( // No authorized accounts found, try again with all accounts for sign-up flow Log.d("GoogleAuthProvider", "No authorized accounts found, showing all Google accounts for sign-up") try { - (testCredentialManagerProvider ?: credentialManagerProvider).getGoogleCredential( + (this.credentialManagerProvider ?: credentialManagerProvider).getGoogleCredential( context = context, credentialManager = CredentialManager.create(context), serverClientId = provider.serverClientId!!, @@ -186,7 +185,7 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( } } else { // Developer explicitly wants to show all accounts (no fallback needed) - (testCredentialManagerProvider ?: credentialManagerProvider).getGoogleCredential( + (this.credentialManagerProvider ?: credentialManagerProvider).getGoogleCredential( context = context, credentialManager = CredentialManager.create(context), serverClientId = provider.serverClientId!!, @@ -197,7 +196,6 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( idTokenFromResult = result.idToken signInAndLinkWithCredential( - config = config, credential = result.credential, provider = provider, displayName = result.displayName, @@ -231,30 +229,30 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( ) // Re-throw to let UI handle the account linking flow - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: GetCredentialCancellationException) { // User dismissed the Credential Manager sheet - this is a normal user action, // not an error, so it goes to AuthState.Cancelled instead of AuthState.Error. // Swallow (don't rethrow) so rememberGoogleSignInHandler's catch block doesn't // overwrite this state with AuthState.Error. - updateAuthState(AuthState.Cancelled) + emit(AuthState.Cancelled) } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in with google was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } } @@ -276,13 +274,14 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( * * @param context Android context for Credential Manager */ -internal suspend fun FirebaseAuthUI.signOutFromGoogle( +internal suspend fun signOutFromGoogle( + auth: FirebaseAuth, context: Context, credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider = AuthProvider.Google.DefaultCredentialManagerProvider(), ) { try { - if (Provider.fromId(getCurrentUser()?.providerId) != Provider.GOOGLE) return - (testCredentialManagerProvider ?: credentialManagerProvider).clearCredentialState( + if (Provider.fromId(auth.currentUser?.providerId) != Provider.GOOGLE) return + credentialManagerProvider.clearCredentialState( context = context, credentialManager = CredentialManager.create(context) ) 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 69e7bd135..9dfd993fd 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 @@ -4,9 +4,9 @@ import android.app.Activity import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope +import com.firebase.ui.auth.AuthFlowScope 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.canUpgradeAnonymous import com.firebase.ui.auth.util.SignInPreferenceManager @@ -50,10 +50,9 @@ import kotlinx.coroutines.tasks.await * @see signInWithProvider */ @Composable -internal fun FirebaseAuthUI.rememberOAuthSignInHandler( +internal fun AuthFlowScope.rememberOAuthSignInHandler( context: Context, activity: Activity?, - config: AuthUIConfiguration, provider: AuthProvider.OAuth, onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { @@ -68,16 +67,15 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler( try { signInWithProvider( context = context, - config = config, activity = activity, provider = provider ) } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) if (e !is AuthException.AuthCancelledException) onSignInFailure(e) } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } } @@ -124,14 +122,13 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler( * @see AuthProvider.OAuth * @see signInAndLinkWithCredential */ -internal suspend fun FirebaseAuthUI.signInWithProvider( +internal suspend fun AuthFlowScope.signInWithProvider( context: Context, - config: AuthUIConfiguration, activity: Activity, provider: AuthProvider.OAuth, ) { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningInWithProvider(provider.providerName))) + emit(AuthState.Loading(config.stringProvider.loadingSigningInWithProvider(provider.providerName))) // Build OAuth provider with scopes and custom parameters val oauthProvider = OAuthProvider @@ -157,7 +154,6 @@ internal suspend fun FirebaseAuthUI.signInWithProvider( if (credential != null) { // Complete the pending sign-in/link flow signInAndLinkWithCredential( - config = config, credential = credential, provider = provider, displayName = authResult.user?.displayName, @@ -207,7 +203,7 @@ internal suspend fun FirebaseAuthUI.signInWithProvider( ?: throw AuthException.UserNotFoundException( message = "No user is currently signed in for reauthentication" ) - updateAuthState( + emit( AuthState.Success( result = authResult, user = reauthenticatedUser, @@ -215,7 +211,7 @@ internal suspend fun FirebaseAuthUI.signInWithProvider( ) ) } else { - updateAuthStateWithResult(authResult) + emitResult(authResult) } } else { throw AuthException.UnknownException( @@ -236,23 +232,23 @@ internal suspend fun FirebaseAuthUI.signInWithProvider( credential = credential, cause = e ) - updateAuthState(AuthState.Error(accountLinkingException)) + emit(AuthState.Error(accountLinkingException)) throw accountLinkingException } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Signing in with ${provider.providerName} was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(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 8cf9fa7c2..7338c5ac2 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 @@ -2,9 +2,9 @@ package com.firebase.ui.auth.configuration.auth_provider import android.app.Activity import android.content.Context +import com.firebase.ui.auth.AuthFlowScope 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.util.SignInPreferenceManager import com.google.firebase.auth.AuthResult @@ -110,17 +110,16 @@ import kotlinx.coroutines.CancellationException * @throws AuthException.NetworkException if a network error occurs * @throws kotlinx.coroutines.CancellationException if the caller's coroutine is cancelled */ -internal suspend fun FirebaseAuthUI.verifyPhoneNumber( +internal suspend fun AuthFlowScope.verifyPhoneNumber( provider: AuthProvider.Phone, activity: Activity?, phoneNumber: String, - config: AuthUIConfiguration, multiFactorSession: MultiFactorSession? = null, forceResendingToken: PhoneAuthProvider.ForceResendingToken? = null, verifier: AuthProvider.Phone.Verifier = AuthProvider.Phone.DefaultVerifier(), ) { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingVerifyingPhoneNumber)) + emit(AuthState.Loading(config.stringProvider.loadingVerifyingPhoneNumber)) provider.verifyPhoneNumberFlow( auth = auth, activity = activity, @@ -131,11 +130,11 @@ internal suspend fun FirebaseAuthUI.verifyPhoneNumber( ).collect { result -> when (result) { is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { - updateAuthState(AuthState.SMSAutoVerified(credential = result.credential)) + emit(AuthState.SMSAutoVerified(credential = result.credential)) } is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { - updateAuthState( + emit( AuthState.PhoneNumberVerificationRequired( verificationId = result.verificationId, forceResendingToken = result.token, @@ -149,11 +148,11 @@ internal suspend fun FirebaseAuthUI.verifyPhoneNumber( // a retraction from here would race the replacement's own Loading. throw e } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } } @@ -205,19 +204,17 @@ internal suspend fun FirebaseAuthUI.verifyPhoneNumber( * @throws AuthException.AuthCancelledException if the operation is cancelled * @throws AuthException.NetworkException if a network error occurs */ -internal suspend fun FirebaseAuthUI.submitVerificationCode( +internal suspend fun AuthFlowScope.submitVerificationCode( context: Context, - config: AuthUIConfiguration, verificationId: String, code: String, credentialProvider: AuthProvider.Phone.CredentialProvider = AuthProvider.Phone.DefaultCredentialProvider(), ): AuthResult? { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSubmittingVerificationCode)) + emit(AuthState.Loading(config.stringProvider.loadingSubmittingVerificationCode)) val credential = credentialProvider.getCredential(verificationId, code) return signInWithPhoneAuthCredential( context = context, - config = config, credential = credential ) } catch (e: CancellationException) { @@ -225,14 +222,14 @@ internal suspend fun FirebaseAuthUI.submitVerificationCode( message = "Submit verification code was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } } @@ -298,15 +295,13 @@ internal suspend fun FirebaseAuthUI.submitVerificationCode( * @throws AuthException.AuthCancelledException if the operation is cancelled * @throws AuthException.NetworkException if a network error occurs */ -internal suspend fun FirebaseAuthUI.signInWithPhoneAuthCredential( +internal suspend fun AuthFlowScope.signInWithPhoneAuthCredential( context: Context, - config: AuthUIConfiguration, credential: PhoneAuthCredential, ): AuthResult? { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningInWithPhone)) + emit(AuthState.Loading(config.stringProvider.loadingSigningInWithPhone)) val result = signInAndLinkWithCredential( - config = config, credential = credential, ) @@ -335,14 +330,14 @@ internal suspend fun FirebaseAuthUI.signInWithPhoneAuthCredential( message = "Sign in with phone was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/AuthRoute.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/AuthRoute.kt index 347670341..aa9b31195 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/AuthRoute.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/AuthRoute.kt @@ -72,7 +72,7 @@ sealed interface AuthRoute { * type in the same stack cannot mean two configurations, and a wrapper is the cheapest way to * say "this step, but in reauthentication mode". * - * [requestId] and [userUid] make the entry the arming marker itself, which is why nothing else + * [requestId] and [userUid] make the entry the presentation marker itself, which is why nothing else * has to be saved alongside the stack. */ @Serializable 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 ba54cb764..e523f6af9 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 @@ -14,6 +14,9 @@ package com.firebase.ui.auth.ui.screens +import com.firebase.ui.auth.AuthFlowScope +import com.firebase.ui.auth.LocalAuthFlowScope +import com.firebase.ui.auth.hostAuthFlowScope import android.util.Log import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Arrangement @@ -68,7 +71,6 @@ import com.firebase.ui.auth.configuration.DefaultAuthContentTransform import com.firebase.ui.auth.configuration.DefaultAuthPredictivePopContentTransform 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 @@ -105,7 +107,8 @@ import com.firebase.ui.auth.ui.screens.phone.phoneAuthDestinations import com.firebase.ui.auth.ui.screens.phone.rememberPhoneAuthFlowState import com.firebase.ui.auth.ui.screens.reauth.ReauthContentState import com.firebase.ui.auth.ui.screens.reauth.ReauthSceneStrategy -import com.firebase.ui.auth.ui.screens.reauth.armedReauth +import com.firebase.ui.auth.ui.screens.reauth.presentedReauth +import com.firebase.ui.auth.ui.screens.reauth.rememberReauthFlowState import com.firebase.ui.auth.ui.screens.reauth.clearReauth import com.firebase.ui.auth.ui.screens.reauth.navigateReauth import com.firebase.ui.auth.ui.screens.reauth.returnToReauthStart @@ -142,7 +145,7 @@ import kotlinx.coroutines.launch * footer for the *default* method-picker layout. Ignored when [customMethodPickerLayout] is * provided, since that slot takes over the whole screen. * @param reauthContent Optional slot that replaces the default reauthentication bottom sheet, - * receiving a [ReauthContentState]. The library owns the credential exchange. An armed + * receiving a [ReauthContentState]. The library owns the credential exchange. An outstanding * reauthentication survives Activity recreation (rotation) but not process death; if it is lost * the flow surfaces an error rather than dropping the pending operation silently. An enrolled * second factor is challenged over the slot, honouring [mfaChallengeContent]. @@ -180,18 +183,21 @@ fun FirebaseAuthScreen( val observedAuthState by remember(authUI) { authUI.authStateFlow() } .collectAsState(initial = null as AuthState?) - val authState = observedAuthState ?: AuthState.Idle + val rawAuthState = observedAuthState ?: AuthState.Idle + val reauthFlowState = rememberReauthFlowState() + val reauthState = reauthFlowState.phase + val pendingReauth by authUI.pendingReauth.collectAsState() + val hostStateHolder = rememberUpdatedState(rawAuthState) + val hostScope = remember(authUI, configuration, hostStateHolder) { + hostAuthFlowScope(authUI, configuration, hostStateHolder) + } + val authState = rawAuthState val dialogController = rememberTopLevelDialogController(stringProvider) { authState } val lastSuccessfulUserId = remember { mutableStateOf(null) } val pendingLinkingCredential = remember { mutableStateOf(null) } val pendingResolver = remember { mutableStateOf(null) } val mfaEnrollmentFlowState = rememberMfaEnrollmentFlowState() val phoneAuthFlowState = rememberPhoneAuthFlowState(configuration) - DisposableEffect(authUI) { - authUI.addReauthenticationDrainer() - onDispose { authUI.removeReauthenticationDrainer() } - } - val reauthState = authState as? AuthState.Reauthentication val reauthRequest = reauthState?.request val reauthConfig = reauthRequest?.let { configuration.toReauthConfiguration(it.user) } // Keyed to the request, never the host flow's: another operation, maybe another user. @@ -230,14 +236,26 @@ fun FirebaseAuthScreen( } val skipsMethodPicker = startRoute != AuthRoute.MethodPicker val backStack = rememberNavBackStack(startRoute.toKey()) - // The stack is the arming marker: a Reauth entry persists with it, across recreation and death. - val armedReauth = backStack.armedReauth() + // The stack is the presentation marker: a Reauth entry persists with it, across recreation and death. + val presentedReauth = backStack.presentedReauth() val clearReauthPresentation: () -> Unit = remember(backStack) { { backStack.clearReauth() } } + /** + * Ends the request: clears presentation, clears the phase, publishes [terminal], then resolves + * the caller. The order matters — the caller resolves last so a fast retry's outcome stands. + */ + val finishReauth: (AuthState, Boolean) -> Unit = + remember(authUI, clearReauthPresentation, reauthFlowState) { + { terminal, retryOperation -> + clearReauthPresentation() + reauthFlowState.finish(retryOperation) + authUI.updateAuthState(terminal) + } + } val currentOnSignInCancelled = rememberUpdatedState(onSignInCancelled) - val onReauthDismiss: () -> Unit = remember(authUI, clearReauthPresentation) { + val onReauthDismiss: () -> Unit = remember(finishReauth) { { - clearReauthPresentation() - authUI.finishReauthentication(AuthState.Idle) + // The user backed out, so the pending operation is not retried. + finishReauth(AuthState.Idle, false) currentOnSignInCancelled.value() } } @@ -246,12 +264,13 @@ fun FirebaseAuthScreen( * reauthentication entry underneath means step back and cancel the attempt; nothing underneath * means the surface itself is being left. */ - val onLeaveReauthStep: (AuthRoute.Reauth) -> Unit = remember(authUI, backStack, onReauthDismiss) { + val onLeaveReauthStep: (AuthRoute.Reauth) -> Unit = + remember(reauthFlowState, backStack, onReauthDismiss) { { marker -> val below = backStack.getOrNull(backStack.lastIndex - 1) if (below is AuthRoute.Reauth) { backStack.popOrNull() - authUI.updateReauthentication(marker.requestId) { it.attemptCancelled() } + reauthFlowState.update(marker.requestId) { it.attemptCancelled() } } else { onReauthDismiss() } @@ -259,13 +278,17 @@ fun FirebaseAuthScreen( } // The slot *is* the provider chooser, even for one provider, so it always starts at the picker // step. The default sheet skips straight into a lone provider's flow, as it always did. - val reauthStartStep: AuthRoute.Destination = remember(reauthConfig, reauthContent) { - when { - reauthContent != null -> AuthRoute.MethodPicker - reauthConfig != null -> getStartRoute(reauthConfig).toKey() - else -> AuthRoute.MethodPicker + val reauthStartStepFor: (AuthUIConfiguration?) -> AuthRoute.Destination = + remember(reauthContent) { + { config -> + when { + // The slot is the provider chooser, so it starts at the picker even for one. + reauthContent != null -> AuthRoute.MethodPicker + config != null -> getStartRoute(config).toKey() + else -> AuthRoute.MethodPicker + } + } } - } val stepTransitionSpec = configuration.transitions?.transitionSpec ?: DefaultAuthContentTransform val stepPopTransitionSpec = configuration.transitions?.popTransitionSpec @@ -286,10 +309,9 @@ fun FirebaseAuthScreen( val emailProvider = configuration.providers.filterIsInstance().firstOrNull() val logoAsset = configuration.logo - val onOuterProviderSelected = authUI.rememberOnProviderSelected( + val onOuterProviderSelected = hostScope.rememberOnProviderSelected( context = context, activity = activity, - config = configuration, onNavigate = { route -> if (route == AuthRoute.Email) { backStack.navigateToEmailStep(AuthRoute.Email.SignIn(typedEmail.value)) @@ -326,7 +348,9 @@ fun FirebaseAuthScreen( CompositionLocalProvider( LocalAuthUIStringProvider provides configuration.stringProvider, LocalTopLevelDialogController provides dialogController, - LocalAuthUITheme provides (configuration.theme ?: LocalAuthUITheme.current) + LocalAuthUITheme provides (configuration.theme ?: LocalAuthUITheme.current), + // reauthDestinations overrides this with the outstanding request's own flow. + LocalAuthFlowScope provides hostScope, ) { Surface( modifier = modifier @@ -526,6 +550,7 @@ fun FirebaseAuthScreen( context = context, configuration = configuration, stringProvider = stringProvider, + reauthFlowState = reauthFlowState, surface = reauthSurfaceHolder, phoneFlowState = reauthPhoneFlowState, emailContent = emailContent, @@ -548,7 +573,7 @@ fun FirebaseAuthScreen( content = mfaChallengeContent, onSuccess = { result -> pendingResolver.value = null - authUI.updateAuthStateWithResult(result) + hostScope.emitResult(result) }, // Load-bearing pop: Cancelled below then sees the start step, so it skips a reset that blanks the address. onCancel = { @@ -574,17 +599,15 @@ fun FirebaseAuthScreen( EmailLinkPersistenceManager.default.retrieveSessionRecord(context)?.email if (savedEmail != null) { - authUI.signInWithEmailLink( + hostScope.signInWithEmailLink( context = context, - config = configuration, provider = emailProvider, email = savedEmail, emailLink = emailLink ) } else { - authUI.signInWithEmailLink( + hostScope.signInWithEmailLink( context = context, - config = configuration, provider = emailProvider, email = "", emailLink = emailLink @@ -602,31 +625,11 @@ fun FirebaseAuthScreen( previousAuthState.value = state // Guards below use `isAt` (runtime class), not `==`: keys carry arguments, so `==` blanks a live form. val currentKey = backStack.lastOrNull() - val savedPresentation = armedReauth - - if (savedPresentation != null && - state !is AuthState.Reauthentication && - state !is AuthState.Aborted - ) { - clearReauthPresentation() - authUI.updateAuthState( - AuthState.Reauthentication.Interrupted( - requestId = savedPresentation.requestId, - userUid = savedPresentation.userUid, - ) - ) + // A modal reauthentication owns the screen; Aborted is how the host is dismissed. + if (reauthFlowState.phase != null && state !is AuthState.Aborted) { return@LaunchedEffect } - // The challenge entry is on the stack exactly while the state is RequiresMfa: it - // has no resolver to render otherwise, and this is the only place that pops it, so - // no attempt path can strand the user on a dead challenge. - if (state !is AuthState.Reauthentication.RequiresMfa && - backStack.armedReauth()?.step is AuthRoute.MfaChallenge - ) { - backStack.returnToReauthStart() - } - when (state) { is AuthState.Success -> { pendingResolver.value = null @@ -649,127 +652,6 @@ fun FirebaseAuthScreen( } } - is AuthState.Reauthentication.Required -> { - val linked = configuration.providers.filterToLinkedProviders(state.user) - if (linked.isEmpty()) { - clearReauthPresentation() - authUI.finishReauthentication( - AuthState.Error( - AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_no_linked_providers) - ) - ) - ) - return@LaunchedEffect - } - if (armedReauth?.requestId != state.requestId) { - backStack.clearReauth() - backStack.add( - AuthRoute.Reauth( - requestId = state.requestId, - userUid = state.userUid, - step = reauthStartStep, - ) - ) - } - } - - is AuthState.Reauthentication.Succeeded -> { - val success = state.success - if (success.reauthenticatedUid != state.userUid || - success.user.uid != state.userUid - ) { - authUI.updateAuthState( - AuthState.Error( - AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_incomplete) - ) - ) - ) - } else { - authUI.updateAuthState( - AuthState.Reauthentication.RetryingOperation(state.request) - ) - } - } - - is AuthState.Reauthentication.RetryingOperation -> { - val request = state.request - if (!request.hasRetryOperation) { - clearReauthPresentation() - authUI.finishReauthentication( - AuthState.Success( - result = null, - user = request.user, - ) - ) - return@LaunchedEffect - } - val retry = request.claimRetryOperation() - if (retry == null) { - clearReauthPresentation() - authUI.finishReauthentication( - AuthState.Error( - AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_interrupted) - ) - ) - ) - return@LaunchedEffect - } - try { - retry(context) - val currentUser = authUI.auth.currentUser - val outcome = if (currentUser != null) { - AuthState.Success(result = null, user = currentUser) - } else { - AuthState.Idle - } - authUI.updateReauthentication(state.requestId) { - it.operationFinished(outcome) - } - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Exception) { - authUI.updateAuthState(AuthState.Error(e)) - } - } - - is AuthState.Reauthentication.OperationFinished -> { - clearReauthPresentation() - authUI.finishReauthentication(state.outcome) - } - - is AuthState.Reauthentication.Interrupted -> { - clearReauthPresentation() - authUI.finishReauthentication( - AuthState.Error( - AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_interrupted) - ) - ) - ) - } - - is AuthState.Reauthentication -> { - val marker = armedReauth?.takeIf { it.requestId == state.requestId } - ?: AuthRoute.Reauth( - requestId = state.requestId, - userUid = state.userUid, - step = reauthStartStep, - ).also { - backStack.clearReauth() - backStack.add(it) - } - // A real entry, so the challenge is pushed rather than derived; the pop - // for every other state is handled above. - if (state is AuthState.Reauthentication.RequiresMfa && - marker.step !is AuthRoute.MfaChallenge - ) { - backStack.navigateReauth(marker, AuthRoute.MfaChallenge) - } - } - is AuthState.RequiresEmailVerification, is AuthState.RequiresProfileCompletion, -> { @@ -802,8 +684,10 @@ fun FirebaseAuthScreen( } is AuthState.Aborted -> { + // Outside the guard below: the activity host ends nothing itself. + clearReauthPresentation() + reauthFlowState.finish(false) if (activity !is FirebaseAuthActivity) { - clearReauthPresentation() pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -833,10 +717,111 @@ fun FirebaseAuthScreen( } } + // A marker with no request behind it: the process died and took the request with it. + LaunchedEffect(pendingReauth, backStack.presentedReauth()) { + if (backStack.presentedReauth() == null) return@LaunchedEffect + if (pendingReauth != null || reauthFlowState.phase != null) return@LaunchedEffect + clearReauthPresentation() + authUI.updateAuthState( + AuthState.Error( + AuthException.UnknownException( + context.getString(R.string.fui_error_reauth_interrupted) + ) + ) + ) + } + + // Takes on the request waiting on pendingReauth; a recreated screen re-runs it. + LaunchedEffect(pendingReauth) { + val required = pendingReauth ?: return@LaunchedEffect + if (reauthFlowState.phase?.requestId == required.requestId) return@LaunchedEffect + + val reauthConfiguration = configuration.toReauthConfiguration(required.user) + if (reauthConfiguration == null) { + finishReauth( + AuthState.Error( + AuthException.UnknownException( + context.getString(R.string.fui_error_reauth_no_linked_providers) + ) + ), + false, + ) + return@LaunchedEffect + } + // A request whose caller is gone can never complete, so it is reported. + if (!required.request.isResumable) { + finishReauth( + AuthState.Error( + AuthException.UnknownException( + context.getString(R.string.fui_error_reauth_interrupted) + ) + ), + false, + ) + return@LaunchedEffect + } + reauthFlowState.accept(required) + if (backStack.presentedReauth()?.requestId != required.requestId) { + backStack.clearReauth() + backStack.add( + AuthRoute.Reauth( + requestId = required.requestId, + userUid = required.userUid, + step = reauthStartStepFor(reauthConfiguration), + ) + ) + } + } + + // Keyed on the phase, so it also sees transitions the destinations make directly. + LaunchedEffect(reauthFlowState.phase) { + val phase = reauthFlowState.phase ?: return@LaunchedEffect + + // The challenge entry is on the stack exactly while the phase is RequiresMfa. + val marker = backStack.presentedReauth()?.takeIf { it.requestId == phase.requestId } + if (marker != null) { + if (phase is AuthState.Reauthentication.RequiresMfa) { + if (marker.step !is AuthRoute.MfaChallenge) { + backStack.navigateReauth(marker, AuthRoute.MfaChallenge) + } + } else if (marker.step is AuthRoute.MfaChallenge) { + backStack.returnToReauthStart() + } + } + + if (phase is AuthState.Reauthentication.Succeeded) { + val request = phase.request + val success = phase.success + if (success.reauthenticatedUid != phase.userUid || + success.user.uid != phase.userUid + ) { + // Wrong user is a failed attempt, not a dead request. + reauthFlowState.update(phase.requestId) { + AuthState.Reauthentication.AttemptFailed( + request, + AuthException.UnknownException( + context.getString(R.string.fui_error_reauth_incomplete) + ), + ) + } + return@LaunchedEffect + } + // A Success here would claim the pending operation had already succeeded. + val terminal = if (request.hasPendingOperation) { + AuthState.Loading( + context.getString(R.string.fui_loading_reauth_retrying) + ) + } else { + AuthState.Success(result = null, user = request.user) + } + finishReauth(terminal, true) + } + } + // The slot owns the error and loading presentation while it is what is on screen. val reauthSlotActive = reauthContent != null && reauthSurface != null && - armedReauth?.step is AuthRoute.MethodPicker + presentedReauth?.step is AuthRoute.MethodPicker val reauthAttemptFailure = reauthState as? AuthState.Reauthentication.AttemptFailed @@ -941,12 +926,10 @@ fun FirebaseAuthScreen( val loadingMessage = when (val state = authState) { is AuthState.Loading -> state.message is AuthState.Reauthentication.Authenticating -> state.message - is AuthState.Reauthentication.RetryingOperation -> null else -> null } val isLoading = authState is AuthState.Loading || - authState is AuthState.Reauthentication.Authenticating || - authState is AuthState.Reauthentication.RetryingOperation + authState is AuthState.Reauthentication.Authenticating if (isLoading && !reauthSlotActive) { LoadingDialog(loadingMessage ?: stringProvider.progressDialogLoading) } @@ -1169,10 +1152,9 @@ private fun LoadingDialog(message: String) { } @Composable -internal fun FirebaseAuthUI.rememberOnProviderSelected( +internal fun AuthFlowScope.rememberOnProviderSelected( context: android.content.Context, activity: android.app.Activity?, - config: AuthUIConfiguration, onNavigate: (AuthRoute) -> Unit, onUnknownProvider: ((AuthProvider) -> Unit)? = null, onSignInFailure: (AuthException) -> Unit = {}, @@ -1187,18 +1169,18 @@ internal fun FirebaseAuthUI.rememberOnProviderSelected( val twitterProvider = config.providers.filterIsInstance().firstOrNull() val genericOAuthProviders = config.providers.filterIsInstance() - val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler(config, onSignInFailure) } - val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, config, it, onSignInFailure) } + val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler(onSignInFailure) } + val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, it, onSignInFailure) } val onSignInWithFacebook = facebookProvider?.let { - rememberSignInWithFacebookLauncher(context, config, it, onSignInFailure = onSignInFailure) + rememberSignInWithFacebookLauncher(context, it, onSignInFailure = onSignInFailure) } - val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } - val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } - val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } - val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } - val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } + val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, it, onSignInFailure) } + val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, it, onSignInFailure) } + val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, it, onSignInFailure) } + val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, it, onSignInFailure) } + val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, it, onSignInFailure) } val genericOAuthHandlers = genericOAuthProviders.associateWith { - rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) + rememberOAuthSignInHandler(context, activity, it, onSignInFailure) } return { provider -> diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthDestinations.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthDestinations.kt index d42bc235f..d7c447461 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthDestinations.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthDestinations.kt @@ -74,6 +74,8 @@ internal fun EntryProviderScope.emailAuthDestinations( onEmailTyped: (String) -> Unit = {}, onSuccess: (AuthResult) -> Unit = {}, onError: (AuthException) -> Unit = {}, + /** Passed through to [EmailAuthScreen]: where a consumed notification leaves the flow. */ + onNotificationConsumed: (() -> Unit)? = null, ) { val body: @Composable (AuthRoute.Email.Step) -> Unit = { step -> EmailAuthStep( @@ -91,6 +93,7 @@ internal fun EntryProviderScope.emailAuthDestinations( credentialForLinking = credentialForLinking, emailLinkFromDifferentDevice = emailLinkFromDifferentDevice, onEmailTyped = onEmailTyped, + onNotificationConsumed = onNotificationConsumed, onSuccess = onSuccess, onError = onError, ) @@ -131,6 +134,8 @@ internal fun EmailAuthStep( onEmailTyped: (String) -> Unit = {}, onSuccess: (AuthResult) -> Unit = {}, onError: (AuthException) -> Unit = {}, + /** Passed through to [EmailAuthScreen]: where a consumed notification leaves the flow. */ + onNotificationConsumed: (() -> Unit)? = null, ) { if (!configuration.isEmailStepOffered(step)) { LaunchedEffect(entryKey) { @@ -153,6 +158,7 @@ internal fun EmailAuthStep( navigateToStep(AuthRoute.Email.stepFor(targetMode, email)) }, onEmailTyped = onEmailTyped, + onNotificationConsumed = onNotificationConsumed, onSuccess = onSuccess, onError = onError, onCancel = { @@ -235,7 +241,7 @@ internal fun AuthUIConfiguration.isEmailSignUpOffered(): Boolean { /** * Whether the email flow may offer email-link sign-in. False while reauthenticating: a link - * reopens the app with nothing armed, so completing one there reports an interruption instead of + * reopens the app with no request outstanding, so completing one there reports an interruption instead of * finishing the pending operation. */ internal fun AuthUIConfiguration.isEmailLinkSignInOffered(): Boolean { 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 88f16ae8d..0609359f1 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 @@ -14,6 +14,7 @@ package com.firebase.ui.auth.ui.screens.email +import com.firebase.ui.auth.rememberAuthFlowScope import android.content.Context import android.util.Log import androidx.compose.runtime.Composable @@ -160,6 +161,12 @@ fun EmailAuthScreen( mode: EmailAuthMode? = null, onNavigateToMode: ((mode: EmailAuthMode, email: String) -> Unit)? = null, onEmailTyped: (String) -> Unit = {}, + /** + * Where a consumed one-off notification leaves the flow. Null retracts to [AuthState.Idle]; + * reauthentication passes its own, returning the request to provider selection. Explicit + * because this screen no longer decides which flow it is in by reading a relabelled state. + */ + onNotificationConsumed: (() -> Unit)? = null, content: @Composable ((EmailAuthContentState) -> Unit)? = null, ) { require((mode == null) == (onNavigateToMode == null)) { @@ -201,7 +208,9 @@ fun EmailAuthScreen( ) } - val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle) + val authFlowScope = rememberAuthFlowScope(authUI, configuration) + // Under a reauthentication request this is that request's phase, not the host's state. + val authState by authFlowScope.state val isLoading = authState is AuthState.Loading || authState is AuthState.Reauthentication.Authenticating val authCredentialForLinking = remember { credentialForLinking } @@ -263,32 +272,22 @@ fun EmailAuthScreen( ) } // Consumed so the error doesn't leak into a freshly created screen. - authUI.updateAuthState(AuthState.Idle) + authFlowScope.emit(AuthState.Idle) } is AuthState.Cancelled -> { onCancel() - authUI.updateAuthState(AuthState.Idle) + authFlowScope.emit(AuthState.Idle) } is AuthState.PasswordResetLinkSent -> { resetLinkSentLocal = true - authUI.updateAuthState(AuthState.Idle) - } - - is AuthState.Reauthentication.PasswordResetLinkSent -> { - resetLinkSentLocal = true - authUI.updateReauthentication(state.requestId) { it.returnedToProviderSelection() } + onNotificationConsumed?.invoke() ?: authFlowScope.emit(AuthState.Idle) } is AuthState.EmailSignInLinkSent -> { emailSignInLinkSentLocal = true - authUI.updateAuthState(AuthState.Idle) - } - - is AuthState.Reauthentication.EmailSignInLinkSent -> { - emailSignInLinkSentLocal = true - authUI.updateReauthentication(state.requestId) { it.returnedToProviderSelection() } + onNotificationConsumed?.invoke() ?: authFlowScope.emit(AuthState.Idle) } else -> Unit @@ -331,9 +330,8 @@ fun EmailAuthScreen( email == emailTextValue.value && password == passwordTextValue.value } ?: false - authUI.signInWithEmailAndPassword( + authFlowScope.signInWithEmailAndPassword( context = context, - config = configuration, email = emailTextValue.value, password = passwordTextValue.value, credentialForLinking = authCredentialForLinking, @@ -349,17 +347,15 @@ fun EmailAuthScreen( coroutineScope.launch { try { if (emailLinkFromDifferentDevice != null) { - authUI.signInWithEmailLink( + authFlowScope.signInWithEmailLink( context = context, - config = configuration, provider = provider, email = emailTextValue.value, emailLink = emailLinkFromDifferentDevice, ) } else { - authUI.sendSignInLinkToEmail( + authFlowScope.sendSignInLinkToEmail( context = context, - config = configuration, provider = provider, email = emailTextValue.value, credentialForLinking = authCredentialForLinking, @@ -373,9 +369,8 @@ fun EmailAuthScreen( onSignUpClick = { coroutineScope.launch { try { - authUI.createOrLinkUserWithEmailAndPassword( + authFlowScope.createOrLinkUserWithEmailAndPassword( context = context, - config = configuration, provider = provider, name = displayNameValue.value, email = emailTextValue.value, @@ -390,9 +385,8 @@ fun EmailAuthScreen( resetLinkSentLocal = false coroutineScope.launch { try { - authUI.sendPasswordResetEmail( + authFlowScope.sendPasswordResetEmail( email = emailTextValue.value, - config = configuration, actionCodeSettings = configuration.passwordResetActionCodeSettings, ) } catch (e: Exception) { diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt index 6046bcee7..e2f2cd853 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt @@ -114,7 +114,7 @@ fun SignInUI( configuration.isNewEmailAccountsAllowed && !configuration.isReauthenticationMode - // An email link reopens the app with nothing armed, so completing it reports an interruption + // An email link reopens the app with no request outstanding, so completing it reports an interruption // instead of the operation; a reset email leaves the reauth sheet and its request intact. val isEmailLinkSignInOffered = provider.isEmailLinkSignInEnabled && !configuration.isReauthenticationMode 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 14a5f7a0e..368a60093 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 @@ -14,6 +14,7 @@ package com.firebase.ui.auth.ui.screens.phone +import com.firebase.ui.auth.rememberAuthFlowScope import android.content.Context import android.util.Log import androidx.activity.compose.LocalActivity @@ -147,6 +148,16 @@ fun PhoneAuthScreen( onNavigateToStep: ((PhoneAuthStep) -> Unit)? = null, onNavigateBack: (() -> Unit)? = null, flowState: PhoneAuthFlowState? = null, + /** + * Where a consumed one-off notification leaves the flow. Null retracts to [AuthState.Idle]; + * reauthentication passes its own, returning the request to provider selection. + */ + onNotificationConsumed: (() -> Unit)? = null, + /** + * A credential attempt is starting. Null retracts to [AuthState.Idle]; reauthentication passes + * its own, moving the request to its authenticating phase. + */ + onAttemptStarted: (() -> Unit)? = null, content: @Composable ((PhoneAuthContentState) -> Unit)? = null, ) { require( @@ -214,7 +225,9 @@ fun PhoneAuthScreen( } } - val currentAuthState = remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle) + val authFlowScope = rememberAuthFlowScope(authUI, configuration) + // Under a reauthentication request this is that request's phase, not the host's state. + val currentAuthState = authFlowScope.state val authState by currentAuthState val isLoading = authState is AuthState.Loading || authState is AuthState.Reauthentication.Authenticating @@ -224,12 +237,12 @@ fun PhoneAuthScreen( // // Only an ordinary Loading is retracted here. Under a reauthentication request the pending // Loading is published as Reauthentication.Authenticating, which the reauth flow's own teardown - // owns; and were this to write anyway, updateAuthState folds Idle back into the armed request + // owns; and were this to write anyway, updateAuthState folds Idle back into the outstanding request // rather than dropping it. DisposableEffect(authUI) { onDispose { if (currentAuthState.value is AuthState.Loading) { - authUI.updateAuthState(AuthState.Idle) + authFlowScope.emit(AuthState.Idle) } } } @@ -307,25 +320,20 @@ fun PhoneAuthScreen( Log.d("PhoneAuthScreen", "Suppressed auto sign-in: manual submit in flight") // Restoring the submit's Loading both consumes the credential (so it can't // leak to a freshly composed screen) and keeps Verify/Resend disabled. - authUI.updateAuthState( + authFlowScope.emit( AuthState.Loading(configuration.stringProvider.loadingSigningInWithPhone) ) } else { consumedAutoCredential.value = credential // Consumed before the async sign-in call so it can't be clobbered by that // call's own state. - if (state is AuthState.Reauthentication.SmsAutoVerified) { - authUI.updateReauthentication(state.requestId) { it.attemptStarted() } - } else { - authUI.updateAuthState(AuthState.Idle) - } + onAttemptStarted?.invoke() ?: authFlowScope.emit(AuthState.Idle) // The flow's scope, not this step's: a transition can dispose the step this // ran from before the sign-in it started has landed. verificationScope.launch { try { - authUI.signInWithPhoneAuthCredential( + authFlowScope.signInWithPhoneAuthCredential( context = context, - config = configuration, credential = credential ) } catch (e: Exception) { @@ -365,13 +373,13 @@ fun PhoneAuthScreen( ) } // Consumed immediately so this doesn't leak to a freshly created screen. - authUI.updateAuthState(AuthState.Idle) + authFlowScope.emit(AuthState.Idle) } is AuthState.Cancelled -> { onCancel() // Consumed so this doesn't leak to a freshly created screen. - authUI.updateAuthState(AuthState.Idle) + authFlowScope.emit(AuthState.Idle) } is AuthState.Reauthentication.AttemptFailed -> { @@ -419,7 +427,7 @@ fun PhoneAuthScreen( val plural = if (remainingCooldownSeconds != 1L) "s" else "" // Rejected before anything is cancelled: a duplicate tap must not tear down the // healthy in-flight verification it was rejected in favour of. - authUI.updateAuthState( + authFlowScope.emit( AuthState.Error( AuthException.PhoneVerificationCooldownException( message = "Please wait $remainingCooldownSeconds second$plural " + @@ -440,11 +448,10 @@ fun PhoneAuthScreen( // to code entry, and cancelVerification is what ends it. verificationJob.value = verificationScope.launch { try { - authUI.verifyPhoneNumber( + authFlowScope.verifyPhoneNumber( provider = provider, activity = activity, phoneNumber = fullPhoneNumber, - config = configuration, ) } catch (e: Exception) { // Error will be handled by authState flow @@ -463,9 +470,8 @@ fun PhoneAuthScreen( coroutineScope.launch { try { verificationId.value?.let { id -> - authUI.submitVerificationCode( + authFlowScope.submitVerificationCode( context = context, - config = configuration, verificationId = id, code = verificationCodeValue.value ) @@ -487,11 +493,10 @@ fun PhoneAuthScreen( try { // The timer is restarted by the PhoneNumberVerificationRequired branch // above: this call only returns once the verification window closes. - authUI.verifyPhoneNumber( + authFlowScope.verifyPhoneNumber( activity = activity, provider = provider, phoneNumber = fullPhoneNumber, - config = configuration, forceResendingToken = forceResendingToken.value, ) } catch (e: Exception) { @@ -504,15 +509,8 @@ fun PhoneAuthScreen( onChangeNumberClick = { cancelVerification("changing phone number") // Nothing replaces the cancelled attempt here, so this handler retracts its Loading - - // as the armed request's provider-selection phase when one is running, Idle otherwise. - val currentReauthentication = authState as? AuthState.Reauthentication - if (currentReauthentication != null) { - authUI.updateReauthentication(currentReauthentication.requestId) { - it.returnedToProviderSelection() - } - } else { - authUI.updateAuthState(AuthState.Idle) - } + // as the outstanding request's provider-selection phase when one is running, Idle otherwise. + onNotificationConsumed?.invoke() ?: authFlowScope.emit(AuthState.Idle) verificationJob.value = null isSubmittingCode.value = false navigateBack() diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthDestinations.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthDestinations.kt index a047795c3..c481a98b3 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthDestinations.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthDestinations.kt @@ -14,6 +14,11 @@ package com.firebase.ui.auth.ui.screens.reauth +import com.firebase.ui.auth.LocalAuthFlowScope +import com.firebase.ui.auth.AuthFlowScope +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -80,16 +85,9 @@ internal fun AuthState.Reauthentication?.toReauthSurface( is AuthState.Reauthentication.SmsAutoVerified, is AuthState.Reauthentication.PasswordResetLinkSent, is AuthState.Reauthentication.EmailSignInLinkSent, - // The surface gates the operation, so it stays up for the retry rather than uncovering - // the flow underneath for the length of it. + // Momentary, but the surface stays up rather than flashing the flow underneath. is AuthState.Reauthentication.Succeeded, - is AuthState.Reauthentication.RetryingOperation, -> state.request - - // The outcome is already in, or the request's retry callback was lost with the process. - is AuthState.Reauthentication.OperationFinished, - is AuthState.Reauthentication.Interrupted, - -> null } ?: return null val reauthConfiguration = configuration.toReauthConfiguration(request.user) ?: return null return ReauthSurface(state, request, reauthConfiguration) @@ -99,8 +97,8 @@ internal fun AuthState.Reauthentication?.toReauthSurface( internal fun NavBackStack.reauthEntries(): List = filterIsInstance() -/** The armed reauthentication, or null. The back stack *is* the arming marker. */ -internal fun NavBackStack.armedReauth(): AuthRoute.Reauth? = +/** The reauthentication currently presented, or null. The back stack *is* the presentation marker. */ +internal fun NavBackStack.presentedReauth(): AuthRoute.Reauth? = reauthEntries().lastOrNull() /** Removes every reauthentication entry. Index 0 is always a non-reauth entry, so never empties. */ @@ -140,7 +138,7 @@ internal fun NavBackStack.navigateReauth( * leaves it bare when [reauthContent] owns presentation. * * @param surface The one condition for the reauthentication surface. [ReauthSceneStrategy] gates - * the sheet on it and the entry renders what it resolves to, so an entry with nothing armed is + * the sheet on it and the entry renders what it resolves to, so an entry with no request outstanding is * never composed at all. * @param phoneFlowState What the reauthentication phone steps share across a step switch — see * [PhoneAuthFlowState]. Reauthentication's own instance, whose lifetime is the request's: nothing @@ -155,6 +153,7 @@ internal fun EntryProviderScope.reauthDestinations( configuration: AuthUIConfiguration, stringProvider: AuthUIStringProvider, surface: State, + reauthFlowState: ReauthFlowState, phoneFlowState: PhoneAuthFlowState, emailContent: (@Composable (EmailAuthContentState) -> Unit)?, phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?, @@ -176,7 +175,7 @@ internal fun EntryProviderScope.reauthDestinations( }, ) { key -> // Read through the snapshot, never through captured values — the entry rule at - // FirebaseAuthScreen's entryProvider. Nothing armed is the same condition the sheet exists + // FirebaseAuthScreen's entryProvider. No request outstanding is the same condition the sheet exists // on; a key naming an older request is the host's stack one composition behind the state, // and this entry writes to the id it names, so it renders nothing rather than the wrong one. val reauthSurface = surface.value ?: return@entry @@ -186,22 +185,35 @@ internal fun EntryProviderScope.reauthDestinations( val reauthConfig = reauthSurface.configuration val reauthRequired = AuthState.Reauthentication.Required(request) val mfaResolver = (reauthState as? AuthState.Reauthentication.RequiresMfa)?.resolver - // The retry counts as loading: the surface stays up for it, so it has to say it is busy. val isLoading = reauthState is AuthState.Reauthentication.Authenticating || - reauthState is AuthState.Reauthentication.Succeeded || - reauthState is AuthState.Reauthentication.RetryingOperation + reauthState is AuthState.Reauthentication.Succeeded val exception = (reauthState as? AuthState.Reauthentication.AttemptFailed) ?.exception ?.let { if (it is AuthException) it else AuthException.from(it, stringProvider) } val error = exception?.let { getRecoveryMessage(it, stringProvider) } - val onProviderSelected = authUI.rememberOnProviderSelected( + // Built here, where the entry's early return guarantees the configuration exists. + val reauthStateHolder = remember(reauthFlowState) { + derivedStateOf { reauthFlowState.phase ?: AuthState.Idle } + } + val reauthScope = remember(authUI, reauthConfig, reauthFlowState, reauthStateHolder) { + AuthFlowScope( + auth = authUI.auth, + config = reauthConfig, + credentialManagerProvider = authUI.testCredentialManagerProvider, + loginManagerProvider = authUI.testLoginManagerProvider, + state = reauthStateHolder, + sink = reauthFlowState.sink(hostFallback = { authUI.updateAuthState(it) }), + ) + } + + val onProviderSelected = reauthScope.rememberOnProviderSelected( context = context, activity = activity, - config = reauthConfig, onNavigate = { route -> backStack.navigateReauth(key, route.toKey()) }, ) + CompositionLocalProvider(LocalAuthFlowScope provides reauthScope) { when (val step = key.step) { is AuthRoute.MethodPicker -> { if (reauthContent != null) { @@ -214,7 +226,7 @@ internal fun EntryProviderScope.reauthDestinations( if (provider !is AuthProvider.Email && provider !is AuthProvider.Phone ) { - authUI.updateReauthentication(key.requestId) { + reauthFlowState.update(key.requestId) { it.attemptStarted() } } @@ -255,6 +267,9 @@ internal fun EntryProviderScope.reauthDestinations( isStepBelow = { false }, onCancel = { onLeaveStep(key) }, prefillEmail = { reauthRequired.user.email }, + onNotificationConsumed = { + reauthFlowState.update(key.requestId) { it.returnedToProviderSelection() } + }, ) is AuthRoute.Phone.Step -> PhoneAuthScreen( @@ -275,6 +290,12 @@ internal fun EntryProviderScope.reauthDestinations( backStack.navigateReauth(key, AuthRoute.Phone.EnterPhoneNumber) }, flowState = phoneFlowState, + onNotificationConsumed = { + reauthFlowState.update(key.requestId) { it.returnedToProviderSelection() } + }, + onAttemptStarted = { + reauthFlowState.update(key.requestId) { it.attemptStarted() } + }, ) // Only the state moves: the host pops the entry off whatever the state becomes, so @@ -284,16 +305,42 @@ internal fun EntryProviderScope.reauthDestinations( resolver = mfaResolver, auth = authUI.auth, content = mfaChallengeContent, - onSuccess = { authUI.publishReauthenticationSuccess() }, + // The one exchange no provider owns, so the stamp is made here. + onSuccess = { + val reauthenticated = authUI.auth.currentUser + if (reauthenticated == null) { + reauthFlowState.update(key.requestId) { + AuthState.Reauthentication.AttemptFailed( + request, + AuthException.UserNotFoundException( + message = "No user is currently signed in for reauthentication" + ), + ) + } + } else { + reauthFlowState.update(key.requestId) { + AuthState.Reauthentication.Succeeded( + request, + AuthState.Success( + result = null, + user = reauthenticated, + reauthenticatedUid = reauthenticated.uid, + ), + ) + } + } + }, onCancel = { - authUI.updateReauthentication(key.requestId) { it.attemptCancelled() } + reauthFlowState.update(key.requestId) { it.attemptCancelled() } }, - onError = { e -> authUI.updateAuthState(AuthState.Error(e)) }, + // The request's flow: the public channel would report this as a sign-in error. + onError = { e -> reauthScope.emit(AuthState.Error(e)) }, ) } else -> Unit } + } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthFlowState.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthFlowState.kt new file mode 100644 index 000000000..a4f9323df --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthFlowState.kt @@ -0,0 +1,148 @@ +/* + * 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.reauth + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.AuthStateSink + +/** + * The reauthentication phase machine of one + * [com.firebase.ui.auth.ui.screens.FirebaseAuthScreen], scoped to its composition. + * + * @since 10.0.0 + */ +internal class ReauthFlowState internal constructor( + private val phaseState: MutableState, +) { + /** The live phase, or null when no request is outstanding. */ + val phase: AuthState.Reauthentication? get() = phaseState.value + + /** The live request, or null when none is outstanding. */ + val request: AuthState.Reauthentication.Request? get() = phaseState.value?.request + + /** Arms [required], replacing any request already held. */ + fun accept(required: AuthState.Reauthentication.Required) { + phaseState.value = required + } + + /** + * Drops the request and tells its awaiting caller whether to retry. Every way a request ends + * comes through here, so no caller is left suspended. + */ + fun finish(retryOperation: Boolean) { + val request = phaseState.value?.request + phaseState.value = null + if (retryOperation) request?.resolve() else request?.decline() + } + + /** + * Applies [transition] to the live phase while [requestId] still names it — the caller's key + * may be a composition behind. A null transition result is a no-op. + */ + fun update(requestId: String, transition: (AuthState.Reauthentication) -> AuthState?) { + val current = phaseState.value ?: return + if (current.requestId != requestId) return + val next = transition(current) as? AuthState.Reauthentication ?: return + phaseState.value = next + } + + /** Moves the live phase to [phase] unconditionally. */ + fun moveTo(phase: AuthState.Reauthentication) { + phaseState.value = phase + } + + /** + * This request's state sink, for the provider code driving its credential exchange. Everything + * [fold] absorbs becomes a phase; [hostFallback] takes what it declines. + */ + fun sink(hostFallback: AuthStateSink): AuthStateSink = AuthStateSink { state -> + if (fold(state) == null) hostFallback.emit(state) + } + + /** + * Folds an ordinary [state] published by provider code into the live phase, returning the + * phase it became, or null when [state] is not part of the credential exchange. + */ + fun fold(state: AuthState): AuthState.Reauthentication? { + if (state is AuthState.Reauthentication) return null + val current = phaseState.value ?: return null + val request = current.request ?: return null + + val next = when (state) { + is AuthState.Loading -> AuthState.Reauthentication.Authenticating(request, state.message) + + is AuthState.Error -> + if (state.exception is AuthException.AuthCancelledException) { + AuthState.Reauthentication.Required(request) + } else { + AuthState.Reauthentication.AttemptFailed(request, state.exception) + } + + is AuthState.Cancelled -> AuthState.Reauthentication.Required(request) + + is AuthState.RequiresMfa -> + AuthState.Reauthentication.RequiresMfa(request, state.resolver, state.hint) + + is AuthState.PhoneNumberVerificationRequired -> + AuthState.Reauthentication.PhoneNumberVerificationRequired( + request = request, + verificationId = state.verificationId, + forceResendingToken = state.forceResendingToken, + ) + + is AuthState.SMSAutoVerified -> + AuthState.Reauthentication.SmsAutoVerified(request, state.credential) + + is AuthState.PasswordResetLinkSent -> + AuthState.Reauthentication.PasswordResetLinkSent(request) + + is AuthState.EmailSignInLinkSent -> + AuthState.Reauthentication.EmailSignInLinkSent(request) + + // Only a stamped Success proves this user was re-verified. + is AuthState.Success -> + if (state.reauthenticatedUid != null) { + AuthState.Reauthentication.Succeeded(request, state) + } else { + current + } + + // Must not detach the request from the caller waiting on it. + is AuthState.Idle, + is AuthState.RequiresEmailVerification, + is AuthState.RequiresProfileCompletion, + -> current + + // Not part of the credential exchange: let the host flow handle it. + else -> return null + } + phaseState.value = next + return next + } +} + +/** + * Creates and remembers the [ReauthFlowState] for one screen, alongside + * `rememberPhoneAuthFlowState` and `rememberMfaEnrollmentFlowState`. The phase does not survive + * recreation; the request on `FirebaseAuthUI.pendingReauth` does. + */ +@Composable +internal fun rememberReauthFlowState(): ReauthFlowState = + remember { ReauthFlowState(mutableStateOf(null)) } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSceneStrategy.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSceneStrategy.kt index 46e7c3ad6..d4eb5048f 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSceneStrategy.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSceneStrategy.kt @@ -74,7 +74,7 @@ private fun NavEntry.reauthOverlay(): ReauthOverlay? = metadata[ReauthOv * owned by the scene so `NavDisplay` keeps their saveable state. * * @param surface The one condition for the surface: the sheet composes only while this resolves, - * so a reauthentication entry with nothing armed shows neither sheet nor scrim, and the entry it + * so a reauthentication entry with no request outstanding shows neither sheet nor scrim, and the entry it * would have composed is never reached. * @param transitionSpec Applied to step changes inside the overlay, so they animate the way the * flow underneath animates. There is no predictive-pop counterpart: that gesture drives @@ -146,9 +146,9 @@ private data class ReauthScene( // Existence and content answer to one condition: no surface, no sheet and no scrim. // Latched for the same reason the run is kept — the surface is released as the entries // are popped, and dropping the sheet there would cut its hide short. - val armed = remember { mutableStateOf(false) } - if (surface.value != null) armed.value = true - val presentation = top?.reauthOverlay()?.presentation?.takeIf { armed.value } + val hasPresented = remember { mutableStateOf(false) } + if (surface.value != null) hasPresented.value = true + val presentation = top?.reauthOverlay()?.presentation?.takeIf { hasPresented.value } when (presentation) { ReauthPresentation.Sheet -> { val state = rememberModalBottomSheetState(skipPartiallyExpanded = true) diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 8347d61f4..0d21329a6 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -21,6 +21,7 @@ Signing in with email link... Sending password reset email... Signing out... + Finishing that action... Deleting account... Sign in Continue diff --git a/auth/src/test/java/com/firebase/ui/auth/AuthFlowScopeTestSupport.kt b/auth/src/test/java/com/firebase/ui/auth/AuthFlowScopeTestSupport.kt new file mode 100644 index 000000000..b3a25fcb5 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/AuthFlowScopeTestSupport.kt @@ -0,0 +1,45 @@ +/* + * 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 + +import androidx.compose.runtime.mutableStateOf +import com.firebase.ui.auth.configuration.AuthUIConfiguration + +/** + * This instance's own flow, for driving provider code from a test. + * + * Deliberately the *host* sink, so `authStateFlow()` still carries what provider code publishes and + * every assertion written against it keeps the meaning it had before provider code moved off the + * [FirebaseAuthUI] receiver. Converting those assertions to a recording sink instead would have + * quietly dropped the ones that assert [FirebaseAuthUI]'s own combine and staleness behaviour + * rather than a provider's — and they would still have passed. + * + * Use [recordingScope] where the point is isolation: that a state does *not* reach the host flow. + */ +internal fun FirebaseAuthUI.flowScope(config: AuthUIConfiguration): AuthFlowScope = + hostAuthFlowScope(this, config, mutableStateOf(AuthState.Idle)) + +/** A scope whose states are collected in [recorded] and go nowhere else. */ +internal fun FirebaseAuthUI.recordingScope( + config: AuthUIConfiguration, + recorded: MutableList, +): AuthFlowScope = AuthFlowScope( + auth = auth, + config = config, + credentialManagerProvider = testCredentialManagerProvider, + loginManagerProvider = testLoginManagerProvider, + state = mutableStateOf(AuthState.Idle), + sink = { recorded += it }, +) 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 ee856df8b..3a99e322e 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt @@ -14,6 +14,7 @@ package com.firebase.ui.auth +import kotlinx.coroutines.CompletableDeferred import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp @@ -29,6 +30,7 @@ import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.GetTokenResult import com.google.firebase.auth.MultiFactorResolver import com.google.firebase.auth.UserInfo +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -264,12 +266,12 @@ class FirebaseAuthUIAuthStateTest { } /** - * A host calling raw `auth.signOut()` while a reauthentication is armed used to leave the - * internal state at Reauthentication.Required: the combine keeps preferring it, so the reauth UI - * stays up over a signed-out session and every provider fails with an untranslated "no user". + * A host calling raw `auth.signOut()` while a reauthentication is outstanding leaves a request + * nobody can ever satisfy — the user it names is gone. It is dropped, and the caller suspended + * on it is told, rather than waiting on a sheet that can only fail every provider it offers. */ @Test - fun `authStateFlow() clears an armed Reauthentication Required when the user signs out`() = + fun `signing out declines an outstanding reauthentication request`() = runBlocking { `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) `when`(mockFirebaseUser.isEmailVerified).thenReturn(true) @@ -284,12 +286,15 @@ class FirebaseAuthUIAuthStateTest { delay(100) verify(mockFirebaseAuth).addAuthStateListener(listenerCaptor.capture()) - authUI.updateAuthState( - AuthState.Reauthentication.Required(mockFirebaseUser, reason = "Confirm it is you") + // Nothing about the request reaches the state flow. + val resolver = CompletableDeferred() + authUI.pendingReauth.value = raisedReauth( + mockFirebaseUser, + reason = "Confirm it is you", + resolver = resolver, ) delay(100) - assertThat(states.last()) - .isInstanceOf(AuthState.Reauthentication.Required::class.java) + assertThat(states.last()).isNotInstanceOf(AuthState.Reauthentication::class.java) // The host signs out behind the library's back, e.g. authUI.auth.signOut(). `when`(mockFirebaseAuth.currentUser).thenReturn(null) @@ -297,7 +302,10 @@ class FirebaseAuthUIAuthStateTest { delay(200) job.cancel() - assertThat(states.last()).isEqualTo(AuthState.Idle) + // Dropped, and the caller waiting on it told rather than left suspended. + assertThat(authUI.pendingReauth.value).isNull() + assertThat(resolver.isCompleted).isTrue() + assertThat(resolver.getCompleted()).isFalse() } @Test @@ -604,19 +612,19 @@ class FirebaseAuthUIAuthStateTest { val context = ApplicationProvider.getApplicationContext() - try { - authUI.delete(context) - } catch (_: AuthException.InvalidCredentialsException) { - // expected — existing contract preserved - } + val call = launch { runCatching { authUI.delete(context) } } + runCurrent() - assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.Reauthentication.Required::class.java) - val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required + assertThat(authUI.pendingReauth.value).isNotNull() + val state = requireNotNull(authUI.pendingReauth.value) assertThat(state.user).isEqualTo(mockUser) + + state.request.decline() + call.join() } @Test - fun `delete() attaches retryOperation to Reauthentication Required state`() = runTest { + fun `delete() arms a resumable request rather than throwing`() = runTest { val mockUser = mock(FirebaseUser::class.java) val tcs = TaskCompletionSource() tcs.setException( @@ -628,144 +636,36 @@ class FirebaseAuthUIAuthStateTest { `when`(mockUser.delete()).thenReturn(tcs.task) val context = ApplicationProvider.getApplicationContext() - try { authUI.delete(context) } catch (_: AuthException.InvalidCredentialsException) {} - - val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required - // Fails until delete() passes retryOperation into the state - assertThat(state.retryOperation).isNotNull() - } - - @Test - fun `reauthentication provider states retain the same request`() = runTest { - `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") - // Stands in for the composed FirebaseAuthScreen that folding is scoped to. - authUI.addReauthenticationDrainer() - val required = AuthState.Reauthentication.Required(mockFirebaseUser) - authUI.updateAuthState(required) - - authUI.updateAuthState(AuthState.Loading("Signing in")) - val authenticating = authUI.authStateFlow().first() - assertThat(authenticating) - .isInstanceOf(AuthState.Reauthentication.Authenticating::class.java) - assertThat((authenticating as AuthState.Reauthentication).requestId) - .isEqualTo(required.requestId) - - authUI.updateAuthState(AuthState.Error(IllegalArgumentException("wrong password"))) - val failed = authUI.authStateFlow().first() - assertThat(failed) - .isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) - assertThat((failed as AuthState.Reauthentication).requestId) - .isEqualTo(required.requestId) - - authUI.updateAuthState(AuthState.Cancelled) - val resumed = authUI.authStateFlow().first() - assertThat(resumed).isInstanceOf(AuthState.Reauthentication.Required::class.java) - assertThat((resumed as AuthState.Reauthentication.Required).requestId) - .isEqualTo(required.requestId) - } - - @Test - fun `reauthentication email notifications retain the request until consumed`() = runTest { - `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") - authUI.addReauthenticationDrainer() - val required = AuthState.Reauthentication.Required(mockFirebaseUser) - authUI.updateAuthState(required) - - authUI.updateAuthState(AuthState.PasswordResetLinkSent()) - val notification = authUI.authStateFlow().first() - assertThat(notification) - .isInstanceOf(AuthState.Reauthentication.PasswordResetLinkSent::class.java) - assertThat((notification as AuthState.Reauthentication).requestId) - .isEqualTo(required.requestId) - - authUI.updateReauthentication(required.requestId) { it.returnedToProviderSelection() } - val resumed = authUI.authStateFlow().first() - assertThat(resumed).isInstanceOf(AuthState.Reauthentication.Required::class.java) - assertThat((resumed as AuthState.Reauthentication.Required).requestId) - .isEqualTo(required.requestId) - } - - @Test - fun `updateReauthentication ignores a stale requestId`() = runTest { - `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") - val required = AuthState.Reauthentication.Required(mockFirebaseUser) - authUI.updateAuthState(required) - - authUI.updateReauthentication("stale-request-id") { it.attemptStarted() } - - val unchanged = authUI.authStateFlow().first() - assertThat(unchanged).isInstanceOf(AuthState.Reauthentication.Required::class.java) - assertThat((unchanged as AuthState.Reauthentication.Required).requestId) - .isEqualTo(required.requestId) - } - - @Test - fun `attemptCancelled does not rewind a surfaced attempt failure`() = runTest { - `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") - authUI.addReauthenticationDrainer() - val required = AuthState.Reauthentication.Required(mockFirebaseUser) - authUI.updateAuthState(required) - authUI.updateAuthState(AuthState.Error(IllegalArgumentException("wrong password"))) - assertThat(authUI.authStateFlow().first()) - .isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) - - authUI.updateReauthentication(required.requestId) { it.attemptCancelled() } - - val unchanged = authUI.authStateFlow().first() - assertThat(unchanged).isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) - assertThat((unchanged as AuthState.Reauthentication).requestId) - .isEqualTo(required.requestId) - } - - /** - * The phone sub-flow's "Change number" returns to provider selection, and by then a wrong SMS - * code has latched a failure. Clearing it there would erase the only report the user gets. - */ - @Test - fun `returnedToProviderSelection does not wipe a surfaced attempt failure`() = runTest { - `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") - authUI.addReauthenticationDrainer() - val required = AuthState.Reauthentication.Required(mockFirebaseUser) - authUI.updateAuthState(required) - authUI.updateAuthState(AuthState.Error(IllegalArgumentException("wrong sms code"))) - assertThat(authUI.authStateFlow().first()) - .isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) - - authUI.updateReauthentication(required.requestId) { it.returnedToProviderSelection() } - - val unchanged = authUI.authStateFlow().first() - assertThat(unchanged).isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) - assertThat((unchanged as AuthState.Reauthentication).requestId) - .isEqualTo(required.requestId) - } + var thrown: Exception? = null + val call = launch { + try { authUI.delete(context) } catch (e: Exception) { thrown = e } + } + runCurrent() - /** Credentials were already accepted, so a stray attempt must not rewind the retry phase. */ - @Test - fun `attemptStarted does not rewind a retry in flight`() = runTest { - `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") - authUI.addReauthenticationDrainer() - val required = AuthState.Reauthentication.Required(mockFirebaseUser) - authUI.updateAuthState(required) - authUI.updateAuthState(AuthState.Reauthentication.RetryingOperation(required.request)) + val state = requireNotNull(authUI.pendingReauth.value) + assertThat(state.request.hasPendingOperation).isTrue() + assertThat(state.request.isResumable).isTrue() + // One path now: it raises a request and waits, rather than raising one *and* throwing. + assertThat(call.isActive).isTrue() - authUI.updateReauthentication(required.requestId) { it.attemptStarted() } + state.request.decline() + call.join() - assertThat(authUI.authStateFlow().first()) - .isInstanceOf(AuthState.Reauthentication.RetryingOperation::class.java) + // Declining is reported, so the caller knows the account was not deleted. + assertThat(thrown).isInstanceOf(AuthException.AuthCancelledException::class.java) } /** - * `withReauth`/`delete` are public and arm a request with no [FirebaseAuthScreen] composed — - * the caller catches the exception and shows its own UI. Nothing can then drain the request, - * so folding must not apply: the app's own collector has to keep seeing ordinary states. + * `withReauth`/`delete` are public and can raise a request with no [FirebaseAuthScreen] + * composed. Folding is the composed screen's, so the setter stays a plain setter and the app's + * own collector keeps seeing ordinary states. */ @Test - fun `a Success reaches collectors while an undrainable request is armed`() = runTest { + fun `a Success reaches collectors while nothing can accept the request`() = runTest { `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) - authUI.updateAuthState(AuthState.Reauthentication.Required(mockFirebaseUser)) - assertThat(authUI.authStateFlow().first()) - .isInstanceOf(AuthState.Reauthentication.Required::class.java) + authUI.pendingReauth.value = AuthState.Reauthentication.Required(mockFirebaseUser) + assertThat(authUI.pendingReauth.value).isNotNull() authUI.updateAuthState(AuthState.Success(result = null, user = mockFirebaseUser)) @@ -774,12 +674,12 @@ class FirebaseAuthUIAuthStateTest { assertThat(observed).isNotInstanceOf(AuthState.Reauthentication::class.java) } - /** The same escape for Idle: an undrainable arming is replaced, not made permanent. */ + /** The same for Idle: a request nothing can accept is replaced, not made permanent. */ @Test - fun `an Idle write clears an undrainable armed request`() = runTest { + fun `an Idle write clears a request nothing can accept`() = runTest { `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) - authUI.updateAuthState(AuthState.Reauthentication.Required(mockFirebaseUser)) + authUI.pendingReauth.value = AuthState.Reauthentication.Required(mockFirebaseUser) authUI.updateAuthState(AuthState.Idle) @@ -787,20 +687,6 @@ class FirebaseAuthUIAuthStateTest { .isNotInstanceOf(AuthState.Reauthentication::class.java) } - @Test - fun `operationFinished only applies while a retry is in flight`() = runTest { - `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") - val required = AuthState.Reauthentication.Required(mockFirebaseUser) - authUI.updateAuthState(required) - - authUI.updateReauthentication(required.requestId) { - it.operationFinished(AuthState.Success(result = null, user = mockFirebaseUser)) - } - - assertThat(authUI.authStateFlow().first()) - .isInstanceOf(AuthState.Reauthentication.Required::class.java) - } - // ============================================================================================= // withReauth() Tests // ============================================================================================= @@ -817,108 +703,180 @@ class FirebaseAuthUIAuthStateTest { } @Test - fun `withReauth() emits Reauthentication Required when FirebaseAuthRecentLoginRequiredException thrown`() = runTest { + fun `withReauth() arms a resumable request and suspends instead of throwing`() = runTest { val context = ApplicationProvider.getApplicationContext() `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) - authUI.withReauth(context) { - throw FirebaseAuthRecentLoginRequiredException("ERROR_REQUIRES_RECENT_LOGIN", "Recent login required") + val call = launch { + runCatching { + authUI.withReauth(context, reason = "Verify identity to change email") { + throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + } + } } + runCurrent() - assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.Reauthentication.Required::class.java) - val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required + val state = requireNotNull(authUI.pendingReauth.value) assertThat(state.user).isEqualTo(mockFirebaseUser) + assertThat(state.reason).isEqualTo("Verify identity to change email") + assertThat(state.request.hasPendingOperation).isTrue() + // Parked on its own half of the request, so the retry runs here. + assertThat(call.isActive).isTrue() + + state.request.decline() + call.join() } @Test - fun `withReauth() forwards reason to Reauthentication Required state`() = runTest { + fun `withReauth() re-runs the operation when its request resolves to a retry`() = runTest { val context = ApplicationProvider.getApplicationContext() `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + var callCount = 0 - authUI.withReauth(context, reason = "Verify identity to change email") { - throw FirebaseAuthRecentLoginRequiredException("ERROR_REQUIRES_RECENT_LOGIN", "Recent login required") + val call = launch { + authUI.withReauth(context) { + callCount++ + if (callCount == 1) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + } } + runCurrent() + val state = requireNotNull(authUI.pendingReauth.value) + assertThat(callCount).isEqualTo(1) - val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required - assertThat(state.reason).isEqualTo("Verify identity to change email") + state.request.resolve() + call.join() + + assertThat(callCount).isEqualTo(2) } + /** + * The screen hands the retry over on a loading state and steps out of the conversation, so + * whatever the retry ends as has to be published here or that loading never clears. + */ @Test - fun `withReauth() attaches retryOperation that re-invokes the original operation`() = runTest { + fun `a finished retry publishes the session it left behind`() = 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 call = launch { + authUI.withReauth(context) { + if (callCount++ == 0) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + } } + runCurrent() + val state = requireNotNull(authUI.pendingReauth.value) - val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required - assertThat(state.retryOperation).isNotNull() - state.retryOperation!!(context) - assertThat(callCount).isEqualTo(2) + authUI.updateAuthState(AuthState.Loading("Finishing that action...")) + state.request.resolve() + call.join() + + assertThat(authUI.authStateFlow().first()) + .isInstanceOf(AuthState.Success::class.java) } + /** A failed retry is the caller's to report, but the handover state is still the library's. */ @Test - fun `withReauth() retryOperation restores auth state after successful retry`() = runTest { + fun `a retry that fails still clears the handover state`() = runTest { val context = ApplicationProvider.getApplicationContext() - `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) - authUI.addReauthenticationDrainer() + val cause = RuntimeException("Network error") var callCount = 0 + var thrown: Exception? = null - authUI.withReauth(context) { - callCount++ - if (callCount == 1) throw FirebaseAuthRecentLoginRequiredException( - "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" - ) + val call = launch { + try { + authUI.withReauth(context) { + if (callCount++ == 0) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + throw cause + } + } catch (e: Exception) { + thrown = e + } } + runCurrent() + val state = requireNotNull(authUI.pendingReauth.value) - val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required + authUI.updateAuthState(AuthState.Loading("Finishing that action...")) + state.request.resolve() + call.join() - // Reach the retry phase through the uid-gated credential success, not by hand: a Success - // stamped for this request's user is the only thing that may unlock the operation. - authUI.updateAuthState( - AuthState.Success( - result = null, - user = mockFirebaseUser, - reauthenticatedUid = mockFirebaseUser.uid, - ) - ) - val succeeded = authUI.authStateFlow().first() - assertThat(succeeded).isInstanceOf(AuthState.Reauthentication.Succeeded::class.java) - val request = (succeeded as AuthState.Reauthentication.Succeeded).request - assertThat(request.requestId).isEqualTo(state.requestId) - - // What FirebaseAuthScreen does next: claim the operation once, then run it. - authUI.updateAuthState(AuthState.Reauthentication.RetryingOperation(request)) - val retry = requireNotNull(request.claimRetryOperation()) - retry(context) - assertThat(callCount).isEqualTo(2) - // Claimed for good: a second entry into the retry phase has nothing left to run. - assertThat(request.claimRetryOperation()).isNull() + assertThat(thrown).isEqualTo(cause) + assertThat(authUI.authStateFlow().first()) + .isNotInstanceOf(AuthState.Loading::class.java) + } + + /** + * A decline reaches the caller as a throw rather than a quiet return. "You backed out" and + * "your operation ran" are different outcomes, and a caller that cannot tell them apart has to + * guess whether its work happened. + */ + @Test + fun `withReauth() reports a declined request rather than returning quietly`() = runTest { + val context = ApplicationProvider.getApplicationContext() + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + var callCount = 0 + var thrown: Exception? = null + + val call = launch { + try { + authUI.withReauth(context) { + callCount++ + if (callCount == 1) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + } + } catch (e: Exception) { + thrown = e + } + } + runCurrent() + val state = requireNotNull(authUI.pendingReauth.value) + + state.request.decline() + call.join() - // The retry outcome remains attached to the request until the screen consumes it. - val authState = authUI.authStateFlow().first() - assertThat(authState) - .isInstanceOf(AuthState.Reauthentication.OperationFinished::class.java) - val finished = authState as AuthState.Reauthentication.OperationFinished - assertThat(finished.requestId).isEqualTo(state.requestId) - assertThat(finished.outcome).isInstanceOf(AuthState.Success::class.java) + assertThat(callCount).isEqualTo(1) + assertThat(thrown).isInstanceOf(AuthException.AuthCancelledException::class.java) } + /** + * The caller's scope died while the sheet was up. Nothing can resume the operation, and the + * request says so rather than presenting as one that can still complete. + */ @Test - fun `withReauth() does not throw when reauth is needed`() = runTest { + fun `a cancelled caller leaves its request unresumable`() = runTest { val context = ApplicationProvider.getApplicationContext() `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + var callCount = 0 - // Should complete without throwing - authUI.withReauth(context) { - throw FirebaseAuthRecentLoginRequiredException("ERROR_REQUIRES_RECENT_LOGIN", "Recent login required") + val call = launch { + authUI.withReauth(context) { + callCount++ + if (callCount == 1) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + } } + runCurrent() + val state = requireNotNull(authUI.pendingReauth.value) + assertThat(state.request.isResumable).isTrue() + + call.cancel() + call.join() + + assertThat(state.request.isResumable).isFalse() + // Resolving a dead request is a no-op, not a crash, and runs nothing. + state.request.resolve() + assertThat(callCount).isEqualTo(1) } @Test @@ -938,7 +896,7 @@ class FirebaseAuthUIAuthStateTest { } @Test - fun `delete() retryOperation re-invokes delete on execution`() = runTest { + fun `delete() retries the deletion when its request resolves to a retry`() = runTest { val mockUser = mock(FirebaseUser::class.java) val failTcs = TaskCompletionSource() @@ -956,11 +914,12 @@ class FirebaseAuthUIAuthStateTest { .thenReturn(successTcs.task) val context = ApplicationProvider.getApplicationContext() - try { authUI.delete(context) } catch (_: AuthException.InvalidCredentialsException) {} + val call = launch { authUI.delete(context) } + runCurrent() - val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required - // Fails until delete() passes retryOperation into the state - state.retryOperation!!(context) + val state = requireNotNull(authUI.pendingReauth.value) + state.request.resolve() + call.join() verify(mockUser, times(2)).delete() } 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 0bcfdecbd..9bbbb1608 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt @@ -14,6 +14,7 @@ package com.firebase.ui.auth +import kotlinx.coroutines.test.runCurrent import android.content.Context import android.content.Intent import android.net.Uri @@ -643,14 +644,17 @@ class FirebaseAuthUITest { val instance = FirebaseAuthUI.create(defaultApp, mockAuth) val context = ApplicationProvider.getApplicationContext() - // Perform delete and expect mapped exception - try { - instance.delete(context) - assertThat(false).isTrue() // Should not reach here - } catch (e: AuthException.InvalidCredentialsException) { - assertThat(e.message).contains("Recent login required") - assertThat(e.cause).isEqualTo(recentLoginException) - } + // Raises a request and waits, rather than throwing a mapped exception. + val call = launch { runCatching { instance.delete(context) } } + runCurrent() + + val state = requireNotNull(instance.pendingReauth.value) + assertThat(state.user).isEqualTo(mockUser) + assertThat(state.request.hasPendingOperation).isTrue() + assertThat(call.isActive).isTrue() + + state.request.decline() + call.join() } @Test diff --git a/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt b/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt new file mode 100644 index 000000000..b570b7c12 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt @@ -0,0 +1,89 @@ +/* + * 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 + +import androidx.compose.runtime.Composable +import com.google.firebase.auth.FirebaseUser +import kotlinx.coroutines.CompletableDeferred +import java.util.UUID + +/** + * An outstanding request standing in for one a caller is waiting on, running [operation] if and when the + * screen resolves it with a retry. + * + * The resolver is completed from the screen's own effect, and `invokeOnCompletion` runs on the + * completing thread, so [operation] lands exactly where the retry used to — which is what lets a + * test keep asserting on a flag it sets. + */ +internal fun retryingReauth( + user: FirebaseUser, + reason: String? = null, + operation: () -> Unit, +): AuthState.Reauthentication.Required { + val resolver = CompletableDeferred() + resolver.invokeOnCompletion { cause -> + if (cause == null && resolver.getCompleted()) operation() + } + return raisedReauth(user, reason, resolver) +} + +/** An outstanding request with a caller waiting on [resolver], for asserting the decision itself. */ +internal fun raisedReauth( + user: FirebaseUser, + reason: String? = null, + resolver: CompletableDeferred? = null, +): AuthState.Reauthentication.Required = + AuthState.Reauthentication.Required( + AuthState.Reauthentication.Request( + requestId = UUID.randomUUID().toString(), + user = user, + reason = reason, + resolver = resolver, + ) + ) + +/** + * An outstanding request whose caller is already gone — the shape a recreation leaves behind when the + * scope that launched the operation did not survive it. + */ +internal fun abandonedReauth(user: FirebaseUser): AuthState.Reauthentication.Required { + val resolver = CompletableDeferred() + resolver.cancel() + return raisedReauth(user, resolver = resolver) +} + +/** + * Captures the flow a reauthentication surface composes its content in. + * + * The request's own [AuthFlowScope] is what provider code emits into during a credential exchange, + * and every content slot — `reauthContent`, `emailContent`, `phoneContent` — is composed inside it. + * So a test standing in for provider code emits here, exactly where the real thing would, instead + * of writing the process-wide state channel and relying on the host to work out whose state it was. + */ +internal class ReauthScopeProbe { + var scope: AuthFlowScope? = null + private set + + /** Call from inside a content slot. */ + @Composable + fun capture() { + scope = LocalAuthFlowScope.current + } + + /** Emits [state] as the exchange's provider code would. */ + fun emit(state: AuthState) { + requireNotNull(scope) { "No reauthentication surface has been composed yet" }.emit(state) + } +} 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 18f396f9b..937f698d0 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 @@ -14,6 +14,7 @@ package com.firebase.ui.auth.configuration.auth_provider +import com.firebase.ui.auth.flowScope import android.content.Context import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.core.app.ApplicationProvider @@ -123,7 +124,7 @@ class AnonymousAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) - instance.signInAnonymously(config) + instance.flowScope(config).signInAnonymously() verify(mockFirebaseAuth).signInAnonymously() @@ -140,7 +141,7 @@ class AnonymousAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) // Queue signInAnonymously first; first{} suspends and lets the scheduler run it - val job = launch { runCatching { instance.signInAnonymously(config) } } + val job = launch { runCatching { instance.flowScope(config).signInAnonymously() } } val loadingState = instance.authStateFlow().first { it is AuthState.Loading } assertThat((loadingState as AuthState.Loading).message) @@ -160,7 +161,7 @@ class AnonymousAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) try { - instance.signInAnonymously(config) + instance.flowScope(config).signInAnonymously() assertThat(false).isTrue() // Should not reach here } catch (e: AuthException.NetworkException) { assertThat(e.cause).isEqualTo(networkException) @@ -183,7 +184,7 @@ class AnonymousAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) try { - instance.signInAnonymously(config) + instance.flowScope(config).signInAnonymously() assertThat(false).isTrue() // Should not reach here } catch (e: AuthException.AuthCancelledException) { assertThat(e.message).contains("cancelled") @@ -207,7 +208,7 @@ class AnonymousAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) try { - instance.signInAnonymously(config) + instance.flowScope(config).signInAnonymously() assertThat(false).isTrue() // Should not reach here } catch (e: AuthException.UnknownException) { assertThat(e.cause).isEqualTo(genericException) @@ -235,8 +236,7 @@ class AnonymousAuthProviderFirebaseAuthUITest { var launcher: (() -> Unit)? = null composeTestRule.setContent { - launcher = instance.rememberAnonymousSignInHandler( - config = config, + launcher = instance.flowScope(config).rememberAnonymousSignInHandler( onSignInFailure = { reportedFailures.add(it) }, ) } @@ -277,9 +277,8 @@ class AnonymousAuthProviderFirebaseAuthUITest { isAnonymousUpgradeEnabled = true } - instance.createOrLinkUserWithEmailAndPassword( + instance.flowScope(config).createOrLinkUserWithEmailAndPassword( context = applicationContext, - config = config, provider = emailProvider, name = null, email = "test@example.com", @@ -320,9 +319,8 @@ class AnonymousAuthProviderFirebaseAuthUITest { } try { - instance.createOrLinkUserWithEmailAndPassword( + instance.flowScope(config).createOrLinkUserWithEmailAndPassword( context = applicationContext, - config = config, provider = emailProvider, name = null, email = "test@example.com", @@ -369,8 +367,7 @@ class AnonymousAuthProviderFirebaseAuthUITest { isAnonymousUpgradeEnabled = true } - val result = instance.signInAndLinkWithCredential( - config = config, + val result = instance.flowScope(config).signInAndLinkWithCredential( credential = credential ) @@ -411,9 +408,8 @@ class AnonymousAuthProviderFirebaseAuthUITest { isCredentialLinkingEnabled = true } - instance.createOrLinkUserWithEmailAndPassword( + instance.flowScope(config).createOrLinkUserWithEmailAndPassword( context = applicationContext, - config = config, provider = emailProvider, name = null, email = "test@example.com", 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 b1c03621d..4cb63bc4f 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 @@ -14,6 +14,7 @@ package com.firebase.ui.auth.configuration.auth_provider +import com.firebase.ui.auth.flowScope import android.content.Context import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R @@ -148,9 +149,8 @@ class EmailAuthProviderFirebaseAuthUITest { } } - instance.createOrLinkUserWithEmailAndPassword( + instance.flowScope(config).createOrLinkUserWithEmailAndPassword( context = applicationContext, - config = config, provider = emailProvider, name = null, email = "test@example.com", @@ -189,9 +189,8 @@ class EmailAuthProviderFirebaseAuthUITest { isAnonymousUpgradeEnabled = true } - instance.createOrLinkUserWithEmailAndPassword( + instance.flowScope(config).createOrLinkUserWithEmailAndPassword( context = applicationContext, - config = config, provider = emailProvider, name = null, email = "test@example.com", @@ -218,9 +217,8 @@ class EmailAuthProviderFirebaseAuthUITest { } try { - instance.createOrLinkUserWithEmailAndPassword( + instance.flowScope(config).createOrLinkUserWithEmailAndPassword( context = applicationContext, - config = config, provider = emailProvider, name = null, email = "test@example.com", @@ -254,9 +252,8 @@ class EmailAuthProviderFirebaseAuthUITest { } try { - instance.createOrLinkUserWithEmailAndPassword( + instance.flowScope(config).createOrLinkUserWithEmailAndPassword( context = applicationContext, - config = config, provider = emailProvider, name = null, email = "test@example.com", @@ -291,9 +288,8 @@ class EmailAuthProviderFirebaseAuthUITest { }.copy(isReauthenticationMode = true) try { - instance.createOrLinkUserWithEmailAndPassword( + instance.flowScope(config).createOrLinkUserWithEmailAndPassword( context = applicationContext, - config = config, provider = emailProvider, name = null, email = "brand-new@example.com", @@ -327,9 +323,8 @@ class EmailAuthProviderFirebaseAuthUITest { }.copy(isNewEmailAccountsAllowed = false) try { - instance.createOrLinkUserWithEmailAndPassword( + instance.flowScope(config).createOrLinkUserWithEmailAndPassword( context = applicationContext, - config = config, provider = emailProvider, name = null, email = "test@example.com", @@ -360,9 +355,8 @@ class EmailAuthProviderFirebaseAuthUITest { } try { - instance.createOrLinkUserWithEmailAndPassword( + instance.flowScope(config).createOrLinkUserWithEmailAndPassword( context = applicationContext, - config = config, provider = emailProvider, name = null, email = "test@example.com", @@ -405,9 +399,8 @@ class EmailAuthProviderFirebaseAuthUITest { } try { - instance.createOrLinkUserWithEmailAndPassword( + instance.flowScope(config).createOrLinkUserWithEmailAndPassword( context = applicationContext, - config = config, provider = emailProvider, name = null, email = "test@example.com", @@ -452,9 +445,8 @@ class EmailAuthProviderFirebaseAuthUITest { } } - val result = instance.signInWithEmailAndPassword( + val result = instance.flowScope(config).signInWithEmailAndPassword( context = applicationContext, - config = config, email = "test@example.com", password = "Pass@123" ) @@ -488,9 +480,8 @@ class EmailAuthProviderFirebaseAuthUITest { } try { - instance.signInWithEmailAndPassword( + instance.flowScope(config).signInWithEmailAndPassword( context = applicationContext, - config = config, email = "test@example.com", password = "Pass@123" ) @@ -538,9 +529,8 @@ class EmailAuthProviderFirebaseAuthUITest { } try { - instance.signInWithEmailAndPassword( + instance.flowScope(config).signInWithEmailAndPassword( context = applicationContext, - config = config, email = "test@example.com", password = "Pass@123" ) @@ -592,9 +582,8 @@ class EmailAuthProviderFirebaseAuthUITest { } try { - instance.signInWithEmailAndPassword( + instance.flowScope(config).signInWithEmailAndPassword( context = applicationContext, - config = config, email = "test@example.com", password = "Pass@123" ) @@ -636,9 +625,8 @@ class EmailAuthProviderFirebaseAuthUITest { } try { - instance.signInWithEmailAndPassword( + instance.flowScope(config).signInWithEmailAndPassword( context = applicationContext, - config = config, email = "test@example.com", password = "Pass@123" ) @@ -674,9 +662,8 @@ class EmailAuthProviderFirebaseAuthUITest { } try { - instance.signInWithEmailAndPassword( + instance.flowScope(config).signInWithEmailAndPassword( context = applicationContext, - config = config, email = "test@example.com", password = "Pass@123" ) @@ -717,9 +704,8 @@ class EmailAuthProviderFirebaseAuthUITest { } } - instance.signInWithEmailAndPassword( + instance.flowScope(config).signInWithEmailAndPassword( context = applicationContext, - config = config, email = "test@example.com", password = "Pass@123", credentialForLinking = googleCredential @@ -755,8 +741,7 @@ class EmailAuthProviderFirebaseAuthUITest { } } - val result = instance.signInAndLinkWithCredential( - config = config, + val result = instance.flowScope(config).signInAndLinkWithCredential( credential = credential ) @@ -768,7 +753,7 @@ class EmailAuthProviderFirebaseAuthUITest { /** * Only the null-`currentUser` failure was covered, so the *value* of the stamp was free: a * `reauthenticatedUid = null` would still have published a Success, which the screen accepts - * as a completed sign-in while refusing to resume the operation it was armed for. + * as a completed sign-in while refusing to resume the operation it was outstanding for. */ @Test fun `signInAndLinkWithCredential - reauth success stamps the reauthenticated uid`() = runTest { @@ -793,7 +778,7 @@ class EmailAuthProviderFirebaseAuthUITest { providers { provider(emailProvider) } }.copy(isReauthenticationMode = true) - val result = instance.signInAndLinkWithCredential(config = config, credential = credential) + val result = instance.flowScope(config).signInAndLinkWithCredential( credential = credential) assertThat(result).isNull() verify(user).reauthenticate(credential) @@ -836,7 +821,7 @@ class EmailAuthProviderFirebaseAuthUITest { }.copy(isReauthenticationMode = true) assertThat(config.isCredentialLinkingEnabled).isTrue() - instance.signInAndLinkWithCredential(config = config, credential = credential) + instance.flowScope(config).signInAndLinkWithCredential( credential = credential) verify(user).reauthenticate(credential) verify(user, never()).linkWithCredential(any()) @@ -878,7 +863,7 @@ class EmailAuthProviderFirebaseAuthUITest { }.copy(isReauthenticationMode = true) try { - instance.signInAndLinkWithCredential(config = config, credential = credential) + instance.flowScope(config).signInAndLinkWithCredential( credential = credential) assertWithMessage("expected a null currentUser after reauth to throw").fail() } catch (e: Exception) { assertThat(e).isInstanceOf(AuthException.UserNotFoundException::class.java) @@ -914,8 +899,7 @@ class EmailAuthProviderFirebaseAuthUITest { isAnonymousUpgradeEnabled = true } - val result = instance.signInAndLinkWithCredential( - config = config, + val result = instance.flowScope(config).signInAndLinkWithCredential( credential = credential ) @@ -957,8 +941,7 @@ class EmailAuthProviderFirebaseAuthUITest { } try { - instance.signInAndLinkWithCredential( - config = config, + instance.flowScope(config).signInAndLinkWithCredential( credential = credential ) assertThat(false).isTrue() // Should not reach here @@ -1000,8 +983,7 @@ class EmailAuthProviderFirebaseAuthUITest { isCredentialLinkingEnabled = true } - val result = instance.signInAndLinkWithCredential( - config = config, + val result = instance.flowScope(config).signInAndLinkWithCredential( credential = credential ) @@ -1025,7 +1007,7 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) - instance.sendPasswordResetEmail("test@example.com", emailConfig) + instance.flowScope(emailConfig).sendPasswordResetEmail("test@example.com") verify(mockFirebaseAuth).sendPasswordResetEmail( ArgumentMatchers.eq("test@example.com"), @@ -1049,7 +1031,7 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) - instance.sendPasswordResetEmail("test@example.com", emailConfig, actionCodeSettings) + instance.flowScope(emailConfig).sendPasswordResetEmail("test@example.com", actionCodeSettings) verify(mockFirebaseAuth).sendPasswordResetEmail("test@example.com", actionCodeSettings) @@ -1073,7 +1055,7 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) try { - instance.sendPasswordResetEmail("test@example.com", emailConfig) + instance.flowScope(emailConfig).sendPasswordResetEmail("test@example.com") assertThat(false).isTrue() // Should not reach here } catch (e: AuthException.UserNotFoundException) { assertThat(e.cause).isEqualTo(userNotFoundException) @@ -1096,7 +1078,7 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) try { - instance.sendPasswordResetEmail("test@example.com", emailConfig) + instance.flowScope(emailConfig).sendPasswordResetEmail("test@example.com") assertThat(false).isTrue() // Should not reach here } catch (e: AuthException.InvalidCredentialsException) { assertThat(e.cause).isEqualTo(invalidEmailException) @@ -1116,7 +1098,7 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) try { - instance.sendPasswordResetEmail("test@example.com", emailConfig) + instance.flowScope(emailConfig).sendPasswordResetEmail("test@example.com") assertThat(false).isTrue() // Should not reach here } catch (e: AuthException.AuthCancelledException) { assertThat(e.message).contains("cancelled") @@ -1161,9 +1143,8 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) - instance.sendSignInLinkToEmail( + instance.flowScope(config).sendSignInLinkToEmail( context = applicationContext, - config = config, provider = provider, email = "test@example.com", credentialForLinking = null @@ -1213,9 +1194,8 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) - instance.sendSignInLinkToEmail( + instance.flowScope(config).sendSignInLinkToEmail( context = applicationContext, - config = config, provider = provider, email = "test@example.com", credentialForLinking = null @@ -1265,9 +1245,8 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) - instance.sendSignInLinkToEmail( + instance.flowScope(config).sendSignInLinkToEmail( context = applicationContext, - config = config, provider = provider, email = "test@example.com", credentialForLinking = googleCredential @@ -1317,9 +1296,8 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) try { - instance.sendSignInLinkToEmail( + instance.flowScope(config).sendSignInLinkToEmail( context = applicationContext, - config = config, provider = provider, email = "test@example.com", credentialForLinking = null @@ -1357,9 +1335,8 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) try { - instance.signInWithEmailLink( + instance.flowScope(config).signInWithEmailLink( context = applicationContext, - config = config, provider = provider, email = "test@example.com", emailLink = "https://invalid-link.com" @@ -1419,9 +1396,8 @@ class EmailAuthProviderFirebaseAuthUITest { val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code&continueUrl=https://example.com?ui_sid=session123" - val result = instance.signInWithEmailLink( + val result = instance.flowScope(config).signInWithEmailLink( context = applicationContext, - config = config, provider = provider, email = "test@example.com", emailLink = emailLink, @@ -1487,9 +1463,8 @@ class EmailAuthProviderFirebaseAuthUITest { val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code&continueUrl=https://example.com?ui_sid=session123&ui_auid=anon-uid-123" - val result = instance.signInWithEmailLink( + val result = instance.flowScope(config).signInWithEmailLink( context = applicationContext, - config = config, provider = provider, email = "test@example.com", emailLink = emailLink, @@ -1538,9 +1513,8 @@ class EmailAuthProviderFirebaseAuthUITest { val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code123&continueUrl=https://example.com?ui_sid=different-session" try { - instance.signInWithEmailLink( + instance.flowScope(config).signInWithEmailLink( context = applicationContext, - config = config, provider = provider, email = "", // Empty email triggers prompt emailLink = emailLink, @@ -1588,9 +1562,8 @@ class EmailAuthProviderFirebaseAuthUITest { val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code123&continueUrl=https://example.com?ui_sid=different-session&ui_pid=google.com" try { - instance.signInWithEmailLink( + instance.flowScope(config).signInWithEmailLink( context = applicationContext, - config = config, provider = provider, email = "", // Empty email triggers prompt (which detects provider linking) emailLink = emailLink, @@ -1633,9 +1606,8 @@ class EmailAuthProviderFirebaseAuthUITest { val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code&continueUrl=https://example.com?ui_sid=different-session&ui_sd=1" try { - instance.signInWithEmailLink( + instance.flowScope(config).signInWithEmailLink( context = applicationContext, - config = config, provider = provider, email = "test@example.com", emailLink = emailLink @@ -1688,9 +1660,8 @@ class EmailAuthProviderFirebaseAuthUITest { val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code&continueUrl=https://example.com?ui_sid=session123&ui_auid=different-anon-uid" try { - instance.signInWithEmailLink( + instance.flowScope(config).signInWithEmailLink( context = applicationContext, - config = config, provider = provider, email = "test@example.com", emailLink = emailLink, @@ -1739,9 +1710,8 @@ class EmailAuthProviderFirebaseAuthUITest { val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code&continueUrl=https://example.com?ui_sid=session123" try { - instance.signInWithEmailLink( + instance.flowScope(config).signInWithEmailLink( context = applicationContext, - config = config, provider = provider, email = "", // Empty email emailLink = emailLink, @@ -1794,9 +1764,8 @@ class EmailAuthProviderFirebaseAuthUITest { val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=invalid-code&continueUrl=https://example.com?ui_sid=different-session" try { - instance.signInWithEmailLink( + instance.flowScope(config).signInWithEmailLink( context = applicationContext, - config = config, provider = provider, email = "", // Empty email triggers validation which will fail emailLink = emailLink, @@ -1839,9 +1808,8 @@ class EmailAuthProviderFirebaseAuthUITest { val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code&continueUrl=https://example.com" try { - instance.signInWithEmailLink( + instance.flowScope(config).signInWithEmailLink( context = applicationContext, - config = config, provider = provider, email = "test@example.com", emailLink = emailLink, @@ -1873,9 +1841,8 @@ class EmailAuthProviderFirebaseAuthUITest { providers { provider(emailProvider) } } - instance.signInWithEmailAndPassword( + instance.flowScope(config).signInWithEmailAndPassword( context = applicationContext, - config = config, email = "test@example.com", password = "Pass@123" ) @@ -1905,7 +1872,7 @@ class EmailAuthProviderFirebaseAuthUITest { providers { provider(emailProvider) } } - instance.signInAndLinkWithCredential(config = config, credential = credential) + instance.flowScope(config).signInAndLinkWithCredential( credential = credential) val state = instance.authStateFlow().first { it !is AuthState.Loading } assertThat(state).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false)) @@ -1931,9 +1898,8 @@ class EmailAuthProviderFirebaseAuthUITest { providers { provider(emailProvider) } } - instance.createOrLinkUserWithEmailAndPassword( + instance.flowScope(config).createOrLinkUserWithEmailAndPassword( context = applicationContext, - config = config, provider = emailProvider, name = null, email = "new@example.com", @@ -1995,9 +1961,8 @@ class EmailAuthProviderFirebaseAuthUITest { "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code" + "&continueUrl=https://example.com?ui_sid=session123" - val result = instance.signInWithEmailLink( + val result = instance.flowScope(config).signInWithEmailLink( context = applicationContext, - config = config, provider = provider, email = "test@example.com", emailLink = emailLink, @@ -2054,9 +2019,8 @@ class EmailAuthProviderFirebaseAuthUITest { val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code&continueUrl=https://example.com?ui_sid=session123" - instance.signInWithEmailLink( + instance.flowScope(config).signInWithEmailLink( context = applicationContext, - config = config, provider = provider, email = "test@example.com", emailLink = emailLink, 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 fe10118e6..db2931f03 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 @@ -14,6 +14,7 @@ package com.firebase.ui.auth.configuration.auth_provider +import com.firebase.ui.auth.flowScope import android.content.Context import android.net.Uri import androidx.compose.ui.test.junit4.createComposeRule @@ -128,9 +129,8 @@ class FacebookAuthProviderFirebaseAuthUITest { var launcher: (() -> Unit)? = null composeTestRule.setContent { - launcher = instance.rememberSignInWithFacebookLauncher( + launcher = instance.flowScope(config).rememberSignInWithFacebookLauncher( context = applicationContext, - config = config, provider = provider, loginManagerProvider = mockFBAuthCredentialProvider, ) @@ -167,9 +167,8 @@ class FacebookAuthProviderFirebaseAuthUITest { var thrownException: Exception? = null composeTestRule.setContent { - launcher = instance.rememberSignInWithFacebookLauncher( + launcher = instance.flowScope(config).rememberSignInWithFacebookLauncher( context = applicationContext, - config = config, provider = provider, loginManagerProvider = mockFBAuthCredentialProvider, ) @@ -243,9 +242,8 @@ class FacebookAuthProviderFirebaseAuthUITest { instance.authStateFlow().first { it is AuthState.Success } } - instance.signInWithFacebook( + instance.flowScope(config).signInWithFacebook( context = applicationContext, - config = config, provider = provider, accessToken = mockAccessToken, credentialProvider = mockFBAuthCredentialProvider @@ -299,9 +297,8 @@ class FacebookAuthProviderFirebaseAuthUITest { .thenReturn(mockCredential) try { - instance.signInWithFacebook( + instance.flowScope(config).signInWithFacebook( context = applicationContext, - config = config, provider = provider, accessToken = mockAccessToken, credentialProvider = mockFBAuthCredentialProvider @@ -344,9 +341,8 @@ class FacebookAuthProviderFirebaseAuthUITest { }.whenever(provider).fetchFacebookProfile(any()) try { - instance.signInWithFacebook( + instance.flowScope(config).signInWithFacebook( context = applicationContext, - config = config, provider = provider, accessToken = mockAccessToken, credentialProvider = mockFBAuthCredentialProvider 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 b82f010ac..8aaffe004 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 @@ -14,6 +14,7 @@ package com.firebase.ui.auth.configuration.auth_provider +import com.firebase.ui.auth.flowScope import android.content.Context import android.util.Log import androidx.compose.ui.test.junit4.createComposeRule @@ -168,9 +169,8 @@ class GoogleAuthProviderFirebaseAuthUITest { } } - instance.signInWithGoogle( + instance.flowScope(config).signInWithGoogle( context = applicationContext, - config = config, provider = googleProvider, authorizationProvider = mockAuthorizationProvider, credentialManagerProvider = mockCredentialManagerProvider @@ -238,9 +238,8 @@ class GoogleAuthProviderFirebaseAuthUITest { } } - instance.signInWithGoogle( + instance.flowScope(config).signInWithGoogle( context = applicationContext, - config = config, provider = googleProvider, authorizationProvider = mockAuthorizationProvider, credentialManagerProvider = mockCredentialManagerProvider @@ -313,9 +312,8 @@ class GoogleAuthProviderFirebaseAuthUITest { } } - instance.signInWithGoogle( + instance.flowScope(config).signInWithGoogle( context = applicationContext, - config = config, provider = googleProvider, authorizationProvider = mockAuthorizationProvider, credentialManagerProvider = mockCredentialManagerProvider @@ -381,9 +379,8 @@ class GoogleAuthProviderFirebaseAuthUITest { } } - instance.signInWithGoogle( + instance.flowScope(config).signInWithGoogle( context = applicationContext, - config = config, provider = googleProvider, authorizationProvider = mockAuthorizationProvider, credentialManagerProvider = mockCredentialManagerProvider @@ -429,9 +426,8 @@ class GoogleAuthProviderFirebaseAuthUITest { } try { - instance.signInWithGoogle( + instance.flowScope(config).signInWithGoogle( context = applicationContext, - config = config, provider = googleProvider, authorizationProvider = mockAuthorizationProvider, credentialManagerProvider = mockCredentialManagerProvider @@ -486,9 +482,8 @@ class GoogleAuthProviderFirebaseAuthUITest { } try { - instance.signInWithGoogle( + instance.flowScope(config).signInWithGoogle( context = applicationContext, - config = config, provider = googleProvider, authorizationProvider = mockAuthorizationProvider, credentialManagerProvider = mockCredentialManagerProvider @@ -547,9 +542,8 @@ class GoogleAuthProviderFirebaseAuthUITest { } try { - instance.signInWithGoogle( + instance.flowScope(config).signInWithGoogle( context = applicationContext, - config = config, provider = googleProvider, authorizationProvider = mockAuthorizationProvider, credentialManagerProvider = mockCredentialManagerProvider @@ -590,9 +584,8 @@ class GoogleAuthProviderFirebaseAuthUITest { } try { - instance.signInWithGoogle( + instance.flowScope(config).signInWithGoogle( context = applicationContext, - config = config, provider = googleProvider, authorizationProvider = mockAuthorizationProvider, credentialManagerProvider = mockCredentialManagerProvider @@ -636,9 +629,8 @@ class GoogleAuthProviderFirebaseAuthUITest { } // Should not throw - user cancellation is not an error - instance.signInWithGoogle( + instance.flowScope(config).signInWithGoogle( context = applicationContext, - config = config, provider = googleProvider, authorizationProvider = mockAuthorizationProvider, credentialManagerProvider = mockCredentialManagerProvider @@ -699,9 +691,8 @@ class GoogleAuthProviderFirebaseAuthUITest { } } - instance.signInWithGoogle( + instance.flowScope(config).signInWithGoogle( context = applicationContext, - config = config, provider = googleProvider, authorizationProvider = mockAuthorizationProvider, credentialManagerProvider = mockCredentialManagerProvider @@ -762,9 +753,8 @@ class GoogleAuthProviderFirebaseAuthUITest { } } - instance.signInWithGoogle( + instance.flowScope(config).signInWithGoogle( context = applicationContext, - config = config, provider = googleProvider, authorizationProvider = mockAuthorizationProvider, credentialManagerProvider = mockCredentialManagerProvider @@ -823,9 +813,8 @@ class GoogleAuthProviderFirebaseAuthUITest { } } - instance.signInWithGoogle( + instance.flowScope(config).signInWithGoogle( context = applicationContext, - config = config, provider = googleProvider, authorizationProvider = mockAuthorizationProvider, credentialManagerProvider = mockCredentialManagerProvider @@ -886,9 +875,8 @@ class GoogleAuthProviderFirebaseAuthUITest { } } - instance.signInWithGoogle( + instance.flowScope(config).signInWithGoogle( context = applicationContext, - config = config, provider = googleProvider, authorizationProvider = mockAuthorizationProvider, credentialManagerProvider = mockCredentialManagerProvider @@ -949,9 +937,8 @@ class GoogleAuthProviderFirebaseAuthUITest { } } - instance.signInWithGoogle( + instance.flowScope(config).signInWithGoogle( context = applicationContext, - config = config, provider = googleProvider, authorizationProvider = mockAuthorizationProvider, credentialManagerProvider = mockCredentialManagerProvider @@ -1018,9 +1005,8 @@ class GoogleAuthProviderFirebaseAuthUITest { // Verify initial state assertThat(instance.authStateFlow().first()).isEqualTo(AuthState.Idle) - instance.signInWithGoogle( + instance.flowScope(config).signInWithGoogle( context = applicationContext, - config = config, provider = googleProvider, authorizationProvider = mockAuthorizationProvider, credentialManagerProvider = mockCredentialManagerProvider @@ -1071,9 +1057,8 @@ class GoogleAuthProviderFirebaseAuthUITest { var launcher: (() -> Unit)? = null composeTestRule.setContent { - launcher = instance.rememberGoogleSignInHandler( + launcher = instance.flowScope(config).rememberGoogleSignInHandler( context = applicationContext, - config = config, provider = googleProvider, onSignInFailure = { reportedFailures.add(it) }, ) @@ -1118,9 +1103,8 @@ class GoogleAuthProviderFirebaseAuthUITest { var launcher: (() -> Unit)? = null composeTestRule.setContent { - launcher = instance.rememberGoogleSignInHandler( + launcher = instance.flowScope(config).rememberGoogleSignInHandler( context = applicationContext, - config = config, provider = googleProvider, onSignInFailure = { reportedFailures.add(it) }, ) 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 054c75245..644324d47 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 @@ -14,6 +14,7 @@ package com.firebase.ui.auth.configuration.auth_provider +import com.firebase.ui.auth.flowScope import android.app.Activity import android.content.Context import androidx.compose.ui.test.junit4.createComposeRule @@ -137,12 +138,10 @@ class OAuthProviderFirebaseAuthUITest { } } - instance.signInWithProvider( + instance.flowScope(config).signInWithProvider( applicationContext, - config = config, activity = mockActivity, - provider = githubProvider, - ) + provider = githubProvider) // Verify OAuth provider was built and used verify(mockFirebaseAuth).startActivityForSignInWithProvider( @@ -196,12 +195,10 @@ class OAuthProviderFirebaseAuthUITest { providers { provider(appleProvider) } }.copy(isReauthenticationMode = true) - instance.signInWithProvider( + instance.flowScope(config).signInWithProvider( applicationContext, - config = config, activity = mockActivity, - provider = appleProvider, - ) + provider = appleProvider) verify(mockUser).startActivityForReauthenticateWithProvider( eq(mockActivity), @@ -258,12 +255,10 @@ class OAuthProviderFirebaseAuthUITest { }.copy(isReauthenticationMode = true) try { - instance.signInWithProvider( + instance.flowScope(config).signInWithProvider( applicationContext, - config = config, activity = mockActivity, - provider = githubProvider, - ) + provider = githubProvider) assertWithMessage("expected a null currentUser after reauth to throw").fail() } catch (e: Exception) { assertThat(e).isInstanceOf(AuthException.UserNotFoundException::class.java) @@ -310,9 +305,8 @@ class OAuthProviderFirebaseAuthUITest { } } - instance.signInWithProvider( + instance.flowScope(config).signInWithProvider( applicationContext, - config = config, activity = mockActivity, provider = yahooProvider ) @@ -356,9 +350,8 @@ class OAuthProviderFirebaseAuthUITest { } try { - instance.signInWithProvider( + instance.flowScope(config).signInWithProvider( applicationContext, - config = config, activity = mockActivity, provider = githubProvider ) @@ -397,9 +390,8 @@ class OAuthProviderFirebaseAuthUITest { } try { - instance.signInWithProvider( + instance.flowScope(config).signInWithProvider( applicationContext, - config = config, activity = mockActivity, provider = microsoftProvider ) @@ -439,10 +431,9 @@ class OAuthProviderFirebaseAuthUITest { var launcher: (() -> Unit)? = null composeTestRule.setContent { - launcher = instance.rememberOAuthSignInHandler( + launcher = instance.flowScope(config).rememberOAuthSignInHandler( context = applicationContext, activity = mockActivity, - config = config, provider = microsoftProvider, onSignInFailure = { reportedFailures.add(it) }, ) diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt index df78205cb..11f87f249 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt @@ -14,6 +14,7 @@ package com.firebase.ui.auth.configuration.auth_provider +import com.firebase.ui.auth.flowScope import android.app.Activity import android.content.Context import androidx.test.core.app.ApplicationProvider @@ -155,11 +156,10 @@ class PhoneAuthProviderFirebaseAuthUITest { flowOf(AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified(mockCredential)) ) - instance.verifyPhoneNumber( + instance.flowScope(phoneConfig).verifyPhoneNumber( provider = phoneProvider, activity = null, phoneNumber = "+1234567890", - config = phoneConfig, verifier = mockPhoneAuthVerifier ) @@ -201,11 +201,10 @@ class PhoneAuthProviderFirebaseAuthUITest { ) ) - instance.verifyPhoneNumber( + instance.flowScope(phoneConfig).verifyPhoneNumber( provider = phoneProvider, activity = null, phoneNumber = "+1234567890", - config = phoneConfig, verifier = mockPhoneAuthVerifier ) @@ -253,11 +252,10 @@ class PhoneAuthProviderFirebaseAuthUITest { ) ) - instance.verifyPhoneNumber( + instance.flowScope(phoneConfig).verifyPhoneNumber( provider = phoneProvider, activity = null, phoneNumber = "+1234567890", - config = phoneConfig, verifier = mockPhoneAuthVerifier ) @@ -299,11 +297,10 @@ class PhoneAuthProviderFirebaseAuthUITest { ) ) - instance.verifyPhoneNumber( + instance.flowScope(phoneConfig).verifyPhoneNumber( provider = phoneProvider, activity = null, phoneNumber = "+1234567890", - config = phoneConfig, forceResendingToken = mockToken, verifier = mockPhoneAuthVerifier ) @@ -346,11 +343,10 @@ class PhoneAuthProviderFirebaseAuthUITest { ) ) - instance.verifyPhoneNumber( + instance.flowScope(phoneConfig).verifyPhoneNumber( provider = phoneProvider, activity = null, phoneNumber = "+1234567890", - config = phoneConfig, verifier = mockPhoneAuthVerifier ) @@ -392,11 +388,10 @@ class PhoneAuthProviderFirebaseAuthUITest { var thrown: Throwable? = null try { - instance.verifyPhoneNumber( + instance.flowScope(phoneConfig).verifyPhoneNumber( provider = phoneProvider, activity = null, phoneNumber = "+1234567890", - config = phoneConfig, verifier = cancellingVerifier ) } catch (t: Throwable) { @@ -478,11 +473,10 @@ class PhoneAuthProviderFirebaseAuthUITest { } return async(start = CoroutineStart.UNDISPATCHED) { - instance.verifyPhoneNumber( + instance.flowScope(phoneConfig).verifyPhoneNumber( provider = phoneProvider, activity = null, phoneNumber = "+1234567890", - config = phoneConfig, verifier = neverResolvingVerifier ) } @@ -522,9 +516,8 @@ class PhoneAuthProviderFirebaseAuthUITest { } } - val result = instance.submitVerificationCode( + val result = instance.flowScope(config).submitVerificationCode( applicationContext, - config = config, verificationId = "test-verification-id", code = "123456", credentialProvider = mockPhoneAuthCredentialProvider @@ -565,9 +558,8 @@ class PhoneAuthProviderFirebaseAuthUITest { } } - val result = instance.signInWithPhoneAuthCredential( + val result = instance.flowScope(config).signInWithPhoneAuthCredential( applicationContext, - config = config, credential = mockCredential ) @@ -606,9 +598,8 @@ class PhoneAuthProviderFirebaseAuthUITest { isAnonymousUpgradeEnabled = true } - val result = instance.signInWithPhoneAuthCredential( + val result = instance.flowScope(config).signInWithPhoneAuthCredential( applicationContext, - config = config, credential = mockCredential ) diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/SignInStateSequenceTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/SignInStateSequenceTest.kt new file mode 100644 index 000000000..758ac9483 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/SignInStateSequenceTest.kt @@ -0,0 +1,387 @@ +/* + * 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.configuration.auth_provider + +import com.firebase.ui.auth.flowScope +import android.app.Activity +import android.content.Context +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.authUIConfiguration +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseNetworkException +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.OAuthCredential +import com.google.firebase.auth.OAuthProvider +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthProvider +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.After +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.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The ordered sequence of states each sign-in path publishes. + * + * Every other provider test asserts only the state it ends on, so an emission that is dropped, + * duplicated or moved leaves all of them green. That is exactly the failure a mechanical sweep over + * the ~87 `updateAuthState` call sites in provider code can introduce, so these record the order + * itself: a golden net to refactor the provider receiver against, not a specification of anything + * new. + * + * The states are read through `authStateFlow()`, so what they record is what a *consumer* observes. + * That matters: the flow underneath is a `MutableStateFlow` and therefore conflating, so each task + * below is completed only after the collector has been let run. A test that resolved its task up + * front would record `[Idle, Success]` and prove nothing about the `Loading` in between. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class SignInStateSequenceTest { + + private lateinit var mockFirebaseAuth: FirebaseAuth + private lateinit var firebaseApp: FirebaseApp + private lateinit var applicationContext: Context + + @Before + fun setUp() { + mockFirebaseAuth = mock(FirebaseAuth::class.java) + FirebaseAuthUI.clearInstanceCache() + applicationContext = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(applicationContext).forEach { it.delete() } + firebaseApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + runCatching { firebaseApp.delete() } + } + + // ============================================================================================= + // Harness + // ============================================================================================= + + /** Records every state in order from now until the test ends. */ + private fun TestScope.record(instance: FirebaseAuthUI): List { + val recorded = mutableListOf() + backgroundScope.launch { instance.authStateFlow().collect { recorded += it } } + runCurrent() + return object : AbstractList() { + override val size: Int get() = recorded.size + override fun get(index: Int): String = recorded[index].label() + } + } + + /** + * Lets the recorder catch up to [count] states, or fails. + * + * `advanceUntilIdle` is not enough: the recorder collects its own `authStateFlow()`, and an + * idle scheduler does not mean that collection has been resumed. Yielding hands it turns until + * it has. + */ + private suspend fun awaitStates(states: List, count: Int) { + repeat(1_000) { + if (states.size >= count) return + yield() + } + throw AssertionError("Recorded only $states, expected $count states") + } + + /** The state's identity, without the payload: what changed and in what order, not what it held. */ + private fun AuthState.label(): String = when (this) { + is AuthState.Idle -> "Idle" + is AuthState.Loading -> "Loading" + is AuthState.Success -> "Success" + is AuthState.Error -> "Error" + is AuthState.Cancelled -> "Cancelled" + is AuthState.Aborted -> "Aborted" + is AuthState.RequiresEmailVerification -> "RequiresEmailVerification" + is AuthState.RequiresProfileCompletion -> "RequiresProfileCompletion" + is AuthState.RequiresMfa -> "RequiresMfa" + is AuthState.PasswordResetLinkSent -> "PasswordResetLinkSent" + is AuthState.EmailSignInLinkSent -> "EmailSignInLinkSent" + is AuthState.PhoneNumberVerificationRequired -> "PhoneNumberVerificationRequired" + is AuthState.SMSAutoVerified -> "SMSAutoVerified" + is AuthState.Reauthentication -> "Reauthentication.${this::class.simpleName}" + else -> this::class.simpleName ?: "?" + } + + private fun configOf(vararg providers: AuthProvider): AuthUIConfiguration = + authUIConfiguration { + context = applicationContext + providers { providers.forEach { provider(it) } } + } + + private fun signedInResult(): Pair { + val user = mock(FirebaseUser::class.java) + `when`(user.uid).thenReturn("uid-1") + `when`(user.isEmailVerified).thenReturn(true) + `when`(user.providerData).thenReturn(emptyList()) + val result = mock(AuthResult::class.java) + `when`(result.user).thenReturn(user) + return result to user + } + + private fun emailProvider() = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList(), + ) + + // ============================================================================================= + // Anonymous + // ============================================================================================= + + @Test + fun `anonymous sign-in publishes Loading then Success`() = runTest { + val (result, user) = signedInResult() + `when`(user.isAnonymous).thenReturn(true) + val task = TaskCompletionSource() + `when`(mockFirebaseAuth.signInAnonymously()).thenReturn(task.task) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val states = record(instance) + val config = configOf(AuthProvider.Anonymous, emailProvider()) + + val job = launch { runCatching { instance.flowScope(config).signInAnonymously() } } + runCurrent() + task.setResult(result) + runCurrent() + job.join() + awaitStates(states, 3) + + assertThat(states).containsExactly("Idle", "Loading", "Success").inOrder() + } + + @Test + fun `a failed anonymous sign-in publishes Loading then Error`() = runTest { + val task = TaskCompletionSource() + `when`(mockFirebaseAuth.signInAnonymously()).thenReturn(task.task) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val states = record(instance) + val config = configOf(AuthProvider.Anonymous, emailProvider()) + + val job = launch { runCatching { instance.flowScope(config).signInAnonymously() } } + runCurrent() + task.setException(FirebaseNetworkException("Network error")) + runCurrent() + job.join() + awaitStates(states, 3) + + assertThat(states).containsExactly("Idle", "Loading", "Error").inOrder() + } + + // ============================================================================================= + // Email + // ============================================================================================= + + @Test + fun `email password sign-in publishes Loading then Success`() = runTest { + val (result, _) = signedInResult() + val task = TaskCompletionSource() + `when`(mockFirebaseAuth.signInWithEmailAndPassword("a@b.com", "pw1")) + .thenReturn(task.task) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val states = record(instance) + val config = configOf(emailProvider()) + + val job = launch { + runCatching { + instance.flowScope(config).signInWithEmailAndPassword( + context = applicationContext, + email = "a@b.com", + password = "pw1", + // Unavailable under Robolectric, and its own bug — see task_7f7cc65a. + skipCredentialSave = true) + } + } + runCurrent() + task.setResult(result) + runCurrent() + job.join() + awaitStates(states, 3) + + assertThat(states).containsExactly("Idle", "Loading", "Success").inOrder() + } + + @Test + fun `a rejected email password sign-in publishes Loading then Error`() = runTest { + val task = TaskCompletionSource() + `when`(mockFirebaseAuth.signInWithEmailAndPassword("a@b.com", "wrong")) + .thenReturn(task.task) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val states = record(instance) + val config = configOf(emailProvider()) + + val job = launch { + runCatching { + instance.flowScope(config).signInWithEmailAndPassword( + context = applicationContext, + email = "a@b.com", + password = "wrong") + } + } + runCurrent() + task.setException(FirebaseNetworkException("Network error")) + runCurrent() + job.join() + awaitStates(states, 3) + + assertThat(states).containsExactly("Idle", "Loading", "Error").inOrder() + } + + // ============================================================================================= + // OAuth + // ============================================================================================= + + @Test + fun `oauth sign-in publishes Loading then Success`() = runTest { + val (result, _) = signedInResult() + `when`(result.credential).thenReturn(mock(OAuthCredential::class.java)) + val activity = mock(Activity::class.java) + val task = TaskCompletionSource() + `when`(mockFirebaseAuth.pendingAuthResult).thenReturn(null) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`( + mockFirebaseAuth.startActivityForSignInWithProvider( + any(), + any(), + ) + ).thenReturn(task.task) + val github = AuthProvider.Github(customParameters = emptyMap()) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val states = record(instance) + val config = configOf(github) + + val job = launch { + runCatching { + instance.flowScope(config).signInWithProvider( + applicationContext, + activity = activity, + provider = github) + } + } + runCurrent() + task.setResult(result) + runCurrent() + job.join() + awaitStates(states, 3) + + assertThat(states).containsExactly("Idle", "Loading", "Success").inOrder() + } + + // ============================================================================================= + // Phone + // ============================================================================================= + + @Test + fun `phone verification publishes Loading then the code prompt`() = runTest { + val phone = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + ) + val verifier = mock(AuthProvider.Phone.Verifier::class.java) + `when`( + verifier.verifyPhoneNumber( + auth = any(), + activity = anyOrNull(), + phoneNumber = any(), + timeout = eq(60L), + forceResendingToken = anyOrNull(), + multiFactorSession = anyOrNull(), + isInstantVerificationEnabled = eq(true), + ) + ).thenReturn( + flowOf( + AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification( + verificationId = "verification-id-1", + token = mock(PhoneAuthProvider.ForceResendingToken::class.java), + ) + ) + ) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val states = record(instance) + val config = configOf(phone) + + instance.flowScope(config).verifyPhoneNumber( + provider = phone, + activity = null, + phoneNumber = "+1234567890", + verifier = verifier) + runCurrent() + + // Cold flow, so Loading and the prompt land in one turn and conflation hides Loading. + assertThat(states).containsExactly("Idle", "PhoneNumberVerificationRequired").inOrder() + } + + @Test + fun `phone credential sign-in publishes Loading then Success`() = runTest { + val (result, _) = signedInResult() + val credential = mock(PhoneAuthCredential::class.java) + val task = TaskCompletionSource() + `when`(mockFirebaseAuth.signInWithCredential(credential)).thenReturn(task.task) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + val phone = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + ) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val states = record(instance) + val config = configOf(phone) + + val job = launch { + runCatching { + instance.flowScope(config).signInWithPhoneAuthCredential( + context = applicationContext, + credential = credential) + } + } + runCurrent() + task.setResult(result) + runCurrent() + job.join() + awaitStates(states, 3) + + assertThat(states).containsExactly("Idle", "Loading", "Success").inOrder() + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenEmailRecoveryTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenEmailRecoveryTest.kt index c6b4ef2c3..c4b0ce733 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenEmailRecoveryTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenEmailRecoveryTest.kt @@ -14,6 +14,9 @@ package com.firebase.ui.auth.ui.screens +import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker +import com.firebase.ui.auth.ReauthScopeProbe +import com.firebase.ui.auth.retryingReauth import android.content.Context import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedContentTransitionScope @@ -522,17 +525,13 @@ class FirebaseAuthScreenEmailRecoveryTest { // ============================================================================================= /** - * The invariant the recovery veto above rests on. `onRecover` is withheld on - * `configuration.isReauthenticationMode` alone, which does not cover a reauthentication this - * screen is *presenting* — there the outer configuration is an ordinary one. It does not need - * to: while a request is armed, `FirebaseAuthUI.contextualizeReauthenticationState` folds every - * `AuthState.Error` into `AuthState.Reauthentication.AttemptFailed`, so the branch that offers - * recovery is unreachable while a reauthentication surface is up. If that folding ever stopped, - * a recovery could navigate the outer graph out from under the sheet with the request still - * armed — so the folding is asserted here rather than guarded against with a dead branch. + * The invariant the recovery veto above rests on: an exchange's failure never reaches the + * public flow, so the branch offering recovery is unreachable while a surface is up. Were it + * to, a recovery could navigate the outer graph out from under the sheet. */ @Test - fun `an error raised while a reauth request is armed never surfaces as an error state`() { + fun `an error raised while a reauth request is outstanding never surfaces as an error state`() { + val probe = ReauthScopeProbe() val passwordInfo = mock(UserInfo::class.java) `when`(passwordInfo.providerId).thenReturn(EmailAuthProvider.PROVIDER_ID) val user = mock(FirebaseUser::class.java) @@ -540,8 +539,7 @@ class FirebaseAuthScreenEmailRecoveryTest { `when`(user.email).thenReturn(TYPED_EMAIL) `when`(user.uid).thenReturn("reauth-user-uid") - // A second collector on the same flow, so the folded state can be read directly rather - // than inferred from what the dialog happens to render. + // A second collector, to prove directly that nothing from the exchange lands on it. val seen = mutableListOf() composeTestRule.setContent { LaunchedEffect(authUI) { authUI.authStateFlow().collect { seen += it } } @@ -551,26 +549,26 @@ class FirebaseAuthScreenEmailRecoveryTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, + // One provider, so the sheet opens at the email step and the picker never composes. + emailContent = { probe.capture() }, ) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = {}) - ) + authUI.pendingReauth.value = retryingReauth(user) {} } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - authUI.updateAuthState( + probe.emit( AuthState.Error(AuthException.UserNotFoundException(message = "no such user")) ) } composeTestRule.waitForIdle() - assertThat(composeTestRule.runOnIdle { seen.lastOrNull() }) - .isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) + // The exchange's failure never reaches the public flow at all. + assertThat(composeTestRule.runOnIdle { seen.filterIsInstance() }).isEmpty() // Which is what keeps the recovery out of reach: no action button on the dialog, and the // outer graph was not moved to a sign-up form behind the sheet. composeTestRule.onNodeWithTag(FirebaseAuthTestTags.ErrorRecovery.RETRY_BUTTON) diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt index 2b41f5eb6..3025d15a9 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt @@ -14,6 +14,10 @@ package com.firebase.ui.auth.ui.screens +import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker +import com.firebase.ui.auth.ReauthScopeProbe +import com.firebase.ui.auth.abandonedReauth +import com.firebase.ui.auth.retryingReauth import android.content.Context import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform @@ -184,9 +188,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState( - AuthState.Reauthentication.Required(user, reason = "Confirm it is you") - ) + authUI.pendingReauth.value = AuthState.Reauthentication.Required(user, reason = "Confirm it is you") } composeTestRule.waitForIdle() @@ -233,7 +235,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) + signedInAuthUI.pendingReauth.value = AuthState.Reauthentication.Required(user) } composeTestRule.waitForIdle() @@ -268,7 +270,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) + signedInAuthUI.pendingReauth.value = AuthState.Reauthentication.Required(user) } composeTestRule.waitForIdle() @@ -283,12 +285,13 @@ class FirebaseAuthScreenReauthContentStateTest { /** * A dismissed provider sheet (Credential Manager, an OAuth web flow, …) emits - * [AuthState.Cancelled]. While reauthentication is armed that only cancels *that attempt*: the + * [AuthState.Cancelled]. While a reauthentication is outstanding that only cancels *that attempt*: the * slot must stay up, the flow must not report itself cancelled, and the pending sensitive * operation must survive so a later successful reauthentication still runs it. */ @Test - fun `cancelling a provider attempt keeps the reauth slot armed`() { + fun `cancelling a provider attempt keeps the reauth slot open`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") var cancelledCount = 0 var retryRan = false @@ -301,31 +304,31 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = { cancelledCount++ }, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) } composeTestRule.runOnIdle { - authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) - ) + authUI.pendingReauth.value = retryingReauth(user) { retryRan = true } } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() - composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Cancelled()) } + composeTestRule.runOnIdle { probe.emit(AuthState.Cancelled()) } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() assertThat(cancelledCount).isEqualTo(0) assertThat(retryRan).isFalse() - composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Loading()) } + composeTestRule.runOnIdle { probe.emit(AuthState.Loading()) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - authUI.updateAuthState(AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)) + probe.emit(AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)) } + composeTestRule.waitForIdle() composeTestRule.waitUntil(timeoutMillis = 5_000) { retryRan } assertThat(retryRan).isTrue() @@ -336,7 +339,8 @@ class FirebaseAuthScreenReauthContentStateTest { * report the flow as cancelled nor drop the pending operation. */ @Test - fun `cancelling a provider attempt in the default reauth sheet keeps it armed`() { + fun `cancelling a provider attempt in the default reauth sheet keeps it open`() { + val probe = ReauthScopeProbe() val phoneInfo = mock(UserInfo::class.java) `when`(phoneInfo.providerId).thenReturn("phone") val passwordInfo = mock(UserInfo::class.java) @@ -356,27 +360,31 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = { cancelledCount++ }, + // The default sheet has no app slot; the picker layout is inside the request's flow. + customMethodPickerLayout = { providers, onSelected -> + probe.capture() + AuthMethodPicker(providers = providers, onProviderSelected = onSelected) + }, ) } composeTestRule.runOnIdle { - authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) - ) + authUI.pendingReauth.value = retryingReauth(user) { retryRan = true } } composeTestRule.waitForIdle() - composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Cancelled()) } + composeTestRule.runOnIdle { probe.emit(AuthState.Cancelled()) } composeTestRule.waitForIdle() assertThat(cancelledCount).isEqualTo(0) assertThat(retryRan).isFalse() - composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Loading()) } + composeTestRule.runOnIdle { probe.emit(AuthState.Loading()) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - authUI.updateAuthState(AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)) + probe.emit(AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)) } + composeTestRule.waitForIdle() composeTestRule.waitUntil(timeoutMillis = 5_000) { retryRan } assertThat(retryRan).isTrue() @@ -389,6 +397,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test fun `a failed attempt latches a localized error and exception into the slot`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) var captured: ReauthContentState? = null @@ -404,6 +413,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { state -> + probe.capture() captured = state Button( onClick = { state.onProviderSelected(state.providers.first()) }, @@ -416,12 +426,12 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) + signedInAuthUI.pendingReauth.value = AuthState.Reauthentication.Required(user) } composeTestRule.waitForIdle() assertThat(requireNotNull(captured).error).isNull() - composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Error(thrown)) } + composeTestRule.runOnIdle { probe.emit(AuthState.Error(thrown)) } composeTestRule.waitForIdle() assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage) @@ -451,11 +461,11 @@ class FirebaseAuthScreenReauthContentStateTest { /** * When no configured provider is linked to the user there is no reauth UI to show, so nothing - * may stay armed — otherwise a later Loading → Success would consume the pending operation and + * may stay outstanding — otherwise a later Loading → Success would consume the pending operation and * run the sensitive action with no reauthentication at all. */ @Test - fun `no linked providers leaves nothing armed`() { + fun `no linked providers leaves no request outstanding`() { val user = googleOnlyUser("federated@example.com") var slotComposed = false var retryRan = false @@ -475,9 +485,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) - ) + authUI.pendingReauth.value = retryingReauth(user) { retryRan = true } } composeTestRule.waitForIdle() @@ -541,9 +549,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) - ) + signedInAuthUI.pendingReauth.value = retryingReauth(user) { retryRan = true } } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() @@ -569,6 +575,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test fun `a library-published Success runs the pending operation exactly once`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) var retryCount = 0 @@ -581,6 +588,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, @@ -588,20 +596,19 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) - ) + signedInAuthUI.pendingReauth.value = retryingReauth(user) { retryCount++ } } composeTestRule.waitForIdle() - composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Loading()) } + composeTestRule.runOnIdle { probe.emit(AuthState.Loading()) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)) + probe.emit(AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)) } + composeTestRule.waitForIdle() composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount > 0 } - composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Idle) } + composeTestRule.runOnIdle { probe.emit(AuthState.Idle) } composeTestRule.waitForIdle() composeTestRule.waitForIdle() @@ -610,13 +617,14 @@ class FirebaseAuthScreenReauthContentStateTest { /** * The error dialog's recovery actions navigate the *outer* back stack to the non-reauth email - * screen. While a reauthentication is armed both `onRecover` and `onRetry` are withheld, so the + * screen. While a reauthentication is outstanding both `onRecover` and `onRetry` are withheld, so the * dialog has no action to offer and must not render an action button that silently dismisses * instead of recovering. This is the default-sheet path — with a custom slot the error latches * into the slot and no dialog is shown at all. */ @Test - fun `a recoverable error offers no action while reauthentication is armed`() { + fun `a recoverable error offers no action while reauthentication is outstanding`() { + val probe = ReauthScopeProbe() val phoneInfo = mock(UserInfo::class.java) `when`(phoneInfo.providerId).thenReturn("phone") val passwordInfo = mock(UserInfo::class.java) @@ -633,13 +641,16 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, + // The default sheet has no app slot; the picker layout is inside the request's flow. + customMethodPickerLayout = { providers, onSelected -> + probe.capture() + AuthMethodPicker(providers = providers, onProviderSelected = onSelected) + }, ) } composeTestRule.runOnIdle { - authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = {}) - ) + authUI.pendingReauth.value = retryingReauth(user) {} } composeTestRule.waitForIdle() @@ -648,7 +659,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.onAllNodesWithText(stringProvider.passwordHint).assertCountEquals(0) composeTestRule.runOnIdle { - authUI.updateAuthState( + probe.emit( AuthState.Error( AuthException.EmailAlreadyInUseException( message = "already in use", @@ -709,7 +720,7 @@ class FirebaseAuthScreenReauthContentStateTest { * is pending, so provider selection has to be inert. */ @Test - fun `provider selection is inert while reauthentication is armed`() { + fun `provider selection is inert while reauthentication is outstanding`() { val user = passwordOnlyUser("linked@example.com") var retryRan = false var captured: ReauthContentState? = null @@ -741,9 +752,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.onNodeWithTag("pick_password").assertExists() composeTestRule.runOnIdle { - authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) - ) + authUI.pendingReauth.value = retryingReauth(user) { retryRan = true } } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertExists() @@ -768,7 +777,8 @@ class FirebaseAuthScreenReauthContentStateTest { * *first* lambda and ran the wrong sensitive operation after reauthentication. */ @Test - fun `arming a second operation for the same user replaces the first`() { + fun `raising a second operation for the same user replaces the first`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val ran = mutableListOf() @@ -780,6 +790,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) @@ -787,26 +798,23 @@ class FirebaseAuthScreenReauthContentStateTest { // Same user, same (absent) reason: the two states differ only in the attached operation. composeTestRule.runOnIdle { - authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { ran.add("first") }) - ) + authUI.pendingReauth.value = retryingReauth(user) { ran.add("first") } } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { ran.add("second") }) - ) + authUI.pendingReauth.value = retryingReauth(user) { ran.add("second") } } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() - composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Loading()) } + composeTestRule.runOnIdle { probe.emit(AuthState.Loading()) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - authUI.updateAuthState( + probe.emit( AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) ) } + composeTestRule.waitForIdle() composeTestRule.waitUntil(timeoutMillis = 5_000) { ran.isNotEmpty() } composeTestRule.waitForIdle() @@ -821,6 +829,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test fun `a matched reauthentication with no pending operation does not report a sign-in`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val authResult = mock(AuthResult::class.java) `when`(authResult.user).thenReturn(user) @@ -834,6 +843,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, @@ -841,14 +851,14 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState(AuthState.Reauthentication.Required(user, retryOperation = null)) + authUI.pendingReauth.value = AuthState.Reauthentication.Required(user) } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() // The federated stamp shape: a non-null AuthResult alongside the reauthenticated uid. composeTestRule.runOnIdle { - authUI.updateAuthState( + probe.emit( AuthState.Success( result = authResult, user = user, @@ -866,12 +876,13 @@ class FirebaseAuthScreenReauthContentStateTest { /** * The uid comparison is the whole guarantee: a stamped success for *another* account is not - * evidence that the armed user re-proved anything, so the operation must not run and the slot + * evidence that the outstanding user re-proved anything, so the operation must not run and the slot * must stay up. Without this the comparison could be weakened to a null check unnoticed. */ @Test fun `a stamped Success for a different uid does not run the pending operation`() { - val armedUser = passwordOnlyUser("armed@example.com") + val probe = ReauthScopeProbe() + val requestUser = passwordOnlyUser("outstanding@example.com") val otherUser = userLinkedTo("google.com", "other@example.com") var retryRan = false var captured: ReauthContentState? = null @@ -884,6 +895,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { state -> + probe.capture() captured = state Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } @@ -891,18 +903,16 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState( - AuthState.Reauthentication.Required(armedUser, retryOperation = { retryRan = true }) - ) + authUI.pendingReauth.value = retryingReauth(requestUser) { retryRan = true } } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() - assertThat(armedUser.uid).isNotEqualTo(otherUser.uid) + assertThat(requestUser.uid).isNotEqualTo(otherUser.uid) - composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Loading()) } + composeTestRule.runOnIdle { probe.emit(AuthState.Loading()) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - authUI.updateAuthState( + probe.emit( AuthState.Success( result = null, user = otherUser, @@ -922,10 +932,10 @@ class FirebaseAuthScreenReauthContentStateTest { /** * A wrong password for an unverified account ends up here: the consumed Error resets to Idle, * the combine falls back to the live session, and that yields RequiresEmailVerification. It - * resets the back stack to a single entry, which would wipe the stack under the armed slot. + * resets the back stack to a single entry, which would wipe the stack under the outstanding slot. */ @Test - fun `RequiresEmailVerification does not navigate while reauthentication is armed`() { + fun `RequiresEmailVerification does not navigate while reauthentication is outstanding`() { val user = passwordOnlyUser("linked@example.com") var retryRan = false @@ -946,9 +956,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) - ) + authUI.pendingReauth.value = retryingReauth(user) { retryRan = true } } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() @@ -987,6 +995,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test fun `an MFA challenge inside the reauth slot runs the pending operation exactly once`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) val resolver = totpResolver(Tasks.forResult(mock(AuthResult::class.java))) @@ -1005,6 +1014,7 @@ class FirebaseAuthScreenReauthContentStateTest { Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) }, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, @@ -1012,17 +1022,20 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) - ) + signedInAuthUI.pendingReauth.value = retryingReauth(user) { retryCount++ } } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator")) + probe.emit(AuthState.RequiresMfa(resolver, "authenticator")) } composeTestRule.waitForIdle() + // The phase moves the back stack, so the entry renders on the frame after the fold. + composeTestRule.waitForIdle() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule.onAllNodesWithTag("mfa_challenge").fetchSemanticsNodes().isNotEmpty() + } composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed() composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() @@ -1044,6 +1057,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test fun `an MFA challenge inside the default reauth sheet runs the pending operation exactly once`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) val resolver = totpResolver(Tasks.forResult(mock(AuthResult::class.java))) @@ -1057,6 +1071,8 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, + // Password-only, so the sheet opens at the email step and the picker never composes. + emailContent = { probe.capture() }, mfaChallengeContent = { state -> challenge = state Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) @@ -1066,16 +1082,19 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) - ) + signedInAuthUI.pendingReauth.value = retryingReauth(user) { retryCount++ } } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator")) + probe.emit(AuthState.RequiresMfa(resolver, "authenticator")) } composeTestRule.waitForIdle() + // The phase moves the back stack, so the entry renders on the frame after the fold. + composeTestRule.waitForIdle() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule.onAllNodesWithTag("mfa_challenge").fetchSemanticsNodes().isNotEmpty() + } composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed() composeTestRule.runOnIdle { @@ -1091,11 +1110,12 @@ class FirebaseAuthScreenReauthContentStateTest { } /** - * Backing out of the challenge is not abandoning reauthentication: the request stays armed, so + * Backing out of the challenge is not abandoning reauthentication: the request stays outstanding, so * the host must not be told the flow was cancelled and the operation must still be runnable. */ @Test - fun `cancelling the MFA challenge returns to provider selection with the request still armed`() { + fun `cancelling the MFA challenge returns to provider selection with the request still outstanding`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) val resolver = totpResolver(Tasks.forResult(mock(AuthResult::class.java))) @@ -1115,6 +1135,7 @@ class FirebaseAuthScreenReauthContentStateTest { Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) }, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, @@ -1122,15 +1143,18 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) - ) + signedInAuthUI.pendingReauth.value = retryingReauth(user) { retryCount++ } } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator")) + probe.emit(AuthState.RequiresMfa(resolver, "authenticator")) } composeTestRule.waitForIdle() + // The phase moves the back stack, so the entry renders on the frame after the fold. + composeTestRule.waitForIdle() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule.onAllNodesWithTag("mfa_challenge").fetchSemanticsNodes().isNotEmpty() + } composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed() composeTestRule.runOnIdle { requireNotNull(challenge).onCancelClick() } @@ -1142,12 +1166,13 @@ class FirebaseAuthScreenReauthContentStateTest { assertThat(cancelledCount).isEqualTo(0) assertThat(retryCount).isEqualTo(0) - // Still armed: a later genuine reauthentication of the same user still runs the operation. + // Still outstanding: a later genuine reauthentication of the same user still runs the operation. composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( + probe.emit( AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) ) } + composeTestRule.waitForIdle() composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount > 0 } assertThat(retryCount).isEqualTo(1) assertThat(cancelledCount).isEqualTo(0) @@ -1156,6 +1181,7 @@ class FirebaseAuthScreenReauthContentStateTest { /** A failed challenge is an ordinary failed attempt: it latches into the slot's error. */ @Test fun `an MFA challenge failure surfaces as an attempt failure in the reauth slot`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) val resolver = totpResolver(Tasks.forException(RuntimeException("wrong code"))) @@ -1176,6 +1202,7 @@ class FirebaseAuthScreenReauthContentStateTest { Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) }, reauthContent = { state -> + probe.capture() captured = state Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, @@ -1184,15 +1211,18 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) - ) + signedInAuthUI.pendingReauth.value = retryingReauth(user) { retryCount++ } } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator")) + probe.emit(AuthState.RequiresMfa(resolver, "authenticator")) } composeTestRule.waitForIdle() + // The phase moves the back stack, so the entry renders on the frame after the fold. + composeTestRule.waitForIdle() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule.onAllNodesWithTag("mfa_challenge").fetchSemanticsNodes().isNotEmpty() + } composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed() composeTestRule.runOnIdle { @@ -1217,6 +1247,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test fun `an MFA challenge resolved with no current user does not run the pending operation`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val resolver = totpResolver(Tasks.forResult(mock(AuthResult::class.java))) var retryCount = 0 @@ -1235,6 +1266,7 @@ class FirebaseAuthScreenReauthContentStateTest { Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) }, reauthContent = { state -> + probe.capture() captured = state Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, @@ -1243,15 +1275,18 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) - ) + authUI.pendingReauth.value = retryingReauth(user) { retryCount++ } } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - authUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator")) + probe.emit(AuthState.RequiresMfa(resolver, "authenticator")) } composeTestRule.waitForIdle() + // The phase moves the back stack, so the entry renders on the frame after the fold. + composeTestRule.waitForIdle() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule.onAllNodesWithTag("mfa_challenge").fetchSemanticsNodes().isNotEmpty() + } composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed() composeTestRule.runOnIdle { @@ -1296,9 +1331,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) - ) + authUI.pendingReauth.value = retryingReauth(user) { retryRan = true } } composeTestRule.waitForIdle() assertThat(cancelledCount).isEqualTo(0) @@ -1312,9 +1345,17 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.onNodeWithTag("dismiss_reauth").assertDoesNotExist() } - /** Rotating preserves the request-owned failure, including its typed exception. */ + /** + * What recreation keeps, and what it does not. + * + * The request is durable — it lives on `pendingReauth`, outside the composition — so the + * surface returns and the caller is still waiting. The phase is not: it is composition-scoped + * by design, and reconstructing a surfaced failure would mean putting exchange state back on a + * process-scoped channel, which is the coupling this design removes. + */ @Test - fun `a latched slot error survives Activity recreation`() { + fun `recreation keeps the request and drops the surfaced failure`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) var captured: ReauthContentState? = null @@ -1330,6 +1371,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { state -> + probe.capture() captured = state Text(text = "SLOT_ERROR=${state.error}", modifier = Modifier.testTag("slot")) } @@ -1337,10 +1379,10 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) + signedInAuthUI.pendingReauth.value = AuthState.Reauthentication.Required(user) } composeTestRule.waitForIdle() - composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Error(thrown)) } + composeTestRule.runOnIdle { probe.emit(AuthState.Error(thrown)) } composeTestRule.waitForIdle() assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage) @@ -1348,15 +1390,15 @@ class FirebaseAuthScreenReauthContentStateTest { restorationTester.emulateSavedInstanceStateRestore() composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("SLOT_ERROR=$expectedMessage").assertIsDisplayed() - assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage) - assertThat(requireNotNull(captured).exception) - .isInstanceOf(AuthException.InvalidCredentialsException::class.java) + // The request survives recreation; the phase does not. + assertThat(signedInAuthUI.pendingReauth.value).isNotNull() + assertThat(requireNotNull(captured).error).isNull() + composeTestRule.onNodeWithText("SLOT_ERROR=$expectedMessage").assertDoesNotExist() } /** * Rotating part-way through the library's own email sub-flow must not bounce the user back to - * the provider chooser: the active sub-route is saved alongside the arming. + * the provider chooser: the active sub-route is saved alongside the request. */ @Test fun `an active email sub-flow survives Activity recreation`() { @@ -1389,7 +1431,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) + signedInAuthUI.pendingReauth.value = AuthState.Reauthentication.Required(user) } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("pick_provider").performClick() @@ -1405,12 +1447,13 @@ class FirebaseAuthScreenReauthContentStateTest { /** * The likeliest moment to rotate is right after a cancelled or failed attempt. Resetting the - * flow to [AuthState.Idle] there would drop the arming from the process-cached [FirebaseAuthUI] - * and lose the pending operation silently; the arming is re-emitted instead, so a recreation + * flow to [AuthState.Idle] there would drop the request from the process-cached [FirebaseAuthUI] + * and lose the pending operation silently; the request is re-emitted instead, so a recreation * re-derives both it and the operation, and a later genuine reauthentication still runs it. */ @Test fun `the pending operation survives recreation after a cancelled attempt`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) var retryCount = 0 @@ -1424,39 +1467,45 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) - ) + signedInAuthUI.pendingReauth.value = retryingReauth(user) { retryCount++ } } composeTestRule.waitForIdle() - composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Cancelled()) } + composeTestRule.runOnIdle { probe.emit(AuthState.Cancelled()) } composeTestRule.waitForIdle() restorationTester.emulateSavedInstanceStateRestore() composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() - composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Loading()) } + composeTestRule.runOnIdle { probe.emit(AuthState.Loading()) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( + probe.emit( AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) ) } + composeTestRule.waitForIdle() composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount > 0 } assertThat(retryCount).isEqualTo(1) } - /** Activity recreation keeps the request and retry callback in the process-owned AuthState. */ + /** + * Recreation during an in-flight attempt. Nothing retains the caller, so what survives is the + * request latched on the flow — enough to accept the same request again and complete it. The attempt + * itself does not come back: its network call died with the Activity, so the restored screen + * restarts at provider selection rather than showing progress for nothing. + */ @Test fun `an attempt survives Activity recreation and completes the same request`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) var retryCount = 0 @@ -1470,18 +1519,17 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) - ) + signedInAuthUI.pendingReauth.value = retryingReauth(user) { retryCount++ } } composeTestRule.waitForIdle() - composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Loading()) } + composeTestRule.runOnIdle { probe.emit(AuthState.Loading()) } composeTestRule.waitForIdle() restorationTester.emulateSavedInstanceStateRestore() @@ -1494,10 +1542,11 @@ class FirebaseAuthScreenReauthContentStateTest { .assertDoesNotExist() composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( + probe.emit( AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) ) } + composeTestRule.waitForIdle() composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount == 1 } assertThat(retryCount).isEqualTo(1) @@ -1506,12 +1555,13 @@ class FirebaseAuthScreenReauthContentStateTest { /** * Process death, unlike rotation, also takes the process-cached [FirebaseAuthUI] holding the - * arming: the restored screen's first state comes from the persisted session, so it is an + * request: the restored screen's first state comes from the persisted session, so it is an * [AuthState.Success] and no [AuthState.Reauthentication.Required] is ever available to * re-derive from. The pending operation is gone and must still be reported, not dropped. */ @Test - fun `an arming lost to process death is reported rather than dropped`() { + fun `a request lost to process death is reported rather than dropped`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") var retryCount = 0 // Read on every composition, so the restore below observes the replacement instance. @@ -1526,15 +1576,14 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) } composeTestRule.runOnIdle { - currentAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) - ) + currentAuthUI.pendingReauth.value = retryingReauth(user) { retryCount++ } } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() @@ -1554,7 +1603,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() composeTestRule.runOnIdle { - currentAuthUI.updateAuthState( + probe.emit( AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) ) } @@ -1566,10 +1615,11 @@ class FirebaseAuthScreenReauthContentStateTest { /** * The mirror image, and the regression the broadened guard risks: rotation keeps the cached - * [FirebaseAuthUI], so the arming re-derives and must not be reported as interrupted. + * [FirebaseAuthUI], so the request re-derives and must not be reported as interrupted. */ @Test - fun `recreation that can re-derive the arming reports no interruption`() { + fun `recreation that can re-derive the request reports no interruption`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) var retryCount = 0 @@ -1583,15 +1633,14 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) - ) + signedInAuthUI.pendingReauth.value = retryingReauth(user) { retryCount++ } } composeTestRule.waitForIdle() @@ -1604,13 +1653,14 @@ class FirebaseAuthScreenReauthContentStateTest { .onNodeWithText(context.getString(R.string.fui_error_reauth_interrupted)) .assertDoesNotExist() - composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Loading()) } + composeTestRule.runOnIdle { probe.emit(AuthState.Loading()) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( + probe.emit( AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) ) } + composeTestRule.waitForIdle() composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount > 0 } assertThat(retryCount).isEqualTo(1) @@ -1619,6 +1669,7 @@ class FirebaseAuthScreenReauthContentStateTest { /** A real restored Idle is distinguishable from collectAsState's null placeholder. */ @Test fun `process death that restores a signed-out Idle reports interruption`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") var currentAuthUI = signedInAuthUI(user) val restorationTester = StateRestorationTester(composeTestRule) @@ -1631,13 +1682,14 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) } composeTestRule.runOnIdle { - currentAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) + currentAuthUI.pendingReauth.value = AuthState.Reauthentication.Required(user) } composeTestRule.waitForIdle() @@ -1657,21 +1709,15 @@ class FirebaseAuthScreenReauthContentStateTest { } /** - * The sensitive operation must run at most once. Its retry is composition-scoped, so a - * recreation while it is suspended on the network kills it without any outcome being published - * — leaving [AuthState.Reauthentication.RetryingOperation] as the restored screen's first state. - * Firebase `Task`s are not cancellable, so the killed attempt may well have committed already: - * re-running it is the one outcome worse than losing it, which is reported instead. + * One request, one surface, however many times its raised state comes round again — an attempt + * and a back-out both re-enter the raising branch for the same request id. */ @Test - fun `recreation during the retry never runs the operation twice`() { + fun `raising the same request twice does not stack a second surface`() { val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) - val runs = AtomicInteger(0) - val hangForever = CompletableDeferred() - val restorationTester = StateRestorationTester(composeTestRule) - restorationTester.setContent { + composeTestRule.setContent { FirebaseAuthScreen( configuration = emailAndPhoneConfiguration(), authUI = signedInAuthUI, @@ -1685,40 +1731,107 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required( - user, - retryOperation = { - runs.incrementAndGet() - hangForever.await() - }, - ) + signedInAuthUI.pendingReauth.value = retryingReauth(user) {} + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + + // An attempt, then a back-out: the same request re-enters the raising branch. + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Loading("Signing in")) } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Cancelled) } + composeTestRule.waitForIdle() + + // assertIsDisplayed fails outright on more than one match, so this pins the invariant. + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + } + + /** + * The sensitive operation must run at most once. It runs on the caller's own coroutine now, so + * neither a recreation nor a second resolution can start it again: there is no closure on the + * state for a restored screen to claim, and a resolved request ignores being resolved. + */ + @Test + fun `the operation runs once however often its request is resolved`() { + val probe = ReauthScopeProbe() + val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) + val runs = AtomicInteger(0) + val restorationTester = StateRestorationTester(composeTestRule) + val raised = retryingReauth(user) { runs.incrementAndGet() } + + restorationTester.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { + probe.capture() + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + } ) } + + composeTestRule.runOnIdle { signedInAuthUI.pendingReauth.value = raised } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( + probe.emit( AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) ) } + composeTestRule.waitForIdle() composeTestRule.waitUntil(timeoutMillis = 5_000) { runs.get() == 1 } + composeTestRule.waitForIdle() + + // The request is over, so the surface comes down rather than covering the retry. + composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() - // Rotate while the operation is still in flight. restorationTester.emulateSavedInstanceStateRestore() composeTestRule.waitForIdle() composeTestRule.waitForIdle() + assertThat(runs.get()).isEqualTo(1) + raised.request.resolve() + composeTestRule.waitForIdle() assertThat(runs.get()).isEqualTo(1) + } + + /** + * A recreation that outlived the caller: the request is still outstanding and still latched, but the + * coroutine that would run the operation is gone. Presenting the sheet would take credentials + * and then report a success for an operation that can never run, so it is reported instead. + */ + @Test + fun `a request whose caller is gone is reported instead of presented`() { + val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + } + ) + } + + composeTestRule.runOnIdle { + signedInAuthUI.pendingReauth.value = abandonedReauth(user) + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() composeTestRule.waitUntil(timeoutMillis = 5_000) { composeTestRule .onAllNodesWithText(context.getString(R.string.fui_error_reauth_interrupted)) .fetchSemanticsNodes().isNotEmpty() } - composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() - - hangForever.complete(Unit) - composeTestRule.waitForIdle() - assertThat(runs.get()).isEqualTo(1) } /** @@ -1752,7 +1865,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) + signedInAuthUI.pendingReauth.value = AuthState.Reauthentication.Required(user) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Error(thrown)) } @@ -1782,6 +1895,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test fun `the configured transition spec runs for a step change inside the reauth surface`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) val resolver = totpResolver(Tasks.forResult(mock(AuthResult::class.java))) @@ -1802,6 +1916,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, emailContent = { + probe.capture() Text(text = "EMAIL", modifier = Modifier.testTag("reauth_email")) }, mfaChallengeContent = { @@ -1812,13 +1927,13 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) + signedInAuthUI.pendingReauth.value = AuthState.Reauthentication.Required(user) } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_email").assertIsDisplayed() composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator")) + probe.emit(AuthState.RequiresMfa(resolver, "authenticator")) } composeTestRule.waitForIdle() @@ -1834,12 +1949,13 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test fun `leaving the MFA challenge state returns the reauth surface to its start step`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) val resolver = totpResolver(Tasks.forResult(mock(AuthResult::class.java))) var retryCount = 0 var cancelledCount = 0 - val required = AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + val required = retryingReauth(user) { retryCount++ } composeTestRule.setContent { FirebaseAuthScreen( @@ -1852,24 +1968,28 @@ class FirebaseAuthScreenReauthContentStateTest { Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) }, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, ) } - composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(required) } + composeTestRule.runOnIdle { signedInAuthUI.pendingReauth.value = required } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator")) + probe.emit(AuthState.RequiresMfa(resolver, "authenticator")) } composeTestRule.waitForIdle() + // The phase moves the back stack, so the entry renders on the frame after the fold. + composeTestRule.waitForIdle() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule.onAllNodesWithTag("mfa_challenge").fetchSemanticsNodes().isNotEmpty() + } composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed() - // Straight off RequiresMfa, without the challenge's own cancel or error path running. - composeTestRule.runOnIdle { - signedInAuthUI.updateReauthentication(required.requestId) { it.attemptCancelled() } - } + // Straight off RequiresMfa: a Cancelled folds to provider selection. + composeTestRule.runOnIdle { probe.emit(AuthState.Cancelled) } composeTestRule.waitForIdle() composeTestRule.waitForIdle() @@ -1880,17 +2000,16 @@ class FirebaseAuthScreenReauthContentStateTest { } /** - * The surface gates the operation, so it stays up for the retry rather than uncovering the - * flow underneath for its duration, and reports the retry as loading — the slot owns the - * progress it shows, exactly as it does for a credential attempt. + * The surface no longer covers the retry. The operation runs on the caller's coroutine after + * the request ends, so the sheet comes down on the proof and the host publishes its own + * handover progress — which is what stops a `Success` being claimed before the operation runs. */ @Test - fun `the reauth slot stays up and reports loading through the retry`() { + fun `the reauth slot comes down when the credential proof lands`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) - val holdRetry = CompletableDeferred() val runs = AtomicInteger(0) - var captured: ReauthContentState? = null composeTestRule.setContent { FirebaseAuthScreen( @@ -1899,50 +2018,37 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, - reauthContent = { state -> - captured = state + reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required( - user, - retryOperation = { - runs.incrementAndGet() - holdRetry.await() - }, - ) - ) + signedInAuthUI.pendingReauth.value = retryingReauth(user) { runs.incrementAndGet() } } composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( + probe.emit( AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) ) } + composeTestRule.waitForIdle() composeTestRule.waitUntil(timeoutMillis = 5_000) { runs.get() == 1 } composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() - assertThat(requireNotNull(captured).isLoading).isTrue() - // The slot's own progress, not the library's dialog stacked over it. - composeTestRule.onNodeWithText(stringProvider.progressDialogLoading).assertDoesNotExist() - - holdRetry.complete(Unit) - composeTestRule.waitUntil(timeoutMillis = 5_000) { - composeTestRule.onAllNodesWithTag("reauth_slot").fetchSemanticsNodes().isEmpty() - } + composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() } - /** The default sheet is the same surface: it holds its step under the loading dialog. */ + /** The default sheet is the same surface, and comes down on the same condition. */ @Test - fun `the default reauth sheet stays up through the retry`() { + fun `the default reauth sheet comes down when the credential proof lands`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) - val holdRetry = CompletableDeferred() val runs = AtomicInteger(0) composeTestRule.setContent { @@ -1953,6 +2059,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, emailContent = { + probe.capture() Text(text = "EMAIL", modifier = Modifier.testTag("reauth_email")) }, authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, @@ -1960,34 +2067,21 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required( - user, - retryOperation = { - runs.incrementAndGet() - holdRetry.await() - }, - ) - ) + signedInAuthUI.pendingReauth.value = retryingReauth(user) { runs.incrementAndGet() } } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_email").assertIsDisplayed() composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( + probe.emit( AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) ) } + composeTestRule.waitForIdle() composeTestRule.waitUntil(timeoutMillis = 5_000) { runs.get() == 1 } composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("reauth_email").assertIsDisplayed() - composeTestRule.onNodeWithText(stringProvider.progressDialogLoading).assertIsDisplayed() - - holdRetry.complete(Unit) - composeTestRule.waitUntil(timeoutMillis = 5_000) { - composeTestRule.onAllNodesWithTag("reauth_email").fetchSemanticsNodes().isEmpty() - } + composeTestRule.onNodeWithTag("reauth_email").assertDoesNotExist() } /** A [FirebaseAuthUI] over a mocked, *signed-out* [FirebaseAuth]: `authStateFlow()` is Idle. */ diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt index 3fe6426ba..cb0302679 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt @@ -14,6 +14,8 @@ package com.firebase.ui.auth.ui.screens +import com.firebase.ui.auth.ReauthScopeProbe +import com.firebase.ui.auth.retryingReauth import androidx.compose.material3.Text import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @@ -95,6 +97,7 @@ class FirebaseAuthScreenReauthIdleResetTest { @Test fun `wrong password error during reauth does not dismiss the reauth sheet`() { + val probe = ReauthScopeProbe() val mockProviderInfo = mock(UserInfo::class.java) `when`(mockProviderInfo.providerId).thenReturn("password") val mockUser = mock(FirebaseUser::class.java) @@ -122,6 +125,7 @@ class FirebaseAuthScreenReauthIdleResetTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { state -> + probe.capture() capturedError = state.error Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker")) } @@ -130,14 +134,14 @@ class FirebaseAuthScreenReauthIdleResetTest { // Enter the reauth flow. composeTestRule.runOnIdle { - authUI.updateAuthState(AuthState.Reauthentication.Required(mockUser)) + authUI.pendingReauth.value = AuthState.Reauthentication.Required(mockUser) } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_marker").assertIsDisplayed() // Wrong password entered inside the reauth flow becomes failure state on the same request. composeTestRule.runOnIdle { - authUI.updateAuthState(AuthState.Error(Exception("wrong password"))) + probe.emit(AuthState.Error(Exception("wrong password"))) } composeTestRule.waitForIdle() @@ -148,10 +152,9 @@ class FirebaseAuthScreenReauthIdleResetTest { /** * `FirebaseAuthUI.delete()` signs the user out as its *success* condition, so a successful - * retry fires the AuthStateListener with a null current user while the request is still in - * `RetryingOperation`. The listener's stale-state reset used to force `Idle` from every - * `Reauthentication` phase, which cancelled the coroutine running the operation and left the - * saved presentation to report `fui_error_reauth_interrupted` — over a deleted account. + * retry fires the AuthStateListener with a null current user. Sign-out drops the outstanding + * request, and doing that while the operation is still running would report + * `fui_error_reauth_interrupted` over an account that was in fact deleted. * * Screen-level tests mock [FirebaseAuth], so `addAuthStateListener` is inert; the listener is * captured off the mock and invoked from inside the retry operation itself, which is how this @@ -159,6 +162,7 @@ class FirebaseAuthScreenReauthIdleResetTest { */ @Test fun `an operation that signs the user out is reported as completed, not interrupted`() { + val probe = ReauthScopeProbe() val mockProviderInfo = mock(UserInfo::class.java) `when`(mockProviderInfo.providerId).thenReturn("password") val mockUser = mock(FirebaseUser::class.java) @@ -189,6 +193,7 @@ class FirebaseAuthScreenReauthIdleResetTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker")) } ) @@ -202,27 +207,20 @@ class FirebaseAuthScreenReauthIdleResetTest { var operationStarted = false var operationCompleted = false composeTestRule.runOnIdle { - authUI.updateAuthState( - AuthState.Reauthentication.Required( - user = mockUser, - retryOperation = { - operationStarted = true - // Exactly what a successful delete() does: FirebaseAuth drops the user and - // notifies its listeners while the operation is still in flight. - `when`(mockFirebaseAuth.currentUser).thenReturn(null) - listeners.forEach { it.onAuthStateChanged(mockFirebaseAuth) } - yield() - operationCompleted = true - }, - ) - ) + authUI.pendingReauth.value = retryingReauth(mockUser) { + operationStarted = true + // What a successful delete() does: the user is dropped mid-operation. + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + listeners.forEach { it.onAuthStateChanged(mockFirebaseAuth) } + operationCompleted = true + } } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_marker").assertIsDisplayed() // Credentials accepted for the same user, which drives the request into its retry phase. composeTestRule.runOnIdle { - authUI.updateAuthState( + probe.emit( AuthState.Success( result = null, user = mockUser, @@ -236,7 +234,6 @@ class FirebaseAuthScreenReauthIdleResetTest { .getString(R.string.fui_error_reauth_interrupted) assertThat(operationStarted).isTrue() assertThat(operationCompleted).isTrue() - assertThat(observed.filterIsInstance()).isEmpty() assertThat(observed.filterIsInstance().map { it.exception.message }) .doesNotContain(interruptedMessage) } 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 index 62204ea94..5b043caf1 100644 --- 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 @@ -244,7 +244,7 @@ class FirebaseAuthScreenSlotsTest { ) } - authUI.updateAuthState(AuthState.Reauthentication.Required(mockUser)) + authUI.pendingReauth.value = AuthState.Reauthentication.Required(mockUser) composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("custom_reauth_picker").assertIsDisplayed() diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthHostDestinationsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthHostDestinationsTest.kt index c60bb05ee..75bbec581 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthHostDestinationsTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthHostDestinationsTest.kt @@ -14,6 +14,14 @@ package com.firebase.ui.auth.ui.screens.email +import androidx.compose.runtime.derivedStateOf +import com.firebase.ui.auth.LocalAuthFlowScope +import com.firebase.ui.auth.AuthFlowScope +import org.mockito.Mockito.verify +import com.google.firebase.FirebaseNetworkException +import com.google.android.gms.tasks.Tasks +import androidx.compose.runtime.LaunchedEffect +import com.firebase.ui.auth.ui.screens.reauth.rememberReauthFlowState import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -88,6 +96,7 @@ class EmailAuthHostDestinationsTest { private lateinit var applicationContext: Context private lateinit var stringProvider: DefaultAuthUIStringProvider private lateinit var authUI: FirebaseAuthUI + private lateinit var auth: FirebaseAuth /** The harness's own stack, for the assertions that are about keys rather than pixels. */ private var reauthBackStack: NavBackStack? = null @@ -106,7 +115,7 @@ class EmailAuthHostDestinationsTest { .setProjectId("fake-project-id") .build() ) - val auth = mock(FirebaseAuth::class.java) + auth = mock(FirebaseAuth::class.java) `when`(auth.app).thenReturn(app) authUI = FirebaseAuthUI.create(app, auth) } @@ -217,6 +226,54 @@ class EmailAuthHostDestinationsTest { assertThat(dismissed).isEqualTo(0) } + /** + * The point of giving a reauthentication request its own scope: a sub-screen's writes follow + * the flow it is composed in, not the singleton it was handed. + * + * `EmailAuthScreen` is a public composable, so it cannot be given an internal scope as a + * parameter — it reads the ambient one. This pins that: composed under a recording scope, the + * notification it consumes here lands there. Before provider code moved off the + * [FirebaseAuthUI] receiver this same write went to the process-wide channel, where an app + * collecting `authStateFlow()` would have acted on a retraction belonging to a reauthentication + * it was not part of. `ReauthFlowStateTest` covers the other half: what a request's sink does + * with the states it absorbs, and which ones it forwards to the host. + */ + @Test + fun `a sub-screen consumes its notification into the flow it is composed in`() { + val recorded = mutableListOf() + val config = emailConfiguration() + // A one-off notification is the state, so consuming it needs no interaction at all. + val scope = AuthFlowScope( + auth = auth, + config = config, + state = mutableStateOf(AuthState.PasswordResetLinkSent()), + sink = { recorded += it }, + ) + + composeTestRule.setContent { + CompositionLocalProvider( + LocalAuthUIStringProvider provides stringProvider, + LocalAuthFlowScope provides scope, + ) { + EmailAuthScreen( + context = applicationContext, + configuration = config, + authUI = authUI, + mode = EmailAuthMode.SignIn, + onNavigateToMode = { _, _ -> }, + onSuccess = {}, + onError = {}, + onCancel = {}, + ) + } + } + composeTestRule.waitForIdle() + + // Empty would mean it wrote to the singleton instead, which is the regression. + assertThat(recorded).isNotEmpty() + assertThat(recorded.last()).isInstanceOf(AuthState.Idle::class.java) + } + /** * The sheet registers four email destinations rather than one, so "which flow is open" and * "which step the user is on" are different facts. Exactly one thing records the step: the @@ -328,9 +385,10 @@ class EmailAuthHostDestinationsTest { requestId = "request-id", user = user, reason = null, - retryOperation = null, ) } + val reauthFlowState = rememberReauthFlowState() + SideEffect { reauthFlowState.accept(AuthState.Reauthentication.Required(request)) } val backStack = rememberNavBackStack( AuthRoute.Success, AuthRoute.Reauth("request-id", "uid", startStep), @@ -375,6 +433,7 @@ class EmailAuthHostDestinationsTest { stringProvider = stringProvider, surface = surface, phoneFlowState = phoneFlowState, + reauthFlowState = reauthFlowState, emailContent = null, phoneContent = null, mfaChallengeContent = null, diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenReauthEmailLockTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenReauthEmailLockTest.kt index bbdb6fda7..9fed4cfde 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenReauthEmailLockTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenReauthEmailLockTest.kt @@ -293,9 +293,9 @@ class EmailAuthScreenReauthEmailLockTest { /** * The two out-of-band email routes are not equivalent during reauthentication. A password reset - * email leaves the sheet up and the request armed, so it stays available — blocking it stranded + * email leaves the sheet up and the request outstanding, so it stays available — blocking it stranded * a user who had forgotten their password with no route but dismissal. An email *link* reopens - * the app with nothing armed, so completing it reports an interruption instead of finishing the + * the app with no request outstanding, so completing it reports an interruption instead of finishing the * pending operation, and it stays hidden. */ @Test diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt index 767870f88..c6d96b1e5 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt @@ -487,9 +487,9 @@ class SignInUITest { /** * The asymmetry between the two out-of-band email routes during reauthentication. A password - * reset email leaves the reauth sheet up and the request armed, so blocking it only stranded a + * reset email leaves the reauth sheet up and the request outstanding, so blocking it only stranded a * user who had forgotten their password with no route but dismissal. An email *link* reopens - * the app with nothing armed, so completing it reports an interruption instead of finishing the + * the app with no request outstanding, so completing it reports an interruption instead of finishing the * pending operation — useless, and it stays hidden. */ @Test diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthHostDestinationsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthHostDestinationsTest.kt index 249ec1f6d..052ba9b7c 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthHostDestinationsTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthHostDestinationsTest.kt @@ -14,6 +14,10 @@ package com.firebase.ui.auth.ui.screens.phone +import androidx.compose.runtime.derivedStateOf +import com.firebase.ui.auth.AuthFlowScope +import com.firebase.ui.auth.ui.screens.reauth.ReauthFlowState +import com.firebase.ui.auth.ui.screens.reauth.rememberReauthFlowState import android.content.Context import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.EnterTransition @@ -111,7 +115,14 @@ class PhoneAuthHostDestinationsTest { /** The reauthentication harness's own stack, for the assertions that are about keys. */ private var reauthBackStack: NavBackStack? = null - /** The request the reauthentication harness armed, which its own emissions have to carry. */ + /** + * The sheet's phase holder. A reauthentication phase is the request's own state now, not + * something published to the public flow, so a test that wants to stand at a particular step + * puts it here — which is where the sink's fold would have put it. + */ + private var reauthHolder: ReauthFlowState? = null + + /** The request the reauthentication harness raised, which its own emissions have to carry. */ private var reauthRequest: AuthState.Reauthentication.Request? = null private var reauthDismissals = 0 @@ -426,7 +437,6 @@ class PhoneAuthHostDestinationsTest { requestId = REQUEST_ID, user = user, reason = null, - retryOperation = null, ).also { reauthRequest = it } } val backStack = rememberNavBackStack( @@ -451,6 +461,11 @@ class PhoneAuthHostDestinationsTest { val stringProvider = remember { DefaultAuthUIStringProvider(applicationContext) } // Above the display, like the host: a step switch disposes whatever the step it left held. val phoneFlowState = rememberPhoneAuthFlowState(config) + val reauthFlowState = rememberReauthFlowState() + SideEffect { + reauthHolder = reauthFlowState + reauthFlowState.accept(AuthState.Reauthentication.Required(request)) + } CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { NavDisplay( backStack = backStack, @@ -475,6 +490,7 @@ class PhoneAuthHostDestinationsTest { configuration = config, stringProvider = stringProvider, surface = surface, + reauthFlowState = reauthFlowState, phoneFlowState = phoneFlowState, emailContent = null, phoneContent = null, @@ -498,7 +514,7 @@ class PhoneAuthHostDestinationsTest { /** The reauthentication phase Firebase's `onCodeSent` callback ends up published as. */ private fun sendReauthCode(verificationId: String = "reauth-verification-id") { composeTestRule.runOnIdle { - authUI.updateAuthState( + requireNotNull(reauthHolder).moveTo( AuthState.Reauthentication.PhoneNumberVerificationRequired( request = requireNotNull(reauthRequest), verificationId = verificationId, diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt index 41d6d37b3..1f89c2db8 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt @@ -14,6 +14,8 @@ package com.firebase.ui.auth.ui.screens.phone +import com.firebase.ui.auth.ui.screens.reauth.ReauthFlowState +import androidx.compose.runtime.mutableStateOf import android.content.Context import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.junit4.createComposeRule @@ -557,7 +559,7 @@ class PhoneAuthScreenVerificationLifecycleTest { } /** - * The same teardown, but while a reauthentication request is armed. The failure is folded into + * The same teardown, but while a reauthentication request is outstanding. The failure is folded into * [AuthState.Reauthentication.AttemptFailed], so a `when` that only tears down on * [AuthState.Error] leaves the verification open and the late auto-retrieval below starts a * second reauthentication the user never asked for. `resend cancels the superseded @@ -572,13 +574,17 @@ class PhoneAuthScreenVerificationLifecycleTest { val credential = mock(PhoneAuthCredential::class.java) val observed = mutableListOf() + // Stands in for the composed FirebaseAuthScreen, which owns folding. + val required = AuthState.Reauthentication.Required(user) + val reauthFlowState = ReauthFlowState(mutableStateOf(null)) + reauthFlowState.accept(required) val collector = CoroutineScope(Dispatchers.Main.immediate).launch { - authUI.authStateFlow().collect { observed += it } + authUI.authStateFlow().collect { state -> + observed += state + reauthFlowState.fold(state)?.let { authUI.updateAuthState(it) } + } } - // What FirebaseAuthScreen does: register a drainer so ordinary states are folded into the - // armed request, then arm it. - authUI.addReauthenticationDrainer() - authUI.updateAuthState(AuthState.Reauthentication.Required(user)) + authUI.updateAuthState(required) mockStatic(PhoneAuthProvider::class.java).use { statics -> stubGetCredential(statics, credential) diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/reauth/ReauthFlowStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/reauth/ReauthFlowStateTest.kt new file mode 100644 index 000000000..baf4d7a31 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/reauth/ReauthFlowStateTest.kt @@ -0,0 +1,350 @@ +/* + * 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.reauth + +import androidx.compose.runtime.mutableStateOf +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.google.common.truth.Truth.assertThat +import com.google.firebase.auth.FirebaseUser +import kotlinx.coroutines.CompletableDeferred +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import java.util.UUID + +/** + * The phase machine on its own, which is what `FirebaseAuthUI.contextualizeReauthenticationState` + * and `updateReauthentication` used to be. Nothing here needs a composition or a flow: the holder + * reads the phase it owns rather than read-modify-writing the flow it is written to. + */ +class ReauthFlowStateTest { + + private val user: FirebaseUser = mock(FirebaseUser::class.java).also { + `when`(it.uid).thenReturn("uid-reauth") + } + + private fun holder() = ReauthFlowState(mutableStateOf(null)) + + private fun request(resolver: CompletableDeferred? = null) = + AuthState.Reauthentication.Request( + requestId = UUID.randomUUID().toString(), + user = user, + reason = null, + resolver = resolver, + ) + + private fun ReauthFlowState.accepted( + resolver: CompletableDeferred? = null, + ): AuthState.Reauthentication.Request { + val request = request(resolver) + accept(AuthState.Reauthentication.Required(request)) + return request + } + + /** With no request outstanding there is no conversation to fold a provider state into. */ + @Test + fun `nothing is folded while nothing is outstanding`() { + val holder = holder() + + assertThat(holder.fold(AuthState.Loading("Signing in"))).isNull() + assertThat(holder.phase).isNull() + } + + @Test + fun `provider states are folded onto the same request`() { + val holder = holder() + val request = holder.accepted() + + val authenticating = holder.fold(AuthState.Loading("Signing in")) + assertThat(authenticating) + .isInstanceOf(AuthState.Reauthentication.Authenticating::class.java) + assertThat(authenticating!!.requestId).isEqualTo(request.requestId) + + val failed = holder.fold(AuthState.Error(IllegalArgumentException("wrong password"))) + assertThat(failed).isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) + assertThat(failed!!.requestId).isEqualTo(request.requestId) + + val resumed = holder.fold(AuthState.Cancelled) + assertThat(resumed).isInstanceOf(AuthState.Reauthentication.Required::class.java) + assertThat(resumed!!.requestId).isEqualTo(request.requestId) + } + + @Test + fun `email notifications keep the request until they are consumed`() { + val holder = holder() + val request = holder.accepted() + + val notification = holder.fold(AuthState.PasswordResetLinkSent()) + assertThat(notification) + .isInstanceOf(AuthState.Reauthentication.PasswordResetLinkSent::class.java) + assertThat(notification!!.requestId).isEqualTo(request.requestId) + + holder.update(request.requestId) { it.returnedToProviderSelection() } + + assertThat(holder.phase).isInstanceOf(AuthState.Reauthentication.Required::class.java) + assertThat(holder.phase!!.requestId).isEqualTo(request.requestId) + } + + @Test + fun `update ignores a stale requestId`() { + val holder = holder() + val request = holder.accepted() + + holder.update("stale-request-id") { it.attemptStarted() } + + assertThat(holder.phase).isInstanceOf(AuthState.Reauthentication.Required::class.java) + assertThat(holder.phase!!.requestId).isEqualTo(request.requestId) + } + + @Test + fun `attemptCancelled does not rewind a surfaced attempt failure`() { + val holder = holder() + val request = holder.accepted() + holder.fold(AuthState.Error(IllegalArgumentException("wrong password"))) + + holder.update(request.requestId) { it.attemptCancelled() } + + assertThat(holder.phase).isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) + } + + /** + * The phone sub-flow's "Change number" returns to provider selection, and by then a wrong SMS + * code has latched a failure. Clearing it there would erase the only report the user gets. + */ + @Test + fun `returnedToProviderSelection does not wipe a surfaced attempt failure`() { + val holder = holder() + val request = holder.accepted() + holder.fold(AuthState.Error(IllegalArgumentException("wrong sms code"))) + + holder.update(request.requestId) { it.returnedToProviderSelection() } + + assertThat(holder.phase).isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) + } + + /** Credentials were already accepted, so a stray attempt must not rewind the proof. */ + @Test + fun `attemptStarted does not rewind an accepted proof`() { + val holder = holder() + val request = holder.accepted() + `when`(user.uid).thenReturn("uid-reauth") + holder.fold( + AuthState.Success(result = null, user = user, reauthenticatedUid = "uid-reauth") + ) + assertThat(holder.phase).isInstanceOf(AuthState.Reauthentication.Succeeded::class.java) + + holder.update(request.requestId) { it.attemptStarted() } + + assertThat(holder.phase).isInstanceOf(AuthState.Reauthentication.Succeeded::class.java) + } + + /** Only a stamped Success proves this user was re-verified. */ + @Test + fun `an unstamped Success leaves the phase alone`() { + val holder = holder() + holder.accepted() + + val folded = holder.fold(AuthState.Success(result = null, user = user)) + + assertThat(folded).isInstanceOf(AuthState.Reauthentication.Required::class.java) + assertThat(holder.phase).isInstanceOf(AuthState.Reauthentication.Required::class.java) + } + + /** + * The audit rule: whatever `fold` declines has to be ended by a handler that clears the phase + * *and* resolves the caller. `Aborted` is the one such state, and `FirebaseAuthScreen`'s own + * branch is what covers it — so this pins the decline itself, which is the half that a new + * `AuthState` member would silently inherit. + */ + @Test + fun `fold declines Aborted, leaving the phase for the screen to end`() { + val holder = holder() + holder.accepted() + + assertThat(holder.fold(AuthState.Aborted)).isNull() + assertThat(holder.phase).isInstanceOf(AuthState.Reauthentication.Required::class.java) + } + + /** Arming states are the caller's own signal, never something to fold back onto themselves. */ + @Test + fun `fold declines a reauthentication state`() { + val holder = holder() + val request = holder.accepted() + + assertThat(holder.fold(AuthState.Reauthentication.Required(request))).isNull() + } + + /** + * Both channels the phase travels on conflate equal values, so this is what decides whether a + * write lands. Re-raising a live request is deliberately inert; a transition that has to be + * seen changes the phase type instead. + */ + @Test + fun `Required identifies its request, so only a different request is a different state`() { + val request = request() + + assertThat(AuthState.Reauthentication.Required(request)) + .isEqualTo(AuthState.Reauthentication.Required(request)) + assertThat(AuthState.Reauthentication.Required(request)) + .isNotEqualTo(AuthState.Reauthentication.Required(request())) + } + + @Test + fun `finish resolves the waiting caller with the retry decision`() { + val holder = holder() + val resolver = CompletableDeferred() + holder.accepted(resolver) + + holder.finish(true) + + assertThat(holder.phase).isNull() + assertThat(resolver.isCompleted).isTrue() + assertThat(resolver.getCompleted()).isTrue() + } + + /** + * A decline resumes the caller by throwing. "You backed out" and "your operation ran" are + * different outcomes, and a caller that cannot tell them apart has to guess whether its work + * happened. + */ + @Test + fun `finish without a retry fails the caller rather than returning quietly`() { + val holder = holder() + val resolver = CompletableDeferred() + holder.accepted(resolver) + + holder.finish(false) + + assertThat(resolver.isCompleted).isTrue() + assertThat(resolver.getCompleted()).isFalse() + } + + /** A standalone flow has no operation behind it, so there is nothing to resolve. */ + @Test + fun `finish is a no-op for a request with no caller`() { + val holder = holder() + holder.accepted() + + holder.finish(true) + + assertThat(holder.phase).isNull() + } + + @Test + fun `a resolved request ignores being resolved again`() { + val resolver = CompletableDeferred() + val request = request(resolver) + + request.resolve() + request.decline() + + assertThat(resolver.getCompleted()).isTrue() + } + + @Test + fun `a request whose caller was cancelled is not resumable`() { + val resolver = CompletableDeferred() + val request = request(resolver) + assertThat(request.isResumable).isTrue() + + resolver.cancel() + + assertThat(request.isResumable).isFalse() + // No crash, and nothing to hand back to. + request.resolve() + } + + /** No caller means nothing to lose, so a standalone request is always presentable. */ + @Test + fun `a request with no caller is always resumable`() { + assertThat(request().isResumable).isTrue() + assertThat(request().hasPendingOperation).isFalse() + } + + @Test + fun `an attempt failure carries the exception it folded`() { + val holder = holder() + holder.accepted() + val cause = AuthException.UnknownException("nope") + + val folded = holder.fold(AuthState.Error(cause)) + + assertThat((folded as AuthState.Reauthentication.AttemptFailed).exception).isEqualTo(cause) + } + + // ============================================================================================= + // Sink isolation + // ============================================================================================= + + /** + * The point of giving the request its own sink: provider code driving a credential exchange + * publishes into the phase, and an app collecting `authStateFlow()` never sees a failure that + * belongs to a conversation it is not part of. + */ + @Test + fun `the request's sink keeps the exchange off the host flow`() { + val holder = holder() + holder.accepted() + val host = mutableListOf() + val sink = holder.sink(hostFallback = { host += it }) + + sink.emit(AuthState.Loading("Signing in")) + sink.emit(AuthState.Error(AuthException.UnknownException("wrong password"))) + + assertThat(host).isEmpty() + assertThat(holder.phase).isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) + } + + /** What the exchange does not own is still the host's, so the sink forwards it rather than eating it. */ + @Test + fun `the request's sink forwards what the exchange does not own`() { + val holder = holder() + holder.accepted() + val host = mutableListOf() + val sink = holder.sink(hostFallback = { host += it }) + + sink.emit(AuthState.Aborted) + + assertThat(host).hasSize(1) + assertThat(host.single()).isInstanceOf(AuthState.Aborted::class.java) + } + + /** With no request outstanding there is no exchange to absorb into, so everything is the host's. */ + @Test + fun `the sink forwards everything while nothing is outstanding`() { + val holder = holder() + val host = mutableListOf() + val sink = holder.sink(hostFallback = { host += it }) + + sink.emit(AuthState.Loading("Signing in")) + + assertThat(host).hasSize(1) + assertThat(host.single()).isInstanceOf(AuthState.Loading::class.java) + } + + /** A cancellation is the user backing out of a sub-flow, not a failure to report. */ + @Test + fun `a cancelled attempt returns to provider selection rather than surfacing`() { + val holder = holder() + holder.accepted() + + val folded = holder.fold( + AuthState.Error(AuthException.AuthCancelledException(message = "cancelled")) + ) + + assertThat(folded).isInstanceOf(AuthState.Reauthentication.Required::class.java) + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSurfaceGateTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSurfaceGateTest.kt index f286d7395..981808ad9 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSurfaceGateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSurfaceGateTest.kt @@ -14,6 +14,8 @@ package com.firebase.ui.auth.ui.screens.reauth +import androidx.compose.runtime.derivedStateOf +import com.firebase.ui.auth.AuthFlowScope import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -65,7 +67,7 @@ import org.robolectric.annotation.Config /** * The reauthentication surface exists on exactly one condition: a resolved [ReauthSurface]. * - * A saved back stack can carry an [AuthRoute.Reauth] entry into a process with no armed request — + * A saved back stack can carry an [AuthRoute.Reauth] entry into a process with no outstanding request — * the state machine publishes `Interrupted` and pops it, but the entry composes first. This pins * what it composes: nothing. Driving [ReauthSceneStrategy] and [reauthDestinations] directly is * what makes the unarmed entry reachable at all; `FirebaseAuthScreen` never leaves one standing @@ -116,15 +118,15 @@ class ReauthSurfaceGateTest { /** So the absence asserted below is a real absence, not a matcher that never matches. */ @Test - fun `an armed request composes the sheet`() { - setContent(armed = true) + fun `an outstanding request composes the sheet`() { + setContent(presented = true) composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1) } @Test - fun `a reauth entry with no armed request composes no sheet and no scrim`() { - setContent(armed = false) + fun `a reauth entry with no outstanding request composes no sheet and no scrim`() { + setContent(presented = false) // The sheet owns the scrim and both live in the sheet's own window, so no sheet node means // neither is on screen; the flow underneath is what the user is left looking at. @@ -133,16 +135,16 @@ class ReauthSurfaceGateTest { } /** - * The entry renders the armed request, so a key naming a different one must render nothing: - * every write it would offer goes to the id the key names, which is no longer the armed one. + * The entry renders the outstanding request, so a key naming a different one must render nothing: + * every write it would offer goes to the id the key names, which is no longer the outstanding one. */ @Test fun `an entry keyed to a request the surface no longer holds is handed nothing`() { val stale = request("stale") - val armed = request("armed") + val raised = request("raised") composeTestRule.setContent { - Harness(reauthState = armed, entryRequestId = stale.requestId, useSlot = true) + Harness(reauthState = raised, entryRequestId = stale.requestId, useSlot = true) } composeTestRule.waitForIdle() @@ -161,7 +163,7 @@ class ReauthSurfaceGateTest { val state = mutableStateOf(first) composeTestRule.setContent { - Harness(reauthState = state.value, armedRequest = state, useSlot = true) + Harness(reauthState = state.value, presentedRequest = state, useSlot = true) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { state.value = second } @@ -170,8 +172,8 @@ class ReauthSurfaceGateTest { assertThat(handedOut.filter { it.substringBefore('/') != it.substringAfter('/') }).isEmpty() } - private fun setContent(armed: Boolean) { - val state = if (armed) AuthState.Reauthentication.Required(passwordUser()) else null + private fun setContent(presented: Boolean) { + val state = if (presented) AuthState.Reauthentication.Required(passwordUser()) else null composeTestRule.setContent { Harness(state) } composeTestRule.waitForIdle() } @@ -182,11 +184,11 @@ class ReauthSurfaceGateTest { .also { labels[it.requestId] = label } /** - * `FirebaseAuthScreen`'s reauthentication wiring, with the armed state under test control. + * `FirebaseAuthScreen`'s reauthentication wiring, with the outstanding state under test control. * - * @param entryRequestId The id the reauthentication entry is keyed to. Defaults to the armed - * request's, which is what the host's steady state looks like. - * @param armedRequest When given, the stack is re-armed from it in a `LaunchedEffect`, the way + * @param entryRequestId The id the reauthentication entry is keyed to. Defaults to the + * outstanding request's, which is what the host's steady state looks like. + * @param presentedRequest When given, the stack is rebuilt from it in a `LaunchedEffect`, the way * the host does — which is what puts a composition between a new request and its entry. * @param useSlot Installs a `reauthContent` slot that records what the entry hands it. The * slot is the only path to the entry's `updateReauthentication` writes, so nothing recorded @@ -196,7 +198,7 @@ class ReauthSurfaceGateTest { private fun Harness( reauthState: AuthState.Reauthentication?, entryRequestId: String = reauthState?.requestId ?: "unarmed-request", - armedRequest: MutableState? = null, + presentedRequest: MutableState? = null, useSlot: Boolean = false, ) { val context = ApplicationProvider.getApplicationContext() @@ -222,11 +224,11 @@ class ReauthSurfaceGateTest { step = AuthRoute.MethodPicker, ), ) - if (armedRequest != null) { - // FirebaseAuthScreen's `Reauthentication.Required` branch, reduced to the re-arming. - val armedId = armedRequest.value?.requestId + if (presentedRequest != null) { + // FirebaseAuthScreen's `Reauthentication.Required` branch, reduced to the re-entry. + val armedId = presentedRequest.value?.requestId LaunchedEffect(armedId) { - if (armedId != null && backStack.armedReauth()?.requestId != armedId) { + if (armedId != null && backStack.presentedReauth()?.requestId != armedId) { backStack.clearReauth() backStack.add( AuthRoute.Reauth( @@ -240,7 +242,7 @@ class ReauthSurfaceGateTest { } val slot: (@Composable (ReauthContentState) -> Unit)? = if (useSlot) { { state -> - val keyed = labels[backStack.armedReauth()?.requestId] ?: "unlabelled" + val keyed = labels[backStack.presentedReauth()?.requestId] ?: "unlabelled" val handed = state.reason SideEffect { handedOut += "$keyed/$handed" } } @@ -256,6 +258,7 @@ class ReauthSurfaceGateTest { ) } val phoneFlowState = rememberPhoneAuthFlowState(configuration) + val reauthFlowState = rememberReauthFlowState() CompositionLocalProvider( LocalAuthUIStringProvider provides configuration.stringProvider, ) { @@ -275,6 +278,7 @@ class ReauthSurfaceGateTest { configuration = configuration, stringProvider = DefaultAuthUIStringProvider(context), surface = surface, + reauthFlowState = reauthFlowState, phoneFlowState = phoneFlowState, emailContent = null, phoneContent = null, 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 index 0c8d5e0ec..ea26c2f89 100644 --- 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 @@ -27,11 +27,18 @@ import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringPro 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.awaitWithLooper import com.firebase.ui.auth.testutil.ensureFreshUser import com.firebase.ui.auth.testutil.ensureTestFirebaseApp import com.firebase.ui.auth.testutil.verifyEmailInEmulator import com.firebase.ui.auth.ui.screens.reauth.ReauthContentState import com.google.common.truth.Truth.assertThat +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import kotlinx.coroutines.yield import org.junit.After import org.junit.Assume @@ -47,6 +54,9 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) class ReauthFlowTest { + /** Runs `withReauth` on the looper these tests already pump. */ + private val reauthScope = CoroutineScope(Dispatchers.Main.immediate) + @get:Rule val composeAndroidTestRule = createAndroidComposeRule() @@ -75,6 +85,7 @@ class ReauthFlowTest { @After fun tearDown() { + reauthScope.cancel() authUI.auth.signOut() FirebaseAuthUI.clearInstanceCache() emulatorApi.clearEmulatorData() @@ -113,6 +124,7 @@ class ReauthFlowTest { var currentAuthState: AuthState = AuthState.Idle var retryOperationCalled = false + var attempts = 0 val configuration = authUIConfiguration { context = applicationContext @@ -168,16 +180,19 @@ class ReauthFlowTest { // 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" } + requireNotNull(authUI.auth.currentUser) { "User must be signed in" } - // Step 2: Emit Reauthentication.Required to simulate an operation requiring reauth. - authUI.updateAuthState( - AuthState.Reauthentication.Required( - user = signedInUser, - reason = "Please verify your identity to continue", - retryOperation = { retryOperationCalled = true }, - ) - ) + // Step 2: the first attempt fails the way Firebase fails one, so `withReauth` raises it. + reauthScope.launch { + runCatching { + authUI.withReauth(applicationContext, reason = "Please verify your identity to continue") { + if (attempts++ == 0) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + retryOperationCalled = true + } + } + } shadowOf(Looper.getMainLooper()).idle() @@ -240,6 +255,7 @@ class ReauthFlowTest { var currentAuthState: AuthState = AuthState.Idle var retryOperationCalled = false + var attempts = 0 var capturedState: ReauthContentState? = null val expectedReason = "Sensitive operation requires sign-in" @@ -290,14 +306,17 @@ class ReauthFlowTest { shadowOf(Looper.getMainLooper()).idle() - // Emit Reauthentication.Required to trigger the custom reauthContent slot. - authUI.updateAuthState( - AuthState.Reauthentication.Required( - user = capturedUser, - reason = expectedReason, - retryOperation = { retryOperationCalled = true }, - ) - ) + // The first attempt fails the way Firebase fails one, so `withReauth` raises the request. + reauthScope.launch { + runCatching { + authUI.withReauth(applicationContext, reason = expectedReason) { + if (attempts++ == 0) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + retryOperationCalled = true + } + } + } shadowOf(Looper.getMainLooper()).idle() @@ -356,9 +375,10 @@ class ReauthFlowTest { ) } - val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" } + requireNotNull(authUI.auth.currentUser) { "User must be signed in" } var retryOperationCalled = false + var attempts = 0 val configuration = authUIConfiguration { context = applicationContext @@ -401,13 +421,17 @@ class ReauthFlowTest { shadowOf(Looper.getMainLooper()).idle() - authUI.updateAuthState( - AuthState.Reauthentication.Required( - user = signedInUser, - reason = "Please verify your identity to continue", - retryOperation = { retryOperationCalled = true }, - ) - ) + // The first attempt fails the way Firebase fails one, so `withReauth` raises the request. + reauthScope.launch { + runCatching { + authUI.withReauth(applicationContext, reason = "Please verify your identity to continue") { + if (attempts++ == 0) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + retryOperationCalled = true + } + } + } shadowOf(Looper.getMainLooper()).idle() @@ -465,6 +489,7 @@ class ReauthFlowTest { var currentAuthState: AuthState = AuthState.Idle var retryOperationCalled = false + var attempts = 0 val configuration = authUIConfiguration { context = applicationContext @@ -518,16 +543,20 @@ class ReauthFlowTest { } composeAndroidTestRule.onNodeWithText("AUTHENTICATED").assertIsDisplayed() - val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" } + requireNotNull(authUI.auth.currentUser) { "User must be signed in" } // Step 2: emit Reauthentication.Required with a retryOperation. - authUI.updateAuthState( - AuthState.Reauthentication.Required( - user = signedInUser, - reason = "Please verify your identity to continue", - retryOperation = { retryOperationCalled = true }, - ) - ) + // The first attempt fails the way Firebase fails one, so `withReauth` raises the request. + reauthScope.launch { + runCatching { + authUI.withReauth(applicationContext, reason = "Please verify your identity to continue") { + if (attempts++ == 0) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + retryOperationCalled = true + } + } + } shadowOf(Looper.getMainLooper()).idle() @@ -599,6 +628,7 @@ class ReauthFlowTest { var currentAuthState: AuthState = AuthState.Idle var retryOperationStarted = false + var attempts = 0 var retryOperationCompleted = false val configuration = authUIConfiguration { @@ -652,24 +682,23 @@ class ReauthFlowTest { } composeAndroidTestRule.onNodeWithText("AUTHENTICATED").assertIsDisplayed() - val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" } + requireNotNull(authUI.auth.currentUser) { "User must be signed in" } - // Step 2: arm a request whose operation signs the user out, as delete() would. - authUI.updateAuthState( - AuthState.Reauthentication.Required( - user = signedInUser, - reason = "Please verify your identity to continue", - retryOperation = { + // Step 2: an operation that signs the user out, as delete() does. + reauthScope.launch { + runCatching { + authUI.withReauth(applicationContext, reason = "Please verify your identity to continue") { + if (attempts++ == 0) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) retryOperationStarted = true authUI.auth.signOut() - // The suspension point is what makes a dropped request observable: if the - // sign-out clears the request, this coroutine is cancelled here and never - // reaches the line below. + // A suspension point makes a dropped operation observable. yield() retryOperationCompleted = true - }, - ) - ) + } + } + } shadowOf(Looper.getMainLooper()).idle() composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { @@ -708,4 +737,276 @@ class ReauthFlowTest { .assertCountEquals(0) } + /** + * The password change this ticket asked to be checked by hand, done here instead: the retry + * runs a real `updatePassword` against the emulator, and afterwards only the new password + * signs the account in. + * + * Firebase's [FirebaseAuthRecentLoginRequiredException] is thrown rather than waited for — the + * emulator does not age tokens. + */ + @Test + fun `reauthenticating to change the password leaves the new password working`() { + val email = "reauth-password-${System.currentTimeMillis()}@example.com" + val password = "test123" + val newPassword = "changed456" + + val user = ensureFreshUser(authUI, email, password) + requireNotNull(user) { "Failed to create user" } + + try { + verifyEmailInEmulator(authUI, emulatorApi, user) + } catch (e: Exception) { + Assume.assumeTrue( + "Skipping: Firebase Auth Emulator OOB codes not available. Error: ${e.message}", + false + ) + } + + authUI.auth.signOut() + shadowOf(Looper.getMainLooper()).idle() + + var currentAuthState: AuthState = AuthState.Idle + var passwordChanged = false + var failure: Throwable? = null + var attempts = 0 + + 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: initial sign-in through the main screen. + 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 + } + composeAndroidTestRule.onNodeWithText("AUTHENTICATED").assertIsDisplayed() + + // Step 2: the first attempt fails the way Firebase fails one, so `withReauth` raises it. + reauthScope.launch { + failure = runCatching { + authUI.withReauth(applicationContext, reason = "Confirm it's you to change your password") { + if (attempts++ == 0) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + val updated = CompletableDeferred() + requireNotNull(authUI.auth.currentUser).updatePassword(newPassword) + .addOnSuccessListener { updated.complete(Unit) } + .addOnFailureListener { updated.completeExceptionally(it) } + updated.await() + passwordChanged = true + } + }.exceptionOrNull() + } + + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.onAllNodesWithText(stringProvider.emailHint) + .fetchSemanticsNodes().isNotEmpty() + } + + // Step 3: reauthenticate, which runs the password change. + composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint) + .performScrollTo() + .performTextInput(password) + composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault.uppercase()) + .performScrollTo() + .performClick() + + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + passwordChanged + } + + assertThat(failure).isNull() + + // The change took: the old password no longer signs this account in, the new one does. + authUI.auth.signOut() + shadowOf(Looper.getMainLooper()).idle() + + val withOldPassword = runCatching { + authUI.auth.signInWithEmailAndPassword(email, password).awaitWithLooper() + } + assertThat(withOldPassword.isFailure).isTrue() + + val withNewPassword = authUI.auth.signInWithEmailAndPassword(email, newPassword) + .awaitWithLooper() + assertThat(withNewPassword.user?.uid).isEqualTo(user.uid) + } + + /** + * The account deletion this ticket asked to be checked by hand, done here instead: the retry + * calls [FirebaseAuthUI.delete], which now completes instead of reporting invalid credentials, + * and the account is gone from the emulator afterwards. + */ + @Test + fun `reauthenticating to delete the account leaves it gone`() { + val email = "reauth-delete-${System.currentTimeMillis()}@example.com" + val password = "test123" + + val user = ensureFreshUser(authUI, email, password) + requireNotNull(user) { "Failed to create user" } + val uid = user.uid + + try { + verifyEmailInEmulator(authUI, emulatorApi, user) + } catch (e: Exception) { + Assume.assumeTrue( + "Skipping: Firebase Auth Emulator OOB codes not available. Error: ${e.message}", + false + ) + } + + authUI.auth.signOut() + shadowOf(Looper.getMainLooper()).idle() + + var currentAuthState: AuthState = AuthState.Idle + var deleted = false + var failure: Throwable? = null + var attempts = 0 + + 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: initial sign-in through the main screen. + 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 + } + composeAndroidTestRule.onNodeWithText("AUTHENTICATED").assertIsDisplayed() + + // Step 2: the first attempt fails the way Firebase fails one, so `withReauth` raises it. + reauthScope.launch { + failure = runCatching { + authUI.withReauth(applicationContext, reason = "Confirm it's you to delete your account") { + if (attempts++ == 0) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + authUI.delete(applicationContext) + deleted = true + } + }.exceptionOrNull() + } + + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.onAllNodesWithText(stringProvider.emailHint) + .fetchSemanticsNodes().isNotEmpty() + } + + // Step 3: reauthenticate, which runs the deletion. + composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint) + .performScrollTo() + .performTextInput(password) + composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault.uppercase()) + .performScrollTo() + .performClick() + + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + deleted + } + + repeat(5) { + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.waitForIdle() + } + + assertThat(failure).isNull() + assertThat(authUI.auth.currentUser).isNull() + assertThat(currentAuthState).isInstanceOf(AuthState.Idle::class.java) + composeAndroidTestRule.onAllNodesWithText(stringProvider.errorDialogTitle) + .assertCountEquals(0) + + // The account is gone, not just signed out. + val signInAfterDelete = runCatching { + authUI.auth.signInWithEmailAndPassword(email, password).awaitWithLooper() + } + assertThat(signInAfterDelete.isFailure).isTrue() + assertThat(signInAfterDelete.getOrNull()?.user?.uid).isNotEqualTo(uid) + } + }