From d27083aa7615ce0f25a94c18a7dd39215a05207a Mon Sep 17 00:00:00 2001 From: demolaf Date: Thu, 3 Sep 2026 19:56:17 +0100 Subject: [PATCH 01/15] refactor(auth)!: replace the retained reauth closure with a resolver and a screen-scoped phase holder --- .../demo/auth/HighLevelApiDemoActivity.kt | 11 +- .../java/com/firebase/ui/auth/AuthState.kt | 84 ++--- .../com/firebase/ui/auth/FirebaseAuthUI.kt | 287 +++------------- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 296 ++++++++++------ .../ui/screens/email/EmailAuthDestinations.kt | 6 + .../auth/ui/screens/email/EmailAuthScreen.kt | 20 +- .../auth/ui/screens/phone/PhoneAuthScreen.kt | 25 +- .../ui/screens/reauth/ReauthDestinations.kt | 56 ++- .../auth/ui/screens/reauth/ReauthFlowState.kt | 166 +++++++++ auth/src/main/res/values/strings.xml | 1 + .../ui/auth/FirebaseAuthUIAuthStateTest.kt | 323 ++++++------------ .../firebase/ui/auth/FirebaseAuthUITest.kt | 21 +- .../firebase/ui/auth/ReauthTestRequests.kt | 64 ++++ .../FirebaseAuthScreenEmailRecoveryTest.kt | 3 +- ...irebaseAuthScreenReauthContentStateTest.kt | 194 ++++++----- .../FirebaseAuthScreenReauthIdleResetTest.kt | 22 +- .../email/EmailAuthHostDestinationsTest.kt | 5 +- .../phone/PhoneAuthHostDestinationsTest.kt | 5 +- ...honeAuthScreenVerificationLifecycleTest.kt | 17 +- .../ui/screens/reauth/ReauthFlowStateTest.kt | 284 +++++++++++++++ .../screens/reauth/ReauthSurfaceGateTest.kt | 2 + 21 files changed, 1103 insertions(+), 789 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthFlowState.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/screens/reauth/ReauthFlowStateTest.kt 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..a6483ed15 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,10 @@ 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) { Log.e("HighLevelApiDemoActivity", "Delete failed", e) } finally { @@ -567,6 +569,11 @@ 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: Exception) { updateError = "Failed to update password. Please try again." } finally { 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..bcc07ef9a 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 /** @@ -260,7 +261,7 @@ abstract class AuthState private constructor() { * * 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. + * request itself is process-local because the caller it resolves to cannot be serialized. */ sealed class Reauthentication : AuthState() { abstract val requestId: String @@ -273,21 +274,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. + * Resolving it runs the retry in the caller's own coroutine, which is why nothing + * retains the caller's closure here. + */ + 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 this request ever carried an operation, even after it was claimed. */ - val hasRetryOperation: Boolean = retryOperation != null + /** + * Whether the awaiting caller is still there to resume. False once its coroutine died + * with the scope that launched it, which is a request that can no longer complete + * however well the credential exchange goes. + */ + val isResumable: Boolean get() = resolver?.isActive != false /** - * 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. + * Hands the outcome to the awaiting caller, if any. Idempotent, and a no-op once the + * caller is gone, so every terminal path can resolve without checking first. */ - fun claimRetryOperation(): (suspend (android.content.Context) -> Unit)? = - retryOperation.also { retryOperation = null } + fun resolve(retryOperation: Boolean) { + resolver?.complete(retryOperation) + } } /** @@ -305,13 +316,11 @@ abstract class AuthState private constructor() { 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,8 +328,6 @@ 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 override fun equals(other: Any?): Boolean = other is Required && requestId == other.requestId @@ -395,7 +402,11 @@ 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 credential exchange: + * the screen validates the proof, resolves the awaiting caller and ends the request, and + * the caller's own retry publishes ordinary states from there. + */ internal class Succeeded( override val request: Request, val success: Success, @@ -404,43 +415,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 +461,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..61768f503 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,6 +33,8 @@ 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 @@ -40,6 +42,8 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.tasks.await +import java.util.UUID +import kotlin.coroutines.coroutineContext import java.util.concurrent.ConcurrentHashMap /** @@ -81,7 +85,6 @@ class FirebaseAuthUI private constructor( private val _authStateFlow = MutableStateFlow(AuthState.Idle) /** 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 @@ -244,19 +247,13 @@ class FirebaseAuthUI private constructor( ?: throw AuthException.UserNotFoundException( message = "No user is currently signed in" ) - val linked = configuration.providers.filterToLinkedProviders(currentUser) - check(linked.isNotEmpty()) { + // One definition of what a reauthentication configuration is, shared with the screen's + // own arming path: a linked credential is not a proof of identity, so neither enables + // linking or upgrade. + 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) } @@ -332,14 +329,13 @@ 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 + // Nothing to protect here any more: the retry runs in the caller's own + // coroutine, after the screen has already ended the request. A signed-out + // user cannot reauthenticate, so an armed request is stale by definition. + is AuthState.Reauthentication -> true 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) } trySend(buildState(firebaseAuth.currentUser)) } @@ -379,147 +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) { @@ -632,44 +490,17 @@ 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. + * [AuthState.Reauthentication.Required] and suspends. [FirebaseAuthScreen] observes that state + * and presents a reauthentication sheet; once credentials are accepted the [operation] runs + * again on this same coroutine, so nothing about the caller is retained by the library. + * + * If the user backs out, or this coroutine's scope is cancelled while the sheet is up, 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. * @@ -697,60 +528,50 @@ class FirebaseAuthUI private constructor( } catch (e: FirebaseAuthRecentLoginRequiredException) { val user = auth.currentUser ?: throw AuthException.UserNotFoundException(message = "No user is currently signed in") + // The caller's half of the request, parented to the caller's own job: a scope that + // dies cancels this with it, which is how the screen tells a request it can still + // complete from one whose operation can never run again. + val resolver = CompletableDeferred(parent = coroutineContext[Job]) updateAuthState( AuthState.Reauthentication.Required( - 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) - } - }, + AuthState.Reauthentication.Request( + requestId = UUID.randomUUID().toString(), + user = user, + reason = reason, + resolver = resolver, + ) ) ) + if (resolver.await()) operation() } } + /** + * 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) }, + // The whole reauthentication dance is withReauth's: arm once, retry once, and no + // branch here that both emits Required and throws for the same condition. + 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: CancellationException) { // Handle coroutine cancellation val cancelledException = AuthException.AuthCancelledException( 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..574656d28 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 @@ -68,7 +68,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 @@ -106,6 +105,7 @@ 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.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 @@ -180,18 +180,28 @@ fun FirebaseAuthScreen( val observedAuthState by remember(authUI) { authUI.authStateFlow() } .collectAsState(initial = null as AuthState?) - val authState = observedAuthState ?: AuthState.Idle + val rawAuthState = observedAuthState ?: AuthState.Idle + // Composition-scoped, so its existence *is* the answer to "is there a screen able to drive an + // armed request to completion?" — no counter on the singleton, and a phase that cannot outlive + // the Activity and re-arm an unrelated sign-in. + val reauthFlowState = rememberReauthFlowState() + val reauthState = reauthFlowState.phase + /** + * What the host may act on. While a request is armed, an ordinary state published by provider + * code belongs to the credential exchange and the phase is what reports it — but `fold` runs + * in an effect, so the raw state is on the flow for a frame first. Without this the host's own + * dialogs act on it in between, putting a sign-in error dialog, retry action and all, over the + * reauthentication sheet. The effects below read `observedAuthState` directly, so the states + * `fold` declines still reach them. + */ + val authState = reauthState?.takeIf { rawAuthState !is AuthState.Reauthentication } + ?: 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. @@ -233,11 +243,28 @@ fun FirebaseAuthScreen( // The stack is the arming marker: a Reauth entry persists with it, across recreation and death. val armedReauth = backStack.armedReauth() val clearReauthPresentation: () -> Unit = remember(backStack) { { backStack.clearReauth() } } + /** + * Ends the armed request: clears its presentation, clears the phase, publishes [terminal], and + * only then resolves the caller waiting on it. + * + * One helper because every terminal site does the same four things in the same order, and the + * order carries two rules. The phase goes before [terminal] is published, or `fold` folds the + * terminal state straight back into the request it is ending. The caller is resolved last, or + * a fast-resuming retry's real outcome is overwritten by this stale one. + */ + 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 +273,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 +287,19 @@ 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, 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. + reauthContent != null -> AuthRoute.MethodPicker + config != null -> getStartRoute(config).toKey() + else -> AuthRoute.MethodPicker + } + } } - } val stepTransitionSpec = configuration.transitions?.transitionSpec ?: DefaultAuthContentTransform val stepPopTransitionSpec = configuration.transitions?.popTransitionSpec @@ -526,6 +560,7 @@ fun FirebaseAuthScreen( context = context, configuration = configuration, stringProvider = stringProvider, + reauthFlowState = reauthFlowState, surface = reauthSurfaceHolder, phoneFlowState = reauthPhoneFlowState, emailContent = emailContent, @@ -604,20 +639,67 @@ fun FirebaseAuthScreen( val currentKey = backStack.lastOrNull() val savedPresentation = armedReauth + // A marker that outlived its phase: the Activity was recreated with the request + // still armed. Nothing here can be driven, so report it and clear up. if (savedPresentation != null && + reauthFlowState.phase == null && state !is AuthState.Reauthentication && state !is AuthState.Aborted ) { clearReauthPresentation() authUI.updateAuthState( - AuthState.Reauthentication.Interrupted( - requestId = savedPresentation.requestId, - userUid = savedPresentation.userUid, + AuthState.Error( + AuthException.UnknownException( + context.getString(R.string.fui_error_reauth_interrupted) + ) ) ) return@LaunchedEffect } + // A latched reauthentication state with no phase: this screen was recreated while + // the request was armed. The phase is composition-scoped and gone, but its value + // is still on the flow, so the exchange is re-armed from it rather than abandoned. + if (state is AuthState.Reauthentication && reauthFlowState.phase == null) { + val request = state.request + if (request == null || !request.isResumable) { + clearReauthPresentation() + authUI.updateAuthState( + AuthState.Error( + AuthException.UnknownException( + context.getString(R.string.fui_error_reauth_interrupted) + ) + ) + ) + return@LaunchedEffect + } + // Two phases must not come back: `Authenticating`'s network call died with the + // Activity, and re-entering `Succeeded` would resolve the caller a second time + // for an operation that may already have committed. Both restart at provider + // selection. Everything else is the user's own position in the exchange, and a + // surfaced failure is the only report they got, so it is restored as it was. + when (state) { + is AuthState.Reauthentication.Authenticating, + is AuthState.Reauthentication.Succeeded, + -> authUI.updateAuthState( + AuthState.Reauthentication.Required(request) + ) + + is AuthState.Reauthentication.Required -> reauthFlowState.arm(state) + + else -> reauthFlowState.moveTo(state) + } + } + + // Ordinary states published by provider code while a request is armed belong to + // the credential exchange, not to the host flow. The holder folds them into its + // phase and publishes that, which is what the setter used to do on the singleton's + // behalf; the branches below then only ever see states that are the host's. + reauthFlowState.fold(state)?.let { folded -> + authUI.updateAuthState(folded) + 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. @@ -650,105 +732,46 @@ fun FirebaseAuthScreen( } is AuthState.Reauthentication.Required -> { - val linked = configuration.providers.filterToLinkedProviders(state.user) - if (linked.isEmpty()) { - clearReauthPresentation() - authUI.finishReauthentication( + val armingConfig = configuration.toReauthConfiguration(state.user) + if (armingConfig == null) { + finishReauth( 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, - ) + ), + false, ) return@LaunchedEffect } - val retry = request.claimRetryOperation() - if (retry == null) { - clearReauthPresentation() - authUI.finishReauthentication( + // A request whose caller died with its scope cannot be completed however + // well the exchange goes, so it is reported rather than presented. This is + // what tells a rotation that kept its caller from one that lost it. + if (!state.request.isResumable) { + finishReauth( AuthState.Error( AuthException.UnknownException( context.getString(R.string.fui_error_reauth_interrupted) ) - ) + ), + false, ) 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) + reauthFlowState.arm(state) + if (armedReauth?.requestId != state.requestId) { + backStack.clearReauth() + backStack.add( + AuthRoute.Reauth( + requestId = state.requestId, + userUid = state.userUid, + // From the arming state, not the composition value: this + // effect is what writes the phase, so anything derived from + // it in composition is still a frame behind here. + step = reauthStartStepFor(armingConfig), ) ) - ) + } } is AuthState.Reauthentication -> { @@ -756,7 +779,10 @@ fun FirebaseAuthScreen( ?: AuthRoute.Reauth( requestId = state.requestId, userUid = state.userUid, - step = reauthStartStep, + step = state.request + ?.let { configuration.toReauthConfiguration(it.user) } + ?.let { reauthStartStepFor(it) } + ?: AuthRoute.MethodPicker, ).also { backStack.clearReauth() backStack.add(it) @@ -802,8 +828,16 @@ fun FirebaseAuthScreen( } is AuthState.Aborted -> { + // Outside the host guard on purpose. `fold` declines Aborted, so nothing + // else clears the phase or resolves the caller — and under the activity + // host FirebaseAuthActivity owns the rest of the teardown, so a clear + // placed inside the guard would leave that host holding an armed request + // and a caller suspended forever. An activity-scoped caller has its own + // cancellation to fall back on, an unscoped one has nothing, and this + // cannot tell them apart, so it resolves unconditionally. + clearReauthPresentation() + reauthFlowState.finish(false) if (activity !is FirebaseAuthActivity) { - clearReauthPresentation() pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -833,6 +867,52 @@ fun FirebaseAuthScreen( } } + /** + * The phase's own effect. The effect above is keyed on the flow, so it never sees a + * transition the destinations make straight on the holder — an MFA proof, a cancelled + * attempt, a consumed notification. Keying on the phase catches all of them. + * + * Mirroring the phase onto the flow keeps one story for the sub-screens and for app + * code: provider screens read their loading and error state from there, and a phase + * that only ever existed in this holder would show them neither. + */ + LaunchedEffect(reauthFlowState.phase) { + val phase = reauthFlowState.phase ?: return@LaunchedEffect + if (observedAuthState != phase) authUI.updateAuthState(phase) + + if (phase is AuthState.Reauthentication.Succeeded) { + val request = phase.request + val success = phase.success + if (success.reauthenticatedUid != phase.userUid || + success.user.uid != phase.userUid + ) { + // Proof for the wrong user is a failed attempt, not a dead request: the + // surface stays up reporting it so the user can try the right account. + reauthFlowState.update(phase.requestId) { + AuthState.Reauthentication.AttemptFailed( + request, + AuthException.UnknownException( + context.getString(R.string.fui_error_reauth_incomplete) + ), + ) + } + return@LaunchedEffect + } + // The retry runs on the caller's own coroutine, so this publishes the handover + // rather than an outcome: a Success here would claim the pending operation had + // already succeeded. A standalone flow has no operation behind it, so for that + // one reauthenticating *is* the outcome. + 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 && @@ -941,12 +1021,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) } 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..5b23fcf71 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 = { 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..65a21995b 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 @@ -160,6 +160,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)) { @@ -273,22 +279,12 @@ fun EmailAuthScreen( is AuthState.PasswordResetLinkSent -> { resetLinkSentLocal = true - authUI.updateAuthState(AuthState.Idle) - } - - is AuthState.Reauthentication.PasswordResetLinkSent -> { - resetLinkSentLocal = true - authUI.updateReauthentication(state.requestId) { it.returnedToProviderSelection() } + onNotificationConsumed?.invoke() ?: authUI.updateAuthState(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() ?: authUI.updateAuthState(AuthState.Idle) } else -> Unit 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..e8e4c3895 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 @@ -147,6 +147,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( @@ -314,11 +324,7 @@ fun PhoneAuthScreen( 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() ?: authUI.updateAuthState(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 { @@ -505,14 +511,7 @@ fun PhoneAuthScreen( 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) - } + onNotificationConsumed?.invoke() ?: authUI.updateAuthState(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..6a356a6da 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 @@ -80,16 +80,10 @@ 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: the screen validates the proof and ends the request on it. The surface stays + // up for that 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) @@ -155,6 +149,7 @@ internal fun EntryProviderScope.reauthDestinations( configuration: AuthUIConfiguration, stringProvider: AuthUIStringProvider, surface: State, + reauthFlowState: ReauthFlowState, phoneFlowState: PhoneAuthFlowState, emailContent: (@Composable (EmailAuthContentState) -> Unit)?, phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?, @@ -186,10 +181,8 @@ 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) } @@ -214,7 +207,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 +248,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 +271,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,9 +286,35 @@ internal fun EntryProviderScope.reauthDestinations( resolver = mfaResolver, auth = authUI.auth, content = mfaChallengeContent, - onSuccess = { authUI.publishReauthenticationSuccess() }, + // The one credential exchange no provider owns, so the stamp is made here: + // no current user means nothing was re-proved, and the attempt is reported as + // a failure rather than moved on as an unstamped success. + 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)) }, ) 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..94ddf210f --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthFlowState.kt @@ -0,0 +1,166 @@ +/* + * 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 + +/** + * The reauthentication phase machine of one + * [com.firebase.ui.auth.ui.screens.FirebaseAuthScreen]. + * + * Scoped to the composition that created it, which is what makes it the answer to "is there a + * screen able to drive an armed request to completion?" — the question + * `FirebaseAuthUI.addReauthenticationDrainer` used to answer with a counter on the singleton. + * Every transition below runs from a composed screen, so an arming with nothing composed stays + * inert without anything having to count screens. + * + * @since 10.0.0 + */ +internal class ReauthFlowState internal constructor( + private val phaseState: MutableState, +) { + /** The live phase, or null when no request is armed. */ + val phase: AuthState.Reauthentication? get() = phaseState.value + + /** The live request, or null when none is armed. */ + val request: AuthState.Reauthentication.Request? get() = phaseState.value?.request + + /** Arms [required], replacing any request already held. */ + fun arm(required: AuthState.Reauthentication.Required) { + phaseState.value = required + } + + /** + * Drops the armed request and tells its awaiting caller whether to retry. + * + * Resolving here rather than at each call site is what stops a caller being left suspended + * forever: every way a request ends comes through this, including the ones that end it because + * the user backed out. + */ + fun finish(retryOperation: Boolean) { + val request = phaseState.value?.request + phaseState.value = null + request?.resolve(retryOperation) + } + + /** + * Applies [transition] to the live phase while [requestId] still names it. + * + * The id check is what `FirebaseAuthUI.updateReauthentication` needed against a shared flow + * any caller could have overwritten. Here it only guards a back stack entry one composition + * behind the phase, so it compares a key the caller already holds rather than arbitrating + * between writers. + */ + 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 + } + + /** + * 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. + * + * This is `FirebaseAuthUI.contextualizeReauthenticationState` relocated off the singleton's + * setter. Provider implementations still publish only ordinary states and still need no + * parallel session storage of their own, but the mapping now reads the phase it owns instead + * of read-modify-writing the flow it is being written to. + */ + 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. An unstamped one is an + // ambient FirebaseAuth emission and leaves the phase alone. + is AuthState.Success -> + if (state.reauthenticatedUid != null) { + AuthState.Reauthentication.Succeeded(request, state) + } else { + current + } + + // Ambient emissions and notification cleanup while a request is armed. They 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 + * [com.firebase.ui.auth.ui.screens.FirebaseAuthScreen]. + * + * Called once, above the `NavDisplay`, alongside `rememberPhoneAuthFlowState` and + * `rememberMfaEnrollmentFlowState`, and composition-scoped like both: a phase that outlived its + * Activity would let the next screen re-arm from it, putting a reauthentication sheet into an + * unrelated sign-in. What survives recreation is the back stack's + * [com.firebase.ui.auth.ui.screens.AuthRoute.Reauth] marker and the armed + * [AuthState.Reauthentication.Required] itself, which is enough to arm again — and the request's + * resolver is what says whether the caller behind it is still there to resume. + */ +@Composable +internal fun rememberReauthFlowState(): ReauthFlowState = + remember { ReauthFlowState(mutableStateOf(null)) } 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/FirebaseAuthUIAuthStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt index ee856df8b..4b3f62204 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt @@ -29,6 +29,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 @@ -604,19 +605,20 @@ class FirebaseAuthUIAuthStateTest { val context = ApplicationProvider.getApplicationContext() - try { - authUI.delete(context) - } catch (_: AuthException.InvalidCredentialsException) { - // expected — existing contract preserved - } + val call = launch { authUI.delete(context) } + runCurrent() - assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.Reauthentication.Required::class.java) + assertThat(authUI.authStateFlow().first()) + .isInstanceOf(AuthState.Reauthentication.Required::class.java) val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required assertThat(state.user).isEqualTo(mockUser) + + state.request.resolve(false) + 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,136 +630,24 @@ class FirebaseAuthUIAuthStateTest { `when`(mockUser.delete()).thenReturn(tcs.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 - 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() } + assertThat(state.request.hasPendingOperation).isTrue() + assertThat(state.request.isResumable).isTrue() + // One path for this condition now: it arms and waits, where it used to arm *and* throw an + // InvalidCredentialsException the caller had to catch and ignore. + assertThat(call.isActive).isTrue() - val unchanged = authUI.authStateFlow().first() - assertThat(unchanged).isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) - assertThat((unchanged as AuthState.Reauthentication).requestId) - .isEqualTo(required.requestId) + state.request.resolve(false) + call.join() } /** - * 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) - } - - /** 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)) - - authUI.updateReauthentication(required.requestId) { it.attemptStarted() } - - assertThat(authUI.authStateFlow().first()) - .isInstanceOf(AuthState.Reauthentication.RetryingOperation::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 arm 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 { @@ -774,7 +664,7 @@ 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: an undrainable arming is replaced, not made permanent. */ @Test fun `an Idle write clears an undrainable armed request`() = runTest { `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") @@ -787,20 +677,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 +693,108 @@ 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 { + 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 assertThat(state.user).isEqualTo(mockFirebaseUser) - } - - @Test - fun `withReauth() forwards reason to Reauthentication Required state`() = runTest { - val context = ApplicationProvider.getApplicationContext() - `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) - - authUI.withReauth(context, reason = "Verify identity to change email") { - throw FirebaseAuthRecentLoginRequiredException("ERROR_REQUIRES_RECENT_LOGIN", "Recent login required") - } - - val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required 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 will run here rather than anywhere + // the library would have to hold on to it. + assertThat(call.isActive).isTrue() + + state.request.resolve(false) + call.join() } @Test - fun `withReauth() attaches retryOperation that re-invokes the original operation`() = 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) { - callCount++ - if (callCount == 1) 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 = authUI.authStateFlow().first() as AuthState.Reauthentication.Required - assertThat(state.retryOperation).isNotNull() - state.retryOperation!!(context) + assertThat(callCount).isEqualTo(1) + + state.request.resolve(true) + call.join() + assertThat(callCount).isEqualTo(2) } @Test - fun `withReauth() retryOperation restores auth state after successful retry`() = runTest { + fun `withReauth() leaves the operation alone when its request resolves without a retry`() = + runTest { + val context = ApplicationProvider.getApplicationContext() + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + var callCount = 0 + + val call = launch { + authUI.withReauth(context) { + callCount++ + if (callCount == 1) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + } + } + runCurrent() + val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required + + state.request.resolve(false) + call.join() + + assertThat(callCount).isEqualTo(1) + } + + /** + * 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 `a cancelled caller leaves its request unresumable`() = runTest { val context = ApplicationProvider.getApplicationContext() - `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) - authUI.addReauthenticationDrainer() 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) { + callCount++ + if (callCount == 1) throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + } } - + runCurrent() val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required + assertThat(state.request.isResumable).isTrue() - // 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() + call.cancel() + 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) - } - - @Test - fun `withReauth() does not throw when reauth is needed`() = runTest { - val context = ApplicationProvider.getApplicationContext() - `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) - - // Should complete without throwing - authUI.withReauth(context) { - throw FirebaseAuthRecentLoginRequiredException("ERROR_REQUIRES_RECENT_LOGIN", "Recent login required") - } + assertThat(state.request.isResumable).isFalse() + // Resolving a dead request is a no-op, not a crash, and runs nothing. + state.request.resolve(true) + assertThat(callCount).isEqualTo(1) } @Test @@ -938,7 +814,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 +832,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) + state.request.resolve(true) + 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..4d1df5d7e 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,18 @@ 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) - } + // Arms and waits for the reauthentication it needs, rather than throwing a mapped + // exception the caller had to catch and ignore before showing its own reauth UI. + val call = launch { instance.delete(context) } + runCurrent() + + val state = instance.authStateFlow().first() as AuthState.Reauthentication.Required + assertThat(state.user).isEqualTo(mockUser) + assertThat(state.request.hasPendingOperation).isTrue() + assertThat(call.isActive).isTrue() + + state.request.resolve(false) + 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..2336f5a0c --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt @@ -0,0 +1,64 @@ +/* + * 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 com.google.firebase.auth.FirebaseUser +import kotlinx.coroutines.CompletableDeferred +import java.util.UUID + +/** + * An armed 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 armedReauthRequest(user, reason, resolver) +} + +/** An armed request with a caller waiting on [resolver], for asserting the decision itself. */ +internal fun armedReauthRequest( + 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 armed 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 armedReauthRequest(user, resolver = resolver) +} 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..1f2c03862 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,7 @@ package com.firebase.ui.auth.ui.screens +import com.firebase.ui.auth.retryingReauth import android.content.Context import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedContentTransitionScope @@ -557,7 +558,7 @@ class FirebaseAuthScreenEmailRecoveryTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = {}) + retryingReauth(user) {} ) } composeTestRule.waitForIdle() 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..c47e4ecbb 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,8 @@ package com.firebase.ui.auth.ui.screens +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 @@ -308,7 +310,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) + retryingReauth(user) { retryRan = true } ) } composeTestRule.waitForIdle() @@ -361,7 +363,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) + retryingReauth(user) { retryRan = true } ) } composeTestRule.waitForIdle() @@ -476,7 +478,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) + retryingReauth(user) { retryRan = true } ) } composeTestRule.waitForIdle() @@ -542,7 +544,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) + retryingReauth(user) { retryRan = true } ) } composeTestRule.waitForIdle() @@ -589,7 +591,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + retryingReauth(user) { retryCount++ } ) } composeTestRule.waitForIdle() @@ -638,7 +640,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = {}) + retryingReauth(user) {} ) } composeTestRule.waitForIdle() @@ -742,7 +744,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) + retryingReauth(user) { retryRan = true } ) } composeTestRule.waitForIdle() @@ -788,13 +790,13 @@ 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") }) + retryingReauth(user) { ran.add("first") } ) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { ran.add("second") }) + retryingReauth(user) { ran.add("second") } ) } composeTestRule.waitForIdle() @@ -841,7 +843,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState(AuthState.Reauthentication.Required(user, retryOperation = null)) + authUI.updateAuthState(AuthState.Reauthentication.Required(user)) } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() @@ -892,7 +894,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.Reauthentication.Required(armedUser, retryOperation = { retryRan = true }) + retryingReauth(armedUser) { retryRan = true } ) } composeTestRule.waitForIdle() @@ -947,7 +949,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) + retryingReauth(user) { retryRan = true } ) } composeTestRule.waitForIdle() @@ -1013,7 +1015,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + retryingReauth(user) { retryCount++ } ) } composeTestRule.waitForIdle() @@ -1067,7 +1069,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + retryingReauth(user) { retryCount++ } ) } composeTestRule.waitForIdle() @@ -1123,7 +1125,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + retryingReauth(user) { retryCount++ } ) } composeTestRule.waitForIdle() @@ -1185,7 +1187,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + retryingReauth(user) { retryCount++ } ) } composeTestRule.waitForIdle() @@ -1244,7 +1246,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + retryingReauth(user) { retryCount++ } ) } composeTestRule.waitForIdle() @@ -1297,7 +1299,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) + retryingReauth(user) { retryRan = true } ) } composeTestRule.waitForIdle() @@ -1312,7 +1314,11 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.onNodeWithTag("dismiss_reauth").assertDoesNotExist() } - /** Rotating preserves the request-owned failure, including its typed exception. */ + /** + * The phase is composition-scoped, so rotating destroys it — but its value is still latched on + * the flow, and a surfaced failure is the only report the user got, so the restored screen + * re-arms from it rather than restarting them at provider selection with nothing said. + */ @Test fun `a latched slot error survives Activity recreation`() { val user = passwordOnlyUser("linked@example.com") @@ -1431,7 +1437,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + retryingReauth(user) { retryCount++ } ) } composeTestRule.waitForIdle() @@ -1454,7 +1460,12 @@ class FirebaseAuthScreenReauthContentStateTest { 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 re-arm the same request 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 user = passwordOnlyUser("linked@example.com") @@ -1477,7 +1488,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + retryingReauth(user) { retryCount++ } ) } composeTestRule.waitForIdle() @@ -1533,7 +1544,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { currentAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + retryingReauth(user) { retryCount++ } ) } composeTestRule.waitForIdle() @@ -1590,7 +1601,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + retryingReauth(user) { retryCount++ } ) } composeTestRule.waitForIdle() @@ -1657,19 +1668,17 @@ 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. + * 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 `recreation during the retry never runs the operation twice`() { + fun `the operation runs once however often its request is resolved`() { val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) val runs = AtomicInteger(0) - val hangForever = CompletableDeferred() val restorationTester = StateRestorationTester(composeTestRule) + val armed = retryingReauth(user) { runs.incrementAndGet() } restorationTester.setContent { FirebaseAuthScreen( @@ -1684,17 +1693,7 @@ class FirebaseAuthScreenReauthContentStateTest { ) } - composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required( - user, - retryOperation = { - runs.incrementAndGet() - hangForever.await() - }, - ) - ) - } + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(armed) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( @@ -1702,23 +1701,55 @@ class FirebaseAuthScreenReauthContentStateTest { ) } 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) + armed.request.resolve(true) + composeTestRule.waitForIdle() assertThat(runs.get()).isEqualTo(1) + } + + /** + * A recreation that outlived the caller: the request is still armed 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.updateAuthState(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) } /** @@ -1839,7 +1870,7 @@ class FirebaseAuthScreenReauthContentStateTest { 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( @@ -1866,10 +1897,9 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.waitForIdle() 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, without the challenge's own cancel or error path running: + // an ordinary Cancelled folds to provider selection, which is the phase leaving RequiresMfa. + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Cancelled) } composeTestRule.waitForIdle() composeTestRule.waitForIdle() @@ -1880,17 +1910,15 @@ 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 user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) - val holdRetry = CompletableDeferred() val runs = AtomicInteger(0) - var captured: ReauthContentState? = null composeTestRule.setContent { FirebaseAuthScreen( @@ -1899,25 +1927,18 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, - reauthContent = { state -> - captured = state + reauthContent = { Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required( - user, - retryOperation = { - runs.incrementAndGet() - holdRetry.await() - }, - ) - ) + signedInAuthUI.updateAuthState(retryingReauth(user) { runs.incrementAndGet() }) } composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) @@ -1926,23 +1947,14 @@ class FirebaseAuthScreenReauthContentStateTest { 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 user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) - val holdRetry = CompletableDeferred() val runs = AtomicInteger(0) composeTestRule.setContent { @@ -1960,15 +1972,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - AuthState.Reauthentication.Required( - user, - retryOperation = { - runs.incrementAndGet() - holdRetry.await() - }, - ) - ) + signedInAuthUI.updateAuthState(retryingReauth(user) { runs.incrementAndGet() }) } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_email").assertIsDisplayed() @@ -1981,13 +1985,7 @@ class FirebaseAuthScreenReauthContentStateTest { 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..6615666ba 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,7 @@ package com.firebase.ui.auth.ui.screens +import com.firebase.ui.auth.retryingReauth import androidx.compose.material3.Text import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @@ -203,18 +204,14 @@ class FirebaseAuthScreenReauthIdleResetTest { 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 - }, - ) + retryingReauth(mockUser) { + operationStarted = true + // Exactly what a successful delete() does: FirebaseAuth drops the user and + // notifies its listeners while the operation is running. + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + listeners.forEach { it.onAuthStateChanged(mockFirebaseAuth) } + operationCompleted = true + } ) } composeTestRule.waitForIdle() @@ -236,7 +233,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/email/EmailAuthHostDestinationsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthHostDestinationsTest.kt index c60bb05ee..9d06f7686 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,7 @@ package com.firebase.ui.auth.ui.screens.email +import com.firebase.ui.auth.ui.screens.reauth.rememberReauthFlowState import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -328,9 +329,10 @@ class EmailAuthHostDestinationsTest { requestId = "request-id", user = user, reason = null, - retryOperation = null, ) } + val reauthFlowState = rememberReauthFlowState() + SideEffect { reauthFlowState.arm(AuthState.Reauthentication.Required(request)) } val backStack = rememberNavBackStack( AuthRoute.Success, AuthRoute.Reauth("request-id", "uid", startStep), @@ -375,6 +377,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/phone/PhoneAuthHostDestinationsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthHostDestinationsTest.kt index 249ec1f6d..4351201a2 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,7 @@ package com.firebase.ui.auth.ui.screens.phone +import com.firebase.ui.auth.ui.screens.reauth.rememberReauthFlowState import android.content.Context import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.EnterTransition @@ -426,7 +427,6 @@ class PhoneAuthHostDestinationsTest { requestId = REQUEST_ID, user = user, reason = null, - retryOperation = null, ).also { reauthRequest = it } } val backStack = rememberNavBackStack( @@ -451,6 +451,8 @@ 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 { reauthFlowState.arm(AuthState.Reauthentication.Required(request)) } CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { NavDisplay( backStack = backStack, @@ -475,6 +477,7 @@ class PhoneAuthHostDestinationsTest { configuration = config, stringProvider = stringProvider, surface = surface, + reauthFlowState = reauthFlowState, phoneFlowState = phoneFlowState, emailContent = null, phoneContent = null, 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..e966b2257 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 @@ -572,13 +574,18 @@ class PhoneAuthScreenVerificationLifecycleTest { val credential = mock(PhoneAuthCredential::class.java) val observed = mutableListOf() + // Stands in for the composed FirebaseAuthScreen, which is what owns folding now: fold each + // ordinary provider state into the armed request and publish the phase this screen reads. + val required = AuthState.Reauthentication.Required(user) + val reauthFlowState = ReauthFlowState(mutableStateOf(null)) + reauthFlowState.arm(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..e71915b78 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/reauth/ReauthFlowStateTest.kt @@ -0,0 +1,284 @@ +/* + * 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.armed( + resolver: CompletableDeferred? = null, + ): AuthState.Reauthentication.Request { + val request = request(resolver) + arm(AuthState.Reauthentication.Required(request)) + return request + } + + /** + * The counter `addReauthenticationDrainer` kept is gone because this is the same question: + * with nothing armed there is no conversation for a provider state to belong to, so it stays + * the host's. + */ + @Test + fun `nothing is folded while nothing is armed`() { + 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.armed() + + 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.armed() + + 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.armed() + + 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.armed() + 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.armed() + 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.armed() + `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.armed() + + 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.armed() + + 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.armed() + + assertThat(holder.fold(AuthState.Reauthentication.Required(request))).isNull() + } + + @Test + fun `finish resolves the waiting caller with the retry decision`() { + val holder = holder() + val resolver = CompletableDeferred() + holder.armed(resolver) + + holder.finish(true) + + assertThat(holder.phase).isNull() + assertThat(resolver.isCompleted).isTrue() + assertThat(resolver.getCompleted()).isTrue() + } + + @Test + fun `finish without a retry unblocks the caller rather than abandoning it`() { + val holder = holder() + val resolver = CompletableDeferred() + holder.armed(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.armed() + + 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(true) + request.resolve(false) + + 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(true) + } + + /** 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.armed() + val cause = AuthException.UnknownException("nope") + + val folded = holder.fold(AuthState.Error(cause)) + + assertThat((folded as AuthState.Reauthentication.AttemptFailed).exception).isEqualTo(cause) + } + + /** 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.armed() + + 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..08d4c1411 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 @@ -256,6 +256,7 @@ class ReauthSurfaceGateTest { ) } val phoneFlowState = rememberPhoneAuthFlowState(configuration) + val reauthFlowState = rememberReauthFlowState() CompositionLocalProvider( LocalAuthUIStringProvider provides configuration.stringProvider, ) { @@ -275,6 +276,7 @@ class ReauthSurfaceGateTest { configuration = configuration, stringProvider = DefaultAuthUIStringProvider(context), surface = surface, + reauthFlowState = reauthFlowState, phoneFlowState = phoneFlowState, emailContent = null, phoneContent = null, From 276e821248d75f8a5ffd4c8e7bfe6fcaa9360ce1 Mon Sep 17 00:00:00 2001 From: demolaf Date: Thu, 3 Sep 2026 20:04:11 +0100 Subject: [PATCH 02/15] test(auth): record the ordered state sequence of each sign-in path --- .../auth_provider/SignInStateSequenceTest.kt | 399 ++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/SignInStateSequenceTest.kt 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..4d35d1155 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/SignInStateSequenceTest.kt @@ -0,0 +1,399 @@ +/* + * 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 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.signInAnonymously(config) } } + 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.signInAnonymously(config) } } + 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.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "a@b.com", + password = "pw1", + // The credential-manager save is unavailable under Robolectric and throws + // past this path's own handlers, which is its own bug and not this one's. + // Skipped here so the sequence recorded is the state machine's. + 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.signInWithEmailAndPassword( + context = applicationContext, + config = config, + 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.signInWithProvider( + applicationContext, + config = config, + 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.verifyPhoneNumber( + provider = phone, + activity = null, + phoneNumber = "+1234567890", + config = config, + verifier = verifier, + ) + runCurrent() + + // The verifier's flow is cold and already has its emission, so Loading and the prompt land + // in the same turn: conflation means a consumer sees only the prompt. + 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.signInWithPhoneAuthCredential( + context = applicationContext, + config = config, + credential = credential, + ) + } + } + runCurrent() + task.setResult(result) + runCurrent() + job.join() + awaitStates(states, 3) + + assertThat(states).containsExactly("Idle", "Loading", "Success").inOrder() + } +} From 5ebe5806a8a21ed49abde1ea2d81a99e23e97eb8 Mon Sep 17 00:00:00 2001 From: demolaf Date: Thu, 3 Sep 2026 20:23:57 +0100 Subject: [PATCH 03/15] refactor(auth)!: give provider code its own flow scope instead of the FirebaseAuthUI singleton --- .../com/firebase/ui/auth/AuthFlowScope.kt | 129 +++++++++++++++++ .../com/firebase/ui/auth/FirebaseAuthUI.kt | 50 +++---- .../AnonymousAuthProvider+FirebaseAuthUI.kt | 24 ++-- .../EmailAuthProvider+FirebaseAuthUI.kt | 136 ++++++++---------- .../FacebookAuthProvider+FirebaseAuthUI.kt | 50 +++---- .../GoogleAuthProvider+FirebaseAuthUI.kt | 43 +++--- .../OAuthProvider+FirebaseAuthUI.kt | 28 ++-- .../PhoneAuthProvider+FirebaseAuthUI.kt | 39 +++-- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 47 +++--- .../auth/ui/screens/email/EmailAuthScreen.kt | 19 ++- .../auth/ui/screens/phone/PhoneAuthScreen.kt | 16 +-- .../ui/screens/reauth/ReauthDestinations.kt | 22 ++- .../auth/ui/screens/reauth/ReauthFlowState.kt | 13 ++ .../ui/auth/AuthFlowScopeTestSupport.kt | 43 ++++++ ...AnonymousAuthProviderFirebaseAuthUITest.kt | 26 ++-- .../EmailAuthProviderFirebaseAuthUITest.kt | 130 ++++++----------- .../FacebookAuthProviderFirebaseAuthUI.kt | 16 +-- .../GoogleAuthProviderFirebaseAuthUITest.kt | 52 +++---- .../OAuthProviderFirebaseAuthUITest.kt | 31 ++-- .../PhoneAuthProviderFirebaseAuthUITest.kt | 31 ++-- .../auth_provider/SignInStateSequenceTest.kt | 35 ++--- .../ui/screens/reauth/ReauthFlowStateTest.kt | 50 +++++++ 22 files changed, 582 insertions(+), 448 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/AuthFlowScopeTestSupport.kt 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..7abf50af1 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt @@ -0,0 +1,129 @@ +/* + * 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.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 rather than against [FirebaseAuthUI], which is what makes + * "provider implementations do not write to the process-wide state channel" a rule the compiler + * holds instead of one a reviewer has to hold across ninety-odd hand edits: there is no way to + * reach `_authStateFlow` from here. Two sinks exist — the host's, which writes the public flow, and + * a reauthentication request's, which writes its own phase and nothing else. + * + * It also carries [config], which used to be an explicit parameter on nearly every provider + * function, so those signatures got shorter rather than longer. + * + * @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, + 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. + * + * Moved off [FirebaseAuthUI] with the rest of provider publishing. The decision itself is + * [authUserState], which the host also needs when it observes FirebaseAuth directly. + */ + 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. + * + * Top-level rather than a member of either [AuthFlowScope] or [FirebaseAuthUI], because both need + * it: provider code reaches it through [AuthFlowScope.emitResult], and [FirebaseAuthUI] calls it + * from the `callbackFlow` that observes FirebaseAuth directly, which has no flow and therefore no + * scope. Its body reads only its three parameters. + */ +internal fun authUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState = + 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) + } + +/** + * The auth flow the current composition belongs to, or null outside one. + * + * Ambient rather than a parameter because the sub-screens that need it — `EmailAuthScreen`, + * `PhoneAuthScreen` — are public composables, and "which conversation am I part of" is a property + * of where they are composed, not of what their caller knows to pass. `FirebaseAuthScreen` + * provides the host's flow; `reauthDestinations` provides the request's, so a credential exchange's + * states go to that request and are never seen by anything collecting the public flow. + */ +internal val LocalAuthFlowScope = staticCompositionLocalOf { null } + +/** + * The flow this composition belongs to: the ambient one when composed inside a flow that provides + * it, and otherwise a fresh one over the host's public state channel — 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 + return remember(ambient, authUI, configuration) { + ambient ?: hostAuthFlowScope(authUI, configuration) + } +} + +/** An [AuthFlowScope] whose states go to [authUI]'s public flow. */ +internal fun hostAuthFlowScope( + authUI: FirebaseAuthUI, + configuration: AuthUIConfiguration, +): AuthFlowScope = AuthFlowScope( + auth = authUI.auth, + config = configuration, + credentialManagerProvider = authUI.testCredentialManagerProvider, + loginManagerProvider = authUI.testLoginManagerProvider, + sink = { authUI.updateAuthState(it) }, +) 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 61768f503..5f168d6ca 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -307,7 +307,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 } @@ -378,21 +378,6 @@ class FirebaseAuthUI private constructor( _authStateFlow.value = 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. @@ -404,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)) } /** @@ -463,8 +433,20 @@ class FirebaseAuthUI private constructor( // Sign out from Firebase Auth auth.signOut() .also { - signOutFromGoogle(context) - signOutFromFacebook() + // These two publish nothing, so they take no sink and no configuration — + // signOut has none to give. The test seams are resolved here rather than + // inside them, which is the last thing either needed this receiver for. + 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) 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..ae1a09c83 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,11 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( val callbackManager = remember { CallbackManager.Factory.create() } val loginManager = LoginManager.getInstance() val currentContext by rememberUpdatedState(context) - val currentConfig by rememberUpdatedState(config) + // The receiver, through a snapshot, exactly where `config` used to be read this way: the + // callback below is registered once under `DisposableEffect(Unit)` — re-registering it on a + // recomposition would be the expensive mistake — so it must not close over the scope this + // composition happened to have. + val currentScope by rememberUpdatedState(this) val currentProvider by rememberUpdatedState(provider) val currentOnSignInFailure by rememberUpdatedState(onSignInFailure) @@ -86,32 +90,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 +127,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 +160,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 +206,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 +239,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/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index 574656d28..7646d9118 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 @@ -186,6 +189,12 @@ fun FirebaseAuthScreen( // the Activity and re-arm an unrelated sign-in. val reauthFlowState = rememberReauthFlowState() val reauthState = reauthFlowState.phase + // The host's own flow. Provider code reaches the public state channel only through this sink, + // which is the whole point of the receiver change: there is no `authUI` on a scope to reach it + // any other way. + val hostScope = remember(authUI, configuration) { + hostAuthFlowScope(authUI, configuration) + } /** * What the host may act on. While a request is armed, an ordinary state published by provider * code belongs to the credential exchange and the phase is what reports it — but `fold` runs @@ -320,10 +329,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)) @@ -360,7 +368,11 @@ fun FirebaseAuthScreen( CompositionLocalProvider( LocalAuthUIStringProvider provides configuration.stringProvider, LocalTopLevelDialogController provides dialogController, - LocalAuthUITheme provides (configuration.theme ?: LocalAuthUITheme.current) + LocalAuthUITheme provides (configuration.theme ?: LocalAuthUITheme.current), + // The host's flow, for every sub-screen composed below. `reauthDestinations` overrides it + // with the armed request's, which is what puts a credential exchange's states on the phase + // instead of on the public channel. + LocalAuthFlowScope provides hostScope, ) { Surface( modifier = modifier @@ -583,7 +595,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 = { @@ -609,17 +621,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 @@ -1247,10 +1257,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 = {}, @@ -1265,18 +1274,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/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt index 65a21995b..31eca2002 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 @@ -207,6 +208,9 @@ fun EmailAuthScreen( ) } + // The flow this screen belongs to: the host's when composed on its own, the armed + // request's when composed inside a reauthentication surface. + val authFlowScope = rememberAuthFlowScope(authUI, configuration) val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle) val isLoading = authState is AuthState.Loading || authState is AuthState.Reauthentication.Authenticating @@ -327,9 +331,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, @@ -345,17 +348,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, @@ -369,9 +370,8 @@ fun EmailAuthScreen( onSignUpClick = { coroutineScope.launch { try { - authUI.createOrLinkUserWithEmailAndPassword( + authFlowScope.createOrLinkUserWithEmailAndPassword( context = context, - config = configuration, provider = provider, name = displayNameValue.value, email = emailTextValue.value, @@ -386,9 +386,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/phone/PhoneAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt index e8e4c3895..2a54f992b 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 @@ -224,6 +225,9 @@ fun PhoneAuthScreen( } } + // The flow this screen belongs to: the host's when composed on its own, the armed + // request's when composed inside a reauthentication surface. + val authFlowScope = rememberAuthFlowScope(authUI, configuration) val currentAuthState = remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle) val authState by currentAuthState val isLoading = authState is AuthState.Loading || @@ -329,9 +333,8 @@ fun PhoneAuthScreen( // 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) { @@ -446,11 +449,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 @@ -469,9 +471,8 @@ fun PhoneAuthScreen( coroutineScope.launch { try { verificationId.value?.let { id -> - authUI.submitVerificationCode( + authFlowScope.submitVerificationCode( context = context, - config = configuration, verificationId = id, code = verificationCodeValue.value ) @@ -493,11 +494,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) { 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 6a356a6da..efd284a6b 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,10 @@ package com.firebase.ui.auth.ui.screens.reauth +import com.firebase.ui.auth.LocalAuthFlowScope +import com.firebase.ui.auth.AuthFlowScope +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 @@ -188,13 +192,26 @@ internal fun EntryProviderScope.reauthDestinations( ?.let { if (it is AuthException) it else AuthException.from(it, stringProvider) } val error = exception?.let { getRecoveryMessage(it, stringProvider) } - val onProviderSelected = authUI.rememberOnProviderSelected( + // This request's own flow. Everything the credential exchange publishes lands on the + // phase rather than on the public state channel, so an app collecting `authStateFlow()` + // never sees a Loading or an Error belonging to a conversation that is not theirs. + val reauthScope = remember(authUI, reauthConfig, reauthFlowState) { + AuthFlowScope( + auth = authUI.auth, + config = reauthConfig, + credentialManagerProvider = authUI.testCredentialManagerProvider, + loginManagerProvider = authUI.testLoginManagerProvider, + 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) { @@ -322,6 +339,7 @@ internal fun EntryProviderScope.reauthDestinations( 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 index 94ddf210f..030f079ba 100644 --- 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 @@ -20,6 +20,7 @@ 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 @@ -80,6 +81,18 @@ internal class ReauthFlowState internal constructor( phaseState.value = phase } + /** + * This request's own state sink, for the provider code driving its credential exchange. + * + * Everything the exchange publishes becomes a phase here rather than a state on the public + * flow, which is what stops an app's collector acting on a `Loading` or `Error` belonging to a + * conversation that is not theirs. [hostFallback] takes what [fold] declines: those states are + * not part of the exchange, so they are still the host's to handle. + */ + 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. 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..1a355e0cf --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/AuthFlowScopeTestSupport.kt @@ -0,0 +1,43 @@ +/* + * 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 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) + +/** 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, + sink = { recorded += it }, +) 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..2822f5d7d 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 ) @@ -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 index 4d35d1155..c4422bf25 100644 --- 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 @@ -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 @@ -180,7 +181,7 @@ class SignInStateSequenceTest { val states = record(instance) val config = configOf(AuthProvider.Anonymous, emailProvider()) - val job = launch { runCatching { instance.signInAnonymously(config) } } + val job = launch { runCatching { instance.flowScope(config).signInAnonymously() } } runCurrent() task.setResult(result) runCurrent() @@ -198,7 +199,7 @@ class SignInStateSequenceTest { val states = record(instance) val config = configOf(AuthProvider.Anonymous, emailProvider()) - val job = launch { runCatching { instance.signInAnonymously(config) } } + val job = launch { runCatching { instance.flowScope(config).signInAnonymously() } } runCurrent() task.setException(FirebaseNetworkException("Network error")) runCurrent() @@ -224,16 +225,14 @@ class SignInStateSequenceTest { val job = launch { runCatching { - instance.signInWithEmailAndPassword( + instance.flowScope(config).signInWithEmailAndPassword( context = applicationContext, - config = config, email = "a@b.com", password = "pw1", // The credential-manager save is unavailable under Robolectric and throws // past this path's own handlers, which is its own bug and not this one's. // Skipped here so the sequence recorded is the state machine's. - skipCredentialSave = true, - ) + skipCredentialSave = true) } } runCurrent() @@ -256,12 +255,10 @@ class SignInStateSequenceTest { val job = launch { runCatching { - instance.signInWithEmailAndPassword( + instance.flowScope(config).signInWithEmailAndPassword( context = applicationContext, - config = config, email = "a@b.com", - password = "wrong", - ) + password = "wrong") } } runCurrent() @@ -298,12 +295,10 @@ class SignInStateSequenceTest { val job = launch { runCatching { - instance.signInWithProvider( + instance.flowScope(config).signInWithProvider( applicationContext, - config = config, activity = activity, - provider = github, - ) + provider = github) } } runCurrent() @@ -349,13 +344,11 @@ class SignInStateSequenceTest { val states = record(instance) val config = configOf(phone) - instance.verifyPhoneNumber( + instance.flowScope(config).verifyPhoneNumber( provider = phone, activity = null, phoneNumber = "+1234567890", - config = config, - verifier = verifier, - ) + verifier = verifier) runCurrent() // The verifier's flow is cold and already has its emission, so Loading and the prompt land @@ -381,11 +374,9 @@ class SignInStateSequenceTest { val job = launch { runCatching { - instance.signInWithPhoneAuthCredential( + instance.flowScope(config).signInWithPhoneAuthCredential( context = applicationContext, - config = config, - credential = credential, - ) + credential = credential) } } runCurrent() 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 index e71915b78..24a2727ee 100644 --- 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 @@ -269,6 +269,56 @@ class ReauthFlowStateTest { 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.armed() + 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.armed() + 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 nothing armed there is no exchange to absorb into, so everything is the host's. */ + @Test + fun `the sink forwards everything while nothing is armed`() { + 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`() { From 4dedd10587743004b13a58ddbdfade77df682406 Mon Sep 17 00:00:00 2001 From: demolaf Date: Thu, 3 Sep 2026 20:27:22 +0100 Subject: [PATCH 04/15] refactor(auth): read the arming guards off the back stack rather than a stale composition value --- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 12 ++++-- ...irebaseAuthScreenReauthContentStateTest.kt | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) 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 7646d9118..7c45e60c2 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 @@ -647,7 +647,12 @@ 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 + // The stack itself, not the composition value derived from it: this effect is + // what writes the stack, so anything derived in composition describes the frame + // before. Recomposition happens to land between runs today, which is why the + // composition value also worked — but the guards below are about what is on the + // stack now, so they read it now. + val savedPresentation = backStack.armedReauth() // A marker that outlived its phase: the Activity was recreated with the request // still armed. Nothing here can be driven, so report it and clear up. @@ -769,7 +774,7 @@ fun FirebaseAuthScreen( return@LaunchedEffect } reauthFlowState.arm(state) - if (armedReauth?.requestId != state.requestId) { + if (backStack.armedReauth()?.requestId != state.requestId) { backStack.clearReauth() backStack.add( AuthRoute.Reauth( @@ -785,7 +790,8 @@ fun FirebaseAuthScreen( } is AuthState.Reauthentication -> { - val marker = armedReauth?.takeIf { it.requestId == state.requestId } + val marker = backStack.armedReauth() + ?.takeIf { it.requestId == state.requestId } ?: AuthRoute.Reauth( requestId = state.requestId, userUid = state.userUid, 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 c47e4ecbb..a7ef8e1fe 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 @@ -1667,6 +1667,45 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() } + /** + * One request, one surface, however many times its arming state comes round again — an attempt + * and a back-out both re-enter the arming branch for the same request id. + */ + @Test + fun `re-arming the same request does not stack a second surface`() { + 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.updateAuthState(retryingReauth(user) {}) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + + // An attempt, then the user backing out of it: the phase returns to provider selection for + // the same request, which re-enters the arming 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 From 097cc2317e659853b2dd447f0523ffb2f0388c2b Mon Sep 17 00:00:00 2001 From: demolaf Date: Thu, 3 Sep 2026 22:10:54 +0100 Subject: [PATCH 05/15] refactor(auth): let a flow's screens read their state from the flow they belong to --- .../com/firebase/ui/auth/AuthFlowScope.kt | 21 ++++++- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 27 +++++---- .../auth/ui/screens/email/EmailAuthScreen.kt | 13 +++-- .../auth/ui/screens/phone/PhoneAuthScreen.kt | 18 +++--- .../ui/screens/reauth/ReauthDestinations.kt | 9 ++- .../ui/auth/AuthFlowScopeTestSupport.kt | 4 +- .../email/EmailAuthHostDestinationsTest.kt | 57 ++++++++++++++++++- .../phone/PhoneAuthHostDestinationsTest.kt | 15 ++++- 8 files changed, 131 insertions(+), 33 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt b/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt index 7abf50af1..227e896a6 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt @@ -15,6 +15,8 @@ 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 @@ -47,6 +49,15 @@ internal class AuthFlowScope( 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. + * + * The read side of [sink], and the reason a reauthentication phase no longer has to be + * published to the public channel for the sub-screens to see it: under a request's scope this + * *is* the phase, so `EmailAuthScreen` and `PhoneAuthScreen` read their spinner and their + * inline error from the conversation they are actually part of. + */ + val state: State, private val sink: AuthStateSink, ) { fun emit(state: AuthState) = sink.emit(state) @@ -111,19 +122,23 @@ internal fun rememberAuthFlowScope( configuration: AuthUIConfiguration, ): AuthFlowScope { val ambient = LocalAuthFlowScope.current - return remember(ambient, authUI, configuration) { - ambient ?: hostAuthFlowScope(authUI, configuration) + val hostState = remember(authUI) { authUI.authStateFlow() } + .collectAsState(AuthState.Idle) + return remember(ambient, authUI, configuration, hostState) { + ambient ?: hostAuthFlowScope(authUI, configuration, hostState) } } -/** An [AuthFlowScope] whose states go to [authUI]'s public flow. */ +/** 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/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index 7c45e60c2..83c45de14 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 @@ -192,16 +192,19 @@ fun FirebaseAuthScreen( // The host's own flow. Provider code reaches the public state channel only through this sink, // which is the whole point of the receiver change: there is no `authUI` on a scope to reach it // any other way. - val hostScope = remember(authUI, configuration) { - hostAuthFlowScope(authUI, configuration) + val hostStateHolder = rememberUpdatedState(rawAuthState) + val hostScope = remember(authUI, configuration, hostStateHolder) { + hostAuthFlowScope(authUI, configuration, hostStateHolder) } /** - * What the host may act on. While a request is armed, an ordinary state published by provider - * code belongs to the credential exchange and the phase is what reports it — but `fold` runs - * in an effect, so the raw state is on the flow for a frame first. Without this the host's own - * dialogs act on it in between, putting a sign-in error dialog, retry action and all, over the - * reauthentication sheet. The effects below read `observedAuthState` directly, so the states - * `fold` declines still reach them. + * What the host may act on. While a request is armed, an ordinary state arriving on the public + * flow — from `withReauth`, or from an app writing it directly — belongs to the credential + * exchange, and the phase is what reports it. `fold` runs in an effect, so the raw state is on + * the flow for a frame first; without this the host's own dialogs act on it in between, putting + * a sign-in error dialog, retry action and all, over the reauthentication sheet. + * + * Provider code under the request's own scope never comes through here at all — that is what + * [AuthFlowScope] fixed. This covers the writers that still reach the public flow directly. */ val authState = reauthState?.takeIf { rawAuthState !is AuthState.Reauthentication } ?: rawAuthState @@ -887,13 +890,13 @@ fun FirebaseAuthScreen( * The phase's own effect. The effect above is keyed on the flow, so it never sees a * transition the destinations make straight on the holder — an MFA proof, a cancelled * attempt, a consumed notification. Keying on the phase catches all of them. - * - * Mirroring the phase onto the flow keeps one story for the sub-screens and for app - * code: provider screens read their loading and error state from there, and a phase - * that only ever existed in this holder would show them neither. */ LaunchedEffect(reauthFlowState.phase) { val phase = reauthFlowState.phase ?: return@LaunchedEffect + // The phase still goes on the public flow. The screens under the request's own + // scope no longer need it there, but the host's `fold` path and the flow-driven + // navigation both do, so this stays until arming itself stops going through the + // flow. It is what an app is expected to ignore: an `AuthState.Reauthentication`. if (observedAuthState != phase) authUI.updateAuthState(phase) if (phase is AuthState.Reauthentication.Succeeded) { 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 31eca2002..cf5409cdc 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 @@ -211,7 +211,10 @@ fun EmailAuthScreen( // The flow this screen belongs to: the host's when composed on its own, the armed // request's when composed inside a reauthentication surface. val authFlowScope = rememberAuthFlowScope(authUI, configuration) - val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle) + // This flow's state, not the process-wide channel's: under a reauthentication request + // that is the request's own phase, so the loading and error below describe the + // conversation this screen is actually part of. + val authState by authFlowScope.state val isLoading = authState is AuthState.Loading || authState is AuthState.Reauthentication.Authenticating val authCredentialForLinking = remember { credentialForLinking } @@ -273,22 +276,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 - onNotificationConsumed?.invoke() ?: authUI.updateAuthState(AuthState.Idle) + onNotificationConsumed?.invoke() ?: authFlowScope.emit(AuthState.Idle) } is AuthState.EmailSignInLinkSent -> { emailSignInLinkSentLocal = true - onNotificationConsumed?.invoke() ?: authUI.updateAuthState(AuthState.Idle) + onNotificationConsumed?.invoke() ?: authFlowScope.emit(AuthState.Idle) } else -> Unit 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 2a54f992b..675fdc67c 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 @@ -228,7 +228,9 @@ fun PhoneAuthScreen( // The flow this screen belongs to: the host's when composed on its own, the armed // request's when composed inside a reauthentication surface. val authFlowScope = rememberAuthFlowScope(authUI, configuration) - val currentAuthState = remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle) + // This flow's state, not the process-wide channel's: under a reauthentication request + // that is the request's own phase. + val currentAuthState = authFlowScope.state val authState by currentAuthState val isLoading = authState is AuthState.Loading || authState is AuthState.Reauthentication.Authenticating @@ -243,7 +245,7 @@ fun PhoneAuthScreen( DisposableEffect(authUI) { onDispose { if (currentAuthState.value is AuthState.Loading) { - authUI.updateAuthState(AuthState.Idle) + authFlowScope.emit(AuthState.Idle) } } } @@ -321,14 +323,14 @@ 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. - onAttemptStarted?.invoke() ?: 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 { @@ -374,13 +376,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 -> { @@ -428,7 +430,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 " + @@ -511,7 +513,7 @@ fun PhoneAuthScreen( 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. - onNotificationConsumed?.invoke() ?: authUI.updateAuthState(AuthState.Idle) + 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 efd284a6b..f7fc2ac75 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 @@ -16,6 +16,7 @@ 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 @@ -195,12 +196,18 @@ internal fun EntryProviderScope.reauthDestinations( // This request's own flow. Everything the credential exchange publishes lands on the // phase rather than on the public state channel, so an app collecting `authStateFlow()` // never sees a Loading or an Error belonging to a conversation that is not theirs. - val reauthScope = remember(authUI, reauthConfig, reauthFlowState) { + // The phase is both what this scope publishes into and what the screens under it render, + // so the request's conversation never has to travel the public channel to be seen. + 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) }), ) } diff --git a/auth/src/test/java/com/firebase/ui/auth/AuthFlowScopeTestSupport.kt b/auth/src/test/java/com/firebase/ui/auth/AuthFlowScopeTestSupport.kt index 1a355e0cf..b3a25fcb5 100644 --- a/auth/src/test/java/com/firebase/ui/auth/AuthFlowScopeTestSupport.kt +++ b/auth/src/test/java/com/firebase/ui/auth/AuthFlowScopeTestSupport.kt @@ -14,6 +14,7 @@ package com.firebase.ui.auth +import androidx.compose.runtime.mutableStateOf import com.firebase.ui.auth.configuration.AuthUIConfiguration /** @@ -28,7 +29,7 @@ import com.firebase.ui.auth.configuration.AuthUIConfiguration * 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) + hostAuthFlowScope(this, config, mutableStateOf(AuthState.Idle)) /** A scope whose states are collected in [recorded] and go nowhere else. */ internal fun FirebaseAuthUI.recordingScope( @@ -39,5 +40,6 @@ internal fun FirebaseAuthUI.recordingScope( config = config, credentialManagerProvider = testCredentialManagerProvider, loginManagerProvider = testLoginManagerProvider, + state = mutableStateOf(AuthState.Idle), sink = { recorded += it }, ) 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 9d06f7686..5833da8f4 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,12 @@ package com.firebase.ui.auth.ui.screens.email +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 @@ -89,6 +95,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 @@ -107,7 +114,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) } @@ -218,6 +225,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 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 4351201a2..216b36f8a 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,7 @@ package com.firebase.ui.auth.ui.screens.phone +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 @@ -112,6 +113,13 @@ class PhoneAuthHostDestinationsTest { /** The reauthentication harness's own stack, for the assertions that are about keys. */ private var reauthBackStack: NavBackStack? = null + /** + * 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 armed, which its own emissions have to carry. */ private var reauthRequest: AuthState.Reauthentication.Request? = null @@ -452,7 +460,10 @@ class PhoneAuthHostDestinationsTest { // Above the display, like the host: a step switch disposes whatever the step it left held. val phoneFlowState = rememberPhoneAuthFlowState(config) val reauthFlowState = rememberReauthFlowState() - SideEffect { reauthFlowState.arm(AuthState.Reauthentication.Required(request)) } + SideEffect { + reauthHolder = reauthFlowState + reauthFlowState.arm(AuthState.Reauthentication.Required(request)) + } CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { NavDisplay( backStack = backStack, @@ -501,7 +512,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, From 5fca2ef9532979e290d278c1312046e0ffcbc9d4 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:18:19 +0100 Subject: [PATCH 06/15] feat(auth)!: report a declined reauthentication to the caller instead of returning quietly --- .../demo/auth/HighLevelApiDemoActivity.kt | 7 ++ .../java/com/firebase/ui/auth/AuthState.kt | 25 +++++-- .../com/firebase/ui/auth/FirebaseAuthUI.kt | 22 +++++-- .../auth/ui/screens/reauth/ReauthFlowState.kt | 2 +- .../ui/auth/FirebaseAuthUIAuthStateTest.kt | 66 ++++++++++++------- .../firebase/ui/auth/FirebaseAuthUITest.kt | 4 +- ...irebaseAuthScreenReauthContentStateTest.kt | 2 +- .../ui/screens/reauth/ReauthFlowStateTest.kt | 13 ++-- 8 files changed, 100 insertions(+), 41 deletions(-) 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 a6483ed15..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 @@ -339,6 +339,9 @@ private fun AppAuthenticatedContent( // 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.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 { @@ -574,6 +577,10 @@ private fun ChangePasswordDialog( // 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/AuthState.kt b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt index bcc07ef9a..0d00820ea 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt @@ -277,7 +277,7 @@ abstract class AuthState private constructor() { /** * Where the caller awaiting this request is parked, or null when nobody is: a * standalone flow from [FirebaseAuthUI.createReauthFlow] has no operation behind it. - * Resolving it runs the retry in the caller's own coroutine, which is why nothing + * Completing it runs the retry in the caller's own coroutine, which is why nothing * retains the caller's closure here. */ val resolver: CompletableDeferred? = null, @@ -293,11 +293,26 @@ abstract class AuthState private constructor() { val isResumable: Boolean get() = resolver?.isActive != false /** - * Hands the outcome to the awaiting caller, if any. Idempotent, and a no-op once the - * caller is gone, so every terminal path can resolve without checking first. + * Credentials were accepted: the awaiting caller resumes and retries its operation. + * Idempotent, and a no-op once the caller is gone, so every terminal path can call it + * without checking first. */ - fun resolve(retryOperation: Boolean) { - resolver?.complete(retryOperation) + fun resolve() { + resolver?.complete(true) + } + + /** + * The request ended without proof — the user backed out, or the surface was torn down. + * + * Completed with a value rather than an exception on purpose. This resolver is + * parented to the caller's job so that a dead caller is detectable, and completing a + * parented Deferred *exceptionally* propagates the failure to that parent — declining + * would cancel the caller's whole scope and take its sibling jobs with it. + * [FirebaseAuthUI.withReauth] turns this into a throw in its own frame instead, which + * is an ordinary exception the caller can catch. + */ + fun decline() { + resolver?.complete(false) } } 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 5f168d6ca..245ede094 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -480,9 +480,9 @@ class FirebaseAuthUI private constructor( * and presents a reauthentication sheet; once credentials are accepted the [operation] runs * again on this same coroutine, so nothing about the caller is retained by the library. * - * If the user backs out, or this coroutine's scope is cancelled while the sheet is up, the - * operation is not retried. A caller that must survive Activity recreation should launch from - * a scope that does too. + * If the user backs out, this throws [AuthException.AuthCancelledException] and the operation + * is not retried — so a caller can always tell a decline from a completed operation. A caller + * that must survive Activity recreation should launch from a scope that does too. * * All other exceptions propagate normally. * @@ -498,6 +498,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( @@ -524,7 +525,15 @@ class FirebaseAuthUI private constructor( ) ) ) - if (resolver.await()) operation() + // Thrown from this frame rather than out of the resolver: the resolver is parented to + // the caller's job, so failing it would cancel the caller's whole scope instead of + // just this call. A caller always learns whether its operation ran. + if (!resolver.await()) { + throw AuthException.AuthCancelledException( + message = "Reauthentication was cancelled" + ) + } + operation() } } @@ -554,6 +563,11 @@ class FirebaseAuthUI private constructor( // The user is deleted and therefore signed out. updateAuthState(AuthState.Idle) } + } catch (e: AuthException.AuthCancelledException) { + // The user declined the reauthentication. The screen already published the terminal + // state for that, so republishing it as an Error would put a dialog over a flow the + // user deliberately left. + throw e } catch (e: CancellationException) { // Handle coroutine cancellation val cancelledException = AuthException.AuthCancelledException( 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 index 030f079ba..23253ee43 100644 --- 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 @@ -58,7 +58,7 @@ internal class ReauthFlowState internal constructor( fun finish(retryOperation: Boolean) { val request = phaseState.value?.request phaseState.value = null - request?.resolve(retryOperation) + if (retryOperation) request?.resolve() else request?.decline() } /** 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 4b3f62204..b70b6dc3b 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt @@ -605,7 +605,7 @@ class FirebaseAuthUIAuthStateTest { val context = ApplicationProvider.getApplicationContext() - val call = launch { authUI.delete(context) } + val call = launch { runCatching { authUI.delete(context) } } runCurrent() assertThat(authUI.authStateFlow().first()) @@ -613,7 +613,7 @@ class FirebaseAuthUIAuthStateTest { val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required assertThat(state.user).isEqualTo(mockUser) - state.request.resolve(false) + state.request.decline() call.join() } @@ -630,7 +630,10 @@ class FirebaseAuthUIAuthStateTest { `when`(mockUser.delete()).thenReturn(tcs.task) val context = ApplicationProvider.getApplicationContext() - val call = launch { authUI.delete(context) } + var thrown: Exception? = null + val call = launch { + try { authUI.delete(context) } catch (e: Exception) { thrown = e } + } runCurrent() val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required @@ -640,8 +643,11 @@ class FirebaseAuthUIAuthStateTest { // InvalidCredentialsException the caller had to catch and ignore. assertThat(call.isActive).isTrue() - state.request.resolve(false) + state.request.decline() call.join() + + // Declining is reported, so the caller knows the account was not deleted. + assertThat(thrown).isInstanceOf(AuthException.AuthCancelledException::class.java) } /** @@ -698,10 +704,12 @@ class FirebaseAuthUIAuthStateTest { `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) val call = launch { - authUI.withReauth(context, reason = "Verify identity to change email") { - throw FirebaseAuthRecentLoginRequiredException( - "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" - ) + runCatching { + authUI.withReauth(context, reason = "Verify identity to change email") { + throw FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + } } } runCurrent() @@ -714,7 +722,7 @@ class FirebaseAuthUIAuthStateTest { // the library would have to hold on to it. assertThat(call.isActive).isTrue() - state.request.resolve(false) + state.request.decline() call.join() } @@ -736,35 +744,45 @@ class FirebaseAuthUIAuthStateTest { val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required assertThat(callCount).isEqualTo(1) - state.request.resolve(true) + state.request.resolve() call.join() assertThat(callCount).isEqualTo(2) } + /** + * 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() leaves the operation alone when its request resolves without a retry`() = - runTest { - val context = ApplicationProvider.getApplicationContext() - `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) - var callCount = 0 + 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 { + 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 = authUI.authStateFlow().first() as AuthState.Reauthentication.Required + } + runCurrent() + val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required - state.request.resolve(false) - call.join() + state.request.decline() + call.join() - assertThat(callCount).isEqualTo(1) - } + 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 @@ -793,7 +811,7 @@ class FirebaseAuthUIAuthStateTest { assertThat(state.request.isResumable).isFalse() // Resolving a dead request is a no-op, not a crash, and runs nothing. - state.request.resolve(true) + state.request.resolve() assertThat(callCount).isEqualTo(1) } @@ -836,7 +854,7 @@ class FirebaseAuthUIAuthStateTest { runCurrent() val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required - state.request.resolve(true) + 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 4d1df5d7e..910371a83 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt @@ -646,7 +646,7 @@ class FirebaseAuthUITest { // Arms and waits for the reauthentication it needs, rather than throwing a mapped // exception the caller had to catch and ignore before showing its own reauth UI. - val call = launch { instance.delete(context) } + val call = launch { runCatching { instance.delete(context) } } runCurrent() val state = instance.authStateFlow().first() as AuthState.Reauthentication.Required @@ -654,7 +654,7 @@ class FirebaseAuthUITest { assertThat(state.request.hasPendingOperation).isTrue() assertThat(call.isActive).isTrue() - state.request.resolve(false) + state.request.decline() call.join() } 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 a7ef8e1fe..6a7b5b643 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 @@ -1750,7 +1750,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.waitForIdle() assertThat(runs.get()).isEqualTo(1) - armed.request.resolve(true) + armed.request.resolve() composeTestRule.waitForIdle() assertThat(runs.get()).isEqualTo(1) } 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 index 24a2727ee..1b56ea701 100644 --- 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 @@ -204,8 +204,13 @@ class ReauthFlowStateTest { 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 unblocks the caller rather than abandoning it`() { + fun `finish without a retry fails the caller rather than returning quietly`() { val holder = holder() val resolver = CompletableDeferred() holder.armed(resolver) @@ -232,8 +237,8 @@ class ReauthFlowStateTest { val resolver = CompletableDeferred() val request = request(resolver) - request.resolve(true) - request.resolve(false) + request.resolve() + request.decline() assertThat(resolver.getCompleted()).isTrue() } @@ -248,7 +253,7 @@ class ReauthFlowStateTest { assertThat(request.isResumable).isFalse() // No crash, and nothing to hand back to. - request.resolve(true) + request.resolve() } /** No caller means nothing to lose, so a standalone request is always presentable. */ From 7a9869be5685c978ac57acf4d5f45241ea7593c7 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:22:38 +0100 Subject: [PATCH 07/15] refactor(auth): name what happens to a reauthentication request instead of calling it arming --- .../java/com/firebase/ui/auth/AuthState.kt | 2 +- .../com/firebase/ui/auth/FirebaseAuthUI.kt | 6 +- .../firebase/ui/auth/ui/screens/AuthRoute.kt | 2 +- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 46 ++++++------- .../ui/screens/email/EmailAuthDestinations.kt | 2 +- .../auth/ui/screens/email/EmailAuthScreen.kt | 2 +- .../ui/auth/ui/screens/email/SignInUI.kt | 2 +- .../auth/ui/screens/phone/PhoneAuthScreen.kt | 6 +- .../ui/screens/reauth/ReauthDestinations.kt | 8 +-- .../auth/ui/screens/reauth/ReauthFlowState.kt | 20 +++--- .../ui/screens/reauth/ReauthSceneStrategy.kt | 8 +-- .../ui/auth/FirebaseAuthUIAuthStateTest.kt | 15 ++-- .../firebase/ui/auth/ReauthTestRequests.kt | 12 ++-- .../EmailAuthProviderFirebaseAuthUITest.kt | 2 +- .../FirebaseAuthScreenEmailRecoveryTest.kt | 6 +- ...irebaseAuthScreenReauthContentStateTest.kt | 68 +++++++++---------- .../email/EmailAuthHostDestinationsTest.kt | 2 +- .../EmailAuthScreenReauthEmailLockTest.kt | 4 +- .../ui/auth/ui/screens/email/SignInUITest.kt | 4 +- .../phone/PhoneAuthHostDestinationsTest.kt | 4 +- ...honeAuthScreenVerificationLifecycleTest.kt | 6 +- .../ui/screens/reauth/ReauthFlowStateTest.kt | 44 ++++++------ .../screens/reauth/ReauthSurfaceGateTest.kt | 44 ++++++------ 23 files changed, 158 insertions(+), 157 deletions(-) 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 0d00820ea..e0bfbf1ba 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt @@ -363,7 +363,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, 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 245ede094..31b6f2621 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -248,7 +248,7 @@ class FirebaseAuthUI private constructor( message = "No user is currently signed in" ) // One definition of what a reauthentication configuration is, shared with the screen's - // own arming path: a linked credential is not a proof of identity, so neither enables + // own path for raising one: a linked credential is not a proof of identity, so neither enables // linking or upgrade. val reauthConfig = configuration.toReauthConfiguration(currentUser) checkNotNull(reauthConfig) { @@ -331,7 +331,7 @@ class FirebaseAuthUI private constructor( -> true // Nothing to protect here any more: the retry runs in the caller's own // coroutine, after the screen has already ended the request. A signed-out - // user cannot reauthenticate, so an armed request is stale by definition. + // user cannot reauthenticate, so an outstanding request is stale by definition. is AuthState.Reauthentication -> true else -> false } @@ -549,7 +549,7 @@ class FirebaseAuthUI private constructor( */ suspend fun delete(context: Context) { try { - // The whole reauthentication dance is withReauth's: arm once, retry once, and no + // The whole reauthentication dance is withReauth's: raise once, retry once, and no // branch here that both emits Required and throws for the same condition. withReauth(context) { val currentUser = auth.currentUser 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 83c45de14..36c913b45 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 @@ -107,7 +107,7 @@ 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 @@ -145,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]. @@ -185,8 +185,8 @@ fun FirebaseAuthScreen( .collectAsState(initial = null as AuthState?) val rawAuthState = observedAuthState ?: AuthState.Idle // Composition-scoped, so its existence *is* the answer to "is there a screen able to drive an - // armed request to completion?" — no counter on the singleton, and a phase that cannot outlive - // the Activity and re-arm an unrelated sign-in. + // outstanding request to completion?" — no counter on the singleton, and a phase that cannot outlive + // the Activity and be accepted into an unrelated sign-in. val reauthFlowState = rememberReauthFlowState() val reauthState = reauthFlowState.phase // The host's own flow. Provider code reaches the public state channel only through this sink, @@ -197,7 +197,7 @@ fun FirebaseAuthScreen( hostAuthFlowScope(authUI, configuration, hostStateHolder) } /** - * What the host may act on. While a request is armed, an ordinary state arriving on the public + * What the host may act on. While a request is outstanding, an ordinary state arriving on the public * flow — from `withReauth`, or from an app writing it directly — belongs to the credential * exchange, and the phase is what reports it. `fold` runs in an effect, so the raw state is on * the flow for a frame first; without this the host's own dialogs act on it in between, putting @@ -252,11 +252,11 @@ 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 armed request: clears its presentation, clears the phase, publishes [terminal], and + * Ends the outstanding request: clears its presentation, clears the phase, publishes [terminal], and * only then resolves the caller waiting on it. * * One helper because every terminal site does the same four things in the same order, and the @@ -373,7 +373,7 @@ fun FirebaseAuthScreen( LocalTopLevelDialogController provides dialogController, LocalAuthUITheme provides (configuration.theme ?: LocalAuthUITheme.current), // The host's flow, for every sub-screen composed below. `reauthDestinations` overrides it - // with the armed request's, which is what puts a credential exchange's states on the phase + // with the outstanding request's, which is what puts a credential exchange's states on the phase // instead of on the public channel. LocalAuthFlowScope provides hostScope, ) { @@ -655,10 +655,10 @@ fun FirebaseAuthScreen( // before. Recomposition happens to land between runs today, which is why the // composition value also worked — but the guards below are about what is on the // stack now, so they read it now. - val savedPresentation = backStack.armedReauth() + val savedPresentation = backStack.presentedReauth() // A marker that outlived its phase: the Activity was recreated with the request - // still armed. Nothing here can be driven, so report it and clear up. + // still outstanding. Nothing here can be driven, so report it and clear up. if (savedPresentation != null && reauthFlowState.phase == null && state !is AuthState.Reauthentication && @@ -676,8 +676,8 @@ fun FirebaseAuthScreen( } // A latched reauthentication state with no phase: this screen was recreated while - // the request was armed. The phase is composition-scoped and gone, but its value - // is still on the flow, so the exchange is re-armed from it rather than abandoned. + // the request was outstanding. The phase is composition-scoped and gone, but its value + // is still on the flow, so the exchange is accepted again from it rather than abandoned. if (state is AuthState.Reauthentication && reauthFlowState.phase == null) { val request = state.request if (request == null || !request.isResumable) { @@ -703,13 +703,13 @@ fun FirebaseAuthScreen( AuthState.Reauthentication.Required(request) ) - is AuthState.Reauthentication.Required -> reauthFlowState.arm(state) + is AuthState.Reauthentication.Required -> reauthFlowState.accept(state) else -> reauthFlowState.moveTo(state) } } - // Ordinary states published by provider code while a request is armed belong to + // Ordinary states published by provider code while a request is outstanding belong to // the credential exchange, not to the host flow. The holder folds them into its // phase and publishes that, which is what the setter used to do on the singleton's // behalf; the branches below then only ever see states that are the host's. @@ -722,7 +722,7 @@ fun FirebaseAuthScreen( // 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.presentedReauth()?.step is AuthRoute.MfaChallenge ) { backStack.returnToReauthStart() } @@ -776,14 +776,14 @@ fun FirebaseAuthScreen( ) return@LaunchedEffect } - reauthFlowState.arm(state) - if (backStack.armedReauth()?.requestId != state.requestId) { + reauthFlowState.accept(state) + if (backStack.presentedReauth()?.requestId != state.requestId) { backStack.clearReauth() backStack.add( AuthRoute.Reauth( requestId = state.requestId, userUid = state.userUid, - // From the arming state, not the composition value: this + // From the request itself, not the composition value: this // effect is what writes the phase, so anything derived from // it in composition is still a frame behind here. step = reauthStartStepFor(armingConfig), @@ -793,7 +793,7 @@ fun FirebaseAuthScreen( } is AuthState.Reauthentication -> { - val marker = backStack.armedReauth() + val marker = backStack.presentedReauth() ?.takeIf { it.requestId == state.requestId } ?: AuthRoute.Reauth( requestId = state.requestId, @@ -850,7 +850,7 @@ fun FirebaseAuthScreen( // Outside the host guard on purpose. `fold` declines Aborted, so nothing // else clears the phase or resolves the caller — and under the activity // host FirebaseAuthActivity owns the rest of the teardown, so a clear - // placed inside the guard would leave that host holding an armed request + // placed inside the guard would leave that host holding an outstanding request // and a caller suspended forever. An activity-scoped caller has its own // cancellation to fall back on, an unscoped one has nothing, and this // cannot tell them apart, so it resolves unconditionally. @@ -895,7 +895,7 @@ fun FirebaseAuthScreen( val phase = reauthFlowState.phase ?: return@LaunchedEffect // The phase still goes on the public flow. The screens under the request's own // scope no longer need it there, but the host's `fold` path and the flow-driven - // navigation both do, so this stays until arming itself stops going through the + // navigation both do, so this stays until requests stop being raised through the // flow. It is what an app is expected to ignore: an `AuthState.Reauthentication`. if (observedAuthState != phase) authUI.updateAuthState(phase) @@ -935,7 +935,7 @@ fun FirebaseAuthScreen( // 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 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 5b23fcf71..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 @@ -241,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 cf5409cdc..d9577b321 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 @@ -208,7 +208,7 @@ fun EmailAuthScreen( ) } - // The flow this screen belongs to: the host's when composed on its own, the armed + // The flow this screen belongs to: the host's when composed on its own, the outstanding // request's when composed inside a reauthentication surface. val authFlowScope = rememberAuthFlowScope(authUI, configuration) // This flow's state, not the process-wide channel's: under a reauthentication request 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 675fdc67c..b66f293be 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 @@ -225,7 +225,7 @@ fun PhoneAuthScreen( } } - // The flow this screen belongs to: the host's when composed on its own, the armed + // The flow this screen belongs to: the host's when composed on its own, the outstanding // request's when composed inside a reauthentication surface. val authFlowScope = rememberAuthFlowScope(authUI, configuration) // This flow's state, not the process-wide channel's: under a reauthentication request @@ -240,7 +240,7 @@ 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 { @@ -512,7 +512,7 @@ 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. + // 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 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 f7fc2ac75..b001e2a02 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 @@ -98,8 +98,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. */ @@ -139,7 +139,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 @@ -176,7 +176,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 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 index 23253ee43..8a4272806 100644 --- 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 @@ -27,9 +27,9 @@ import com.firebase.ui.auth.AuthStateSink * [com.firebase.ui.auth.ui.screens.FirebaseAuthScreen]. * * Scoped to the composition that created it, which is what makes it the answer to "is there a - * screen able to drive an armed request to completion?" — the question + * screen able to drive an outstanding request to completion?" — the question * `FirebaseAuthUI.addReauthenticationDrainer` used to answer with a counter on the singleton. - * Every transition below runs from a composed screen, so an arming with nothing composed stays + * Every transition below runs from a composed screen, so a request nothing has accepted stays * inert without anything having to count screens. * * @since 10.0.0 @@ -37,19 +37,19 @@ import com.firebase.ui.auth.AuthStateSink internal class ReauthFlowState internal constructor( private val phaseState: MutableState, ) { - /** The live phase, or null when no request is armed. */ + /** 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 armed. */ + /** 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 arm(required: AuthState.Reauthentication.Required) { + fun accept(required: AuthState.Reauthentication.Required) { phaseState.value = required } /** - * Drops the armed request and tells its awaiting caller whether to retry. + * Drops the outstanding request and tells its awaiting caller whether to retry. * * Resolving here rather than at each call site is what stops a caller being left suspended * forever: every way a request ends comes through this, including the ones that end it because @@ -147,7 +147,7 @@ internal class ReauthFlowState internal constructor( current } - // Ambient emissions and notification cleanup while a request is armed. They must not + // Ambient emissions and notification cleanup while a request is outstanding. They must not // detach the request from the caller waiting on it. is AuthState.Idle, is AuthState.RequiresEmailVerification, @@ -168,10 +168,10 @@ internal class ReauthFlowState internal constructor( * * Called once, above the `NavDisplay`, alongside `rememberPhoneAuthFlowState` and * `rememberMfaEnrollmentFlowState`, and composition-scoped like both: a phase that outlived its - * Activity would let the next screen re-arm from it, putting a reauthentication sheet into an + * Activity would let the next screen accept it, putting a reauthentication sheet into an * unrelated sign-in. What survives recreation is the back stack's - * [com.firebase.ui.auth.ui.screens.AuthRoute.Reauth] marker and the armed - * [AuthState.Reauthentication.Required] itself, which is enough to arm again — and the request's + * [com.firebase.ui.auth.ui.screens.AuthRoute.Reauth] marker and the raised + * [AuthState.Reauthentication.Required] itself, which is enough to accept it again — and the request's * resolver is what says whether the caller behind it is still there to resume. */ @Composable 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/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt index b70b6dc3b..53ce74931 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt @@ -265,12 +265,12 @@ class FirebaseAuthUIAuthStateTest { } /** - * A host calling raw `auth.signOut()` while a reauthentication is armed used to leave the + * A host calling raw `auth.signOut()` while a reauthentication is outstanding 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". */ @Test - fun `authStateFlow() clears an armed Reauthentication Required when the user signs out`() = + fun `authStateFlow() clears an outstanding Reauthentication Required when the user signs out`() = runBlocking { `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) `when`(mockFirebaseUser.isEmailVerified).thenReturn(true) @@ -639,7 +639,8 @@ class FirebaseAuthUIAuthStateTest { val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required assertThat(state.request.hasPendingOperation).isTrue() assertThat(state.request.isResumable).isTrue() - // One path for this condition now: it arms and waits, where it used to arm *and* throw an + // One path for this condition now: it raises a request and waits, where it used to raise one + // *and* throw an // InvalidCredentialsException the caller had to catch and ignore. assertThat(call.isActive).isTrue() @@ -651,12 +652,12 @@ class FirebaseAuthUIAuthStateTest { } /** - * `withReauth`/`delete` are public and can arm a request with no [FirebaseAuthScreen] + * `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)) @@ -670,9 +671,9 @@ class FirebaseAuthUIAuthStateTest { assertThat(observed).isNotInstanceOf(AuthState.Reauthentication::class.java) } - /** The same 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)) diff --git a/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt b/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt index 2336f5a0c..04c330a47 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt @@ -19,7 +19,7 @@ import kotlinx.coroutines.CompletableDeferred import java.util.UUID /** - * An armed request standing in for one a caller is waiting on, running [operation] if and when the + * 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 @@ -35,11 +35,11 @@ internal fun retryingReauth( resolver.invokeOnCompletion { cause -> if (cause == null && resolver.getCompleted()) operation() } - return armedReauthRequest(user, reason, resolver) + return raisedReauth(user, reason, resolver) } -/** An armed request with a caller waiting on [resolver], for asserting the decision itself. */ -internal fun armedReauthRequest( +/** 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, @@ -54,11 +54,11 @@ internal fun armedReauthRequest( ) /** - * An armed request whose caller is already gone — the shape a recreation leaves behind when the + * 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 armedReauthRequest(user, resolver = resolver) + return raisedReauth(user, resolver = resolver) } 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 2822f5d7d..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 @@ -753,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 { 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 1f2c03862..7ac980dfb 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 @@ -526,14 +526,14 @@ 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 + * to: while a request is outstanding, `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. + * outstanding — so the folding is asserted here rather than guarded against with a dead branch. */ @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 passwordInfo = mock(UserInfo::class.java) `when`(passwordInfo.providerId).thenReturn(EmailAuthProvider.PROVIDER_ID) val user = mock(FirebaseUser::class.java) 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 6a7b5b643..87ede6f83 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 @@ -285,12 +285,12 @@ 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 user = passwordOnlyUser("linked@example.com") var cancelledCount = 0 var retryRan = false @@ -338,7 +338,7 @@ 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 phoneInfo = mock(UserInfo::class.java) `when`(phoneInfo.providerId).thenReturn("phone") val passwordInfo = mock(UserInfo::class.java) @@ -453,11 +453,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 @@ -612,13 +612,13 @@ 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 phoneInfo = mock(UserInfo::class.java) `when`(phoneInfo.providerId).thenReturn("phone") val passwordInfo = mock(UserInfo::class.java) @@ -711,7 +711,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 @@ -770,7 +770,7 @@ 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 user = passwordOnlyUser("linked@example.com") val ran = mutableListOf() @@ -868,12 +868,12 @@ 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 requestUser = passwordOnlyUser("outstanding@example.com") val otherUser = userLinkedTo("google.com", "other@example.com") var retryRan = false var captured: ReauthContentState? = null @@ -894,12 +894,12 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - retryingReauth(armedUser) { retryRan = true } + 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.waitForIdle() @@ -924,10 +924,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 @@ -1093,11 +1093,11 @@ 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 user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) val resolver = totpResolver(Tasks.forResult(mock(AuthResult::class.java))) @@ -1144,7 +1144,7 @@ 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( AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) @@ -1362,7 +1362,7 @@ class FirebaseAuthScreenReauthContentStateTest { /** * 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`() { @@ -1411,8 +1411,8 @@ 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 @@ -1462,7 +1462,7 @@ class FirebaseAuthScreenReauthContentStateTest { /** * Recreation during an in-flight attempt. Nothing retains the caller, so what survives is the - * request latched on the flow — enough to re-arm the same request and complete it. The attempt + * 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. */ @@ -1517,12 +1517,12 @@ 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 user = passwordOnlyUser("linked@example.com") var retryCount = 0 // Read on every composition, so the restore below observes the replacement instance. @@ -1577,10 +1577,10 @@ 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 user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) var retryCount = 0 @@ -1668,11 +1668,11 @@ class FirebaseAuthScreenReauthContentStateTest { } /** - * One request, one surface, however many times its arming state comes round again — an attempt - * and a back-out both re-enter the arming branch for the same request id. + * 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 `re-arming the same request does not stack a second surface`() { + fun `raising the same request twice does not stack a second surface`() { val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) @@ -1696,7 +1696,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() // An attempt, then the user backing out of it: the phase returns to provider selection for - // the same request, which re-enters the arming branch. + // the same request, which re-enters the raising branch. composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Loading("Signing in")) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Cancelled) } @@ -1717,7 +1717,7 @@ class FirebaseAuthScreenReauthContentStateTest { val signedInAuthUI = signedInAuthUI(user) val runs = AtomicInteger(0) val restorationTester = StateRestorationTester(composeTestRule) - val armed = retryingReauth(user) { runs.incrementAndGet() } + val raised = retryingReauth(user) { runs.incrementAndGet() } restorationTester.setContent { FirebaseAuthScreen( @@ -1732,7 +1732,7 @@ class FirebaseAuthScreenReauthContentStateTest { ) } - composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(armed) } + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(raised) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( @@ -1750,13 +1750,13 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.waitForIdle() assertThat(runs.get()).isEqualTo(1) - armed.request.resolve() + raised.request.resolve() composeTestRule.waitForIdle() assertThat(runs.get()).isEqualTo(1) } /** - * A recreation that outlived the caller: the request is still armed and still latched, but the + * 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. */ 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 5833da8f4..a03c8c914 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 @@ -387,7 +387,7 @@ class EmailAuthHostDestinationsTest { ) } val reauthFlowState = rememberReauthFlowState() - SideEffect { reauthFlowState.arm(AuthState.Reauthentication.Required(request)) } + SideEffect { reauthFlowState.accept(AuthState.Reauthentication.Required(request)) } val backStack = rememberNavBackStack( AuthRoute.Success, AuthRoute.Reauth("request-id", "uid", startStep), 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 216b36f8a..0bf87405b 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 @@ -120,7 +120,7 @@ class PhoneAuthHostDestinationsTest { */ private var reauthHolder: ReauthFlowState? = null - /** The request the reauthentication harness armed, which its own emissions have to carry. */ + /** The request the reauthentication harness raised, which its own emissions have to carry. */ private var reauthRequest: AuthState.Reauthentication.Request? = null private var reauthDismissals = 0 @@ -462,7 +462,7 @@ class PhoneAuthHostDestinationsTest { val reauthFlowState = rememberReauthFlowState() SideEffect { reauthHolder = reauthFlowState - reauthFlowState.arm(AuthState.Reauthentication.Required(request)) + reauthFlowState.accept(AuthState.Reauthentication.Required(request)) } CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { NavDisplay( 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 e966b2257..6e0fd3319 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 @@ -559,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 @@ -575,10 +575,10 @@ class PhoneAuthScreenVerificationLifecycleTest { val observed = mutableListOf() // Stands in for the composed FirebaseAuthScreen, which is what owns folding now: fold each - // ordinary provider state into the armed request and publish the phase this screen reads. + // ordinary provider state into the outstanding request and publish the phase this screen reads. val required = AuthState.Reauthentication.Required(user) val reauthFlowState = ReauthFlowState(mutableStateOf(null)) - reauthFlowState.arm(required) + reauthFlowState.accept(required) val collector = CoroutineScope(Dispatchers.Main.immediate).launch { authUI.authStateFlow().collect { state -> observed += state 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 index 1b56ea701..08d8a0c9b 100644 --- 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 @@ -46,21 +46,21 @@ class ReauthFlowStateTest { resolver = resolver, ) - private fun ReauthFlowState.armed( + private fun ReauthFlowState.accepted( resolver: CompletableDeferred? = null, ): AuthState.Reauthentication.Request { val request = request(resolver) - arm(AuthState.Reauthentication.Required(request)) + accept(AuthState.Reauthentication.Required(request)) return request } /** * The counter `addReauthenticationDrainer` kept is gone because this is the same question: - * with nothing armed there is no conversation for a provider state to belong to, so it stays + * with no request outstanding there is no conversation for a provider state to belong to, so it stays * the host's. */ @Test - fun `nothing is folded while nothing is armed`() { + fun `nothing is folded while nothing is outstanding`() { val holder = holder() assertThat(holder.fold(AuthState.Loading("Signing in"))).isNull() @@ -70,7 +70,7 @@ class ReauthFlowStateTest { @Test fun `provider states are folded onto the same request`() { val holder = holder() - val request = holder.armed() + val request = holder.accepted() val authenticating = holder.fold(AuthState.Loading("Signing in")) assertThat(authenticating) @@ -89,7 +89,7 @@ class ReauthFlowStateTest { @Test fun `email notifications keep the request until they are consumed`() { val holder = holder() - val request = holder.armed() + val request = holder.accepted() val notification = holder.fold(AuthState.PasswordResetLinkSent()) assertThat(notification) @@ -105,7 +105,7 @@ class ReauthFlowStateTest { @Test fun `update ignores a stale requestId`() { val holder = holder() - val request = holder.armed() + val request = holder.accepted() holder.update("stale-request-id") { it.attemptStarted() } @@ -116,7 +116,7 @@ class ReauthFlowStateTest { @Test fun `attemptCancelled does not rewind a surfaced attempt failure`() { val holder = holder() - val request = holder.armed() + val request = holder.accepted() holder.fold(AuthState.Error(IllegalArgumentException("wrong password"))) holder.update(request.requestId) { it.attemptCancelled() } @@ -131,7 +131,7 @@ class ReauthFlowStateTest { @Test fun `returnedToProviderSelection does not wipe a surfaced attempt failure`() { val holder = holder() - val request = holder.armed() + val request = holder.accepted() holder.fold(AuthState.Error(IllegalArgumentException("wrong sms code"))) holder.update(request.requestId) { it.returnedToProviderSelection() } @@ -143,7 +143,7 @@ class ReauthFlowStateTest { @Test fun `attemptStarted does not rewind an accepted proof`() { val holder = holder() - val request = holder.armed() + val request = holder.accepted() `when`(user.uid).thenReturn("uid-reauth") holder.fold( AuthState.Success(result = null, user = user, reauthenticatedUid = "uid-reauth") @@ -159,7 +159,7 @@ class ReauthFlowStateTest { @Test fun `an unstamped Success leaves the phase alone`() { val holder = holder() - holder.armed() + holder.accepted() val folded = holder.fold(AuthState.Success(result = null, user = user)) @@ -176,7 +176,7 @@ class ReauthFlowStateTest { @Test fun `fold declines Aborted, leaving the phase for the screen to end`() { val holder = holder() - holder.armed() + holder.accepted() assertThat(holder.fold(AuthState.Aborted)).isNull() assertThat(holder.phase).isInstanceOf(AuthState.Reauthentication.Required::class.java) @@ -186,7 +186,7 @@ class ReauthFlowStateTest { @Test fun `fold declines a reauthentication state`() { val holder = holder() - val request = holder.armed() + val request = holder.accepted() assertThat(holder.fold(AuthState.Reauthentication.Required(request))).isNull() } @@ -195,7 +195,7 @@ class ReauthFlowStateTest { fun `finish resolves the waiting caller with the retry decision`() { val holder = holder() val resolver = CompletableDeferred() - holder.armed(resolver) + holder.accepted(resolver) holder.finish(true) @@ -213,7 +213,7 @@ class ReauthFlowStateTest { fun `finish without a retry fails the caller rather than returning quietly`() { val holder = holder() val resolver = CompletableDeferred() - holder.armed(resolver) + holder.accepted(resolver) holder.finish(false) @@ -225,7 +225,7 @@ class ReauthFlowStateTest { @Test fun `finish is a no-op for a request with no caller`() { val holder = holder() - holder.armed() + holder.accepted() holder.finish(true) @@ -266,7 +266,7 @@ class ReauthFlowStateTest { @Test fun `an attempt failure carries the exception it folded`() { val holder = holder() - holder.armed() + holder.accepted() val cause = AuthException.UnknownException("nope") val folded = holder.fold(AuthState.Error(cause)) @@ -286,7 +286,7 @@ class ReauthFlowStateTest { @Test fun `the request's sink keeps the exchange off the host flow`() { val holder = holder() - holder.armed() + holder.accepted() val host = mutableListOf() val sink = holder.sink(hostFallback = { host += it }) @@ -301,7 +301,7 @@ class ReauthFlowStateTest { @Test fun `the request's sink forwards what the exchange does not own`() { val holder = holder() - holder.armed() + holder.accepted() val host = mutableListOf() val sink = holder.sink(hostFallback = { host += it }) @@ -311,9 +311,9 @@ class ReauthFlowStateTest { assertThat(host.single()).isInstanceOf(AuthState.Aborted::class.java) } - /** With nothing armed there is no exchange to absorb into, so everything is the host's. */ + /** 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 armed`() { + fun `the sink forwards everything while nothing is outstanding`() { val holder = holder() val host = mutableListOf() val sink = holder.sink(hostFallback = { host += it }) @@ -328,7 +328,7 @@ class ReauthFlowStateTest { @Test fun `a cancelled attempt returns to provider selection rather than surfacing`() { val holder = holder() - holder.armed() + holder.accepted() val folded = holder.fold( AuthState.Error(AuthException.AuthCancelledException(message = "cancelled")) 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 08d4c1411..e9d224c3b 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 @@ -65,7 +65,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 +116,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 +133,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 +161,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 +170,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 +182,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 +196,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 +222,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 +240,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" } } From 0b17f77c24befea1647983c196929e4951d8056a Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:06:53 +0100 Subject: [PATCH 08/15] refactor(auth)!: give reauthentication requests their own channel instead of the shared state flow --- .../com/firebase/ui/auth/FirebaseAuthUI.kt | 45 ++- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 253 ++++++-------- .../ui/screens/reauth/ReauthDestinations.kt | 13 +- .../ui/auth/FirebaseAuthUIAuthStateTest.kt | 52 +-- .../firebase/ui/auth/FirebaseAuthUITest.kt | 2 +- .../firebase/ui/auth/ReauthTestRequests.kt | 25 ++ .../FirebaseAuthScreenEmailRecoveryTest.kt | 24 +- ...irebaseAuthScreenReauthContentStateTest.kt | 322 +++++++++++------- .../FirebaseAuthScreenReauthIdleResetTest.kt | 15 +- .../ui/screens/FirebaseAuthScreenSlotsTest.kt | 2 +- .../email/EmailAuthHostDestinationsTest.kt | 1 + .../phone/PhoneAuthHostDestinationsTest.kt | 2 + .../screens/reauth/ReauthSurfaceGateTest.kt | 2 + 13 files changed, 418 insertions(+), 340 deletions(-) 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 31b6f2621..9a18d66ed 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -41,6 +41,7 @@ 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 @@ -84,6 +85,19 @@ class FirebaseAuthUI private constructor( private val _authStateFlow = MutableStateFlow(AuthState.Idle) + /** + * The reauthentication request waiting for a screen to take it on, or null. + * + * A dedicated channel rather than a lane on [_authStateFlow]. Raising a request and reporting + * auth state are different conversations, and sharing one channel is what forced every reader + * to work out whose states it was looking at — an `AuthState.Error` that meant "sign-in failed" + * or "the reauthentication attempt failed" depending on context nothing carried. + * + * Process-scoped because the caller is: it outlives the screen that presents it, which is what + * lets a recreated screen pick the same request up rather than reconstruct it. + */ + internal val pendingReauth = MutableStateFlow(null) + /** How many composed [FirebaseAuthScreen]s can currently drive a reauthentication request. */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @@ -329,13 +343,12 @@ class FirebaseAuthUI private constructor( is AuthState.RequiresEmailVerification, is AuthState.RequiresProfileCompletion, -> true - // Nothing to protect here any more: the retry runs in the caller's own - // coroutine, after the screen has already ended the request. A signed-out - // user cannot reauthenticate, so an outstanding request is stale by definition. - is AuthState.Reauthentication -> true else -> false } if (isStale) updateAuthState(AuthState.Idle) + // A signed-out user cannot reauthenticate, so an outstanding request is stale + // by definition — and its caller is told rather than left waiting. + pendingReauth.getAndUpdate { null }?.request?.decline() } trySend(buildState(firebaseAuth.currentUser)) } @@ -515,20 +528,26 @@ class FirebaseAuthUI private constructor( // dies cancels this with it, which is how the screen tells a request it can still // complete from one whose operation can never run again. val resolver = CompletableDeferred(parent = coroutineContext[Job]) - updateAuthState( - AuthState.Reauthentication.Required( - AuthState.Reauthentication.Request( - requestId = UUID.randomUUID().toString(), - user = user, - reason = reason, - resolver = resolver, - ) + val required = AuthState.Reauthentication.Required( + AuthState.Reauthentication.Request( + requestId = UUID.randomUUID().toString(), + user = user, + reason = reason, + resolver = resolver, ) ) + // One request at a time. A second one replaces the first, and the caller it displaces + // is told so rather than left waiting on a request no screen will ever present. + pendingReauth.getAndUpdate { required }?.request?.decline() + val retry = try { + resolver.await() + } finally { + pendingReauth.compareAndSet(required, null) + } // Thrown from this frame rather than out of the resolver: the resolver is parented to // the caller's job, so failing it would cancel the caller's whole scope instead of // just this call. A caller always learns whether its operation ran. - if (!resolver.await()) { + if (!retry) { throw AuthException.AuthCancelledException( message = "Reauthentication was cancelled" ) 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 36c913b45..182ece53b 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 @@ -189,6 +189,9 @@ fun FirebaseAuthScreen( // the Activity and be accepted into an unrelated sign-in. val reauthFlowState = rememberReauthFlowState() val reauthState = reauthFlowState.phase + // The request waiting to be taken on, watched on its own channel rather than picked out of the + // state flow. Process-scoped, so a recreated screen sees the same one and takes it on again. + val pendingReauth by authUI.pendingReauth.collectAsState() // The host's own flow. Provider code reaches the public state channel only through this sink, // which is the whole point of the receiver change: there is no `authUI` on a scope to reach it // any other way. @@ -196,18 +199,7 @@ fun FirebaseAuthScreen( val hostScope = remember(authUI, configuration, hostStateHolder) { hostAuthFlowScope(authUI, configuration, hostStateHolder) } - /** - * What the host may act on. While a request is outstanding, an ordinary state arriving on the public - * flow — from `withReauth`, or from an app writing it directly — belongs to the credential - * exchange, and the phase is what reports it. `fold` runs in an effect, so the raw state is on - * the flow for a frame first; without this the host's own dialogs act on it in between, putting - * a sign-in error dialog, retry action and all, over the reauthentication sheet. - * - * Provider code under the request's own scope never comes through here at all — that is what - * [AuthFlowScope] fixed. This covers the writers that still reach the public flow directly. - */ - val authState = reauthState?.takeIf { rawAuthState !is AuthState.Reauthentication } - ?: rawAuthState + val authState = rawAuthState val dialogController = rememberTopLevelDialogController(stringProvider) { authState } val lastSuccessfulUserId = remember { mutableStateOf(null) } val pendingLinkingCredential = remember { mutableStateOf(null) } @@ -650,83 +642,18 @@ fun FirebaseAuthScreen( previousAuthState.value = state // Guards below use `isAt` (runtime class), not `==`: keys carry arguments, so `==` blanks a live form. val currentKey = backStack.lastOrNull() - // The stack itself, not the composition value derived from it: this effect is - // what writes the stack, so anything derived in composition describes the frame - // before. Recomposition happens to land between runs today, which is why the - // composition value also worked — but the guards below are about what is on the - // stack now, so they read it now. - val savedPresentation = backStack.presentedReauth() - - // A marker that outlived its phase: the Activity was recreated with the request - // still outstanding. Nothing here can be driven, so report it and clear up. - if (savedPresentation != null && - reauthFlowState.phase == null && - state !is AuthState.Reauthentication && - state !is AuthState.Aborted - ) { - clearReauthPresentation() - authUI.updateAuthState( - AuthState.Error( - AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_interrupted) - ) - ) - ) + // A reauthentication owns the screen while it is up. The host must not navigate or + // tear down underneath a modal sheet, and an ambient Success from the signed-in + // session is not this flow's to act on. `Aborted` is the exception: it is how the + // host itself is dismissed, and it ends the request on the way out. + // + // This one rule replaces what `contextualizeReauthenticationState` and then `fold` + // did by mapping every state onto a phase — the exchange's own states no longer + // arrive here at all, so all that is left to say is who owns the screen. + if (reauthFlowState.phase != null && state !is AuthState.Aborted) { return@LaunchedEffect } - // A latched reauthentication state with no phase: this screen was recreated while - // the request was outstanding. The phase is composition-scoped and gone, but its value - // is still on the flow, so the exchange is accepted again from it rather than abandoned. - if (state is AuthState.Reauthentication && reauthFlowState.phase == null) { - val request = state.request - if (request == null || !request.isResumable) { - clearReauthPresentation() - authUI.updateAuthState( - AuthState.Error( - AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_interrupted) - ) - ) - ) - return@LaunchedEffect - } - // Two phases must not come back: `Authenticating`'s network call died with the - // Activity, and re-entering `Succeeded` would resolve the caller a second time - // for an operation that may already have committed. Both restart at provider - // selection. Everything else is the user's own position in the exchange, and a - // surfaced failure is the only report they got, so it is restored as it was. - when (state) { - is AuthState.Reauthentication.Authenticating, - is AuthState.Reauthentication.Succeeded, - -> authUI.updateAuthState( - AuthState.Reauthentication.Required(request) - ) - - is AuthState.Reauthentication.Required -> reauthFlowState.accept(state) - - else -> reauthFlowState.moveTo(state) - } - } - - // Ordinary states published by provider code while a request is outstanding belong to - // the credential exchange, not to the host flow. The holder folds them into its - // phase and publishes that, which is what the setter used to do on the singleton's - // behalf; the branches below then only ever see states that are the host's. - reauthFlowState.fold(state)?.let { folded -> - authUI.updateAuthState(folded) - 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.presentedReauth()?.step is AuthRoute.MfaChallenge - ) { - backStack.returnToReauthStart() - } - when (state) { is AuthState.Success -> { pendingResolver.value = null @@ -749,72 +676,6 @@ fun FirebaseAuthScreen( } } - is AuthState.Reauthentication.Required -> { - val armingConfig = configuration.toReauthConfiguration(state.user) - if (armingConfig == null) { - finishReauth( - AuthState.Error( - AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_no_linked_providers) - ) - ), - false, - ) - return@LaunchedEffect - } - // A request whose caller died with its scope cannot be completed however - // well the exchange goes, so it is reported rather than presented. This is - // what tells a rotation that kept its caller from one that lost it. - if (!state.request.isResumable) { - finishReauth( - AuthState.Error( - AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_interrupted) - ) - ), - false, - ) - return@LaunchedEffect - } - reauthFlowState.accept(state) - if (backStack.presentedReauth()?.requestId != state.requestId) { - backStack.clearReauth() - backStack.add( - AuthRoute.Reauth( - requestId = state.requestId, - userUid = state.userUid, - // From the request itself, not the composition value: this - // effect is what writes the phase, so anything derived from - // it in composition is still a frame behind here. - step = reauthStartStepFor(armingConfig), - ) - ) - } - } - - is AuthState.Reauthentication -> { - val marker = backStack.presentedReauth() - ?.takeIf { it.requestId == state.requestId } - ?: AuthRoute.Reauth( - requestId = state.requestId, - userUid = state.userUid, - step = state.request - ?.let { configuration.toReauthConfiguration(it.user) } - ?.let { reauthStartStepFor(it) } - ?: AuthRoute.MethodPicker, - ).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, -> { @@ -886,6 +747,75 @@ fun FirebaseAuthScreen( } } + /** + * A presentation marker with no request behind it: the process died while a + * reauthentication was outstanding, and the request went with it — it was never + * serializable. The back stack survives, so without this the restored screen would + * render an empty sheet over a flow with nothing to drive 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 [FirebaseAuthUI.pendingReauth]. + * + * This is what the `Reauthentication.Required` branch of the state effect used to do, + * moved onto the request's own channel. A recreated screen runs it again against the + * same request, which is why nothing has to reconstruct one from a latched state. + */ + 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 died with its scope cannot be completed however well the + // exchange goes, so it is reported rather than presented. This is what tells a + // rotation that kept its caller from one that lost it. + 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), + ) + ) + } + } + /** * The phase's own effect. The effect above is keyed on the flow, so it never sees a * transition the destinations make straight on the holder — an MFA proof, a cancelled @@ -893,11 +823,20 @@ fun FirebaseAuthScreen( */ LaunchedEffect(reauthFlowState.phase) { val phase = reauthFlowState.phase ?: return@LaunchedEffect - // The phase still goes on the public flow. The screens under the request's own - // scope no longer need it there, but the host's `fold` path and the flow-driven - // navigation both do, so this stays until requests stop being raised through the - // flow. It is what an app is expected to ignore: an `AuthState.Reauthentication`. - if (observedAuthState != phase) authUI.updateAuthState(phase) + + // The challenge entry is on the stack exactly while the phase is RequiresMfa: it + // has no resolver to render otherwise, and this is the only place that moves it, + // so no attempt path can strand the user on a dead challenge. + 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 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 b001e2a02..b7d90bb4c 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 @@ -193,11 +193,9 @@ internal fun EntryProviderScope.reauthDestinations( ?.let { if (it is AuthException) it else AuthException.from(it, stringProvider) } val error = exception?.let { getRecoveryMessage(it, stringProvider) } - // This request's own flow. Everything the credential exchange publishes lands on the - // phase rather than on the public state channel, so an app collecting `authStateFlow()` - // never sees a Loading or an Error belonging to a conversation that is not theirs. - // The phase is both what this scope publishes into and what the screens under it render, - // so the request's conversation never has to travel the public channel to be seen. + // This request's own flow, built where the configuration it needs is guaranteed to exist: + // the entry returns early when there is no surface, so the scope can never fall back to + // the host's and put a credential exchange's states on the public channel. val reauthStateHolder = remember(reauthFlowState) { derivedStateOf { reauthFlowState.phase ?: AuthState.Idle } } @@ -340,7 +338,10 @@ internal fun EntryProviderScope.reauthDestinations( onCancel = { reauthFlowState.update(key.requestId) { it.attemptCancelled() } }, - onError = { e -> authUI.updateAuthState(AuthState.Error(e)) }, + // The request's own flow, like every other outcome of this exchange. Writing + // the public channel here would report a failed second factor as an ordinary + // sign-in error to anything collecting `authStateFlow()`. + onError = { e -> reauthScope.emit(AuthState.Error(e)) }, ) } 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 53ce74931..976f48c8d 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 @@ -265,12 +266,12 @@ class FirebaseAuthUIAuthStateTest { } /** - * A host calling raw `auth.signOut()` while a reauthentication is outstanding 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 outstanding 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) @@ -285,12 +286,17 @@ class FirebaseAuthUIAuthStateTest { delay(100) verify(mockFirebaseAuth).addAuthStateListener(listenerCaptor.capture()) - authUI.updateAuthState( - AuthState.Reauthentication.Required(mockFirebaseUser, reason = "Confirm it is you") + // The request travels its own channel now, so nothing about it reaches the state + // flow — which is the point: an app collecting `authStateFlow()` is not part of this + // conversation. + 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) @@ -298,7 +304,11 @@ class FirebaseAuthUIAuthStateTest { delay(200) job.cancel() - assertThat(states.last()).isEqualTo(AuthState.Idle) + // A signed-out user cannot reauthenticate, so the request is dropped and the caller + // waiting on it is told rather than left suspended. + assertThat(authUI.pendingReauth.value).isNull() + assertThat(resolver.isCompleted).isTrue() + assertThat(resolver.getCompleted()).isFalse() } @Test @@ -608,9 +618,8 @@ class FirebaseAuthUIAuthStateTest { 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() @@ -636,7 +645,7 @@ class FirebaseAuthUIAuthStateTest { } runCurrent() - val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required + val state = requireNotNull(authUI.pendingReauth.value) assertThat(state.request.hasPendingOperation).isTrue() assertThat(state.request.isResumable).isTrue() // One path for this condition now: it raises a request and waits, where it used to raise one @@ -660,9 +669,8 @@ class FirebaseAuthUIAuthStateTest { 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)) @@ -676,7 +684,7 @@ class FirebaseAuthUIAuthStateTest { 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) @@ -715,7 +723,7 @@ class FirebaseAuthUIAuthStateTest { } runCurrent() - 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() @@ -742,7 +750,7 @@ class FirebaseAuthUIAuthStateTest { } } runCurrent() - val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required + val state = requireNotNull(authUI.pendingReauth.value) assertThat(callCount).isEqualTo(1) state.request.resolve() @@ -776,7 +784,7 @@ class FirebaseAuthUIAuthStateTest { } } runCurrent() - val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required + val state = requireNotNull(authUI.pendingReauth.value) state.request.decline() call.join() @@ -804,7 +812,7 @@ class FirebaseAuthUIAuthStateTest { } } runCurrent() - val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required + val state = requireNotNull(authUI.pendingReauth.value) assertThat(state.request.isResumable).isTrue() call.cancel() @@ -854,7 +862,7 @@ class FirebaseAuthUIAuthStateTest { val call = launch { authUI.delete(context) } runCurrent() - val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required + val state = requireNotNull(authUI.pendingReauth.value) state.request.resolve() call.join() 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 910371a83..cce066450 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt @@ -649,7 +649,7 @@ class FirebaseAuthUITest { val call = launch { runCatching { instance.delete(context) } } runCurrent() - val state = instance.authStateFlow().first() as AuthState.Reauthentication.Required + val state = requireNotNull(instance.pendingReauth.value) assertThat(state.user).isEqualTo(mockUser) assertThat(state.request.hasPendingOperation).isTrue() assertThat(call.isActive).isTrue() diff --git a/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt b/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt index 04c330a47..b570b7c12 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt @@ -14,6 +14,7 @@ package com.firebase.ui.auth +import androidx.compose.runtime.Composable import com.google.firebase.auth.FirebaseUser import kotlinx.coroutines.CompletableDeferred import java.util.UUID @@ -62,3 +63,27 @@ internal fun abandonedReauth(user: FirebaseUser): AuthState.Reauthentication.Req 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/ui/screens/FirebaseAuthScreenEmailRecoveryTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenEmailRecoveryTest.kt index 7ac980dfb..939c03aa2 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,8 @@ 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 @@ -534,6 +536,7 @@ class FirebaseAuthScreenEmailRecoveryTest { */ @Test 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) @@ -541,8 +544,8 @@ 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 on the same flow, to prove directly that nothing from the exchange + // lands on it rather than inferring it from what the dialog happens to render. val seen = mutableListOf() composeTestRule.setContent { LaunchedEffect(authUI) { authUI.authStateFlow().collect { seen += it } } @@ -552,26 +555,31 @@ class FirebaseAuthScreenEmailRecoveryTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, + // One linked provider, so the sheet opens straight at the email step and the + // method picker is never composed — the probe hooks in there instead. The slot + // is not `reauthContent`, so `reauthSlotActive` is unaffected and the dialog + // behaviour these assertions are about is unchanged. + emailContent = { probe.capture() }, ) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - authUI.updateAuthState( - retryingReauth(user) {} - ) + 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 now — a stronger property + // than the folded phase this used to assert, and the reason the recovery stays out of + // reach below. + 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 87ede6f83..918c379e7 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,8 @@ 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 @@ -186,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() @@ -235,7 +235,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) + signedInAuthUI.pendingReauth.value = AuthState.Reauthentication.Required(user) } composeTestRule.waitForIdle() @@ -270,7 +270,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) + signedInAuthUI.pendingReauth.value = AuthState.Reauthentication.Required(user) } composeTestRule.waitForIdle() @@ -291,6 +291,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test 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 @@ -303,31 +304,31 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = { cancelledCount++ }, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) } composeTestRule.runOnIdle { - authUI.updateAuthState( - retryingReauth(user) { 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() @@ -339,6 +340,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test 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) @@ -358,27 +360,34 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = { cancelledCount++ }, + // The default sheet renders no app slot, so the probe hooks in through the + // method-picker layout — composed inside the request's own flow, and unlike + // reauthContent it does not flip reauthSlotActive, so the dialog behaviour these + // assertions are about is unchanged. The picker itself is the default one. + customMethodPickerLayout = { providers, onSelected -> + probe.capture() + AuthMethodPicker(providers = providers, onProviderSelected = onSelected) + }, ) } composeTestRule.runOnIdle { - authUI.updateAuthState( - retryingReauth(user) { 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() @@ -391,6 +400,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 @@ -406,6 +416,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { state -> + probe.capture() captured = state Button( onClick = { state.onProviderSelected(state.providers.first()) }, @@ -418,12 +429,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) @@ -477,9 +488,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState( - retryingReauth(user) { retryRan = true } - ) + authUI.pendingReauth.value = retryingReauth(user) { retryRan = true } } composeTestRule.waitForIdle() @@ -543,9 +552,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - retryingReauth(user) { retryRan = true } - ) + signedInAuthUI.pendingReauth.value = retryingReauth(user) { retryRan = true } } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() @@ -571,6 +578,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 @@ -583,6 +591,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, @@ -590,20 +599,19 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - retryingReauth(user) { 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() @@ -619,6 +627,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test 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) @@ -635,13 +644,19 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, + // The default sheet renders no app slot, so the probe hooks in through the + // method-picker layout — composed inside the request's own flow, and unlike + // reauthContent it does not flip reauthSlotActive, so the dialog behaviour these + // assertions are about is unchanged. The picker itself is the default one. + customMethodPickerLayout = { providers, onSelected -> + probe.capture() + AuthMethodPicker(providers = providers, onProviderSelected = onSelected) + }, ) } composeTestRule.runOnIdle { - authUI.updateAuthState( - retryingReauth(user) {} - ) + authUI.pendingReauth.value = retryingReauth(user) {} } composeTestRule.waitForIdle() @@ -650,7 +665,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.onAllNodesWithText(stringProvider.passwordHint).assertCountEquals(0) composeTestRule.runOnIdle { - authUI.updateAuthState( + probe.emit( AuthState.Error( AuthException.EmailAlreadyInUseException( message = "already in use", @@ -743,9 +758,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.onNodeWithTag("pick_password").assertExists() composeTestRule.runOnIdle { - authUI.updateAuthState( - retryingReauth(user) { retryRan = true } - ) + authUI.pendingReauth.value = retryingReauth(user) { retryRan = true } } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertExists() @@ -771,6 +784,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test fun `raising a second operation for the same user replaces the first`() { + val probe = ReauthScopeProbe() val user = passwordOnlyUser("linked@example.com") val ran = mutableListOf() @@ -782,6 +796,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) @@ -789,26 +804,23 @@ class FirebaseAuthScreenReauthContentStateTest { // Same user, same (absent) reason: the two states differ only in the attached operation. composeTestRule.runOnIdle { - authUI.updateAuthState( - retryingReauth(user) { ran.add("first") } - ) + authUI.pendingReauth.value = retryingReauth(user) { ran.add("first") } } composeTestRule.waitForIdle() composeTestRule.runOnIdle { - authUI.updateAuthState( - retryingReauth(user) { 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() @@ -823,6 +835,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) @@ -836,6 +849,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, @@ -843,14 +857,14 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState(AuthState.Reauthentication.Required(user)) + 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, @@ -873,6 +887,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test fun `a stamped Success for a different uid does not run the pending operation`() { + val probe = ReauthScopeProbe() val requestUser = passwordOnlyUser("outstanding@example.com") val otherUser = userLinkedTo("google.com", "other@example.com") var retryRan = false @@ -886,6 +901,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { state -> + probe.capture() captured = state Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } @@ -893,18 +909,16 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState( - retryingReauth(requestUser) { retryRan = true } - ) + authUI.pendingReauth.value = retryingReauth(requestUser) { retryRan = true } } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() 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, @@ -948,9 +962,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState( - retryingReauth(user) { retryRan = true } - ) + authUI.pendingReauth.value = retryingReauth(user) { retryRan = true } } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() @@ -989,6 +1001,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))) @@ -1007,6 +1020,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") }, @@ -1014,17 +1028,20 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - retryingReauth(user) { 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() @@ -1046,6 +1063,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))) @@ -1059,6 +1077,10 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, + // A password-only user, so the sheet opens straight at the email step and the + // method picker is never composed — the probe hooks in there. Not `reauthContent`, + // so `reauthSlotActive` and the sheet's own behaviour are unaffected. + emailContent = { probe.capture() }, mfaChallengeContent = { state -> challenge = state Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) @@ -1068,16 +1090,19 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - retryingReauth(user) { 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 { @@ -1098,6 +1123,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test 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))) @@ -1117,6 +1143,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") }, @@ -1124,15 +1151,18 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - retryingReauth(user) { 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() } @@ -1146,10 +1176,11 @@ class FirebaseAuthScreenReauthContentStateTest { // 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) @@ -1158,6 +1189,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"))) @@ -1178,6 +1210,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")) }, @@ -1186,15 +1219,18 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - retryingReauth(user) { 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 { @@ -1219,6 +1255,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 @@ -1237,6 +1274,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")) }, @@ -1245,15 +1283,18 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState( - retryingReauth(user) { 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 { @@ -1298,9 +1339,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState( - retryingReauth(user) { retryRan = true } - ) + authUI.pendingReauth.value = retryingReauth(user) { retryRan = true } } composeTestRule.waitForIdle() assertThat(cancelledCount).isEqualTo(0) @@ -1315,12 +1354,16 @@ class FirebaseAuthScreenReauthContentStateTest { } /** - * The phase is composition-scoped, so rotating destroys it — but its value is still latched on - * the flow, and a surfaced failure is the only report the user got, so the restored screen - * re-arms from it rather than restarting them at provider selection with nothing said. + * 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 @@ -1336,6 +1379,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { state -> + probe.capture() captured = state Text(text = "SLOT_ERROR=${state.error}", modifier = Modifier.testTag("slot")) } @@ -1343,10 +1387,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) @@ -1354,10 +1398,13 @@ 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 on its own channel, so the surface comes back and the + // same operation is still waiting on it. The *failure* does not: a phase is composition + // -scoped by design, and only `Required` is durable. The user is returned to provider + // selection with a clean slate rather than shown an error from before the recreation. + assertThat(signedInAuthUI.pendingReauth.value).isNotNull() + assertThat(requireNotNull(captured).error).isNull() + composeTestRule.onNodeWithText("SLOT_ERROR=$expectedMessage").assertDoesNotExist() } /** @@ -1395,7 +1442,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() @@ -1417,6 +1464,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @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 @@ -1430,31 +1478,31 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - retryingReauth(user) { 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) @@ -1468,6 +1516,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @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 @@ -1481,18 +1530,17 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - retryingReauth(user) { 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() @@ -1505,10 +1553,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) @@ -1523,6 +1572,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test 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. @@ -1537,15 +1587,14 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) } composeTestRule.runOnIdle { - currentAuthUI.updateAuthState( - retryingReauth(user) { retryCount++ } - ) + currentAuthUI.pendingReauth.value = retryingReauth(user) { retryCount++ } } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() @@ -1565,7 +1614,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() composeTestRule.runOnIdle { - currentAuthUI.updateAuthState( + probe.emit( AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) ) } @@ -1581,6 +1630,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test 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 @@ -1594,15 +1644,14 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState( - retryingReauth(user) { retryCount++ } - ) + signedInAuthUI.pendingReauth.value = retryingReauth(user) { retryCount++ } } composeTestRule.waitForIdle() @@ -1615,13 +1664,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) @@ -1630,6 +1680,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) @@ -1642,13 +1693,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() @@ -1690,7 +1742,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(retryingReauth(user) {}) + signedInAuthUI.pendingReauth.value = retryingReauth(user) {} } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() @@ -1713,6 +1765,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @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) @@ -1727,18 +1780,20 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) } - composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(raised) } + 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() @@ -1779,7 +1834,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(abandonedReauth(user)) + signedInAuthUI.pendingReauth.value = abandonedReauth(user) } composeTestRule.waitForIdle() @@ -1822,7 +1877,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)) } @@ -1852,6 +1907,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))) @@ -1872,6 +1928,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, emailContent = { + probe.capture() Text(text = "EMAIL", modifier = Modifier.testTag("reauth_email")) }, mfaChallengeContent = { @@ -1882,13 +1939,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() @@ -1904,6 +1961,7 @@ 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))) @@ -1922,23 +1980,29 @@ 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: // an ordinary Cancelled folds to provider selection, which is the phase leaving RequiresMfa. - composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Cancelled) } + composeTestRule.runOnIdle { probe.emit(AuthState.Cancelled) } composeTestRule.waitForIdle() composeTestRule.waitForIdle() @@ -1955,6 +2019,7 @@ class FirebaseAuthScreenReauthContentStateTest { */ @Test 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 runs = AtomicInteger(0) @@ -1967,22 +2032,24 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(retryingReauth(user) { runs.incrementAndGet() }) + 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() @@ -1992,6 +2059,7 @@ class FirebaseAuthScreenReauthContentStateTest { /** The default sheet is the same surface, and comes down on the same condition. */ @Test 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 runs = AtomicInteger(0) @@ -2004,6 +2072,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, emailContent = { + probe.capture() Text(text = "EMAIL", modifier = Modifier.testTag("reauth_email")) }, authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, @@ -2011,16 +2080,17 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(retryingReauth(user) { runs.incrementAndGet() }) + 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() 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 6615666ba..a521fb12f 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,7 @@ 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 @@ -96,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) @@ -123,6 +125,7 @@ class FirebaseAuthScreenReauthIdleResetTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { state -> + probe.capture() capturedError = state.error Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker")) } @@ -131,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() @@ -160,6 +163,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) @@ -190,6 +194,7 @@ class FirebaseAuthScreenReauthIdleResetTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { + probe.capture() Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker")) } ) @@ -203,8 +208,7 @@ class FirebaseAuthScreenReauthIdleResetTest { var operationStarted = false var operationCompleted = false composeTestRule.runOnIdle { - authUI.updateAuthState( - retryingReauth(mockUser) { + authUI.pendingReauth.value = retryingReauth(mockUser) { operationStarted = true // Exactly what a successful delete() does: FirebaseAuth drops the user and // notifies its listeners while the operation is running. @@ -212,14 +216,13 @@ class FirebaseAuthScreenReauthIdleResetTest { 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, 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 a03c8c914..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,7 @@ 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 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 0bf87405b..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,8 @@ 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 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 e9d224c3b..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 From a78d1c20d235d8b8fc2ff064494371de21f8b0e1 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:27:19 +0100 Subject: [PATCH 09/15] docs(auth): say what the reauthentication api does instead of arguing for it --- .../com/firebase/ui/auth/AuthFlowScope.kt | 43 ++-------- .../java/com/firebase/ui/auth/AuthState.kt | 40 +++------- .../com/firebase/ui/auth/FirebaseAuthUI.kt | 50 +++--------- .../FacebookAuthProvider+FirebaseAuthUI.kt | 5 +- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 79 +++---------------- .../auth/ui/screens/email/EmailAuthScreen.kt | 6 +- .../auth/ui/screens/phone/PhoneAuthScreen.kt | 5 +- .../ui/screens/reauth/ReauthDestinations.kt | 15 +--- .../auth/ui/screens/reauth/ReauthFlowState.kt | 55 +++---------- .../ui/auth/FirebaseAuthUIAuthStateTest.kt | 14 +--- .../firebase/ui/auth/FirebaseAuthUITest.kt | 3 +- .../auth_provider/SignInStateSequenceTest.kt | 7 +- .../FirebaseAuthScreenEmailRecoveryTest.kt | 12 +-- ...irebaseAuthScreenReauthContentStateTest.kt | 25 ++---- .../FirebaseAuthScreenReauthIdleResetTest.kt | 3 +- ...honeAuthScreenVerificationLifecycleTest.kt | 3 +- 16 files changed, 78 insertions(+), 287 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt b/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt index 227e896a6..e05cb6388 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt @@ -31,16 +31,8 @@ internal fun interface AuthStateSink { } /** - * One auth flow's collaborators, and where its states go. - * - * Provider code is written against this rather than against [FirebaseAuthUI], which is what makes - * "provider implementations do not write to the process-wide state channel" a rule the compiler - * holds instead of one a reviewer has to hold across ninety-odd hand edits: there is no way to - * reach `_authStateFlow` from here. Two sinks exist — the host's, which writes the public flow, and - * a reauthentication request's, which writes its own phase and nothing else. - * - * It also carries [config], which used to be an explicit parameter on nearly every provider - * function, so those signatures got shorter rather than longer. + * 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 */ @@ -50,12 +42,8 @@ internal class AuthFlowScope( val credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null, val loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null, /** - * What this flow is currently doing, for the screens rendering it. - * - * The read side of [sink], and the reason a reauthentication phase no longer has to be - * published to the public channel for the sub-screens to see it: under a request's scope this - * *is* the phase, so `EmailAuthScreen` and `PhoneAuthScreen` read their spinner and their - * inline error from the conversation they are actually part of. + * 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, @@ -65,9 +53,6 @@ internal class AuthFlowScope( /** * 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. - * - * Moved off [FirebaseAuthUI] with the rest of provider publishing. The decision itself is - * [authUserState], which the host also needs when it observes FirebaseAuth directly. */ fun emitResult(result: AuthResult?, defaultIsNewUser: Boolean = false) { val user = result?.user @@ -84,11 +69,6 @@ internal class AuthFlowScope( * 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. - * - * Top-level rather than a member of either [AuthFlowScope] or [FirebaseAuthUI], because both need - * it: provider code reaches it through [AuthFlowScope.emitResult], and [FirebaseAuthUI] calls it - * from the `callbackFlow` that observes FirebaseAuth directly, which has no flow and therefore no - * scope. Its body reads only its three parameters. */ internal fun authUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState = if (!user.isEmailVerified && @@ -100,21 +80,12 @@ internal fun authUserState(user: FirebaseUser, result: AuthResult?, isNewUser: B AuthState.Success(result = result, user = user, isNewUser = isNewUser) } -/** - * The auth flow the current composition belongs to, or null outside one. - * - * Ambient rather than a parameter because the sub-screens that need it — `EmailAuthScreen`, - * `PhoneAuthScreen` — are public composables, and "which conversation am I part of" is a property - * of where they are composed, not of what their caller knows to pass. `FirebaseAuthScreen` - * provides the host's flow; `reauthDestinations` provides the request's, so a credential exchange's - * states go to that request and are never seen by anything collecting the public flow. - */ +/** The auth flow the current composition belongs to, or null outside one. */ internal val LocalAuthFlowScope = staticCompositionLocalOf { null } /** - * The flow this composition belongs to: the ambient one when composed inside a flow that provides - * it, and otherwise a fresh one over the host's public state channel — which is what a consumer - * composing `EmailAuthScreen` or `PhoneAuthScreen` on its own gets. + * 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( 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 e0bfbf1ba..8fbea3373 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt @@ -257,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 the caller it resolves to 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 @@ -275,41 +272,26 @@ abstract class AuthState private constructor() { val user: FirebaseUser, val reason: String?, /** - * Where the caller awaiting this request is parked, or null when nobody is: a + * Where the caller awaiting this request is parked, or null when nobody is — a * standalone flow from [FirebaseAuthUI.createReauthFlow] has no operation behind it. - * Completing it runs the retry in the caller's own coroutine, which is why nothing - * retains the caller's closure here. */ val resolver: CompletableDeferred? = null, ) { /** 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. False once its coroutine died - * with the scope that launched it, which is a request that can no longer complete - * however well the credential exchange goes. - */ + /** Whether the awaiting caller is still there to resume. */ val isResumable: Boolean get() = resolver?.isActive != false - /** - * Credentials were accepted: the awaiting caller resumes and retries its operation. - * Idempotent, and a no-op once the caller is gone, so every terminal path can call it - * without checking first. - */ + /** Credentials were accepted: the caller resumes and retries. Idempotent. */ fun resolve() { resolver?.complete(true) } /** - * The request ended without proof — the user backed out, or the surface was torn down. - * - * Completed with a value rather than an exception on purpose. This resolver is - * parented to the caller's job so that a dead caller is detectable, and completing a - * parented Deferred *exceptionally* propagates the failure to that parent — declining - * would cancel the caller's whole scope and take its sibling jobs with it. - * [FirebaseAuthUI.withReauth] turns this into a throw in its own frame instead, which - * is an ordinary exception the caller can catch. + * 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 decline() { resolver?.complete(false) @@ -417,11 +399,7 @@ abstract class AuthState private constructor() { override val userUid: String get() = request.user.uid } - /** - * Credentials were accepted for the request's user. Terminal for the credential exchange: - * the screen validates the proof, resolves the awaiting caller and ends the request, and - * the caller's own retry publishes ordinary states from there. - */ + /** Credentials were accepted for the request's user. Terminal for the exchange. */ internal class Succeeded( override val request: Request, val success: Success, 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 9a18d66ed..e8a69f791 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -86,15 +86,8 @@ class FirebaseAuthUI private constructor( private val _authStateFlow = MutableStateFlow(AuthState.Idle) /** - * The reauthentication request waiting for a screen to take it on, or null. - * - * A dedicated channel rather than a lane on [_authStateFlow]. Raising a request and reporting - * auth state are different conversations, and sharing one channel is what forced every reader - * to work out whose states it was looking at — an `AuthState.Error` that meant "sign-in failed" - * or "the reauthentication attempt failed" depending on context nothing carried. - * - * Process-scoped because the caller is: it outlives the screen that presents it, which is what - * lets a recreated screen pick the same request up rather than reconstruct it. + * 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) @@ -248,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 @@ -261,9 +252,6 @@ class FirebaseAuthUI private constructor( ?: throw AuthException.UserNotFoundException( message = "No user is currently signed in" ) - // One definition of what a reauthentication configuration is, shared with the screen's - // own path for raising one: a linked credential is not a proof of identity, so neither enables - // linking or upgrade. val reauthConfig = configuration.toReauthConfiguration(currentUser) checkNotNull(reauthConfig) { "No configured providers are linked to the current user" @@ -346,8 +334,7 @@ class FirebaseAuthUI private constructor( else -> false } if (isStale) updateAuthState(AuthState.Idle) - // A signed-out user cannot reauthenticate, so an outstanding request is stale - // by definition — and its caller is told rather than left waiting. + // A signed-out user cannot reauthenticate; the caller is told, not dropped. pendingReauth.getAndUpdate { null }?.request?.decline() } trySend(buildState(firebaseAuth.currentUser)) @@ -446,9 +433,6 @@ class FirebaseAuthUI private constructor( // Sign out from Firebase Auth auth.signOut() .also { - // These two publish nothing, so they take no sink and no configuration — - // signOut has none to give. The test seams are resolved here rather than - // inside them, which is the last thing either needed this receiver for. signOutFromGoogle( auth = auth, context = context, @@ -488,14 +472,13 @@ class FirebaseAuthUI private constructor( /** * Executes a sensitive operation, automatically handling reauthentication if required. * - * If the [operation] throws [FirebaseAuthRecentLoginRequiredException], this method emits - * [AuthState.Reauthentication.Required] and suspends. [FirebaseAuthScreen] observes that state - * and presents a reauthentication sheet; once credentials are accepted the [operation] runs - * again on this same coroutine, so nothing about the caller is retained by the library. + * 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 — so a caller can always tell a decline from a completed operation. A caller - * that must survive Activity recreation should launch from a scope that does too. + * is not retried. A caller that must survive Activity recreation should launch from a scope + * that does too. * * All other exceptions propagate normally. * @@ -524,9 +507,7 @@ class FirebaseAuthUI private constructor( } catch (e: FirebaseAuthRecentLoginRequiredException) { val user = auth.currentUser ?: throw AuthException.UserNotFoundException(message = "No user is currently signed in") - // The caller's half of the request, parented to the caller's own job: a scope that - // dies cancels this with it, which is how the screen tells a request it can still - // complete from one whose operation can never run again. + // 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( @@ -536,17 +517,14 @@ class FirebaseAuthUI private constructor( resolver = resolver, ) ) - // One request at a time. A second one replaces the first, and the caller it displaces - // is told so rather than left waiting on a request no screen will ever present. + // 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) } - // Thrown from this frame rather than out of the resolver: the resolver is parented to - // the caller's job, so failing it would cancel the caller's whole scope instead of - // just this call. A caller always learns whether its operation ran. + // Not through the resolver: failing a parented Deferred cancels the caller's scope. if (!retry) { throw AuthException.AuthCancelledException( message = "Reauthentication was cancelled" @@ -568,8 +546,6 @@ class FirebaseAuthUI private constructor( */ suspend fun delete(context: Context) { try { - // The whole reauthentication dance is withReauth's: raise once, retry once, and no - // branch here that both emits Required and throws for the same condition. withReauth(context) { val currentUser = auth.currentUser ?: throw AuthException.UserNotFoundException( @@ -583,9 +559,7 @@ class FirebaseAuthUI private constructor( updateAuthState(AuthState.Idle) } } catch (e: AuthException.AuthCancelledException) { - // The user declined the reauthentication. The screen already published the terminal - // state for that, so republishing it as an Error would put a dialog over a flow the - // user deliberately left. + // Declined, not failed: the screen already published the terminal state. throw e } catch (e: CancellationException) { // Handle coroutine cancellation 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 ae1a09c83..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 @@ -67,10 +67,7 @@ internal fun AuthFlowScope.rememberSignInWithFacebookLauncher( val callbackManager = remember { CallbackManager.Factory.create() } val loginManager = LoginManager.getInstance() val currentContext by rememberUpdatedState(context) - // The receiver, through a snapshot, exactly where `config` used to be read this way: the - // callback below is registered once under `DisposableEffect(Unit)` — re-registering it on a - // recomposition would be the expensive mistake — so it must not close over the scope this - // composition happened to have. + // 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) 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 182ece53b..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 @@ -184,17 +184,9 @@ fun FirebaseAuthScreen( val observedAuthState by remember(authUI) { authUI.authStateFlow() } .collectAsState(initial = null as AuthState?) val rawAuthState = observedAuthState ?: AuthState.Idle - // Composition-scoped, so its existence *is* the answer to "is there a screen able to drive an - // outstanding request to completion?" — no counter on the singleton, and a phase that cannot outlive - // the Activity and be accepted into an unrelated sign-in. val reauthFlowState = rememberReauthFlowState() val reauthState = reauthFlowState.phase - // The request waiting to be taken on, watched on its own channel rather than picked out of the - // state flow. Process-scoped, so a recreated screen sees the same one and takes it on again. val pendingReauth by authUI.pendingReauth.collectAsState() - // The host's own flow. Provider code reaches the public state channel only through this sink, - // which is the whole point of the receiver change: there is no `authUI` on a scope to reach it - // any other way. val hostStateHolder = rememberUpdatedState(rawAuthState) val hostScope = remember(authUI, configuration, hostStateHolder) { hostAuthFlowScope(authUI, configuration, hostStateHolder) @@ -248,13 +240,8 @@ fun FirebaseAuthScreen( val presentedReauth = backStack.presentedReauth() val clearReauthPresentation: () -> Unit = remember(backStack) { { backStack.clearReauth() } } /** - * Ends the outstanding request: clears its presentation, clears the phase, publishes [terminal], and - * only then resolves the caller waiting on it. - * - * One helper because every terminal site does the same four things in the same order, and the - * order carries two rules. The phase goes before [terminal] is published, or `fold` folds the - * terminal state straight back into the request it is ending. The caller is resolved last, or - * a fast-resuming retry's real outcome is overwritten by this stale one. + * 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) { @@ -295,9 +282,7 @@ fun FirebaseAuthScreen( remember(reauthContent) { { config -> when { - // 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. + // 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 @@ -364,9 +349,7 @@ fun FirebaseAuthScreen( LocalAuthUIStringProvider provides configuration.stringProvider, LocalTopLevelDialogController provides dialogController, LocalAuthUITheme provides (configuration.theme ?: LocalAuthUITheme.current), - // The host's flow, for every sub-screen composed below. `reauthDestinations` overrides it - // with the outstanding request's, which is what puts a credential exchange's states on the phase - // instead of on the public channel. + // reauthDestinations overrides this with the outstanding request's own flow. LocalAuthFlowScope provides hostScope, ) { Surface( @@ -642,14 +625,7 @@ fun FirebaseAuthScreen( previousAuthState.value = state // Guards below use `isAt` (runtime class), not `==`: keys carry arguments, so `==` blanks a live form. val currentKey = backStack.lastOrNull() - // A reauthentication owns the screen while it is up. The host must not navigate or - // tear down underneath a modal sheet, and an ambient Success from the signed-in - // session is not this flow's to act on. `Aborted` is the exception: it is how the - // host itself is dismissed, and it ends the request on the way out. - // - // This one rule replaces what `contextualizeReauthenticationState` and then `fold` - // did by mapping every state onto a phase — the exchange's own states no longer - // arrive here at all, so all that is left to say is who owns the screen. + // A modal reauthentication owns the screen; Aborted is how the host is dismissed. if (reauthFlowState.phase != null && state !is AuthState.Aborted) { return@LaunchedEffect } @@ -708,13 +684,7 @@ fun FirebaseAuthScreen( } is AuthState.Aborted -> { - // Outside the host guard on purpose. `fold` declines Aborted, so nothing - // else clears the phase or resolves the caller — and under the activity - // host FirebaseAuthActivity owns the rest of the teardown, so a clear - // placed inside the guard would leave that host holding an outstanding request - // and a caller suspended forever. An activity-scoped caller has its own - // cancellation to fall back on, an unscoped one has nothing, and this - // cannot tell them apart, so it resolves unconditionally. + // Outside the guard below: the activity host ends nothing itself. clearReauthPresentation() reauthFlowState.finish(false) if (activity !is FirebaseAuthActivity) { @@ -747,12 +717,7 @@ fun FirebaseAuthScreen( } } - /** - * A presentation marker with no request behind it: the process died while a - * reauthentication was outstanding, and the request went with it — it was never - * serializable. The back stack survives, so without this the restored screen would - * render an empty sheet over a flow with nothing to drive it. - */ + // 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 @@ -766,13 +731,7 @@ fun FirebaseAuthScreen( ) } - /** - * Takes on the request waiting on [FirebaseAuthUI.pendingReauth]. - * - * This is what the `Reauthentication.Required` branch of the state effect used to do, - * moved onto the request's own channel. A recreated screen runs it again against the - * same request, which is why nothing has to reconstruct one from a latched state. - */ + // 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 @@ -789,9 +748,7 @@ fun FirebaseAuthScreen( ) return@LaunchedEffect } - // A request whose caller died with its scope cannot be completed however well the - // exchange goes, so it is reported rather than presented. This is what tells a - // rotation that kept its caller from one that lost it. + // A request whose caller is gone can never complete, so it is reported. if (!required.request.isResumable) { finishReauth( AuthState.Error( @@ -816,17 +773,11 @@ fun FirebaseAuthScreen( } } - /** - * The phase's own effect. The effect above is keyed on the flow, so it never sees a - * transition the destinations make straight on the holder — an MFA proof, a cancelled - * attempt, a consumed notification. Keying on the phase catches all of them. - */ + // 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: it - // has no resolver to render otherwise, and this is the only place that moves it, - // so no attempt path can strand the user on a dead challenge. + // 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) { @@ -844,8 +795,7 @@ fun FirebaseAuthScreen( if (success.reauthenticatedUid != phase.userUid || success.user.uid != phase.userUid ) { - // Proof for the wrong user is a failed attempt, not a dead request: the - // surface stays up reporting it so the user can try the right account. + // Wrong user is a failed attempt, not a dead request. reauthFlowState.update(phase.requestId) { AuthState.Reauthentication.AttemptFailed( request, @@ -856,10 +806,7 @@ fun FirebaseAuthScreen( } return@LaunchedEffect } - // The retry runs on the caller's own coroutine, so this publishes the handover - // rather than an outcome: a Success here would claim the pending operation had - // already succeeded. A standalone flow has no operation behind it, so for that - // one reauthenticating *is* the outcome. + // 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) 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 d9577b321..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 @@ -208,12 +208,8 @@ fun EmailAuthScreen( ) } - // The flow this screen belongs to: the host's when composed on its own, the outstanding - // request's when composed inside a reauthentication surface. val authFlowScope = rememberAuthFlowScope(authUI, configuration) - // This flow's state, not the process-wide channel's: under a reauthentication request - // that is the request's own phase, so the loading and error below describe the - // conversation this screen is actually part of. + // 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 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 b66f293be..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 @@ -225,11 +225,8 @@ fun PhoneAuthScreen( } } - // The flow this screen belongs to: the host's when composed on its own, the outstanding - // request's when composed inside a reauthentication surface. val authFlowScope = rememberAuthFlowScope(authUI, configuration) - // This flow's state, not the process-wide channel's: under a reauthentication request - // that is the request's own phase. + // 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 || 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 b7d90bb4c..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 @@ -85,8 +85,7 @@ internal fun AuthState.Reauthentication?.toReauthSurface( is AuthState.Reauthentication.SmsAutoVerified, is AuthState.Reauthentication.PasswordResetLinkSent, is AuthState.Reauthentication.EmailSignInLinkSent, - // Momentary: the screen validates the proof and ends the request on it. The surface stays - // up for that rather than flashing the flow underneath. + // Momentary, but the surface stays up rather than flashing the flow underneath. is AuthState.Reauthentication.Succeeded, -> state.request } ?: return null @@ -193,9 +192,7 @@ internal fun EntryProviderScope.reauthDestinations( ?.let { if (it is AuthException) it else AuthException.from(it, stringProvider) } val error = exception?.let { getRecoveryMessage(it, stringProvider) } - // This request's own flow, built where the configuration it needs is guaranteed to exist: - // the entry returns early when there is no surface, so the scope can never fall back to - // the host's and put a credential exchange's states on the public channel. + // Built here, where the entry's early return guarantees the configuration exists. val reauthStateHolder = remember(reauthFlowState) { derivedStateOf { reauthFlowState.phase ?: AuthState.Idle } } @@ -308,9 +305,7 @@ internal fun EntryProviderScope.reauthDestinations( resolver = mfaResolver, auth = authUI.auth, content = mfaChallengeContent, - // The one credential exchange no provider owns, so the stamp is made here: - // no current user means nothing was re-proved, and the attempt is reported as - // a failure rather than moved on as an unstamped success. + // The one exchange no provider owns, so the stamp is made here. onSuccess = { val reauthenticated = authUI.auth.currentUser if (reauthenticated == null) { @@ -338,9 +333,7 @@ internal fun EntryProviderScope.reauthDestinations( onCancel = { reauthFlowState.update(key.requestId) { it.attemptCancelled() } }, - // The request's own flow, like every other outcome of this exchange. Writing - // the public channel here would report a failed second factor as an ordinary - // sign-in error to anything collecting `authStateFlow()`. + // The request's flow: the public channel would report this as a sign-in error. onError = { e -> reauthScope.emit(AuthState.Error(e)) }, ) } 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 index 8a4272806..a4f9323df 100644 --- 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 @@ -24,13 +24,7 @@ import com.firebase.ui.auth.AuthStateSink /** * The reauthentication phase machine of one - * [com.firebase.ui.auth.ui.screens.FirebaseAuthScreen]. - * - * Scoped to the composition that created it, which is what makes it the answer to "is there a - * screen able to drive an outstanding request to completion?" — the question - * `FirebaseAuthUI.addReauthenticationDrainer` used to answer with a counter on the singleton. - * Every transition below runs from a composed screen, so a request nothing has accepted stays - * inert without anything having to count screens. + * [com.firebase.ui.auth.ui.screens.FirebaseAuthScreen], scoped to its composition. * * @since 10.0.0 */ @@ -49,11 +43,8 @@ internal class ReauthFlowState internal constructor( } /** - * Drops the outstanding request and tells its awaiting caller whether to retry. - * - * Resolving here rather than at each call site is what stops a caller being left suspended - * forever: every way a request ends comes through this, including the ones that end it because - * the user backed out. + * 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 @@ -62,12 +53,8 @@ internal class ReauthFlowState internal constructor( } /** - * Applies [transition] to the live phase while [requestId] still names it. - * - * The id check is what `FirebaseAuthUI.updateReauthentication` needed against a shared flow - * any caller could have overwritten. Here it only guards a back stack entry one composition - * behind the phase, so it compares a key the caller already holds rather than arbitrating - * between writers. + * 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 @@ -82,12 +69,8 @@ internal class ReauthFlowState internal constructor( } /** - * This request's own state sink, for the provider code driving its credential exchange. - * - * Everything the exchange publishes becomes a phase here rather than a state on the public - * flow, which is what stops an app's collector acting on a `Loading` or `Error` belonging to a - * conversation that is not theirs. [hostFallback] takes what [fold] declines: those states are - * not part of the exchange, so they are still the host's to handle. + * 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) @@ -96,11 +79,6 @@ internal class ReauthFlowState internal constructor( /** * 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. - * - * This is `FirebaseAuthUI.contextualizeReauthenticationState` relocated off the singleton's - * setter. Provider implementations still publish only ordinary states and still need no - * parallel session storage of their own, but the mapping now reads the phase it owns instead - * of read-modify-writing the flow it is being written to. */ fun fold(state: AuthState): AuthState.Reauthentication? { if (state is AuthState.Reauthentication) return null @@ -138,8 +116,7 @@ internal class ReauthFlowState internal constructor( is AuthState.EmailSignInLinkSent -> AuthState.Reauthentication.EmailSignInLinkSent(request) - // Only a stamped Success proves this user was re-verified. An unstamped one is an - // ambient FirebaseAuth emission and leaves the phase alone. + // Only a stamped Success proves this user was re-verified. is AuthState.Success -> if (state.reauthenticatedUid != null) { AuthState.Reauthentication.Succeeded(request, state) @@ -147,8 +124,7 @@ internal class ReauthFlowState internal constructor( current } - // Ambient emissions and notification cleanup while a request is outstanding. They must not - // detach the request from the caller waiting on it. + // Must not detach the request from the caller waiting on it. is AuthState.Idle, is AuthState.RequiresEmailVerification, is AuthState.RequiresProfileCompletion, @@ -163,16 +139,9 @@ internal class ReauthFlowState internal constructor( } /** - * Creates and remembers the [ReauthFlowState] for one - * [com.firebase.ui.auth.ui.screens.FirebaseAuthScreen]. - * - * Called once, above the `NavDisplay`, alongside `rememberPhoneAuthFlowState` and - * `rememberMfaEnrollmentFlowState`, and composition-scoped like both: a phase that outlived its - * Activity would let the next screen accept it, putting a reauthentication sheet into an - * unrelated sign-in. What survives recreation is the back stack's - * [com.firebase.ui.auth.ui.screens.AuthRoute.Reauth] marker and the raised - * [AuthState.Reauthentication.Required] itself, which is enough to accept it again — and the request's - * resolver is what says whether the caller behind it is still there to resume. + * 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 = 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 976f48c8d..80c18a058 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt @@ -286,9 +286,7 @@ class FirebaseAuthUIAuthStateTest { delay(100) verify(mockFirebaseAuth).addAuthStateListener(listenerCaptor.capture()) - // The request travels its own channel now, so nothing about it reaches the state - // flow — which is the point: an app collecting `authStateFlow()` is not part of this - // conversation. + // Nothing about the request reaches the state flow. val resolver = CompletableDeferred() authUI.pendingReauth.value = raisedReauth( mockFirebaseUser, @@ -304,8 +302,7 @@ class FirebaseAuthUIAuthStateTest { delay(200) job.cancel() - // A signed-out user cannot reauthenticate, so the request is dropped and the caller - // waiting on it is told rather than left suspended. + // 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() @@ -648,9 +645,7 @@ class FirebaseAuthUIAuthStateTest { val state = requireNotNull(authUI.pendingReauth.value) assertThat(state.request.hasPendingOperation).isTrue() assertThat(state.request.isResumable).isTrue() - // One path for this condition now: it raises a request and waits, where it used to raise one - // *and* throw an - // InvalidCredentialsException the caller had to catch and ignore. + // One path now: it raises a request and waits, rather than raising one *and* throwing. assertThat(call.isActive).isTrue() state.request.decline() @@ -727,8 +722,7 @@ class FirebaseAuthUIAuthStateTest { 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 will run here rather than anywhere - // the library would have to hold on to it. + // Parked on its own half of the request, so the retry runs here. assertThat(call.isActive).isTrue() state.request.decline() 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 cce066450..9bbbb1608 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt @@ -644,8 +644,7 @@ class FirebaseAuthUITest { val instance = FirebaseAuthUI.create(defaultApp, mockAuth) val context = ApplicationProvider.getApplicationContext() - // Arms and waits for the reauthentication it needs, rather than throwing a mapped - // exception the caller had to catch and ignore before showing its own reauth UI. + // Raises a request and waits, rather than throwing a mapped exception. val call = launch { runCatching { instance.delete(context) } } runCurrent() 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 index c4422bf25..758ac9483 100644 --- 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 @@ -229,9 +229,7 @@ class SignInStateSequenceTest { context = applicationContext, email = "a@b.com", password = "pw1", - // The credential-manager save is unavailable under Robolectric and throws - // past this path's own handlers, which is its own bug and not this one's. - // Skipped here so the sequence recorded is the state machine's. + // Unavailable under Robolectric, and its own bug — see task_7f7cc65a. skipCredentialSave = true) } } @@ -351,8 +349,7 @@ class SignInStateSequenceTest { verifier = verifier) runCurrent() - // The verifier's flow is cold and already has its emission, so Loading and the prompt land - // in the same turn: conflation means a consumer sees only the prompt. + // Cold flow, so Loading and the prompt land in one turn and conflation hides Loading. assertThat(states).containsExactly("Idle", "PhoneNumberVerificationRequired").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 939c03aa2..4957ad6a6 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 @@ -544,8 +544,7 @@ class FirebaseAuthScreenEmailRecoveryTest { `when`(user.email).thenReturn(TYPED_EMAIL) `when`(user.uid).thenReturn("reauth-user-uid") - // A second collector on the same flow, to prove directly that nothing from the exchange - // lands on it rather than inferring it 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 } } @@ -555,10 +554,7 @@ class FirebaseAuthScreenEmailRecoveryTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, - // One linked provider, so the sheet opens straight at the email step and the - // method picker is never composed — the probe hooks in there instead. The slot - // is not `reauthContent`, so `reauthSlotActive` is unaffected and the dialog - // behaviour these assertions are about is unchanged. + // One provider, so the sheet opens at the email step and the picker never composes. emailContent = { probe.capture() }, ) } @@ -576,9 +572,7 @@ class FirebaseAuthScreenEmailRecoveryTest { } composeTestRule.waitForIdle() - // The exchange's failure never reaches the public flow at all now — a stronger property - // than the folded phase this used to assert, and the reason the recovery stays out of - // reach below. + // 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. 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 918c379e7..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 @@ -360,10 +360,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = { cancelledCount++ }, - // The default sheet renders no app slot, so the probe hooks in through the - // method-picker layout — composed inside the request's own flow, and unlike - // reauthContent it does not flip reauthSlotActive, so the dialog behaviour these - // assertions are about is unchanged. The picker itself is the default one. + // 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) @@ -644,10 +641,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, - // The default sheet renders no app slot, so the probe hooks in through the - // method-picker layout — composed inside the request's own flow, and unlike - // reauthContent it does not flip reauthSlotActive, so the dialog behaviour these - // assertions are about is unchanged. The picker itself is the default one. + // 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) @@ -1077,9 +1071,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, - // A password-only user, so the sheet opens straight at the email step and the - // method picker is never composed — the probe hooks in there. Not `reauthContent`, - // so `reauthSlotActive` and the sheet's own behaviour are unaffected. + // Password-only, so the sheet opens at the email step and the picker never composes. emailContent = { probe.capture() }, mfaChallengeContent = { state -> challenge = state @@ -1398,10 +1390,7 @@ class FirebaseAuthScreenReauthContentStateTest { restorationTester.emulateSavedInstanceStateRestore() composeTestRule.waitForIdle() - // The request survives recreation on its own channel, so the surface comes back and the - // same operation is still waiting on it. The *failure* does not: a phase is composition - // -scoped by design, and only `Required` is durable. The user is returned to provider - // selection with a clean slate rather than shown an error from before the recreation. + // The request survives recreation; the phase does not. assertThat(signedInAuthUI.pendingReauth.value).isNotNull() assertThat(requireNotNull(captured).error).isNull() composeTestRule.onNodeWithText("SLOT_ERROR=$expectedMessage").assertDoesNotExist() @@ -1747,8 +1736,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() - // An attempt, then the user backing out of it: the phase returns to provider selection for - // the same request, which re-enters the raising branch. + // 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) } @@ -2000,8 +1988,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed() - // Straight off RequiresMfa, without the challenge's own cancel or error path running: - // an ordinary Cancelled folds to provider selection, which is the phase leaving RequiresMfa. + // Straight off RequiresMfa: a Cancelled folds to provider selection. composeTestRule.runOnIdle { probe.emit(AuthState.Cancelled) } composeTestRule.waitForIdle() composeTestRule.waitForIdle() 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 a521fb12f..9e64864b6 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 @@ -210,8 +210,7 @@ class FirebaseAuthScreenReauthIdleResetTest { composeTestRule.runOnIdle { authUI.pendingReauth.value = retryingReauth(mockUser) { operationStarted = true - // Exactly what a successful delete() does: FirebaseAuth drops the user and - // notifies its listeners while the operation is running. + // What a successful delete() does: the user is dropped mid-operation. `when`(mockFirebaseAuth.currentUser).thenReturn(null) listeners.forEach { it.onAuthStateChanged(mockFirebaseAuth) } operationCompleted = true 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 6e0fd3319..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 @@ -574,8 +574,7 @@ class PhoneAuthScreenVerificationLifecycleTest { val credential = mock(PhoneAuthCredential::class.java) val observed = mutableListOf() - // Stands in for the composed FirebaseAuthScreen, which is what owns folding now: fold each - // ordinary provider state into the outstanding request and publish the phase this screen reads. + // Stands in for the composed FirebaseAuthScreen, which owns folding. val required = AuthState.Reauthentication.Required(user) val reauthFlowState = ReauthFlowState(mutableStateOf(null)) reauthFlowState.accept(required) From eafa9e74d8b9abc832e9dae368f3e68ac9ad2fa8 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:40:28 +0100 Subject: [PATCH 10/15] test(auth): drive the reauthentication e2e tests through withReauth --- .../FirebaseAuthScreenEmailRecoveryTest.kt | 11 +- .../FirebaseAuthScreenReauthIdleResetTest.kt | 7 +- .../ui/screens/reauth/ReauthFlowStateTest.kt | 6 +- .../ui/auth/ui/screens/ReauthFlowTest.kt | 127 ++++++++++++------ 4 files changed, 91 insertions(+), 60 deletions(-) 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 4957ad6a6..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 @@ -525,14 +525,9 @@ 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 outstanding, `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 - * outstanding — 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 outstanding never surfaces as an error state`() { 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 9e64864b6..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 @@ -152,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 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 index 08d8a0c9b..19926fe0b 100644 --- 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 @@ -54,11 +54,7 @@ class ReauthFlowStateTest { return request } - /** - * The counter `addReauthenticationDrainer` kept is gone because this is the same question: - * with no request outstanding there is no conversation for a provider state to belong to, so it stays - * the host's. - */ + /** 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() 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..ca0a9531c 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 @@ -1,5 +1,10 @@ package com.firebase.ui.auth.ui.screens +import kotlinx.coroutines.launch +import kotlinx.coroutines.cancel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineScope +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException import android.content.Context import android.os.Looper import androidx.activity.ComponentActivity @@ -47,6 +52,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 +83,7 @@ class ReauthFlowTest { @After fun tearDown() { + reauthScope.cancel() authUI.auth.signOut() FirebaseAuthUI.clearInstanceCache() emulatorApi.clearEmulatorData() @@ -113,6 +122,7 @@ class ReauthFlowTest { var currentAuthState: AuthState = AuthState.Idle var retryOperationCalled = false + var attempts = 0 val configuration = authUIConfiguration { context = applicationContext @@ -171,13 +181,19 @@ class ReauthFlowTest { val signedInUser = 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 }, - ) - ) + // A real sensitive operation: the first attempt fails the way Firebase fails + // one, so `withReauth` raises the request itself rather than the test poking + // a state object. It suspends here until the sheet resolves 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 +256,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" @@ -291,13 +308,19 @@ 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 }, - ) - ) + // A real sensitive operation: the first attempt fails the way Firebase fails + // one, so `withReauth` raises the request itself rather than the test poking + // a state object. It suspends here until the sheet resolves it. + 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() @@ -359,6 +382,7 @@ class ReauthFlowTest { val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" } var retryOperationCalled = false + var attempts = 0 val configuration = authUIConfiguration { context = applicationContext @@ -401,13 +425,19 @@ class ReauthFlowTest { shadowOf(Looper.getMainLooper()).idle() - authUI.updateAuthState( - AuthState.Reauthentication.Required( - user = signedInUser, - reason = "Please verify your identity to continue", - retryOperation = { retryOperationCalled = true }, - ) - ) + // A real sensitive operation: the first attempt fails the way Firebase fails + // one, so `withReauth` raises the request itself rather than the test poking + // a state object. It suspends here until the sheet resolves 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() @@ -465,6 +495,7 @@ class ReauthFlowTest { var currentAuthState: AuthState = AuthState.Idle var retryOperationCalled = false + var attempts = 0 val configuration = authUIConfiguration { context = applicationContext @@ -521,13 +552,19 @@ class ReauthFlowTest { val signedInUser = 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 }, - ) - ) + // A real sensitive operation: the first attempt fails the way Firebase fails + // one, so `withReauth` raises the request itself rather than the test poking + // a state object. It suspends here until the sheet resolves 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() @@ -599,6 +636,7 @@ class ReauthFlowTest { var currentAuthState: AuthState = AuthState.Idle var retryOperationStarted = false + var attempts = 0 var retryOperationCompleted = false val configuration = authUIConfiguration { @@ -655,21 +693,24 @@ class ReauthFlowTest { val signedInUser = 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 = { - 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. - yield() - retryOperationCompleted = true - }, - ) - ) + // A real sensitive operation: the first attempt fails the way Firebase fails + // one, so `withReauth` raises the request itself rather than the test poking + // a state object. It suspends here until the sheet resolves 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" + ) + retryOperationStarted = true + authUI.auth.signOut() + // A suspension point makes a dropped operation observable: the retry runs on + // this scope, so anything that cancelled it would stop here. + yield() + retryOperationCompleted = true + } + } + } shadowOf(Looper.getMainLooper()).idle() composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { From fa66e51c93e27ccd50dbf07cd36cb6375f872522 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:26:47 +0100 Subject: [PATCH 11/15] fix(auth): clear the reauthentication handover state however the retry ends --- .../com/firebase/ui/auth/FirebaseAuthUI.kt | 10 ++- .../ui/auth/FirebaseAuthUIAuthStateTest.kt | 61 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) 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 e8a69f791..2b1625641 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -530,7 +530,15 @@ class FirebaseAuthUI private constructor( message = "Reauthentication was cancelled" ) } - operation() + // 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 + ) + } } } 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 80c18a058..3a99e322e 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt @@ -753,6 +753,67 @@ class FirebaseAuthUIAuthStateTest { 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 `a finished retry publishes the session it left behind`() = runTest { + val context = ApplicationProvider.getApplicationContext() + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + var callCount = 0 + + 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) + + 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 `a retry that fails still clears the handover state`() = runTest { + val context = ApplicationProvider.getApplicationContext() + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + val cause = RuntimeException("Network error") + var callCount = 0 + var thrown: Exception? = null + + 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) + + authUI.updateAuthState(AuthState.Loading("Finishing that action...")) + state.request.resolve() + call.join() + + 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 From 1481fbfc016623d79c2c88a047c02198142f5b11 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:26:47 +0100 Subject: [PATCH 12/15] test(auth): cover a password change and an account deletion through reauthentication --- .../ui/auth/ui/screens/ReauthFlowTest.kt | 334 ++++++++++++++++-- 1 file changed, 297 insertions(+), 37 deletions(-) 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 ca0a9531c..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 @@ -1,10 +1,5 @@ package com.firebase.ui.auth.ui.screens -import kotlinx.coroutines.launch -import kotlinx.coroutines.cancel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.CoroutineScope -import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException import android.content.Context import android.os.Looper import androidx.activity.ComponentActivity @@ -32,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 @@ -178,19 +180,16 @@ 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. - // A real sensitive operation: the first attempt fails the way Firebase fails - // one, so `withReauth` raises the request itself rather than the test poking - // a state object. It suspends here until the sheet resolves it. + // 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 + retryOperationCalled = true } } } @@ -307,17 +306,14 @@ class ReauthFlowTest { shadowOf(Looper.getMainLooper()).idle() - // Emit Reauthentication.Required to trigger the custom reauthContent slot. - // A real sensitive operation: the first attempt fails the way Firebase fails - // one, so `withReauth` raises the request itself rather than the test poking - // a state object. It suspends here until the sheet resolves it. + // 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 + retryOperationCalled = true } } } @@ -379,7 +375,7 @@ 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 @@ -425,16 +421,14 @@ class ReauthFlowTest { shadowOf(Looper.getMainLooper()).idle() - // A real sensitive operation: the first attempt fails the way Firebase fails - // one, so `withReauth` raises the request itself rather than the test poking - // a state object. It suspends here until the sheet resolves it. + // 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 + retryOperationCalled = true } } } @@ -549,19 +543,17 @@ 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. - // A real sensitive operation: the first attempt fails the way Firebase fails - // one, so `withReauth` raises the request itself rather than the test poking - // a state object. It suspends here until the sheet resolves it. + // 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 + retryOperationCalled = true } } } @@ -690,24 +682,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: arm a request whose operation signs the user out, as delete() would. - // A real sensitive operation: the first attempt fails the way Firebase fails - // one, so `withReauth` raises the request itself rather than the test poking - // a state object. It suspends here until the sheet resolves it. + // 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() - // A suspension point makes a dropped operation observable: the retry runs on - // this scope, so anything that cancelled it would stop here. - yield() - retryOperationCompleted = true + retryOperationStarted = true + authUI.auth.signOut() + // A suspension point makes a dropped operation observable. + yield() + retryOperationCompleted = true } } } @@ -749,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) + } + } From da2be3a028b51f0921674a20c5e80936ee23a896 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:50:51 +0100 Subject: [PATCH 13/15] refactor(auth)!: make Reauthentication.Required's user constructor internal --- auth/src/main/java/com/firebase/ui/auth/AuthState.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 8fbea3373..68371dd55 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt @@ -310,7 +310,8 @@ 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, ) : this( From 26b1fbf2604dfff89656930355fdb69fb53cc9b5 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:57:04 +0100 Subject: [PATCH 14/15] test(auth): pin what makes one reauthentication request state differ from another --- .../main/java/com/firebase/ui/auth/AuthState.kt | 4 ++++ .../auth/ui/screens/reauth/ReauthFlowStateTest.kt | 15 +++++++++++++++ 2 files changed, 19 insertions(+) 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 68371dd55..a59a88bf4 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt @@ -327,6 +327,10 @@ abstract class AuthState private constructor() { val user: FirebaseUser get() = request.user val reason: String? get() = request.reason + /** + * 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 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 index 19926fe0b..baf4d7a31 100644 --- 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 @@ -187,6 +187,21 @@ class ReauthFlowStateTest { 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() From bf937e918db7c7495594f142efdd3f1b544f68c9 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:59:44 +0100 Subject: [PATCH 15/15] fix(auth): read a user's email once when deciding what their sign-in means --- .../main/java/com/firebase/ui/auth/AuthFlowScope.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt b/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt index e05cb6388..3a3564a60 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt @@ -70,15 +70,17 @@ internal class AuthFlowScope( * 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 = - if (!user.isEmailVerified && - user.email != null && +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 = user.email!!) + 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 }