From 08e8887cd132a77f91bba2f5bea1ba15020ab6fc Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 2 Sep 2026 16:21:06 +0100 Subject: [PATCH] feat(auth): make each phone auth step a real navigation destination --- auth/build.gradle.kts | 3 + .../firebase/ui/auth/ui/screens/AuthRoute.kt | 19 +- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 52 +- .../ui/screens/phone/PhoneAuthDestinations.kt | 206 +++++ .../auth/ui/screens/phone/PhoneAuthScreen.kt | 117 ++- .../ui/screens/reauth/ReauthDestinations.kt | 16 + .../email/EmailAuthHostDestinationsTest.kt | 3 + .../phone/PhoneAuthHostDestinationsTest.kt | 578 ++++++++++++++ .../phone/PhoneAuthRouteNavigationTest.kt | 721 ++++++++++++++++++ .../screens/reauth/ReauthSurfaceGateTest.kt | 3 + 10 files changed, 1655 insertions(+), 63 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthDestinations.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthHostDestinationsTest.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthRouteNavigationTest.kt diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index 65106a91e..b4a700553 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -169,4 +169,7 @@ dependencies { tasks.withType().configureEach { jvmArgs("-javaagent:${mockitoAgent.asPath}") + // The suite OOMs the test worker on Gradle's 512m default, killing the run part-way. + // Cumulative retention across the module's Robolectric suites, not any one test. + maxHeapSize = "2g" } 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 591e0b79a..347670341 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 @@ -22,6 +22,7 @@ import androidx.navigation3.runtime.metadata import androidx.navigation3.scene.Scene import com.firebase.ui.auth.mfa.MfaEnrollmentStep import com.firebase.ui.auth.ui.screens.email.EmailAuthMode +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthStep import kotlinx.serialization.Serializable /** @@ -37,9 +38,6 @@ import kotlinx.serialization.Serializable * a step. Deliberately **not** a [NavKey]: it can never be put on a back stack, and [toKey] is * the one way to turn it into something that can be. * - * [Phone] registers its steps, but [com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen] still - * drives its own step internally, so every step of that flow renders the same screen. - * * @since 10.0.0 */ @Serializable @@ -148,8 +146,9 @@ sealed interface AuthRoute { override fun startKey(): Destination = EnterPhoneNumber /** - * One step per screen the phone flow walks through. Public API, and kept even though - * [com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen] drives its own step internally. + * One step per screen the phone flow walks through. The internal + * `AuthRoute.Phone.Step.phoneStep` extension maps a live key to the [PhoneAuthStep] + * [com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen] renders. */ @Serializable sealed interface Step : Destination @@ -162,6 +161,9 @@ sealed interface AuthRoute { internal val steps: List get() = listOf(EnterPhoneNumber, EnterVerificationCode) + + internal fun stepFor(phoneStep: PhoneAuthStep): Step = + steps.first { it.phoneStep == phoneStep } } /** @@ -209,6 +211,13 @@ internal val AuthRoute.Email.Step.mode: EmailAuthMode is AuthRoute.Email.EmailLinkSignIn -> EmailAuthMode.EmailLinkSignIn } +/** Which [PhoneAuthStep] the hosted screen renders for this step. */ +internal val AuthRoute.Phone.Step.phoneStep: PhoneAuthStep + get() = when (this) { + AuthRoute.Phone.EnterPhoneNumber -> PhoneAuthStep.EnterPhoneNumber + AuthRoute.Phone.EnterVerificationCode -> PhoneAuthStep.EnterVerificationCode + } + /** Which [MfaEnrollmentStep] the hosted screen renders for this step. */ internal val AuthRoute.MfaEnrollment.Step.enrollmentStep: MfaEnrollmentStep get() = when (this) { 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 2532f2b6d..f8ef02ead 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 @@ -42,6 +42,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -98,7 +99,9 @@ import com.firebase.ui.auth.ui.screens.mfa.mfaEnrollmentDestinations import com.firebase.ui.auth.ui.screens.mfa.mfaEnrollmentStartStep import com.firebase.ui.auth.ui.screens.mfa.rememberMfaEnrollmentFlowState import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState -import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen +import com.firebase.ui.auth.ui.screens.phone.exitPhoneAuth +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 @@ -182,6 +185,7 @@ fun FirebaseAuthScreen( val pendingLinkingCredential = remember { mutableStateOf(null) } val pendingResolver = remember { mutableStateOf(null) } val mfaEnrollmentFlowState = rememberMfaEnrollmentFlowState() + val phoneAuthFlowState = rememberPhoneAuthFlowState(configuration) DisposableEffect(authUI) { authUI.addReauthenticationDrainer() onDispose { authUI.removeReauthenticationDrainer() } @@ -189,6 +193,10 @@ fun FirebaseAuthScreen( 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. + val reauthPhoneFlowState = key(reauthRequest?.requestId) { + rememberPhoneAuthFlowState(reauthConfig ?: configuration) + } /** * The reauthentication surface, or null when there is none. One signal: [ReauthSceneStrategy] * decides whether the sheet exists on it and the entry renders what it resolves to. @@ -393,29 +401,20 @@ fun FirebaseAuthScreen( }, ) - val phoneStep: @Composable () -> Unit = { - PhoneAuthScreen( - context = context, - configuration = configuration, - authUI = authUI, - content = phoneContent, - onSuccess = {}, - onError = { exception -> - onSignInFailure(exception) - }, - onCancel = { - if (!skipsMethodPicker && !backStack.popOrNull()) { - backStack.resetBackStackTo(AuthRoute.MethodPicker) - } + phoneAuthDestinations( + backStack = backStack, + context = context, + configuration = configuration, + authUI = authUI, + flowState = phoneAuthFlowState, + content = phoneContent, + onError = { exception -> onSignInFailure(exception) }, + onCancel = { + if (!skipsMethodPicker && !backStack.exitPhoneAuth()) { + backStack.resetBackStackTo(AuthRoute.MethodPicker) } - ) - } - entry( - metadata = authRouteMetadata(AuthRoute.Phone.EnterPhoneNumber) - ) { phoneStep() } - entry( - metadata = authRouteMetadata(AuthRoute.Phone.EnterVerificationCode) - ) { phoneStep() } + }, + ) entry(metadata = authRouteMetadata(AuthRoute.Success)) { val uiContext = remember(authState, stringProvider) { @@ -524,6 +523,7 @@ fun FirebaseAuthScreen( configuration = configuration, stringProvider = stringProvider, surface = reauthSurfaceHolder, + phoneFlowState = reauthPhoneFlowState, emailContent = emailContent, phoneContent = phoneContent, mfaChallengeContent = mfaChallengeContent, @@ -815,7 +815,11 @@ fun FirebaseAuthScreen( pendingLinkingCredential.value = null lastSuccessfulUserId.value = null typedEmail.value = null - if (!currentKey.isAt(startRoute)) { + // Number entry is a real position in the flow, so a retraction + // reached there stays put rather than resetting out of it. + if (!currentKey.isAt(startRoute) && + currentKey !is AuthRoute.Phone.EnterPhoneNumber + ) { backStack.resetBackStackTo(startRoute) } } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthDestinations.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthDestinations.kt new file mode 100644 index 000000000..49452c21a --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthDestinations.kt @@ -0,0 +1,206 @@ +/* + * 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.phone + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.data.CountryData +import com.firebase.ui.auth.ui.screens.AuthRoute +import com.firebase.ui.auth.ui.screens.authRouteMetadata +import com.firebase.ui.auth.ui.screens.phoneStep +import com.firebase.ui.auth.ui.screens.popOrNull +import com.firebase.ui.auth.ui.screens.pushUnique +import com.firebase.ui.auth.util.CountryUtils +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job + +/** + * Everything a [PhoneAuthScreen] step needs that must outlive the step being left. + * + * Moving between steps disposes whatever the step being left held in composition, which would + * otherwise take the typed number, the verification id and the live verification attempt with it. + * Remembered by the host *above* the [androidx.navigation3.ui.NavDisplay] and handed to every step + * through [phoneAuthDestinations], this is what a step reads and writes instead of its own local + * state. + * + * [phoneNumber], [verificationCode], [verificationId], [forceResendingToken] and + * [resendTimerSeconds] are backed by [rememberSaveable] and survive Activity recreation. + * [selectedCountry] is not, matching what the un-hosted screen always did. + * + * @since 10.0.0 + */ +class PhoneAuthFlowState internal constructor( + val phoneNumber: MutableState, + val verificationCode: MutableState, + val selectedCountry: MutableState, + val verificationId: MutableState, + val forceResendingToken: MutableState, + val resendTimerSeconds: MutableIntState, + /** The number and start time of the attempt the cooldown check rejects a duplicate of. */ + internal val pendingVerificationPhoneNumber: MutableState, + internal val verificationStartTime: MutableState, + /** + * The live verification attempt, and a scope outliving the step that started it: the attempt + * stays open until Firebase's auto-retrieval timeout, so a step-scoped scope would cancel it + * on the way to code entry. + */ + internal val verificationJob: MutableState, + internal val verificationScope: CoroutineScope, + /** + * The verification id already navigated on, and the auto-verified credential already signed in + * with. Both steps observe the same auth state and are composed together for the length of a + * transition, so both would otherwise act on the same emission twice. + */ + internal val navigatedVerificationId: MutableState, + internal val consumedAutoCredential: MutableState, +) + +/** + * Creates and remembers the [PhoneAuthFlowState] a host installs [phoneAuthDestinations] with. + * Called once, above the `NavDisplay`, so the same instance is handed to every step. + * + * Seeds [PhoneAuthFlowState.phoneNumber] and [PhoneAuthFlowState.selectedCountry] from + * [configuration]'s phone provider, and from the platform default when it offers none. + */ +@Composable +fun rememberPhoneAuthFlowState(configuration: AuthUIConfiguration): PhoneAuthFlowState { + val provider = configuration.providers.filterIsInstance().firstOrNull() + val phoneNumber = rememberSaveable { mutableStateOf(provider?.defaultNumber ?: "") } + val verificationCode = rememberSaveable { mutableStateOf("") } + val selectedCountry = remember { + mutableStateOf( + provider?.defaultCountryCode?.let { code -> CountryUtils.findByCountryCode(code) } + ?: CountryUtils.getDefaultCountry() + ) + } + val verificationId = rememberSaveable { mutableStateOf(null) } + val forceResendingToken = + rememberSaveable { mutableStateOf(null) } + val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) } + val pendingVerificationPhoneNumber = remember { mutableStateOf(null) } + val verificationStartTime = remember { mutableStateOf(null) } + val verificationJob = remember { mutableStateOf(null) } + val verificationScope = rememberCoroutineScope() + val navigatedVerificationId = remember { mutableStateOf(null) } + val consumedAutoCredential = remember { mutableStateOf(null) } + return remember { + PhoneAuthFlowState( + phoneNumber = phoneNumber, + verificationCode = verificationCode, + selectedCountry = selectedCountry, + verificationId = verificationId, + forceResendingToken = forceResendingToken, + resendTimerSeconds = resendTimerSeconds, + pendingVerificationPhoneNumber = pendingVerificationPhoneNumber, + verificationStartTime = verificationStartTime, + verificationJob = verificationJob, + verificationScope = verificationScope, + navigatedVerificationId = navigatedVerificationId, + consumedAutoCredential = consumedAutoCredential, + ) + } +} + +/** + * Registers the phone flow's two steps on [this] entry provider, each rendering its own step of a + * [PhoneAuthScreen] driven from the outside. + * + * Reaching code entry is a push, so back returns to number entry. Leaving goes through + * [exitPhoneAuth], which drops both entries at once. + * + * @param flowState The state that must outlive a step switch — see [PhoneAuthFlowState]. Shared by + * every step this registers, and expected to be `remember`-ed by the host once, above the + * `NavDisplay`. + * @param onCancel Invoked when the flow is *left*, not when stepping back to number entry. + */ +internal fun EntryProviderScope.phoneAuthDestinations( + backStack: NavBackStack, + context: Context, + configuration: AuthUIConfiguration, + authUI: FirebaseAuthUI, + flowState: PhoneAuthFlowState, + content: (@Composable (PhoneAuthContentState) -> Unit)?, + onCancel: () -> Unit, + onError: (AuthException) -> Unit = {}, +) { + val body: @Composable (AuthRoute.Phone.Step) -> Unit = { key -> + PhoneAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + // The host's own auth-state observer owns where a completed sign-in navigates. + onSuccess = {}, + onError = onError, + onCancel = onCancel, + step = key.phoneStep, + onNavigateToStep = { target -> + backStack.navigateToPhoneStep(AuthRoute.Phone.stepFor(target)) + }, + onNavigateBack = { backStack.popOrNull() }, + flowState = flowState, + content = content, + ) + } + + entry( + metadata = authRouteMetadata(AuthRoute.Phone.EnterPhoneNumber) + ) { body(it) } + entry( + metadata = authRouteMetadata(AuthRoute.Phone.EnterVerificationCode) + ) { body(it) } +} + +/** + * Pushes [step], leaving the step below reachable, and does nothing when it is already on top. + * + * Upholds [pushUnique]'s precondition: the flow only moves forward through here — number entry to + * code entry — and every backward move goes through [popOrNull] instead. + */ +internal fun NavBackStack.navigateToPhoneStep(step: AuthRoute.Phone.Step) { + if (lastOrNull() == step) return + pushUnique(step) +} + +/** + * Leaves the phone flow from whatever depth it reached, in one write: truncates to the lowest phone + * step on the stack rather than popping repeatedly, so it does not matter how deep the flow went or + * whether it was entered more than once. + * + * Returns whether anything was removed. Changes nothing, and returns false, when no step is on the + * stack or the flow is the whole of it — `NavDisplay` throws on an empty back stack, so the caller + * decides what replaces it. + */ +internal fun NavBackStack.exitPhoneAuth(): Boolean { + val lowestStep = indexOfFirst { it is AuthRoute.Phone.Step } + if (lowestStep <= 0) return false + while (size > lowestStep) removeAt(size - 1) + return true +} 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 e87154432..14a5f7a0e 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 @@ -23,7 +23,6 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -42,8 +41,6 @@ import com.firebase.ui.auth.data.CountryData import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController import com.firebase.ui.auth.util.CountryUtils import com.google.firebase.auth.AuthResult -import com.google.firebase.auth.PhoneAuthProvider -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -124,6 +121,16 @@ class PhoneAuthContentState( * @param onCancel Callback invoked when the user cancels the authentication flow. * @param modifier Applied once to the [Box] hosting the rendered content; it propagates minimum * constraints so it doesn't change how content is measured. + * @param step The step to render. When null this composable owns the step itself, starting at + * [PhoneAuthStep.EnterPhoneNumber]. Goes together with [onNavigateToStep], [onNavigateBack] and + * [flowState]: passing any of the four without the rest throws. + * @param onNavigateToStep Invoked instead of changing local state when a sent code moves the flow + * on to [PhoneAuthStep.EnterVerificationCode]. Always a push. Goes together with [step]. + * @param onNavigateBack Invoked instead of changing local state when the user asks to change the + * number they entered. Hosted, this is a pop back to [PhoneAuthStep.EnterPhoneNumber]. Goes + * together with [step]. + * @param flowState The data a step switch must not dispose — see [PhoneAuthFlowState]. Goes + * together with [step]. * @param content A composable lambda that receives [PhoneAuthContentState] to render the UI for * each step. If null, the default UI for the current step is rendered. */ @@ -136,39 +143,66 @@ fun PhoneAuthScreen( onError: (AuthException) -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier, + step: PhoneAuthStep? = null, + onNavigateToStep: ((PhoneAuthStep) -> Unit)? = null, + onNavigateBack: (() -> Unit)? = null, + flowState: PhoneAuthFlowState? = null, content: @Composable ((PhoneAuthContentState) -> Unit)? = null, ) { + require( + (step == null) == (onNavigateToStep == null) && + (onNavigateToStep == null) == (onNavigateBack == null) && + (onNavigateBack == null) == (flowState == null) + ) { + "PhoneAuthScreen's step, onNavigateToStep, onNavigateBack and flowState go together: " + + "pass all four to drive the step from outside, or none to let the screen own it. " + + "Got step=$step, onNavigateToStep=" + + "${if (onNavigateToStep == null) "null" else "a callback"}, onNavigateBack=" + + "${if (onNavigateBack == null) "null" else "a callback"}, flowState=" + + "${if (flowState == null) "null" else "provided"}." + } + val activity = LocalActivity.current val provider = configuration.providers.filterIsInstance().first() val stringProvider = LocalAuthUIStringProvider.current val dialogController = LocalTopLevelDialogController.current val coroutineScope = rememberCoroutineScope() - val step = rememberSaveable { mutableStateOf(PhoneAuthStep.EnterPhoneNumber) } - val phoneNumberValue = rememberSaveable { mutableStateOf(provider.defaultNumber ?: "") } - val verificationCodeValue = rememberSaveable { mutableStateOf("") } - val selectedCountry = remember { - mutableStateOf( - provider.defaultCountryCode?.let { code -> - CountryUtils.findByCountryCode(code) - } ?: CountryUtils.getDefaultCountry() - ) + // Read only when this composable owns the step. + val localStep = rememberSaveable { mutableStateOf(PhoneAuthStep.EnterPhoneNumber) } + val currentStep = step ?: localStep.value + val navigateToStep: (PhoneAuthStep) -> Unit = { target -> + if (onNavigateToStep != null) onNavigateToStep(target) else localStep.value = target + } + val navigateBack: () -> Unit = { + if (onNavigateBack != null) { + onNavigateBack() + } else { + localStep.value = PhoneAuthStep.EnterPhoneNumber + } } + + val effectiveFlowState = flowState ?: rememberPhoneAuthFlowState(configuration) + val phoneNumberValue = effectiveFlowState.phoneNumber + val verificationCodeValue = effectiveFlowState.verificationCode + val selectedCountry = effectiveFlowState.selectedCountry + val verificationId = effectiveFlowState.verificationId + val forceResendingToken = effectiveFlowState.forceResendingToken + val resendTimerSeconds = effectiveFlowState.resendTimerSeconds + val pendingVerificationPhoneNumber = effectiveFlowState.pendingVerificationPhoneNumber + val verificationStartTime = effectiveFlowState.verificationStartTime + val verificationJob = effectiveFlowState.verificationJob + val verificationScope = effectiveFlowState.verificationScope + val navigatedVerificationId = effectiveFlowState.navigatedVerificationId + val consumedAutoCredential = effectiveFlowState.consumedAutoCredential + val fullPhoneNumber = remember(selectedCountry.value, phoneNumberValue.value) { CountryUtils.formatPhoneNumber(selectedCountry.value.dialCode, phoneNumberValue.value) } - val verificationId = rememberSaveable { mutableStateOf(null) } - val forceResendingToken = - rememberSaveable { mutableStateOf(null) } - val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) } - val pendingVerificationPhoneNumber = remember { mutableStateOf(null) } - val verificationStartTime = remember { mutableStateOf(null) } - - // Verification is a long-lived collection: it stays open until Firebase's auto-retrieval - // timeout, so a superseded attempt must be cancelled or it keeps writing auth state. - val verificationJob = remember { mutableStateOf(null) } - // Not rememberSaveable: the coroutine that clears this dies with the composition, so a value - // restored after rotation would latch forever and permanently disable auto sign-in. + + // Transient to code entry, so a step switch resets it. Not rememberSaveable either: the + // coroutine that clears this dies with the composition, so a value restored after rotation + // would latch forever and permanently disable auto sign-in. val isSubmittingCode = remember { mutableStateOf(false) } // Logged, not silent: which attempt was torn down and why is the first thing needed from a @@ -227,13 +261,14 @@ fun PhoneAuthScreen( is AuthState.PhoneNumberVerificationRequired, is AuthState.Reauthentication.PhoneNumberVerificationRequired -> { - verificationId.value = when (state) { + val id = when (state) { is AuthState.PhoneNumberVerificationRequired -> state.verificationId is AuthState.Reauthentication.PhoneNumberVerificationRequired -> { state.verificationId } else -> error("Unreachable phone verification state") } + verificationId.value = id forceResendingToken.value = when (state) { is AuthState.PhoneNumberVerificationRequired -> state.forceResendingToken is AuthState.Reauthentication.PhoneNumberVerificationRequired -> { @@ -241,7 +276,12 @@ fun PhoneAuthScreen( } else -> error("Unreachable phone verification state") } - step.value = PhoneAuthStep.EnterVerificationCode + // A step re-entered by backing out re-runs this effect on the state it left with, + // so the move it already made must not repeat. + if (navigatedVerificationId.value != id) { + navigatedVerificationId.value = id + navigateToStep(PhoneAuthStep.EnterVerificationCode) + } resendTimerSeconds.intValue = provider.timeout.toInt() // Start 60-second countdown } @@ -257,9 +297,13 @@ fun PhoneAuthScreen( pendingVerificationPhoneNumber.value = null verificationStartTime.value = null - // A manually submitted code is already signing in: auto-verifying now would run a - // second concurrent sign-in with the same phone number. - if (isSubmittingCode.value) { + // Both steps observe this emission while a step transition has them composed + // together, and one credential can only be signed in with once. + if (consumedAutoCredential.value === credential) { + Log.d("PhoneAuthScreen", "Suppressed auto sign-in: credential already consumed") + } else if (isSubmittingCode.value) { + // A manually submitted code is already signing in: auto-verifying now would + // run a second concurrent sign-in with the same phone number. 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. @@ -267,6 +311,7 @@ fun PhoneAuthScreen( AuthState.Loading(configuration.stringProvider.loadingSigningInWithPhone) ) } else { + consumedAutoCredential.value = credential // Consumed before the async sign-in call so it can't be clobbered by that // call's own state. if (state is AuthState.Reauthentication.SmsAutoVerified) { @@ -274,7 +319,9 @@ fun PhoneAuthScreen( } else { authUI.updateAuthState(AuthState.Idle) } - coroutineScope.launch { + // The flow's scope, not this step's: a transition can dispose the step this + // ran from before the sign-in it started has landed. + verificationScope.launch { try { authUI.signInWithPhoneAuthCredential( context = context, @@ -342,7 +389,7 @@ fun PhoneAuthScreen( } val state = PhoneAuthContentState( - step = step.value, + step = currentStep, isLoading = isLoading, error = errorMessage, phoneNumber = phoneNumberValue.value, @@ -389,7 +436,9 @@ fun PhoneAuthScreen( pendingVerificationPhoneNumber.value = fullPhoneNumber verificationStartTime.value = currentTime - verificationJob.value = coroutineScope.launch { + // The flow's scope, not this step's: this collection stays open past the move + // to code entry, and cancelVerification is what ends it. + verificationJob.value = verificationScope.launch { try { authUI.verifyPhoneNumber( provider = provider, @@ -434,7 +483,7 @@ fun PhoneAuthScreen( onResendCodeClick = { if (resendTimerSeconds.intValue == 0) { cancelVerification("code resent") - verificationJob.value = coroutineScope.launch { + verificationJob.value = verificationScope.launch { try { // The timer is restarted by the PhoneNumberVerificationRequired branch // above: this call only returns once the verification window closes. @@ -466,7 +515,7 @@ fun PhoneAuthScreen( } verificationJob.value = null isSubmittingCode.value = false - step.value = PhoneAuthStep.EnterPhoneNumber + navigateBack() verificationCodeValue.value = "" verificationId.value = null forceResendingToken.value = null 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 7fcbc6d4c..a047795c3 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 @@ -42,7 +42,9 @@ import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState import com.firebase.ui.auth.ui.screens.email.EmailAuthStep import com.firebase.ui.auth.ui.screens.mfa.MfaChallengeScreen import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthFlowState import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen +import com.firebase.ui.auth.ui.screens.phoneStep import com.firebase.ui.auth.ui.screens.rememberOnProviderSelected import com.firebase.ui.auth.ui.screens.toKey import com.google.firebase.auth.FirebaseUser @@ -140,6 +142,9 @@ internal fun NavBackStack.navigateReauth( * @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 * 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 + * the host flow typed reaches it, and nothing it holds outlives the request. */ @OptIn(ExperimentalMaterial3Api::class) internal fun EntryProviderScope.reauthDestinations( @@ -150,6 +155,7 @@ internal fun EntryProviderScope.reauthDestinations( configuration: AuthUIConfiguration, stringProvider: AuthUIStringProvider, surface: State, + phoneFlowState: PhoneAuthFlowState, emailContent: (@Composable (EmailAuthContentState) -> Unit)?, phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?, mfaChallengeContent: (@Composable (MfaChallengeContentState) -> Unit)?, @@ -258,7 +264,17 @@ internal fun EntryProviderScope.reauthDestinations( content = phoneContent, onSuccess = {}, onError = {}, + // onLeaveStep owns pop-vs-dismiss, and cancels the attempt with it. onCancel = { onLeaveStep(key) }, + step = step.phoneStep, + onNavigateToStep = { target -> + backStack.navigateReauth(key, AuthRoute.Phone.stepFor(target)) + }, + // Number entry inside the surface: a pop while it is below, a move to it when not. + onNavigateBack = { + backStack.navigateReauth(key, AuthRoute.Phone.EnterPhoneNumber) + }, + flowState = phoneFlowState, ) // Only the state moves: the host pops the entry off whatever the state becomes, so 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 6d50d1f1c..c60bb05ee 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 @@ -47,6 +47,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.firebase.ui.auth.ui.screens.AuthRoute +import com.firebase.ui.auth.ui.screens.phone.rememberPhoneAuthFlowState import com.firebase.ui.auth.ui.screens.popOrNull import com.firebase.ui.auth.ui.screens.reauth.ReauthSceneStrategy import com.firebase.ui.auth.ui.screens.reauth.reauthDestinations @@ -348,6 +349,7 @@ class EmailAuthHostDestinationsTest { popTransitionSpec = DefaultAuthContentTransform, ) } + val phoneFlowState = rememberPhoneAuthFlowState(config) CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { NavDisplay( backStack = backStack, @@ -372,6 +374,7 @@ class EmailAuthHostDestinationsTest { configuration = config, stringProvider = stringProvider, surface = surface, + phoneFlowState = phoneFlowState, 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 new file mode 100644 index 000000000..249ec1f6d --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthHostDestinationsTest.kt @@ -0,0 +1,578 @@ +/* + * 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.phone + +import android.content.Context +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +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.AuthUITransitions +import com.firebase.ui.auth.configuration.DefaultAuthContentTransform +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.ui.FirebaseAuthTestTags +import com.firebase.ui.auth.ui.screens.AuthRoute +import com.firebase.ui.auth.ui.screens.AuthSuccessUiContext +import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen +import com.firebase.ui.auth.ui.screens.authRoute +import com.firebase.ui.auth.ui.screens.popOrNull +import com.firebase.ui.auth.ui.screens.reauth.ReauthSceneStrategy +import com.firebase.ui.auth.ui.screens.reauth.reauthDestinations +import com.firebase.ui.auth.ui.screens.reauth.toReauthSurface +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.UserInfo +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The phone flow as [FirebaseAuthScreen] itself installs it: the `flowState` it remembers, the + * `onCancel` it supplies, and the two entries [phoneAuthDestinations] registers on its display. + * + * [PhoneAuthRouteNavigationTest] drives the same extension through its own bare `NavDisplay`, so it + * pins the helpers but not the host's use of them — reverting the production call sites would leave + * it green. These render the real screen, whose back stack is not reachable from a test, and read + * the flow's position off what is on screen instead. + * + * The reauthentication surface is the phone flow's *other* host, installing the same steps through + * [com.firebase.ui.auth.ui.screens.reauth.reauthDestinations]. The `ReauthPhoneSheet` harness below + * drives that one, as `EmailAuthHostDestinationsTest` does for email. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class PhoneAuthHostDestinationsTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var applicationContext: Context + private lateinit var mockAuth: FirebaseAuth + private lateinit var authUI: FirebaseAuthUI + + private var pressBack: (() -> Unit)? = null + private var uiContext: AuthSuccessUiContext? = null + private val enteredRoutes = mutableListOf() + + /** The reauthentication harness's own stack, for the assertions that are about keys. */ + private var reauthBackStack: NavBackStack? = null + + /** The request the reauthentication harness armed, which its own emissions have to carry. */ + private var reauthRequest: AuthState.Reauthentication.Request? = null + + private var reauthDismissals = 0 + + @Before + fun setUp() { + FirebaseAuthUI.clearInstanceCache() + applicationContext = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(applicationContext).forEach { it.delete() } + val app = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + )!! + mockAuth = mock(FirebaseAuth::class.java) + `when`(mockAuth.app).thenReturn(app) + authUI = FirebaseAuthUI.create(app, mockAuth) + } + + @After + fun tearDown() { + pressBack = null + uiContext = null + enteredRoutes.clear() + reauthBackStack = null + reauthRequest = null + reauthDismissals = 0 + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { + try { + it.delete() + } catch (_: Exception) { + } + } + } + + /** + * The headline defect. Code entry shared number entry's back-stack entry, so the only thing + * left for the system back gesture to pop was the phone flow itself. + */ + @Test + fun `system back from code entry returns to number entry`() { + start() + enterPhoneFlow() + sendCode() + assertAtCodeEntry() + + back() + + assertAtNumberEntry() + assertStillInTheFlow() + } + + /** + * The other half of "one destination each": the configured screen transition has to run for the + * move between the two steps, which it cannot when neither step is entered. + */ + @Test + fun `moving to code entry animates as its own destination`() { + start() + enterPhoneFlow() + + sendCode() + + // Entering the flow at all proves the recorder works, so the missing code-entry route + // below is a real absence rather than a spec that never ran. + assertThat(enteredRoutes).contains(AuthRoute.Phone.EnterPhoneNumber) + assertThat(enteredRoutes).contains(AuthRoute.Phone.EnterVerificationCode) + } + + /** + * `AuthSuccessUiContext.onNavigate` takes any [AuthRoute], code entry included. Registered but + * never navigated to, that entry used to resolve to a screen whose own state still said number + * entry, so the step the host asked for was silently swapped for the other one. + */ + @Test + fun `a host navigating to code entry lands on code entry`() { + start() + signIn() + + composeTestRule.runOnIdle { + requireNotNull(uiContext).onNavigate(AuthRoute.Phone.EnterVerificationCode) + } + composeTestRule.waitForIdle() + + assertAtCodeEntry() + } + + /** + * Code entry's own back arrow leaves the flow rather than stepping back through it — that is + * what the "change number" control is for. Leaving from there has two entries to drop, and a + * single pop drops one. + */ + @Test + fun `leaving from code entry drops number entry with it`() { + start() + enterPhoneFlow() + sendCode() + assertAtCodeEntry() + + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.VerificationCode.BACK_BUTTON) + .performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST) + .assertIsDisplayed() + composeTestRule.onAllNodesWithTag(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD) + .assertCountEquals(0) + } + + /** + * "Change number" retracts the attempt it is abandoning, and that retraction runs through the + * host's own abandonment reset on its way back — which used to send a multi-provider + * configuration all the way out to the method picker. + */ + @Test + fun `changing the number returns to number entry rather than the method picker`() { + start() + enterPhoneFlow() + sendCode() + assertAtCodeEntry() + + composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.VerificationCode.CHANGE_PHONE_NUMBER_BUTTON) + .performClick() + composeTestRule.waitForIdle() + + assertAtNumberEntry() + assertStillInTheFlow() + } + + /** The number typed before the code was sent is what code entry confirms back to the user. */ + @Test + fun `the typed number survives the move to code entry`() { + start() + enterPhoneFlow() + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD) + .performTextInput(PHONE_NUMBER) + + sendCode() + + assertAtCodeEntry() + composeTestRule.onNodeWithText(FULL_PHONE_NUMBER, substring = true).assertIsDisplayed() + } + + /** + * The reauthentication surface keys every entry to one step, and it renders whatever the key + * names. A key naming code entry that renders number entry means the key is being ignored. + */ + @Test + fun `a reauthentication entry keyed to code entry renders code entry`() { + startReauthSheet(startStep = AuthRoute.Phone.EnterVerificationCode) + + assertAtCodeEntry() + } + + /** Sending the code inside reauthentication reaches code entry as an entry of its own. */ + @Test + fun `sending the code inside reauthentication pushes a second reauthentication entry`() { + startReauthSheet() + assertAtNumberEntry() + + sendReauthCode() + + assertThat(reauthBackStack?.toList()).containsExactly( + AuthRoute.Success, + AuthRoute.Reauth(REQUEST_ID, REAUTH_UID, AuthRoute.Phone.EnterPhoneNumber), + AuthRoute.Reauth(REQUEST_ID, REAUTH_UID, AuthRoute.Phone.EnterVerificationCode), + ).inOrder() + } + + /** + * Back from a reauthentication step steps back through the surface while another of its entries + * is underneath, and dismisses it only when none is. With both phone steps sharing one entry + * there was never one underneath, so back abandoned the reauthentication. + */ + @Test + fun `back from reauthentication code entry returns to number entry`() { + startReauthSheet() + sendReauthCode() + assertAtCodeEntry() + + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.VerificationCode.BACK_BUTTON) + .performClick() + composeTestRule.waitForIdle() + + assertAtNumberEntry() + assertThat(reauthDismissals).isEqualTo(0) + } + + // ============================================================================================= + // Harness + // ============================================================================================= + + /** + * Renders the real screen on a configuration offering email *and* phone, so the method picker + * is the entry underneath the phone flow and "left the flow" is distinguishable from "stepped + * back inside it". + */ + private fun start() { + composeTestRule.setContent { Host() } + composeTestRule.waitForIdle() + } + + @Composable + private fun Host() { + val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + SideEffect { pressBack = dispatcher?.let { { it.onBackPressed() } } } + + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + authenticatedContent = { _, context -> + uiContext = context + Text(text = "authenticated", modifier = Modifier.testTag(AUTHENTICATED_TAG)) + }, + ) + } + + /** Enters the phone flow the way the method picker does. */ + private fun enterPhoneFlow() { + composeTestRule.onNodeWithText(PHONE_PROVIDER_LABEL).performClick() + composeTestRule.waitForIdle() + assertAtNumberEntry() + } + + /** + * The emission Firebase's `onCodeSent` callback ends up publishing, which is the only thing + * that moves the flow on to code entry. + */ + private fun sendCode(verificationId: String = "verification-id-1") { + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.PhoneNumberVerificationRequired( + verificationId = verificationId, + forceResendingToken = mock(PhoneAuthProvider.ForceResendingToken::class.java), + ) + ) + } + composeTestRule.waitForIdle() + } + + /** Puts the screen on its authenticated destination, where `onNavigate` is reachable. */ + private fun signIn() { + val user = mock(FirebaseUser::class.java) + `when`(user.uid).thenReturn("phone-host-user") + `when`(user.email).thenReturn(null) + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.Success(result = null, user = user, isNewUser = false)) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(AUTHENTICATED_TAG).assertIsDisplayed() + } + + private fun back() { + composeTestRule.runOnUiThread { requireNotNull(pressBack).invoke() } + composeTestRule.waitForIdle() + } + + private fun assertAtNumberEntry() { + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD) + .assertIsDisplayed() + composeTestRule.onAllNodesWithTag(FirebaseAuthTestTags.VerificationCode.CODE_FIELD) + .assertCountEquals(0) + } + + private fun assertAtCodeEntry() { + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.VerificationCode.CODE_FIELD) + .assertIsDisplayed() + composeTestRule.onAllNodesWithTag(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD) + .assertCountEquals(0) + } + + /** No method picker on screen, so the step move stayed inside the flow. */ + private fun assertStillInTheFlow() { + composeTestRule.onAllNodesWithTag(FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST) + .assertCountEquals(0) + } + + /** Mounts the reauthentication surface with its phone flow open at [startStep]. */ + private fun startReauthSheet( + startStep: AuthRoute.Destination = AuthRoute.Phone.EnterPhoneNumber, + ) { + composeTestRule.setContent { ReauthPhoneSheet(startStep = startStep) } + composeTestRule.waitForIdle() + } + + /** + * The reauthentication surface as an entry on a host's back stack, which is the only way it + * exists: a `NavDisplay` with one non-reauthentication entry underneath it. + */ + @Composable + private fun ReauthPhoneSheet(startStep: AuthRoute.Destination) { + val config = phoneReauthConfiguration() + val user = remember { + val info = mock(UserInfo::class.java) + `when`(info.providerId).thenReturn(PhoneAuthProvider.PROVIDER_ID) + mock(FirebaseUser::class.java).also { + `when`(it.email).thenReturn(null) + `when`(it.uid).thenReturn(REAUTH_UID) + `when`(it.providerData).thenReturn(listOf(info)) + } + } + val request = remember { + AuthState.Reauthentication.Request( + requestId = REQUEST_ID, + user = user, + reason = null, + retryOperation = null, + ).also { reauthRequest = it } + } + val backStack = rememberNavBackStack( + AuthRoute.Success, + AuthRoute.Reauth(REQUEST_ID, REAUTH_UID, startStep), + ) + SideEffect { reauthBackStack = backStack } + val surface = remember { + mutableStateOf( + AuthState.Reauthentication.Required(request).toReauthSurface(config) + ) + } + val onDismiss: () -> Unit = { reauthDismissals++ } + val strategy = remember { + ReauthSceneStrategy( + surface = surface, + onDismissRequest = onDismiss, + transitionSpec = DefaultAuthContentTransform, + popTransitionSpec = DefaultAuthContentTransform, + ) + } + 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) + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + NavDisplay( + backStack = backStack, + sceneStrategies = listOf(strategy), + onBack = { + val top = backStack.lastOrNull() + if (top is AuthRoute.Reauth && + backStack.getOrNull(backStack.lastIndex - 1) !is AuthRoute.Reauth + ) { + onDismiss() + } else { + backStack.popOrNull() + } + }, + entryProvider = entryProvider { + entry { Box(modifier = Modifier.fillMaxSize()) {} } + reauthDestinations( + backStack = backStack, + authUI = authUI, + activity = null, + context = applicationContext, + configuration = config, + stringProvider = stringProvider, + surface = surface, + phoneFlowState = phoneFlowState, + emailContent = null, + phoneContent = null, + mfaChallengeContent = null, + reauthContent = null, + customMethodPickerLayout = null, + onDismiss = onDismiss, + onLeaveStep = { + if (backStack.getOrNull(backStack.lastIndex - 1) is AuthRoute.Reauth) { + backStack.popOrNull() + } else { + onDismiss() + } + }, + ) + }, + ) + } + } + + /** The reauthentication phase Firebase's `onCodeSent` callback ends up published as. */ + private fun sendReauthCode(verificationId: String = "reauth-verification-id") { + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.Reauthentication.PhoneNumberVerificationRequired( + request = requireNotNull(reauthRequest), + verificationId = verificationId, + forceResendingToken = mock(PhoneAuthProvider.ForceResendingToken::class.java), + ) + ) + } + composeTestRule.waitForIdle() + } + + private fun emailAndPhoneConfiguration(): AuthUIConfiguration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + // timeout = 0 keeps the resend countdown at zero, so no 1-second ticking effect is + // left pending between assertions. + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = "US", + allowedCountries = null, + timeout = 0L, + ) + ) + } + isCredentialManagerEnabled = false + // The default fades would keep the destination being left composed alongside its + // successor, which the "not on screen" assertions above cannot tell from a step that never + // moved. Recording is what pins each step having a destination of its own to animate to. + transitions = AuthUITransitions( + transitionSpec = { + enteredRoutes += targetState.authRoute() + EnterTransition.None togetherWith ExitTransition.None + }, + popTransitionSpec = { + enteredRoutes += targetState.authRoute() + EnterTransition.None togetherWith ExitTransition.None + }, + predictivePopTransitionSpec = { EnterTransition.None togetherWith ExitTransition.None }, + ) + } + + /** Phone alone, so the surface's own start step is the phone flow's rather than the picker. */ + private fun phoneReauthConfiguration(): AuthUIConfiguration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = "US", + allowedCountries = null, + timeout = 0L, + ) + ) + } + isCredentialManagerEnabled = false + }.copy( + isAnonymousUpgradeEnabled = false, + isCredentialLinkingEnabled = false, + isNewEmailAccountsAllowed = false, + isReauthenticationMode = true, + ) + + private companion object { + const val AUTHENTICATED_TAG = "authenticated-destination" + const val PHONE_PROVIDER_LABEL = "Sign in with phone" + const val PHONE_NUMBER = "5555550123" + const val FULL_PHONE_NUMBER = "+15555550123" + const val REQUEST_ID = "reauth-request-id" + const val REAUTH_UID = "reauth-uid" + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthRouteNavigationTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthRouteNavigationTest.kt new file mode 100644 index 000000000..8c2d08364 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthRouteNavigationTest.kt @@ -0,0 +1,721 @@ +/* + * 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.phone + +import android.content.Context +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import androidx.test.core.app.ApplicationProvider +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 +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.ui.screens.AuthRoute +import com.firebase.ui.auth.ui.screens.popOrNull +import com.firebase.ui.auth.util.CountryUtils +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthOptions +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.PhoneAuthProvider.OnVerificationStateChangedCallbacks +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.MockedStatic +import org.mockito.Mockito.atLeastOnce +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.kotlin.any +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Covers moving the phone flow's steps — [AuthRoute.Phone.EnterPhoneNumber] and + * [AuthRoute.Phone.EnterVerificationCode] — onto real navigation destinations. + * + * The unit under test is [phoneAuthDestinations], the entry-provider extension + * [com.firebase.ui.auth.ui.screens.FirebaseAuthScreen] installs, hosted here in a bare `NavDisplay` + * so the back stack can be read directly — the same shape `MfaEnrollmentRouteNavigationTest` uses + * for the enrolment flow. [PhoneAuthHostDestinationsTest] pins the host's own use of it. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class PhoneAuthRouteNavigationTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var applicationContext: Context + private lateinit var mockAuth: FirebaseAuth + private lateinit var authUI: FirebaseAuthUI + + private var backStack: NavBackStack? = null + private var lastState: PhoneAuthContentState? = null + private var pressBack: (() -> Unit)? = null + private val reportedErrors = mutableListOf() + + @Before + fun setUp() { + FirebaseAuthUI.clearInstanceCache() + applicationContext = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(applicationContext).forEach { it.delete() } + val app = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + )!! + mockAuth = mock(FirebaseAuth::class.java) + `when`(mockAuth.app).thenReturn(app) + authUI = FirebaseAuthUI.create(app, mockAuth) + } + + @After + fun tearDown() { + backStack = null + lastState = null + pressBack = null + reportedErrors.clear() + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { + try { + it.delete() + } catch (_: Exception) { + } + } + } + + // ============================================================================================= + // Each step is a destination of its own + // ============================================================================================= + + @Test + fun `a sent code pushes code entry, leaving number entry underneath`() { + start() + + sendCode("verification-id-1") + + assertThat(backStackKeys()).containsExactly( + AuthRoute.Phone.EnterPhoneNumber, + AuthRoute.Phone.EnterVerificationCode, + ).inOrder() + assertThat(requireNotNull(lastState).step).isEqualTo(PhoneAuthStep.EnterVerificationCode) + } + + @Test + fun `back from code entry returns to number entry`() { + start() + sendCode("verification-id-1") + + back() + + assertThat(backStackKeys()).containsExactly(AuthRoute.Phone.EnterPhoneNumber) + assertThat(requireNotNull(lastState).step).isEqualTo(PhoneAuthStep.EnterPhoneNumber) + } + + /** + * The step returned to re-runs the auth-state effect on the emission it left with, so the move + * that effect already made must not repeat and bounce the user straight back. + */ + @Test + fun `back from code entry does not bounce forward on the state it left with`() { + start() + sendCode("verification-id-1") + + back() + // Two frames of settling: a bounce arrives from a LaunchedEffect, not from the pop. + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + assertThat(backStackKeys()).containsExactly(AuthRoute.Phone.EnterPhoneNumber) + } + + @Test + fun `a resend of the same code does not stack a second code-entry entry`() { + start() + sendCode("verification-id-1") + sendCode("verification-id-2") + + assertThat(backStackKeys()).containsExactly( + AuthRoute.Phone.EnterPhoneNumber, + AuthRoute.Phone.EnterVerificationCode, + ).inOrder() + } + + @Test + fun `every declared phone step is reachable directly`() { + start() + + AuthRoute.Phone.steps.forEach { step -> + navigateDirectlyTo(step) + assertThat(currentKey()).isEqualTo(step) + assertThat(requireNotNull(lastState).step).isEqualTo(step.expectedContentStep()) + } + } + + // ============================================================================================= + // What must outlive the step being left + // ============================================================================================= + + /** + * The step being left is disposed along with everything it held in composition, so the number + * typed into it has to live in [PhoneAuthFlowState] to still be there on the way back. + */ + @Test + fun `the typed number and verification id survive a round trip through code entry`() { + start() + typePhoneNumber(TYPED_PHONE_NUMBER) + sendCode("verification-id-1") + assertThat(requireNotNull(lastState).fullPhoneNumber).contains(TYPED_PHONE_NUMBER) + + back() + assertThat(requireNotNull(lastState).phoneNumber).isEqualTo(TYPED_PHONE_NUMBER) + + sendCode("verification-id-2") + assertThat(requireNotNull(lastState).step).isEqualTo(PhoneAuthStep.EnterVerificationCode) + assertThat(requireNotNull(lastState).fullPhoneNumber).contains(TYPED_PHONE_NUMBER) + } + + /** + * The country picked on number entry is half of the number code entry confirms back to the + * user, and it is the one field the un-hosted screen never saved either. + */ + @Test + fun `the country picked on number entry is the one code entry formats with`() { + start() + typePhoneNumber(TYPED_PHONE_NUMBER) + composeTestRule.runOnIdle { + requireNotNull(lastState).onCountrySelected( + requireNotNull(CountryUtils.findByCountryCode(NON_DEFAULT_COUNTRY_CODE)) + ) + } + composeTestRule.waitForIdle() + + sendCode("verification-id-1") + + assertThat(requireNotNull(lastState).step).isEqualTo(PhoneAuthStep.EnterVerificationCode) + assertThat(requireNotNull(lastState).selectedCountry.countryCode) + .isEqualTo(NON_DEFAULT_COUNTRY_CODE) + assertThat(requireNotNull(lastState).fullPhoneNumber).startsWith(NON_DEFAULT_DIAL_CODE) + } + + /** Set on number entry by the emission that moves the flow, and read on code entry. */ + @Test + fun `the resend countdown set on number entry is the one code entry shows`() { + start(timeout = 60L) + + sendCode("verification-id-1") + + assertThat(requireNotNull(lastState).step).isEqualTo(PhoneAuthStep.EnterVerificationCode) + assertThat(requireNotNull(lastState).resendTimer).isEqualTo(60) + } + + /** + * The typed code is transient to code entry in every other sense, but "change number" clears + * it on its way out — from the step being disposed, into state the step returned to still + * holds. + */ + @Test + fun `changing the number clears the code typed into the step being left`() { + start() + sendCode("verification-id-1") + composeTestRule.runOnIdle { requireNotNull(lastState).onVerificationCodeChange("123456") } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { requireNotNull(lastState).onChangeNumberClick() } + composeTestRule.waitForIdle() + + assertThat(currentKey()).isEqualTo(AuthRoute.Phone.EnterPhoneNumber) + sendCode("verification-id-2") + assertThat(requireNotNull(lastState).verificationCode).isEmpty() + } + + /** + * The attempt is a long-lived collection that stays open past the sent code, so a scope tied to + * the step that started it would cancel auto-retrieval on the way to code entry. + */ + @Test + fun `the verification attempt started on number entry is still live on code entry`() { + `when`(mockAuth.signInWithCredential(any())) + .thenReturn(TaskCompletionSource().task) + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + val credential = mock(PhoneAuthCredential::class.java) + statics.`when` { + PhoneAuthProvider.getCredential(any(), any()) + }.thenReturn(credential) + start() + typePhoneNumber(TYPED_PHONE_NUMBER) + sendCodeForReal() + val callbacks = latestCallbacks(statics) + codeSent(callbacks, "verification-id-1") + assertThat(currentKey()).isEqualTo(AuthRoute.Phone.EnterVerificationCode) + + composeTestRule.runOnUiThread { callbacks.onVerificationCompleted(credential) } + composeTestRule.waitForIdle() + + verify(mockAuth, times(1)).signInWithCredential(any()) + } + } + + /** + * The cooldown that rejects a duplicate verification of the same number is recorded on number + * entry and has to still be there when the user backs out of code entry and taps send again. + */ + @Test + fun `the cooldown record survives backing out of code entry`() { + mockStatic(PhoneAuthProvider::class.java).use { statics -> + start(timeout = 60L) + typePhoneNumber(TYPED_PHONE_NUMBER) + sendCodeForReal() + codeSent(latestCallbacks(statics), "verification-id-1") + assertThat(currentKey()).isEqualTo(AuthRoute.Phone.EnterVerificationCode) + + back() + sendCodeForReal() + + assertThat(reportedErrors.map { it::class.java }) + .contains(AuthException.PhoneVerificationCooldownException::class.java) + } + } + + /** + * The attempt outlives the step that started it, so the step that abandons it is the one that + * has to be able to cancel it — a late auto-verification must not sign in behind a user who + * asked to change their number. + */ + @Test + fun `changing the number cancels the attempt started on number entry`() { + `when`(mockAuth.signInWithCredential(any())) + .thenReturn(TaskCompletionSource().task) + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + val credential = mock(PhoneAuthCredential::class.java) + statics.`when` { + PhoneAuthProvider.getCredential(any(), any()) + }.thenReturn(credential) + start() + typePhoneNumber(TYPED_PHONE_NUMBER) + sendCodeForReal() + val callbacks = latestCallbacks(statics) + codeSent(callbacks, "verification-id-1") + + composeTestRule.runOnIdle { requireNotNull(lastState).onChangeNumberClick() } + composeTestRule.waitForIdle() + composeTestRule.runOnUiThread { callbacks.onVerificationCompleted(credential) } + repeat(3) { composeTestRule.waitForIdle() } + + assertThat(currentKey()).isEqualTo(AuthRoute.Phone.EnterPhoneNumber) + verify(mockAuth, never()).signInWithCredential(any()) + } + } + + /** + * A step transition has both steps composed at once, each observing the same auth state off the + * process-scoped [FirebaseAuthUI], and one auto-verified credential can only be signed in with + * once. Two screens sharing one [PhoneAuthFlowState] is that overlap, without an animation + * clock to hold still. + */ + @Test + fun `both steps composed at once auto-verify one credential once`() { + `when`(mockAuth.signInWithCredential(any())) + .thenReturn(TaskCompletionSource().task) + val credential = mock(PhoneAuthCredential::class.java) + val configuration = phoneConfiguration(timeout = 0L) + + composeTestRule.setContent { + CompositionLocalProvider( + LocalAuthUIStringProvider provides configuration.stringProvider + ) { + val shared = rememberPhoneAuthFlowState(configuration) + Row { + PhoneAuthStep.entries.forEach { step -> + PhoneAuthStepUnderTest(configuration, shared, step) + } + } + } + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.SMSAutoVerified(credential)) } + composeTestRule.waitForIdle() + + verify(mockAuth, times(1)).signInWithCredential(any()) + } + + // ============================================================================================= + // Leaving the flow drops every entry it pushed + // ============================================================================================= + + /** + * The defect requirement 5 names: every move between steps is a push, so a single pop from + * code entry strands the user on number entry rather than leaving. + */ + @Test + fun `leaving from code entry drops every entry the flow pushed`() { + val below = AuthRoute.Email.SignIn(KEPT_EMAIL) + val stack = stackOf( + AuthRoute.MethodPicker, + below, + AuthRoute.Phone.EnterPhoneNumber, + AuthRoute.Phone.EnterVerificationCode, + ) + + assertThat(stack.exitPhoneAuth()).isTrue() + + assertThat(stack.toList()).containsExactly(AuthRoute.MethodPicker, below).inOrder() + // Reference equality, not `==`: a reset would put an equal key back rather than leave the + // entry — and with it the composition state it carries — where it was. + assertThat(stack[1]).isSameInstanceAs(below) + } + + /** + * Entered, left, entered again: the flow's entries need not be one unbroken run at the top, and + * a pop loop that stops at the first non-step leaves the earlier ones stranded underneath. + */ + @Test + fun `leaving drops the flow's entries wherever they sit on the stack`() { + val stack = stackOf( + AuthRoute.Success, + AuthRoute.Phone.EnterPhoneNumber, + AuthRoute.MfaChallenge, + AuthRoute.Phone.EnterVerificationCode, + ) + + assertThat(stack.exitPhoneAuth()).isTrue() + + assertThat(stack.toList()).containsExactly(AuthRoute.Success) + } + + /** + * `onCancel` is reachable more than once — a second tap in the same frame, or an + * [AuthState.Cancelled] racing one — and the second call must not eat the destination the first + * one returned to. + */ + @Test + fun `leaving a flow already left changes nothing`() { + val stack = stackOf(AuthRoute.MethodPicker, AuthRoute.Success) + + assertThat(stack.exitPhoneAuth()).isFalse() + + assertThat(stack.toList()) + .containsExactly(AuthRoute.MethodPicker, AuthRoute.Success) + .inOrder() + } + + /** + * Nothing under the flow to return to, as in a phone-only configuration: truncating would empty + * the stack, and `NavDisplay` throws `IllegalArgumentException: NavDisplay backstack cannot be + * empty` from recomposition rather than from the call that emptied it. Reported instead, so the + * caller decides what replaces the flow. + */ + @Test + fun `leaving reports nothing done when the flow is the whole stack`() { + val stack = stackOf( + AuthRoute.Phone.EnterPhoneNumber, + AuthRoute.Phone.EnterVerificationCode, + ) + + assertThat(stack.exitPhoneAuth()).isFalse() + + assertThat(stack.toList()).containsExactly( + AuthRoute.Phone.EnterPhoneNumber, + AuthRoute.Phone.EnterVerificationCode, + ).inOrder() + } + + /** One write per entry dropped, and never one that leaves nothing on the stack. */ + @Test + fun `leaving never empties the stack, even momentarily`() { + val stack = stackOf( + AuthRoute.Success, + AuthRoute.Phone.EnterPhoneNumber, + AuthRoute.Phone.EnterVerificationCode, + ) + val sizes = mutableListOf() + + Snapshot.observe(writeObserver = { sizes += stack.size }) { stack.exitPhoneAuth() } + + assertThat(sizes).isNotEmpty() + assertThat(sizes.min()).isAtLeast(1) + assertThat(stack.toList()).containsExactly(AuthRoute.Success) + } + + @Test + fun `moving to the step already on top writes nothing`() { + val stack = stackOf( + AuthRoute.Phone.EnterPhoneNumber, + AuthRoute.Phone.EnterVerificationCode, + ) + val writes = mutableListOf() + + Snapshot.observe(writeObserver = { writes += it }) { + stack.navigateToPhoneStep(AuthRoute.Phone.EnterVerificationCode) + } + + assertThat(writes).isEmpty() + assertThat(stack.toList()).containsExactly( + AuthRoute.Phone.EnterPhoneNumber, + AuthRoute.Phone.EnterVerificationCode, + ).inOrder() + } + + // ============================================================================================= + // The hosted parameters are all-or-none + // ============================================================================================= + + /** + * The un-hosted screen owning its own step is public API, so the four hosted parameters have to + * arrive together or the screen would drive a step nothing is listening to. + */ + @Test + fun `passing a step without the rest of the hosted parameters throws`() { + val thrown = assertThrows(IllegalArgumentException::class.java) { + composeTestRule.setContent { + PhoneAuthScreen( + context = applicationContext, + configuration = phoneConfiguration(timeout = 0L), + authUI = authUI, + onSuccess = {}, + onError = {}, + onCancel = {}, + step = PhoneAuthStep.EnterVerificationCode, + content = {}, + ) + } + composeTestRule.waitForIdle() + } + + assertThat(thrown).hasMessageThat().contains("go together") + } + + // ============================================================================================= + // Harness + // ============================================================================================= + + private fun stackOf(vararg keys: NavKey): NavBackStack = + NavBackStack().apply { addAll(keys) } + + /** + * The callbacks Firebase was handed by the most recent verification attempt. [PhoneAuthOptions] + * exposes no accessor, only an obfuscated zero-arg method returning them, so locate it + * reflectively and assert exactly one such method exists — as + * [PhoneAuthScreenVerificationLifecycleTest] does. + */ + private fun latestCallbacks( + statics: MockedStatic + ): OnVerificationStateChangedCallbacks { + val captor = ArgumentCaptor.forClass(PhoneAuthOptions::class.java) + statics.verify({ PhoneAuthProvider.verifyPhoneNumber(captor.capture()) }, atLeastOnce()) + val candidates = PhoneAuthOptions::class.java.declaredMethods.filter { + it.parameterCount == 0 && + it.returnType == OnVerificationStateChangedCallbacks::class.java + } + check(candidates.size == 1) { + "Expected exactly one zero-arg accessor returning " + + "OnVerificationStateChangedCallbacks on PhoneAuthOptions, found " + + "${candidates.size}: $candidates" + } + return candidates.single().also { it.isAccessible = true } + .invoke(captor.allValues.last()) as OnVerificationStateChangedCallbacks + } + + private fun phoneConfiguration(timeout: Long): AuthUIConfiguration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = "US", + allowedCountries = null, + timeout = timeout, + ) + ) + } + isCredentialManagerEnabled = false + } + + // timeout = 0 keeps the resend countdown at zero, so no 1-second ticking effect is left + // pending between assertions. + private fun start(timeout: Long = 0L) { + val configuration = phoneConfiguration(timeout) + composeTestRule.setContent { PhoneFlowHost(configuration) } + composeTestRule.waitForIdle() + } + + /** + * The emission Firebase's `onCodeSent` callback ends up publishing, which is the only thing + * that moves the flow on to code entry. + */ + private fun sendCode(verificationId: String) { + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.PhoneNumberVerificationRequired( + verificationId = verificationId, + forceResendingToken = mock(PhoneAuthProvider.ForceResendingToken::class.java), + ) + ) + } + composeTestRule.waitForIdle() + } + + /** The send the user performs, which is what records the cooldown and starts the attempt. */ + private fun sendCodeForReal() { + composeTestRule.runOnIdle { requireNotNull(lastState).onSendCodeClick() } + composeTestRule.waitForIdle() + } + + /** Firebase's `onCodeSent`, which is what publishes the emission that moves the flow. */ + private fun codeSent( + callbacks: OnVerificationStateChangedCallbacks, + verificationId: String, + ) { + composeTestRule.runOnUiThread { + callbacks.onCodeSent( + verificationId, + mock(PhoneAuthProvider.ForceResendingToken::class.java), + ) + } + composeTestRule.waitForIdle() + } + + private fun typePhoneNumber(value: String) { + composeTestRule.runOnIdle { requireNotNull(lastState).onPhoneNumberChange(value) } + composeTestRule.waitForIdle() + } + + /** Enters [step] the way a host's `onNavigate` does — bypassing the screen's own guards. */ + private fun navigateDirectlyTo(step: AuthRoute.Phone.Step) { + composeTestRule.runOnIdle { requireNotNull(backStack).add(step) } + composeTestRule.waitForIdle() + } + + private fun back() { + composeTestRule.runOnUiThread { requireNotNull(pressBack).invoke() } + composeTestRule.waitForIdle() + } + + private fun currentKey(): NavKey? = composeTestRule.runOnIdle { backStack?.lastOrNull() } + + /** The keys on the stack, bottom to top. */ + private fun backStackKeys(): List = + composeTestRule.runOnIdle { backStack?.toList().orEmpty() } + + private fun AuthRoute.Phone.Step.expectedContentStep(): PhoneAuthStep = when (this) { + AuthRoute.Phone.EnterPhoneNumber -> PhoneAuthStep.EnterPhoneNumber + AuthRoute.Phone.EnterVerificationCode -> PhoneAuthStep.EnterVerificationCode + } + + @Composable + private fun PhoneAuthStepUnderTest( + configuration: AuthUIConfiguration, + flowState: PhoneAuthFlowState, + step: PhoneAuthStep, + ) { + PhoneAuthScreen( + context = applicationContext, + configuration = configuration, + authUI = authUI, + onSuccess = {}, + onError = {}, + onCancel = {}, + step = step, + onNavigateToStep = {}, + onNavigateBack = {}, + flowState = flowState, + content = { state -> lastState = state }, + ) + } + + @Composable + private fun PhoneFlowHost(configuration: AuthUIConfiguration) { + val stack = rememberNavBackStack(AuthRoute.Phone.EnterPhoneNumber) + val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + val flowState = rememberPhoneAuthFlowState(configuration) + SideEffect { + backStack = stack + pressBack = dispatcher?.let { { it.onBackPressed() } } + } + + // FirebaseAuthScreen provides this itself; a bare NavDisplay has to. + CompositionLocalProvider( + LocalAuthUIStringProvider provides configuration.stringProvider + ) { + NavDisplay( + backStack = stack, + onBack = { stack.popOrNull() }, + // Transitions would keep two phone destinations composed at once, which has + // nothing to do with the routing under test. + transitionSpec = { EnterTransition.None togetherWith ExitTransition.None }, + popTransitionSpec = { EnterTransition.None togetherWith ExitTransition.None }, + predictivePopTransitionSpec = { _ -> + EnterTransition.None togetherWith ExitTransition.None + }, + entryProvider = entryProvider { + phoneAuthDestinations( + backStack = stack, + context = applicationContext, + configuration = configuration, + authUI = authUI, + flowState = flowState, + content = { state -> lastState = state }, + onCancel = {}, + onError = { reportedErrors += it }, + ) + }, + ) + } + } + + private companion object { + const val TYPED_PHONE_NUMBER = "5555550123" + const val KEPT_EMAIL = "keep@example.com" + const val NON_DEFAULT_COUNTRY_CODE = "GB" + const val NON_DEFAULT_DIAL_CODE = "+44" + } +} 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 f6a347720..f286d7395 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 @@ -43,6 +43,7 @@ import com.firebase.ui.auth.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.ui.screens.AuthRoute +import com.firebase.ui.auth.ui.screens.phone.rememberPhoneAuthFlowState import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions @@ -254,6 +255,7 @@ class ReauthSurfaceGateTest { popTransitionSpec = DefaultAuthContentTransform, ) } + val phoneFlowState = rememberPhoneAuthFlowState(configuration) CompositionLocalProvider( LocalAuthUIStringProvider provides configuration.stringProvider, ) { @@ -273,6 +275,7 @@ class ReauthSurfaceGateTest { configuration = configuration, stringProvider = DefaultAuthUIStringProvider(context), surface = surface, + phoneFlowState = phoneFlowState, emailContent = null, phoneContent = null, mfaChallengeContent = null,