Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions auth/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,7 @@ dependencies {

tasks.withType<Test>().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"
}
19 changes: 14 additions & 5 deletions auth/src/main/java/com/firebase/ui/auth/ui/screens/AuthRoute.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -162,6 +161,9 @@ sealed interface AuthRoute {

internal val steps: List<Step>
get() = listOf(EnterPhoneNumber, EnterVerificationCode)

internal fun stepFor(phoneStep: PhoneAuthStep): Step =
steps.first { it.phoneStep == phoneStep }
}

/**
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -182,13 +185,18 @@ fun FirebaseAuthScreen(
val pendingLinkingCredential = remember { mutableStateOf<AuthCredential?>(null) }
val pendingResolver = remember { mutableStateOf<MultiFactorResolver?>(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.
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.
Expand Down Expand Up @@ -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<AuthRoute.Phone.EnterPhoneNumber>(
metadata = authRouteMetadata(AuthRoute.Phone.EnterPhoneNumber)
) { phoneStep() }
entry<AuthRoute.Phone.EnterVerificationCode>(
metadata = authRouteMetadata(AuthRoute.Phone.EnterVerificationCode)
) { phoneStep() }
},
)

entry<AuthRoute.Success>(metadata = authRouteMetadata(AuthRoute.Success)) {
val uiContext = remember(authState, stringProvider) {
Expand Down Expand Up @@ -524,6 +523,7 @@ fun FirebaseAuthScreen(
configuration = configuration,
stringProvider = stringProvider,
surface = reauthSurfaceHolder,
phoneFlowState = reauthPhoneFlowState,
emailContent = emailContent,
phoneContent = phoneContent,
mfaChallengeContent = mfaChallengeContent,
Expand Down Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>,
val verificationCode: MutableState<String>,
val selectedCountry: MutableState<CountryData>,
val verificationId: MutableState<String?>,
val forceResendingToken: MutableState<PhoneAuthProvider.ForceResendingToken?>,
val resendTimerSeconds: MutableIntState,
/** The number and start time of the attempt the cooldown check rejects a duplicate of. */
internal val pendingVerificationPhoneNumber: MutableState<String?>,
internal val verificationStartTime: MutableState<Long?>,
/**
* 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<Job?>,
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<String?>,
internal val consumedAutoCredential: MutableState<PhoneAuthCredential?>,
)

/**
* 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<AuthProvider.Phone>().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<String?>(null) }
val forceResendingToken =
rememberSaveable { mutableStateOf<PhoneAuthProvider.ForceResendingToken?>(null) }
val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) }
val pendingVerificationPhoneNumber = remember { mutableStateOf<String?>(null) }
val verificationStartTime = remember { mutableStateOf<Long?>(null) }
val verificationJob = remember { mutableStateOf<Job?>(null) }
val verificationScope = rememberCoroutineScope()
val navigatedVerificationId = remember { mutableStateOf<String?>(null) }
val consumedAutoCredential = remember { mutableStateOf<PhoneAuthCredential?>(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<NavKey>.phoneAuthDestinations(
backStack: NavBackStack<NavKey>,
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<AuthRoute.Phone.EnterPhoneNumber>(
metadata = authRouteMetadata(AuthRoute.Phone.EnterPhoneNumber)
) { body(it) }
entry<AuthRoute.Phone.EnterVerificationCode>(
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<NavKey>.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<NavKey>.exitPhoneAuth(): Boolean {
val lowestStep = indexOfFirst { it is AuthRoute.Phone.Step }
if (lowestStep <= 0) return false
while (size > lowestStep) removeAt(size - 1)
return true
}
Loading