diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index bb74b0a75..abcf1afd2 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -68,6 +68,7 @@ dependencies {
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
+ implementation(libs.compose.material.icons.extended)
// Facebook
implementation(libs.facebook.login)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 77abc5776..d9ff55383 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -88,6 +88,12 @@
android:exported="false"
android:theme="@style/Theme.FirebaseUIAndroid" />
+
+
+ // customMethodPickerLayout now renders as the entire screen (no
+ // built-in logo/ToS footer/inset handling), so the terms checkbox
+ // that used to live in customMethodPickerTermsConfiguration is
+ // rendered inline here instead, and this composable owns its own
+ // insets via Modifier.safeDrawingPadding() in SpotlightMethodPicker.
SpotlightMethodPicker(
providers = providers,
onProviderSelected = onProviderSelected,
@@ -181,6 +186,8 @@ fun SpotlightMethodPicker(
val anonymous = groups["anonymous"]?.firstOrNull()
LazyColumn(
+ // customMethodPickerLayout now renders as the entire screen, so this composable is
+ // responsible for its own insets.
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding(),
@@ -298,7 +305,7 @@ fun SpotlightMethodPicker(
}
@Composable
-private fun ProviderIconButton(
+fun ProviderIconButton(
style: AuthUITheme.ProviderStyle,
contentDescription: String,
onClick: () -> Unit,
@@ -335,12 +342,12 @@ private fun ProviderIconButton(
}
@Composable
-private fun AuthUIAsset.asPainter(): Painter = when (this) {
+fun AuthUIAsset.asPainter(): Painter = when (this) {
is AuthUIAsset.Resource -> painterResource(resId)
is AuthUIAsset.Vector -> rememberVectorPainter(image)
}
-private fun styleForProvider(provider: AuthProvider): AuthUITheme.ProviderStyle = when (provider) {
+fun styleForProvider(provider: AuthProvider): AuthUITheme.ProviderStyle = when (provider) {
is AuthProvider.Facebook -> ProviderStyleDefaults.Facebook
is AuthProvider.Twitter -> ProviderStyleDefaults.Twitter
is AuthProvider.Github -> ProviderStyleDefaults.Github
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt
index df069091f..d270beac5 100644
--- a/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt
@@ -22,6 +22,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import com.firebaseui.android.demo.auth.fullcustomization.FullCustomizationDemoActivity
class CustomSlotsThemingDemoActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -46,6 +47,9 @@ class CustomSlotsThemingDemoActivity : ComponentActivity() {
},
onCustomMethodPickerClick = {
startActivity(Intent(this, CustomMethodPickerDemoActivity::class.java))
+ },
+ onFullCustomizationClick = {
+ startActivity(Intent(this, FullCustomizationDemoActivity::class.java))
}
)
}
@@ -60,6 +64,7 @@ fun CustomSlotsDemoChooser(
onPhoneAuthSlotClick: () -> Unit,
onShapeCustomizationClick: () -> Unit,
onCustomMethodPickerClick: () -> Unit,
+ onFullCustomizationClick: () -> Unit,
) {
Column(
modifier = Modifier
@@ -106,6 +111,12 @@ fun CustomSlotsDemoChooser(
description = "Replace the default provider list with a custom layout, and swap the 'By continuing...' footer with a checkbox using customMethodPickerLayout and customMethodPickerTermsConfiguration on FirebaseAuthScreen.",
onClick = onCustomMethodPickerClick
)
+
+ DemoCard(
+ title = "Full Customization",
+ description = "customMethodPickerLayout renders as the entire screen, so this layers a full-bleed background image and scrim behind the custom method picker.",
+ onClick = onFullCustomizationClick
+ )
}
}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt
index cfa10b93b..6ad3e1abe 100644
--- a/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt
@@ -11,8 +11,11 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -32,7 +35,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -58,6 +60,7 @@ import com.firebase.ui.auth.configuration.theme.AuthUIAsset
import com.firebase.ui.auth.configuration.theme.AuthUITheme
import com.firebase.ui.auth.ui.screens.AuthSuccessUiContext
import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen
+import com.firebase.ui.auth.ui.screens.ReauthContentState
import com.firebase.ui.auth.util.EmailLinkConstants
import com.firebase.ui.auth.util.displayIdentifier
import com.firebase.ui.auth.util.getDisplayEmail
@@ -229,13 +232,7 @@ class HighLevelApiDemoActivity : ComponentActivity() {
onSignInCancelled = {
Log.d("HighLevelApiDemoActivity", "Authentication cancelled")
},
- reauthContent = { state, onDismiss ->
- ReauthDialog(
- authUI = authUI,
- state = state,
- onDismiss = onDismiss,
- )
- },
+ reauthContent = { state -> ReauthDialog(state = state) },
authenticatedContent = { state, uiContext ->
AppAuthenticatedContent(state, uiContext)
}
@@ -414,20 +411,15 @@ private fun AppAuthenticatedContent(
}
}
+/**
+ * Custom reauth UI. The slot only chooses a provider — the library owns every credential path, and
+ * for email/phone it presents its own sub-flow, which replaces this dialog while it is up. Keep the
+ * slot stateless for that reason.
+ */
@Composable
-private fun ReauthDialog(
- authUI: FirebaseAuthUI,
- state: AuthState.ReauthenticationRequired,
- onDismiss: () -> Unit,
-) {
- var password by remember { mutableStateOf("") }
- var isVerifying by remember { mutableStateOf(false) }
- var errorMessage by remember { mutableStateOf(null) }
- val coroutineScope = rememberCoroutineScope()
- val email = state.user.email.orEmpty()
-
+private fun ReauthDialog(state: ReauthContentState) {
AlertDialog(
- onDismissRequest = onDismiss,
+ onDismissRequest = state.onDismiss,
containerColor = MaterialTheme.colorScheme.surfaceVariant,
title = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
@@ -442,60 +434,43 @@ private fun ReauthDialog(
}
},
text = {
- Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
Text(
- "Signing in as $email",
+ "Signed in as ${state.user.displayIdentifier()}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
- com.firebase.ui.auth.ui.components.AuthTextField(
- value = password,
- onValueChange = {
- password = it
- errorMessage = null
- },
- label = { Text("Password") },
- isSecureTextField = true,
- isError = errorMessage != null,
- errorMessage = errorMessage,
- )
- }
- },
- dismissButton = {
- TextButton(onClick = onDismiss) { Text("Cancel") }
- },
- confirmButton = {
- Button(
- onClick = {
- coroutineScope.launch {
- isVerifying = true
- errorMessage = null
- try {
- val result = authUI.auth
- .signInWithEmailAndPassword(email, password)
- .await()
- result.user?.let { user ->
- authUI.updateAuthState(AuthState.Success(result, user))
- }
- } catch (e: Exception) {
- errorMessage = "Incorrect password. Please try again."
- } finally {
- isVerifying = false
- }
- }
- },
- enabled = password.isNotBlank() && !isVerifying,
- ) {
- if (isVerifying) {
+ state.error?.let { error ->
+ Text(
+ error,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
- } else {
- Text("Verify")
+ }
+ state.providers.forEach { provider ->
+ Button(
+ onClick = { state.onProviderSelected(provider) },
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Continue with ${provider.providerName}")
+ }
}
}
},
+ confirmButton = {},
+ dismissButton = {
+ TextButton(onClick = state.onDismiss) { Text("Cancel") }
+ },
)
}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationDemoActivity.kt
new file mode 100644
index 000000000..737220a03
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationDemoActivity.kt
@@ -0,0 +1,164 @@
+package com.firebaseui.android.demo.auth.fullcustomization
+
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+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.MfaConfiguration
+import com.firebase.ui.auth.configuration.MfaFactor
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.theme.AuthUIAsset
+import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen
+import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.screens.AuthMethodPickerUI
+import com.firebaseui.android.demo.auth.fullcustomization.screens.AuthenticatedUI
+import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.MfaChallengeUI
+import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.MfaEnrollmentUI
+import com.firebaseui.android.demo.auth.fullcustomization.screens.phone.PhoneSignInUI
+import com.firebaseui.android.demo.auth.fullcustomization.screens.reauth.ReauthUI
+import com.firebaseui.android.demo.auth.fullcustomization.theme.FullCustomizationTheme
+
+class FullCustomizationDemoActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val authUI = FirebaseAuthUI.getInstance()
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ logo = AuthUIAsset.Resource(R.drawable.firebase_auth)
+ tosUrl = "https://policies.google.com/terms"
+ privacyPolicyUrl = "https://policies.google.com/privacy"
+ providers {
+ provider(
+ AuthProvider.Google(
+ scopes = listOf("email"),
+ serverClientId = "406099696497-a12gakvts4epfk5pkio7dphc1anjiggc.apps.googleusercontent.com",
+ )
+ )
+ provider(AuthProvider.Apple(customParameters = emptyMap(), locale = null))
+ provider(AuthProvider.Facebook())
+ provider(AuthProvider.Twitter(customParameters = emptyMap()))
+ provider(AuthProvider.Github(customParameters = emptyMap()))
+ provider(AuthProvider.Microsoft(tenant = null, customParameters = emptyMap()))
+ provider(AuthProvider.Yahoo(customParameters = emptyMap()))
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null
+ )
+ )
+ provider(AuthProvider.Anonymous)
+ }
+ }
+
+ setContent {
+ FullCustomizationTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ onSignInSuccess = { result ->
+ Log.d("FullCustomizationDemo", "Auth success: ${result.user?.uid}")
+ },
+ onSignInFailure = { exception: AuthException ->
+ Log.e("FullCustomizationDemo", "Auth failed", exception)
+ },
+ onSignInCancelled = {
+ Log.d("FullCustomizationDemo", "Auth cancelled")
+ },
+ mfaConfiguration = MfaConfiguration(
+ allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp),
+ requireEnrollment = false,
+ ),
+ customMethodPickerLayout = { providers, onProviderSelected ->
+ MainUI(
+ authUI = authUI,
+ configuration = configuration,
+ providers = providers,
+ onProviderSelected = onProviderSelected,
+ )
+ },
+ phoneContent = { state -> PhoneSignInUI(state) },
+ mfaEnrollmentContent = { state -> MfaEnrollmentUI(state) },
+ mfaChallengeContent = { state -> MfaChallengeUI(state) },
+ reauthContent = { state -> ReauthUI(state) },
+ authenticatedContent = { state, uiContext ->
+ AuthenticatedUI(state = state, uiContext = uiContext)
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun MainUI(
+ authUI: FirebaseAuthUI,
+ configuration: AuthUIConfiguration,
+ providers: List,
+ onProviderSelected: (AuthProvider) -> Unit,
+) {
+ val context = LocalContext.current
+ Box(modifier = Modifier.fillMaxSize()) {
+ Image(
+ painter = painterResource(id = R.drawable.custom_background),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize()
+ )
+ Column(modifier = Modifier.fillMaxSize()) {
+ EmailAuthScreen(
+ context = context,
+ configuration = configuration,
+ authUI = authUI,
+ onSuccess = { result ->
+ Log.d("FullCustomizationDemo", "Auth success: ${result.user?.uid}")
+ },
+ onError = { exception ->
+ Log.e("FullCustomizationDemo", "Auth failed", exception)
+ },
+ onCancel = {
+ Log.d("FullCustomizationDemo", "Auth cancelled")
+ },
+ ) { state ->
+ AuthMethodPickerUI(
+ state = state,
+ auth = authUI.auth,
+ otherProviders = providers.filterNot { it is AuthProvider.Email },
+ onProviderSelected = onProviderSelected,
+ tosUrl = configuration.tosUrl,
+ ppUrl = configuration.privacyPolicyUrl,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthPage.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthPage.kt
new file mode 100644
index 000000000..b96e8090d
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthPage.kt
@@ -0,0 +1,115 @@
+package com.firebaseui.android.demo.auth.fullcustomization.common
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.firebaseui.android.demo.R
+
+/**
+ * The page frame shared by the MFA and reauthentication screens: mascot, headline, a single
+ * elevated card, and bottom-anchored actions.
+ *
+ * The email and phone steps predate this and inline the same structure themselves.
+ *
+ * verticalScroll measures content with infinite max height, and Column distributes weights
+ * against the MIN height when max is infinite (RowColumnMeasurePolicy.kt) — so
+ * heightIn(min = viewport) makes the weighted spacers expand (centering content, anchoring the
+ * actions to the bottom) when everything fits, and collapse to zero (plain scrolling) when it
+ * doesn't.
+ */
+@Composable
+fun AuthPage(
+ @DrawableRes mascot: Int,
+ mascotDescription: String,
+ title: String,
+ cardContentDescription: String,
+ actions: @Composable ColumnScope.() -> Unit,
+ card: @Composable ColumnScope.() -> Unit,
+) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Full-bleed, and deliberately outside the safeDrawingPadding below so it runs edge to
+ // edge under the system bars — same as MainUI and PhoneSignInUI do for their slots.
+ Image(
+ painter = painterResource(id = R.drawable.custom_background),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .heightIn(min = maxHeight)
+ .padding(horizontal = 40.dp, vertical = 24.dp),
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Image(
+ painter = painterResource(id = mascot),
+ contentDescription = mascotDescription,
+ modifier = Modifier.size(72.dp),
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+
+ HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = cardContentDescription },
+ color = MaterialTheme.colorScheme.surface,
+ shape = AuthFieldShape,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ content = card,
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Column(modifier = Modifier.fillMaxWidth(), content = actions)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthTextFieldStyle.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthTextFieldStyle.kt
new file mode 100644
index 000000000..ca66ee7bc
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthTextFieldStyle.kt
@@ -0,0 +1,75 @@
+package com.firebaseui.android.demo.auth.fullcustomization.common
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldColors
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import com.firebaseui.android.demo.R
+
+val AuthFieldShape = RoundedCornerShape(24.dp)
+
+@Composable
+fun authTextFieldColors(): TextFieldColors = OutlinedTextFieldDefaults.colors(
+ unfocusedContainerColor = Color.White,
+ focusedContainerColor = Color.White,
+ disabledContainerColor = Color.White,
+ unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
+ focusedBorderColor = MaterialTheme.colorScheme.secondary,
+)
+
+@Composable
+fun FullCustomizationTextField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ label: String? = null,
+ placeholder: String? = null,
+ leadingIcon: @Composable (() -> Unit)? = null,
+ trailingIcon: @Composable (() -> Unit)? = null,
+ enabled: Boolean = true,
+ isError: Boolean = false,
+ supportingText: String? = null,
+ singleLine: Boolean = true,
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ shape: Shape = AuthFieldShape,
+) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = modifier,
+ label = label?.let { { Text(it) } },
+ placeholder = placeholder?.let { { Text(it) } },
+ leadingIcon = leadingIcon,
+ trailingIcon = trailingIcon,
+ enabled = enabled,
+ isError = isError,
+ supportingText = supportingText?.let { { Text(it) } },
+ singleLine = singleLine,
+ visualTransformation = visualTransformation,
+ keyboardOptions = keyboardOptions,
+ shape = shape,
+ colors = authTextFieldColors(),
+ )
+}
+
+@Composable
+fun EmailFieldIcon() {
+ Image(
+ painter = painterResource(R.drawable.email_at_sign),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/CtaButton.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/CtaButton.kt
new file mode 100644
index 000000000..b147acf12
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/CtaButton.kt
@@ -0,0 +1,59 @@
+package com.firebaseui.android.demo.auth.fullcustomization.common
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.firebaseui.android.demo.auth.fullcustomization.theme.ButtonShape
+
+private val CtaShadowColor = Color(0xFF5D0B47)
+
+@Composable
+fun CtaButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ isLoading: Boolean = false,
+ colors: ButtonColors = ButtonDefaults.buttonColors(),
+) {
+ HardOffsetShadow(
+ shape = ButtonShape,
+ offsetX = 2.dp,
+ offsetY = 4.dp,
+ color = if (enabled) CtaShadowColor else Color.Transparent,
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Button(
+ onClick = onClick,
+ enabled = enabled,
+ shape = ButtonShape,
+ colors = colors,
+ contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(80.dp),
+ ) {
+ if (isLoading) {
+ // Left on the M3 default (colorScheme.primary). Every caller passes
+ // `enabled = ... && !isLoading`, so the button is disabled exactly while the
+ // spinner shows: the container is the translucent disabled fill, and primary
+ // reads clearly against it. Using LocalContentColor here would instead pick up
+ // disabledContentColor (onSurface at 38%) and wash the spinner out.
+ CircularProgressIndicator(modifier = Modifier.size(20.dp))
+ } else {
+ Text(text = text, style = MaterialTheme.typography.titleMedium)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/HardOffsetShadow.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/HardOffsetShadow.kt
new file mode 100644
index 000000000..628bffafd
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/HardOffsetShadow.kt
@@ -0,0 +1,32 @@
+package com.firebaseui.android.demo.auth.fullcustomization.common
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.offset
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun HardOffsetShadow(
+ shape: Shape,
+ modifier: Modifier = Modifier,
+ offsetX: Dp = 3.dp,
+ offsetY: Dp = 6.dp,
+ color: Color = MaterialTheme.colorScheme.primaryContainer,
+ content: @Composable () -> Unit,
+) {
+ Box(modifier = modifier) {
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .offset(x = offsetX, y = offsetY)
+ .background(color = color, shape = shape),
+ )
+ content()
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/OtherSignInMethodsSheet.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/OtherSignInMethodsSheet.kt
new file mode 100644
index 000000000..dc9efb77d
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/OtherSignInMethodsSheet.kt
@@ -0,0 +1,75 @@
+package com.firebaseui.android.demo.auth.fullcustomization.common
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun OtherSignInMethodsSheet(
+ otherProviders: List,
+ onProviderSelected: (AuthProvider) -> Unit,
+ onDismissRequest: () -> Unit,
+ tosUrl: String?,
+ ppUrl: String?,
+) {
+ ModalBottomSheet(
+ onDismissRequest = onDismissRequest,
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ ) {
+ // Scrollable: the demo offers nine alternative providers plus the ToS footer, which
+ // overflows a bottom sheet on shorter screens and in landscape.
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 64.dp),
+ ) {
+ Text(
+ text = "Other sign in methods",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 16.dp)
+ .semantics { contentDescription = "Other sign-in methods sheet title" },
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ otherProviders.forEach { provider ->
+ SheetProviderButton(
+ provider = provider,
+ onClick = {
+ onDismissRequest()
+ onProviderSelected(provider)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ TermsAndPrivacyForm(tosUrl = tosUrl, ppUrl = ppUrl)
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt
new file mode 100644
index 000000000..4b8583543
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt
@@ -0,0 +1,121 @@
+package com.firebaseui.android.demo.auth.fullcustomization.common
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Phone
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.theme.AuthUIAsset
+import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults
+import com.firebaseui.android.demo.auth.fullcustomization.theme.ProviderButtonShape
+
+@Composable
+fun SheetProviderButton(
+ provider: AuthProvider,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val label = providerSheetLabel(provider)
+ val style = when (provider) {
+ is AuthProvider.Google -> ProviderStyleDefaults.Google
+ is AuthProvider.Facebook -> ProviderStyleDefaults.Facebook
+ is AuthProvider.Twitter -> ProviderStyleDefaults.Twitter
+ is AuthProvider.Github -> ProviderStyleDefaults.Github
+ is AuthProvider.Microsoft -> ProviderStyleDefaults.Microsoft
+ is AuthProvider.Yahoo -> ProviderStyleDefaults.Yahoo
+ is AuthProvider.Apple -> ProviderStyleDefaults.Apple
+ is AuthProvider.Anonymous -> ProviderStyleDefaults.Anonymous
+ else -> ProviderStyleDefaults.Email
+ }
+ val backgroundColor = if (provider is AuthProvider.Phone) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ style.backgroundColor
+ }
+ val contentColor = if (provider is AuthProvider.Google) Color.Black else style.contentColor
+ val hasWhiteBackground = backgroundColor == Color.White
+
+ Button(
+ onClick = onClick,
+ shape = ProviderButtonShape,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = backgroundColor,
+ contentColor = contentColor,
+ ),
+ border = if (hasWhiteBackground) BorderStroke(1.dp, Color.Black) else null,
+ contentPadding = PaddingValues(horizontal = 36.dp, vertical = 12.dp),
+ modifier = modifier,
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (provider is AuthProvider.Phone) {
+ Icon(
+ imageVector = Icons.Default.Phone,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ } else {
+ style.icon?.let { icon ->
+ Image(
+ painter = icon.asPainter(),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = label,
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 8.dp),
+ maxLines = 1,
+ overflow = TextOverflow.MiddleEllipsis,
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+ }
+}
+
+private fun providerSheetLabel(provider: AuthProvider): String = when (provider) {
+ is AuthProvider.Google -> "Sign in with Google"
+ is AuthProvider.Facebook -> "Sign in with Facebook"
+ is AuthProvider.Twitter -> "Sign in with X"
+ is AuthProvider.Github -> "Sign in with GitHub"
+ is AuthProvider.Microsoft -> "Sign in with Microsoft"
+ is AuthProvider.Yahoo -> "Sign in with Yahoo"
+ is AuthProvider.Apple -> "Sign in with Apple"
+ is AuthProvider.Phone -> "Sign in with phone"
+ is AuthProvider.Anonymous -> "Continue as guest"
+ else -> "Continue"
+}
+
+@Composable
+private fun AuthUIAsset.asPainter() = when (this) {
+ is AuthUIAsset.Resource -> painterResource(resId)
+ is AuthUIAsset.Vector -> rememberVectorPainter(image)
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthMethodPickerUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthMethodPickerUI.kt
new file mode 100644
index 000000000..94e9cc2e2
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthMethodPickerUI.kt
@@ -0,0 +1,111 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens
+
+import android.util.Log
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState
+import com.firebaseui.android.demo.auth.fullcustomization.common.OtherSignInMethodsSheet
+import com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages.EmailEntryStep
+import com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages.LoginStep
+import com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages.SignUpStep
+import com.google.firebase.auth.FirebaseAuth
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+private enum class FlowStep { EnterEmail, Login, SignUp }
+
+@Composable
+fun AuthMethodPickerUI(
+ state: EmailAuthContentState,
+ auth: FirebaseAuth,
+ otherProviders: List,
+ onProviderSelected: (AuthProvider) -> Unit,
+ tosUrl: String?,
+ ppUrl: String?,
+) {
+ var flowStep by remember { mutableStateOf(FlowStep.EnterEmail) }
+ var showOtherMethods by remember { mutableStateOf(false) }
+ var isCheckingEmail by remember { mutableStateOf(false) }
+ val coroutineScope = rememberCoroutineScope()
+
+ // Password/confirmPassword are hoisted in EmailAuthContentState, not local to LoginStep/
+ // SignUpStep — they survive a round trip back to EnterEmail, so a stale password typed for
+ // one email could carry over if a different email also routes to the same step. Clear them
+ // whenever the user backs out via "Use a different email".
+ val onUseDifferentEmail: () -> Unit = {
+ state.onPasswordChange("")
+ state.onConfirmPasswordChange("")
+ flowStep = FlowStep.EnterEmail
+ }
+
+ // customMethodPickerLayout is the NavHost's start destination and these steps are local
+ // state, so without this the system back press would leave the auth flow entirely.
+ BackHandler(enabled = flowStep != FlowStep.EnterEmail) { onUseDifferentEmail() }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ when (flowStep) {
+ FlowStep.EnterEmail -> EmailEntryStep(
+ email = state.email,
+ onEmailChange = state.onEmailChange,
+ isLoading = state.isLoading || isCheckingEmail,
+ onContinue = {
+ isCheckingEmail = true
+ coroutineScope.launch {
+ val signInMethods = fetchLegacySignInMethods(auth, state.email)
+ flowStep = if (signInMethods.isEmpty()) FlowStep.SignUp else FlowStep.Login
+ isCheckingEmail = false
+ }
+ },
+ onShowOtherMethods = { showOtherMethods = true },
+ )
+
+ FlowStep.Login -> LoginStep(
+ state = state,
+ onUseDifferentEmail = onUseDifferentEmail,
+ )
+
+ FlowStep.SignUp -> SignUpStep(
+ state = state,
+ onUseDifferentEmail = onUseDifferentEmail,
+ )
+ }
+ }
+
+ if (showOtherMethods) {
+ OtherSignInMethodsSheet(
+ otherProviders = otherProviders,
+ onProviderSelected = onProviderSelected,
+ onDismissRequest = { showOtherMethods = false },
+ tosUrl = tosUrl,
+ ppUrl = ppUrl,
+ )
+ }
+}
+
+/**
+ * Whether [email] is already registered, via Firebase Auth's `fetchSignInMethodsForEmail` —
+ * deprecated by Firebase ("legacy") and, depending on the project's Email Enumeration Protection
+ * setting, may always return an empty list regardless of whether the email exists.
+ */
+private suspend fun fetchLegacySignInMethods(auth: FirebaseAuth, email: String): List {
+ return try {
+ @Suppress("DEPRECATION")
+ auth.fetchSignInMethodsForEmail(email)
+ .await()
+ .signInMethods
+ ?.filter { it.isNotBlank() }
+ ?: emptyList()
+ } catch (e: Exception) {
+ Log.w("AuthMethodPickerUI", "fetchSignInMethodsForEmail failed for $email", e)
+ emptyList()
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthenticatedUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthenticatedUI.kt
new file mode 100644
index 000000000..61fea4933
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthenticatedUI.kt
@@ -0,0 +1,279 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens
+
+import android.util.Log
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.ui.screens.AuthSuccessUiContext
+import com.firebase.ui.auth.util.displayIdentifier
+import com.firebase.ui.auth.util.getDisplayEmail
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage
+import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton
+import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+private const val TAG = "FullCustomizationDemo"
+
+/**
+ * Custom UI for `FirebaseAuthScreen.authenticatedContent`.
+ *
+ * Its main job in this demo is making the other slots reachable: "Set up two-factor" navigates to
+ * the flow that `mfaEnrollmentContent` renders, and changing the password is a sensitive operation,
+ * so wrapping it in [com.firebase.ui.auth.FirebaseAuthUI.withReauth] is what provokes
+ * `reauthContent`.
+ *
+ * This slot also receives the email-verification and profile-completion states, which the library
+ * would otherwise render itself — so they are handled here too rather than falling through to a
+ * blank screen.
+ */
+@Composable
+fun AuthenticatedUI(state: AuthState, uiContext: AuthSuccessUiContext) {
+ when (state) {
+ is AuthState.RequiresEmailVerification -> VerifyEmailPage(uiContext)
+ is AuthState.RequiresProfileCompletion -> ProfileCompletionPage(state, uiContext)
+ else -> SignedInPage(uiContext)
+ }
+}
+
+@Composable
+private fun SignedInPage(uiContext: AuthSuccessUiContext) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val authUI = uiContext.authUI
+ // Read on every recomposition rather than remembering: the identifier has to follow the
+ // current user, which changes across sign-out and reauth.
+ val identifier = authUI.getCurrentUser().displayIdentifier()
+
+ var newPassword by remember { mutableStateOf("") }
+ var passwordVisible by remember { mutableStateOf(false) }
+ var isUpdating by remember { mutableStateOf(false) }
+ var statusMessage by remember { mutableStateOf(null) }
+ var isError by remember { mutableStateOf(false) }
+
+ AuthPage(
+ mascot = R.drawable.full_customization_mascot,
+ mascotDescription = "doggo - cute welcome mascot",
+ title = "You're in",
+ cardContentDescription = "authenticated - account card",
+ card = {
+ Text(
+ text = if (identifier.isNotBlank()) "Signed in as $identifier" else "Signed in",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ )
+
+ Text(
+ text = "Changing your password needs a recent sign-in, so it triggers the custom " +
+ "reauth screen.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ FullCustomizationTextField(
+ value = newPassword,
+ onValueChange = {
+ newPassword = it
+ statusMessage = null
+ },
+ label = "New password",
+ enabled = !isUpdating,
+ isError = isError,
+ supportingText = statusMessage,
+ visualTransformation = if (passwordVisible) {
+ VisualTransformation.None
+ } else {
+ PasswordVisualTransformation()
+ },
+ trailingIcon = {
+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
+ Icon(
+ imageVector = if (passwordVisible) {
+ Icons.Default.VisibilityOff
+ } else {
+ Icons.Default.Visibility
+ },
+ contentDescription = null,
+ )
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - new password secure input" },
+ )
+ },
+ actions = {
+ CtaButton(
+ text = "Change password",
+ onClick = {
+ // lifecycleScope rather than rememberCoroutineScope: the reauth overlay
+ // replaces this screen mid-flight, and the retried operation has to outlive it.
+ lifecycleOwner.lifecycleScope.launch {
+ isUpdating = true
+ statusMessage = null
+ isError = false
+ try {
+ authUI.withReauth(
+ context,
+ reason = "Verify your identity to change your password",
+ ) {
+ authUI.getCurrentUser()?.updatePassword(newPassword)?.await()
+ Log.d(TAG, "Password changed successfully")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Password change failed", e)
+ isError = true
+ statusMessage = "Couldn't change the password. Try again."
+ } finally {
+ isUpdating = false
+ }
+ }
+ },
+ enabled = newPassword.length >= 6 && !isUpdating,
+ isLoading = isUpdating,
+ modifier = Modifier.semantics { contentDescription = "button - change password" },
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ CtaButton(
+ text = "Set up two-factor",
+ onClick = uiContext.onManageMfa,
+ enabled = !isUpdating,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ modifier = Modifier.semantics { contentDescription = "button - manage mfa" },
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = uiContext.onSignOut,
+ enabled = !isUpdating,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(uiContext.stringProvider.signOutAction)
+ }
+ },
+ )
+}
+
+@Composable
+private fun VerifyEmailPage(uiContext: AuthSuccessUiContext) {
+ val stringProvider = uiContext.stringProvider
+ val user = uiContext.authUI.getCurrentUser()
+ val emailLabel = user.getDisplayEmail(stringProvider.emailProvider)
+
+ AuthPage(
+ mascot = R.drawable.full_customization_mascot,
+ mascotDescription = "doggo - cute welcome mascot",
+ title = "Check your inbox",
+ cardContentDescription = "authenticated - verify email card",
+ card = {
+ Text(
+ text = stringProvider.verifyEmailInstruction(emailLabel),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ actions = {
+ CtaButton(
+ text = stringProvider.verifiedEmailAction,
+ onClick = uiContext.onReloadUser,
+ modifier = Modifier.semantics {
+ contentDescription = "button - recheck email verification"
+ },
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ CtaButton(
+ text = stringProvider.resendVerificationEmailAction,
+ onClick = { user?.sendEmailVerification() },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ modifier = Modifier.semantics {
+ contentDescription = "button - resend verification email"
+ },
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = uiContext.onSignOut,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringProvider.signOutAction)
+ }
+ },
+ )
+}
+
+@Composable
+private fun ProfileCompletionPage(
+ state: AuthState.RequiresProfileCompletion,
+ uiContext: AuthSuccessUiContext,
+) {
+ val stringProvider = uiContext.stringProvider
+
+ AuthPage(
+ mascot = R.drawable.full_customization_mascot,
+ mascotDescription = "doggo - cute welcome mascot",
+ title = "Almost there",
+ cardContentDescription = "authenticated - profile completion card",
+ card = {
+ Text(
+ text = stringProvider.profileCompletionMessage,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ if (state.missingFields.isNotEmpty()) {
+ Text(
+ text = stringProvider.profileMissingFieldsMessage(
+ state.missingFields.joinToString()
+ ),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ },
+ actions = {
+ TextButton(
+ onClick = uiContext.onSignOut,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringProvider.signOutAction)
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/EmailEntryStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/EmailEntryStep.kt
new file mode 100644
index 000000000..65a933312
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/EmailEntryStep.kt
@@ -0,0 +1,184 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Login
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape
+import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton
+import com.firebaseui.android.demo.auth.fullcustomization.common.EmailFieldIcon
+import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField
+import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow
+import com.firebaseui.android.demo.auth.fullcustomization.theme.IntroShape
+
+@Composable
+fun EmailEntryStep(
+ email: String,
+ onEmailChange: (String) -> Unit,
+ isLoading: Boolean,
+ onContinue: () -> Unit,
+ onShowOtherMethods: () -> Unit,
+) {
+ val isEmailValid = remember(email) {
+ android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()
+ }
+ val showEmailError = email.isNotBlank() && !isEmailValid
+
+ // verticalScroll measures content with infinite max height, and Column distributes weights
+ // against the MIN height when max is infinite (RowColumnMeasurePolicy.kt) — so
+ // heightIn(min = viewport) makes the weighted spacers expand (centering content, anchoring
+ // the link to the bottom) when everything fits, and collapse to zero (plain scrolling) when
+ // it doesn't.
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .heightIn(min = maxHeight)
+ .padding(horizontal = 48.dp, vertical = 24.dp),
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.full_customization_mascot),
+ contentDescription = "doggo - cute welcome mascot",
+ modifier = Modifier
+ .size(96.dp)
+ .offset(y = 12.dp)
+ .zIndex(1f),
+ )
+
+ Surface(
+ color = MaterialTheme.colorScheme.secondary,
+ shape = IntroShape,
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "intro - welcome headline bubble" },
+ ) {
+ Text(
+ text = "Hey there,\nWelcome",
+ style = MaterialTheme.typography.headlineMedium.copy(
+ textAlign = TextAlign.Center,
+ brush = Brush.radialGradient(
+ colors = listOf(
+ Color(0xFFFFF8F8),
+ Color(0xFFFFDDB4),
+ Color(0xFFFFD8EB),
+ ),
+ ),
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 16.dp),
+ )
+ }
+
+ HardOffsetShadow(
+ shape = AuthFieldShape,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "email - sign in card" },
+ color = MaterialTheme.colorScheme.surface,
+ shape = AuthFieldShape,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = "Enter your email address to continue.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ FullCustomizationTextField(
+ value = email,
+ onValueChange = onEmailChange,
+ label = "Email address",
+ leadingIcon = { EmailFieldIcon() },
+ enabled = !isLoading,
+ isError = showEmailError,
+ supportingText = if (showEmailError) "Enter a valid email address" else null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - email address input" },
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ CtaButton(
+ text = "Continue",
+ onClick = onContinue,
+ enabled = isEmailValid && !isLoading,
+ isLoading = isLoading,
+ modifier = Modifier.semantics { contentDescription = "button - continue to password" },
+ )
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ TextButton(
+ onClick = onShowOtherMethods,
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .semantics { contentDescription = "Other sign-in methods button" },
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Login,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Use other sign-in methods")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/LoginStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/LoginStep.kt
new file mode 100644
index 000000000..f26a83260
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/LoginStep.kt
@@ -0,0 +1,193 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape
+import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton
+import com.firebaseui.android.demo.auth.fullcustomization.common.EmailFieldIcon
+import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField
+import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow
+
+@Composable
+fun LoginStep(
+ state: EmailAuthContentState,
+ onUseDifferentEmail: () -> Unit,
+) {
+ var passwordVisible by remember { mutableStateOf(false) }
+
+ // verticalScroll measures content with infinite max height, and Column distributes weights
+ // against the MIN height when max is infinite (RowColumnMeasurePolicy.kt) — so
+ // heightIn(min = viewport) makes the weighted spacers expand (centering content, anchoring
+ // CTAs to the bottom) when everything fits, and collapse to zero (plain scrolling) when it
+ // doesn't.
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .heightIn(min = maxHeight)
+ .padding(horizontal = 40.dp, vertical = 24.dp),
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Image(
+ painter = painterResource(id = R.drawable.full_customization_mascot),
+ contentDescription = "doggo - cute welcome mascot",
+ modifier = Modifier.size(72.dp),
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Login",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+
+ HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "email - login card" },
+ color = MaterialTheme.colorScheme.surface,
+ shape = AuthFieldShape,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ FullCustomizationTextField(
+ value = state.email,
+ onValueChange = {},
+ enabled = false,
+ leadingIcon = { EmailFieldIcon() },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - email address display" },
+ )
+
+ FullCustomizationTextField(
+ value = state.password,
+ onValueChange = state.onPasswordChange,
+ label = "Password",
+ enabled = !state.isLoading,
+ visualTransformation = if (passwordVisible) {
+ VisualTransformation.None
+ } else {
+ PasswordVisualTransformation()
+ },
+ trailingIcon = {
+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
+ Icon(
+ imageVector = if (passwordVisible) {
+ Icons.Default.VisibilityOff
+ } else {
+ Icons.Default.Visibility
+ },
+ contentDescription = null,
+ )
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - password secure input" },
+ )
+
+ Text(
+ text = if (state.resetLinkSent) "Reset link sent!" else "Forgot password?",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ textDecoration = TextDecoration.Underline,
+ textAlign = TextAlign.End,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(enabled = !state.resetLinkSent) {
+ state.onSendResetLinkClick()
+ },
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ CtaButton(
+ text = "Login",
+ onClick = state.onSignInClick,
+ enabled = state.password.isNotBlank() && !state.isLoading,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics { contentDescription = "button - login" },
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ CtaButton(
+ text = if (state.emailSignInLinkSent) "Login link sent!" else "Send login link",
+ onClick = state.onSignInEmailLinkClick,
+ enabled = !state.isLoading,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ modifier = Modifier.semantics { contentDescription = "button - send login link" },
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = onUseDifferentEmail,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Use a different email")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/SignUpStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/SignUpStep.kt
new file mode 100644
index 000000000..14f986b77
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/SignUpStep.kt
@@ -0,0 +1,237 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape
+import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton
+import com.firebaseui.android.demo.auth.fullcustomization.common.EmailFieldIcon
+import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField
+import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow
+
+private val NameFieldStartShape = RoundedCornerShape(
+ topStart = 16.dp,
+ bottomStart = 16.dp,
+ topEnd = 0.dp,
+ bottomEnd = 0.dp,
+)
+private val NameFieldEndShape = RoundedCornerShape(
+ topStart = 0.dp,
+ bottomStart = 0.dp,
+ topEnd = 16.dp,
+ bottomEnd = 16.dp,
+)
+
+@Composable
+fun SignUpStep(
+ state: EmailAuthContentState,
+ onUseDifferentEmail: () -> Unit,
+) {
+ var firstName by remember { mutableStateOf("") }
+ var lastName by remember { mutableStateOf("") }
+ var confirmEmail by remember { mutableStateOf("") }
+
+ // Compared case-insensitively and trimmed: this field uses the default keyboard, which
+ // auto-capitalises on many IMEs, so an exact match would reject the user's own address.
+ val emailsMatch = confirmEmail.isNotBlank() &&
+ confirmEmail.trim().equals(state.email.trim(), ignoreCase = true)
+ val passwordsMatch = state.confirmPassword.isNotBlank() && state.confirmPassword == state.password
+ val canSignUp = firstName.isNotBlank() &&
+ lastName.isNotBlank() &&
+ emailsMatch &&
+ state.password.isNotBlank() &&
+ passwordsMatch &&
+ !state.isLoading
+
+ // verticalScroll measures content with infinite max height, and Column distributes weights
+ // against the MIN height when max is infinite (RowColumnMeasurePolicy.kt) — so
+ // heightIn(min = viewport) makes the weighted spacers expand (centering content, anchoring
+ // CTAs to the bottom) when everything fits, and collapse to zero (plain scrolling) when it
+ // doesn't.
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .heightIn(min = maxHeight)
+ .padding(horizontal = 40.dp, vertical = 24.dp),
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Image(
+ painter = painterResource(id = R.drawable.full_customization_mascot),
+ contentDescription = "doggo - cute welcome mascot",
+ modifier = Modifier.size(72.dp),
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Sign up",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+
+ HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "sign up card" },
+ color = MaterialTheme.colorScheme.surface,
+ shape = AuthFieldShape,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ FullCustomizationTextField(
+ value = firstName,
+ onValueChange = { firstName = it },
+ label = "First name",
+ enabled = !state.isLoading,
+ shape = NameFieldStartShape,
+ modifier = Modifier
+ .weight(1f)
+ .semantics { contentDescription = "text-field - first name" },
+ )
+ FullCustomizationTextField(
+ value = lastName,
+ onValueChange = { lastName = it },
+ label = "Last name",
+ enabled = !state.isLoading,
+ shape = NameFieldEndShape,
+ modifier = Modifier
+ .weight(1f)
+ .semantics { contentDescription = "text-field - last name" },
+ )
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ FullCustomizationTextField(
+ value = state.email,
+ onValueChange = {},
+ enabled = false,
+ leadingIcon = { EmailFieldIcon() },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - email address display" },
+ )
+ FullCustomizationTextField(
+ value = confirmEmail,
+ onValueChange = { confirmEmail = it },
+ label = "Confirm Email",
+ enabled = !state.isLoading,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Email,
+ ),
+ isError = confirmEmail.isNotBlank() && !emailsMatch,
+ supportingText = if (confirmEmail.isNotBlank() && !emailsMatch) {
+ "Emails don't match"
+ } else {
+ null
+ },
+ leadingIcon = { EmailFieldIcon() },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - confirm email" },
+ )
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ FullCustomizationTextField(
+ value = state.password,
+ onValueChange = state.onPasswordChange,
+ label = "Password",
+ enabled = !state.isLoading,
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - password" },
+ )
+ FullCustomizationTextField(
+ value = state.confirmPassword,
+ onValueChange = state.onConfirmPasswordChange,
+ label = "Confirm Password",
+ enabled = !state.isLoading,
+ visualTransformation = PasswordVisualTransformation(),
+ isError = state.confirmPassword.isNotBlank() && !passwordsMatch,
+ supportingText = if (state.confirmPassword.isNotBlank() && !passwordsMatch) {
+ "Passwords don't match"
+ } else {
+ null
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - confirm password" },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ CtaButton(
+ text = "Sign up",
+ onClick = {
+ state.onDisplayNameChange("$firstName $lastName".trim())
+ state.onSignUpClick()
+ },
+ enabled = canSignUp,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics { contentDescription = "button - sign up" },
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = onUseDifferentEmail,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Use a different email")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaChallengeUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaChallengeUI.kt
new file mode 100644
index 000000000..741f41882
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaChallengeUI.kt
@@ -0,0 +1,101 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.MfaFactor
+import com.firebase.ui.auth.mfa.MfaChallengeContentState
+import com.firebase.ui.auth.ui.components.VerificationCodeInputField
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage
+import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton
+
+/**
+ * Custom UI for `FirebaseAuthScreen.mfaChallengeContent` — the second-factor prompt shown during
+ * sign-in when the account has MFA enrolled.
+ */
+@Composable
+fun MfaChallengeUI(state: MfaChallengeContentState) {
+ val isSms = state.factorType == MfaFactor.Sms
+
+ AuthPage(
+ mascot = if (isSms) {
+ R.drawable.full_customization_phone_mascot
+ } else {
+ R.drawable.full_customization_mascot
+ },
+ mascotDescription = "doggo - cute two-factor mascot",
+ title = "One more step",
+ cardContentDescription = "mfa - challenge card",
+ card = {
+ Text(
+ text = if (isSms) {
+ "We sent a code to ${state.maskedPhoneNumber ?: "your phone"}."
+ } else {
+ "Open your authenticator app and enter the 6-digit code for this account."
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ VerificationCodeInputField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - mfa challenge code input" },
+ isError = state.hasError,
+ errorMessage = state.error,
+ onCodeChange = state.onVerificationCodeChange,
+ )
+
+ // canResend already covers "SMS factor and a resend callback exists".
+ if (state.canResend) {
+ Text(
+ text = if (state.resendTimer > 0) {
+ "Resend code in ${state.resendTimer}s"
+ } else {
+ "Resend code"
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.End,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(enabled = state.resendTimer == 0 && !state.isLoading) {
+ state.onResendCodeClick?.invoke()
+ },
+ )
+ }
+ },
+ actions = {
+ CtaButton(
+ text = "Verify",
+ onClick = state.onVerifyClick,
+ enabled = state.isValid && !state.isLoading,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics {
+ contentDescription = "button - verify mfa challenge"
+ },
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = state.onCancelClick,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Cancel")
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaEnrollmentUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaEnrollmentUI.kt
new file mode 100644
index 000000000..b2324ab01
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaEnrollmentUI.kt
@@ -0,0 +1,25 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa
+
+import androidx.compose.runtime.Composable
+import com.firebase.ui.auth.mfa.MfaEnrollmentContentState
+import com.firebase.ui.auth.mfa.MfaEnrollmentStep
+import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages.ConfigureSmsStep
+import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages.ConfigureTotpStep
+import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages.SelectFactorStep
+import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages.VerifyFactorStep
+
+/**
+ * Custom UI for `FirebaseAuthScreen.mfaEnrollmentContent`.
+ *
+ * A single state object drives all five enrollment steps, so this only dispatches on
+ * [MfaEnrollmentContentState.step] — the library owns the step transitions.
+ */
+@Composable
+fun MfaEnrollmentUI(state: MfaEnrollmentContentState) {
+ when (state.step) {
+ MfaEnrollmentStep.SelectFactor -> SelectFactorStep(state)
+ MfaEnrollmentStep.ConfigureSms -> ConfigureSmsStep(state)
+ MfaEnrollmentStep.ConfigureTotp -> ConfigureTotpStep(state)
+ MfaEnrollmentStep.VerifyFactor -> VerifyFactorStep(state)
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureSmsStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureSmsStep.kt
new file mode 100644
index 000000000..de9a85cab
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureSmsStep.kt
@@ -0,0 +1,124 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Phone
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.mfa.MfaEnrollmentContentState
+import com.firebase.ui.auth.ui.components.CountrySelector
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage
+import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton
+import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField
+
+@Composable
+fun ConfigureSmsStep(state: MfaEnrollmentContentState) {
+ AuthPage(
+ mascot = R.drawable.full_customization_phone_mascot,
+ mascotDescription = "doggo - cute phone sign-in mascot",
+ title = "Add your number",
+ cardContentDescription = "mfa - sms setup card",
+ card = {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ // CountrySelector needs a non-null country; the library's own default UI skips the
+ // whole step while the country is still resolving, so match that.
+ state.selectedCountry?.let { country ->
+ Surface(
+ color = Color.White,
+ shape = AuthFieldShape,
+ modifier = Modifier
+ .requiredHeight(56.dp)
+ .border(
+ width = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ shape = AuthFieldShape,
+ )
+ .semantics { contentDescription = "country code selector" },
+ ) {
+ CountrySelector(
+ selectedCountry = country,
+ onCountrySelected = state.onCountrySelected,
+ enabled = !state.isLoading,
+ )
+ }
+ }
+
+ FullCustomizationTextField(
+ value = state.phoneNumber,
+ onValueChange = state.onPhoneNumberChange,
+ placeholder = "Phone number",
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Phone,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ enabled = !state.isLoading,
+ isError = state.hasError,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
+ modifier = Modifier
+ .weight(1f)
+ .semantics { contentDescription = "text-field - mfa phone number input" },
+ )
+ }
+
+ Text(
+ text = state.error
+ ?: "We'll text a code to this number whenever you sign in. " +
+ "Message & data rates may apply.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (state.hasError) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ },
+ actions = {
+ CtaButton(
+ text = "Send code",
+ onClick = state.onSendSmsCodeClick,
+ enabled = state.isValid && !state.isLoading,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics {
+ contentDescription = "button - send mfa sms code"
+ },
+ )
+
+ if (state.canGoBack) {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = state.onBackClick,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Pick a different method")
+ }
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureTotpStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureTotpStep.kt
new file mode 100644
index 000000000..562fe7919
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureTotpStep.kt
@@ -0,0 +1,102 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.mfa.MfaEnrollmentContentState
+import com.firebase.ui.auth.ui.components.QrCodeImage
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage
+import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton
+
+@Composable
+fun ConfigureTotpStep(state: MfaEnrollmentContentState) {
+ AuthPage(
+ mascot = R.drawable.full_customization_mascot,
+ mascotDescription = "doggo - cute security mascot",
+ title = "Scan to set up",
+ cardContentDescription = "mfa - totp setup card",
+ card = {
+ Text(
+ text = "Scan this with your authenticator app, or type the key in by hand.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ state.totpQrCodeUrl?.let { url ->
+ QrCodeImage(
+ content = url,
+ size = 200.dp,
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .border(
+ width = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ shape = AuthFieldShape,
+ )
+ .padding(12.dp)
+ .semantics { contentDescription = "mfa - totp qr code" },
+ )
+ }
+
+ state.totpSecret?.sharedSecretKey?.let { key ->
+ SelectionContainer {
+ Text(
+ text = key,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "mfa - totp shared secret key" },
+ )
+ }
+ }
+
+ state.error?.let { error ->
+ Text(
+ text = error,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ },
+ actions = {
+ CtaButton(
+ text = "I've added it",
+ onClick = state.onContinueToVerifyClick,
+ enabled = state.isValid && !state.isLoading,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics {
+ contentDescription = "button - continue to mfa verification"
+ },
+ )
+
+ if (state.canGoBack) {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = state.onBackClick,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Pick a different method")
+ }
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/SelectFactorStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/SelectFactorStep.kt
new file mode 100644
index 000000000..676ad465c
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/SelectFactorStep.kt
@@ -0,0 +1,142 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.MfaFactor
+import com.firebase.ui.auth.mfa.MfaEnrollmentContentState
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage
+import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton
+import com.google.firebase.auth.MultiFactorInfo
+import com.google.firebase.auth.PhoneMultiFactorGenerator
+import com.google.firebase.auth.TotpMultiFactorGenerator
+
+@Composable
+fun SelectFactorStep(state: MfaEnrollmentContentState) {
+ AuthPage(
+ mascot = R.drawable.full_customization_mascot,
+ mascotDescription = "doggo - cute security mascot",
+ title = "Secure your account",
+ cardContentDescription = "mfa - factor selection card",
+ card = {
+ Text(
+ text = "Add a second step to sign-in, so a password on its own isn't enough.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ state.error?.let { error ->
+ Text(
+ text = error,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+
+ if (state.enrolledFactors.isNotEmpty()) {
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
+
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = "Already on this account",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ )
+
+ state.enrolledFactors.forEach { info ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = enrolledFactorLabel(info),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(
+ onClick = { state.onUnenrollFactor(info) },
+ enabled = !state.isLoading,
+ modifier = Modifier.semantics {
+ contentDescription =
+ "button - remove factor ${enrolledFactorLabel(info)}"
+ },
+ ) {
+ Text("Remove")
+ }
+ }
+ }
+ }
+ }
+ },
+ actions = {
+ state.availableFactors.forEachIndexed { index, factor ->
+ if (index > 0) Spacer(modifier = Modifier.height(16.dp))
+
+ CtaButton(
+ text = factorCtaLabel(factor),
+ onClick = { state.onFactorSelected(factor) },
+ enabled = !state.isLoading,
+ // The first factor carries the primary CTA colour; the rest read as
+ // alternatives, matching how LoginStep tiers its two CTAs.
+ colors = if (index == 0) {
+ ButtonDefaults.buttonColors()
+ } else {
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ },
+ modifier = Modifier.semantics {
+ contentDescription = "button - enroll ${factorCtaLabel(factor)}"
+ },
+ )
+ }
+
+ state.onSkipClick?.let { onSkip ->
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = onSkip,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Not now")
+ }
+ }
+ },
+ )
+}
+
+private fun factorCtaLabel(factor: MfaFactor): String = when (factor) {
+ MfaFactor.Sms -> "Use text message"
+ MfaFactor.Totp -> "Use an authenticator app"
+}
+
+/**
+ * SMS factors carry the phone number as their display name; TOTP factors are often unnamed, so
+ * fall back to the factor id.
+ */
+private fun enrolledFactorLabel(info: MultiFactorInfo): String {
+ val fallback = when (info.factorId) {
+ PhoneMultiFactorGenerator.FACTOR_ID -> "Text message"
+ TotpMultiFactorGenerator.FACTOR_ID -> "Authenticator app"
+ else -> info.factorId
+ }
+ return info.displayName?.takeIf { it.isNotBlank() } ?: fallback
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/VerifyFactorStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/VerifyFactorStep.kt
new file mode 100644
index 000000000..351c73cfc
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/VerifyFactorStep.kt
@@ -0,0 +1,100 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.MfaFactor
+import com.firebase.ui.auth.mfa.MfaEnrollmentContentState
+import com.firebase.ui.auth.ui.components.VerificationCodeInputField
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage
+import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton
+
+@Composable
+fun VerifyFactorStep(state: MfaEnrollmentContentState) {
+ val isSms = state.selectedFactor == MfaFactor.Sms
+ val fullPhoneNumber = "${state.selectedCountry?.dialCode ?: ""}${state.phoneNumber}"
+
+ AuthPage(
+ mascot = if (isSms) {
+ R.drawable.full_customization_phone_mascot
+ } else {
+ R.drawable.full_customization_mascot
+ },
+ mascotDescription = "doggo - cute two-factor mascot",
+ title = "Confirm the code",
+ cardContentDescription = "mfa - enrollment verification card",
+ card = {
+ Text(
+ text = if (isSms) {
+ "We sent a code to $fullPhoneNumber."
+ } else {
+ "Enter the 6-digit code your authenticator app is showing right now."
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ VerificationCodeInputField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - mfa enrollment code input" },
+ isError = state.hasError,
+ errorMessage = state.error,
+ onCodeChange = state.onVerificationCodeChange,
+ )
+
+ // onResendCodeClick is null for TOTP, where there is nothing to resend.
+ state.onResendCodeClick?.let { onResend ->
+ Text(
+ text = if (state.resendTimer > 0) {
+ "Resend code in ${state.resendTimer}s"
+ } else {
+ "Resend code"
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.End,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(enabled = state.resendTimer == 0 && !state.isLoading) {
+ onResend()
+ },
+ )
+ }
+ },
+ actions = {
+ CtaButton(
+ text = "Verify",
+ onClick = state.onVerifyClick,
+ enabled = state.isValid && !state.isLoading,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics {
+ contentDescription = "button - verify mfa enrollment"
+ },
+ )
+
+ if (state.canGoBack) {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = state.onBackClick,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Back")
+ }
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/PhoneSignInUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/PhoneSignInUI.kt
new file mode 100644
index 000000000..ecf4bc41a
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/PhoneSignInUI.kt
@@ -0,0 +1,30 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.phone
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthStep
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.screens.phone.pages.PhoneEntryStep
+import com.firebaseui.android.demo.auth.fullcustomization.screens.phone.pages.PhoneVerificationStep
+
+@Composable
+fun PhoneSignInUI(state: PhoneAuthContentState) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ Image(
+ painter = painterResource(id = R.drawable.custom_background),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ when (state.step) {
+ PhoneAuthStep.EnterPhoneNumber -> PhoneEntryStep(state)
+ PhoneAuthStep.EnterVerificationCode -> PhoneVerificationStep(state)
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneEntryStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneEntryStep.kt
new file mode 100644
index 000000000..38289f1d1
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneEntryStep.kt
@@ -0,0 +1,166 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.phone.pages
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Phone
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.ui.components.CountrySelector
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape
+import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton
+import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField
+import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow
+
+@Composable
+fun PhoneEntryStep(state: PhoneAuthContentState) {
+ val isPhoneValid = remember(state.phoneNumber) {
+ android.util.Patterns.PHONE.matcher(state.phoneNumber).matches()
+ }
+
+ // verticalScroll measures content with infinite max height, and Column distributes weights
+ // against the MIN height when max is infinite (RowColumnMeasurePolicy.kt) — so
+ // heightIn(min = viewport) makes the weighted spacers expand (centering content, anchoring
+ // the CTA to the bottom) when everything fits, and collapse to zero (plain scrolling) when it
+ // doesn't.
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .heightIn(min = maxHeight)
+ .padding(horizontal = 40.dp, vertical = 24.dp),
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Image(
+ painter = painterResource(id = R.drawable.full_customization_phone_mascot),
+ contentDescription = "doggo - cute phone sign-in mascot",
+ modifier = Modifier.size(72.dp),
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Login by phone number",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+
+ HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "phone - sign in card" },
+ color = MaterialTheme.colorScheme.surface,
+ shape = AuthFieldShape,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Surface(
+ color = Color.White,
+ shape = AuthFieldShape,
+ modifier = Modifier
+ .requiredHeight(56.dp)
+ .border(
+ width = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ shape = AuthFieldShape,
+ )
+ .semantics { contentDescription = "country code selector" },
+ ) {
+ CountrySelector(
+ selectedCountry = state.selectedCountry,
+ onCountrySelected = state.onCountrySelected,
+ enabled = !state.isLoading,
+ )
+ }
+
+ FullCustomizationTextField(
+ value = state.phoneNumber,
+ onValueChange = state.onPhoneNumberChange,
+ placeholder = "Phone number",
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Phone,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ enabled = !state.isLoading,
+ isError = state.error != null,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
+ modifier = Modifier
+ .weight(1f)
+ .semantics { contentDescription = "text-field - phone number input" },
+ )
+ }
+
+ Text(
+ text = state.error
+ ?: "By signing in with phone number, an SMS may be sent. " +
+ "Message & data rates may apply.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (state.error != null) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.height(24.dp))
+
+ CtaButton(
+ text = "Sign Up",
+ onClick = state.onSendCodeClick,
+ enabled = isPhoneValid && !state.isLoading,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics { contentDescription = "button - send verification code" },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneVerificationStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneVerificationStep.kt
new file mode 100644
index 000000000..8e9a15318
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneVerificationStep.kt
@@ -0,0 +1,137 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.phone.pages
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.ui.components.VerificationCodeInputField
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape
+import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton
+import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow
+
+@Composable
+fun PhoneVerificationStep(state: PhoneAuthContentState) {
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .heightIn(min = maxHeight)
+ .padding(horizontal = 40.dp, vertical = 24.dp),
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Image(
+ painter = painterResource(id = R.drawable.full_customization_phone_mascot),
+ contentDescription = "doggo - cute phone sign-in mascot",
+ modifier = Modifier.size(72.dp),
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Enter your code",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+
+ HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "phone - verification card" },
+ color = MaterialTheme.colorScheme.surface,
+ shape = AuthFieldShape,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = "We sent a code to ${state.fullPhoneNumber}.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ VerificationCodeInputField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - verification code input" },
+ isError = state.error != null,
+ errorMessage = state.error,
+ onCodeChange = state.onVerificationCodeChange,
+ )
+
+ Text(
+ text = if (state.resendTimer > 0) {
+ "Resend code in ${state.resendTimer}s"
+ } else {
+ "Resend code"
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.End,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(enabled = state.resendTimer == 0) {
+ state.onResendCodeClick()
+ },
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ CtaButton(
+ text = "Verify",
+ onClick = state.onVerifyCodeClick,
+ enabled = state.verificationCode.isNotBlank() && !state.isLoading,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics { contentDescription = "button - verify code" },
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = state.onChangeNumberClick,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Use a different number")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthUI.kt
new file mode 100644
index 000000000..eb6508ff6
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthUI.kt
@@ -0,0 +1,93 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.reauth
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.ui.screens.ReauthContentState
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage
+import com.firebaseui.android.demo.auth.fullcustomization.common.SheetProviderButton
+
+/**
+ * Custom UI for `FirebaseAuthScreen.reauthContent`.
+ *
+ * [ReauthContentState.providers] arrives already filtered to the providers linked to this user, and
+ * [ReauthContentState.onProviderSelected] performs the credential exchange, so this is purely a
+ * chooser: the library owns the reauthentication itself and the dismiss/retry sequencing that
+ * follows it. Picking email or phone hands off to the library's own sub-flow.
+ */
+@Composable
+fun ReauthUI(state: ReauthContentState) {
+ // The slot renders as an overlay outside the NavHost, so nothing else consumes the system back
+ // press — without this it would fall through and finish the Activity mid-reauthentication.
+ BackHandler(enabled = !state.isLoading) { state.onDismiss() }
+
+ AuthPage(
+ mascot = R.drawable.full_customization_mascot,
+ mascotDescription = "doggo - cute welcome mascot",
+ title = "Is that you?",
+ cardContentDescription = "reauth - provider chooser card",
+ card = {
+ Text(
+ text = state.reason
+ ?: "Confirm it's you to continue with ${state.user.email ?: "this account"}.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ state.error?.let { error ->
+ Text(
+ text = error,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .semantics { contentDescription = "reauth - in progress" },
+ )
+ }
+ },
+ actions = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ state.providers.forEach { provider ->
+ SheetProviderButton(
+ provider = provider,
+ onClick = { state.onProviderSelected(provider) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics {
+ contentDescription = "button - reauth with ${provider.providerName}"
+ },
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = state.onDismiss,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Cancel")
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationShapes.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationShapes.kt
new file mode 100644
index 000000000..483b0b741
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationShapes.kt
@@ -0,0 +1,8 @@
+package com.firebaseui.android.demo.auth.fullcustomization.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.unit.dp
+
+val IntroShape = RoundedCornerShape(80.dp)
+val ButtonShape = RoundedCornerShape(36.dp)
+val ProviderButtonShape = RoundedCornerShape(percent = 50)
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTheme.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTheme.kt
new file mode 100644
index 000000000..53b916119
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTheme.kt
@@ -0,0 +1,135 @@
+package com.firebaseui.android.demo.auth.fullcustomization.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import kotlin.math.max
+import kotlin.math.min
+
+private val LightPrimary = Color(0xFF864B6F)
+private val LightOnPrimary = Color(0xFFFFFFFF)
+private val LightPrimaryContainer = Color(0xFFFFD8EB)
+private val LightOnPrimaryContainer = Color(0xFF7B3B73)
+private val LightInversePrimary = Color(0xFFFAB1DA)
+private val LightSecondary = Color(0xFF4C8BFF)
+private val LightOnSecondary = Color(0xFFFFFFFF)
+private val LightSecondaryContainer = Color(0xFFCCE5FF)
+private val LightTertiaryContainer = Color(0xFFFFDDB4)
+private val LightSurface = Color(0xFFFFF8F8)
+private val LightSurfaceBright = Color(0xFFFFF8F8)
+private val LightOnSurface = Color(0xFF211A1D)
+private val LightOnSurfaceVariant = Color(0xFFA08B95)
+private val LightSurfaceContainer = Color(0xFFF9EAEF)
+private val LightSurfaceContainerLow = Color(0xFFFDF0F6)
+private val LightOutline = Color(0xFF81737A)
+private val LightOutlineVariant = Color(0xFFD3C2C9)
+private val LightInverseSurface = Color(0xFF322F35)
+private val LightInverseOnSurface = Color(0xFFF5EFF7)
+
+val FullCustomizationLightColorScheme = lightColorScheme(
+ primary = LightPrimary,
+ onPrimary = LightOnPrimary,
+ primaryContainer = LightPrimaryContainer,
+ onPrimaryContainer = LightOnPrimaryContainer,
+ inversePrimary = LightInversePrimary,
+ secondary = LightSecondary,
+ onSecondary = LightOnSecondary,
+ secondaryContainer = LightSecondaryContainer,
+ tertiaryContainer = LightTertiaryContainer,
+ surface = LightSurface,
+ surfaceBright = LightSurfaceBright,
+ onSurface = LightOnSurface,
+ onSurfaceVariant = LightOnSurfaceVariant,
+ surfaceContainer = LightSurfaceContainer,
+ surfaceContainerLow = LightSurfaceContainerLow,
+ outline = LightOutline,
+ outlineVariant = LightOutlineVariant,
+ inverseSurface = LightInverseSurface,
+ inverseOnSurface = LightInverseOnSurface,
+)
+
+val FullCustomizationDarkColorScheme = darkColorScheme(
+ primary = LightPrimary.withLightness(0.78f),
+ onPrimary = LightOnPrimary.withLightness(0.18f),
+ primaryContainer = LightPrimaryContainer.withLightness(0.28f),
+ onPrimaryContainer = LightOnPrimaryContainer.withLightness(0.88f),
+ inversePrimary = LightPrimary,
+ secondary = LightSecondary.withLightness(0.78f),
+ onSecondary = LightOnSecondary.withLightness(0.18f),
+ secondaryContainer = LightSecondaryContainer.withLightness(0.28f),
+ tertiaryContainer = LightTertiaryContainer.withLightness(0.28f),
+ surface = LightSurface.withLightness(0.10f),
+ surfaceBright = LightSurfaceBright.withLightness(0.20f),
+ onSurface = LightOnSurface.withLightness(0.88f),
+ onSurfaceVariant = LightOnSurfaceVariant.withLightness(0.75f),
+ surfaceContainer = LightSurfaceContainer.withLightness(0.13f),
+ surfaceContainerLow = LightSurfaceContainerLow.withLightness(0.11f),
+ outline = LightOutline.withLightness(0.55f),
+ outlineVariant = LightOutlineVariant.withLightness(0.30f),
+ inverseSurface = LightSurface.withLightness(0.90f),
+ inverseOnSurface = LightOnSurface.withLightness(0.15f),
+)
+
+@Composable
+fun FullCustomizationTheme(content: @Composable () -> Unit) {
+ val colorScheme = if (isSystemInDarkTheme()) {
+ FullCustomizationDarkColorScheme
+ } else {
+ FullCustomizationLightColorScheme
+ }
+ AuthUITheme(
+ theme = AuthUITheme.Default.copy(
+ colorScheme = colorScheme,
+ typography = FullCustomizationTypography,
+ providerButtonShape = ProviderButtonShape,
+ ),
+ content = content,
+ )
+}
+
+private fun Color.withLightness(newLightness: Float): Color {
+ val (h, s, _) = toHsl()
+ return hslToColor(h, s, newLightness.coerceIn(0f, 1f), alpha)
+}
+
+private fun Color.toHsl(): Triple {
+ val r = red
+ val g = green
+ val b = blue
+ val maxC = max(r, max(g, b))
+ val minC = min(r, min(g, b))
+ val l = (maxC + minC) / 2f
+ if (maxC == minC) return Triple(0f, 0f, l)
+ val d = maxC - minC
+ val s = if (l > 0.5f) d / (2f - maxC - minC) else d / (maxC + minC)
+ val h = when (maxC) {
+ r -> ((g - b) / d + (if (g < b) 6f else 0f))
+ g -> ((b - r) / d + 2f)
+ else -> ((r - g) / d + 4f)
+ } / 6f
+ return Triple(h, s, l)
+}
+
+private fun hslToColor(h: Float, s: Float, l: Float, alpha: Float): Color {
+ if (s == 0f) return Color(l, l, l, alpha)
+ fun hueToRgb(p: Float, q: Float, tIn: Float): Float {
+ var t = tIn
+ if (t < 0f) t += 1f
+ if (t > 1f) t -= 1f
+ return when {
+ t < 1f / 6f -> p + (q - p) * 6f * t
+ t < 1f / 2f -> q
+ t < 2f / 3f -> p + (q - p) * (2f / 3f - t) * 6f
+ else -> p
+ }
+ }
+ val q = if (l < 0.5f) l * (1f + s) else l + s - l * s
+ val p = 2f * l - q
+ val r = hueToRgb(p, q, h + 1f / 3f)
+ val g = hueToRgb(p, q, h)
+ val b = hueToRgb(p, q, h - 1f / 3f)
+ return Color(r, g, b, alpha)
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTypography.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTypography.kt
new file mode 100644
index 000000000..da31b8bc2
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTypography.kt
@@ -0,0 +1,59 @@
+package com.firebaseui.android.demo.auth.fullcustomization.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import com.firebaseui.android.demo.R
+
+val BagelFatOne = FontFamily(Font(R.font.bagel_fat_one_regular, FontWeight.Normal))
+
+val Onest = FontFamily(
+ Font(R.font.onest_regular, FontWeight.Normal),
+ Font(R.font.onest_medium, FontWeight.Medium),
+ Font(R.font.onest_semibold, FontWeight.SemiBold),
+ Font(R.font.onest_bold, FontWeight.Bold),
+)
+
+val Roboto = FontFamily(
+ Font(R.font.roboto_regular, FontWeight.Normal),
+ Font(R.font.roboto_medium, FontWeight.Medium),
+ Font(R.font.roboto_semibold, FontWeight.SemiBold),
+ Font(R.font.roboto_bold, FontWeight.Bold),
+)
+
+val FullCustomizationTypography = Typography(
+ headlineSmall = TextStyle(
+ fontFamily = BagelFatOne,
+ fontWeight = FontWeight.Normal,
+ fontSize = 28.sp,
+ lineHeight = 36.sp,
+ ),
+ headlineMedium = TextStyle(
+ fontFamily = BagelFatOne,
+ fontWeight = FontWeight.Normal,
+ fontSize = 36.sp,
+ lineHeight = 44.sp,
+ ),
+ bodyLarge = TextStyle(
+ fontFamily = Onest,
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ ),
+ labelLarge = TextStyle(
+ fontFamily = Roboto,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ titleMedium = TextStyle(
+ fontFamily = Onest,
+ fontWeight = FontWeight.Bold,
+ fontSize = 20.sp,
+ lineHeight = 20.sp,
+ ),
+)
diff --git a/app/src/main/res/drawable-xhdpi/email_at_sign.png b/app/src/main/res/drawable-xhdpi/email_at_sign.png
new file mode 100644
index 000000000..f7082d7ad
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/email_at_sign.png differ
diff --git a/app/src/main/res/drawable-xhdpi/full_customization_mascot.png b/app/src/main/res/drawable-xhdpi/full_customization_mascot.png
new file mode 100644
index 000000000..0a5c3afa3
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/full_customization_mascot.png differ
diff --git a/app/src/main/res/drawable-xhdpi/full_customization_phone_mascot.png b/app/src/main/res/drawable-xhdpi/full_customization_phone_mascot.png
new file mode 100644
index 000000000..6dee4a852
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/full_customization_phone_mascot.png differ
diff --git a/app/src/main/res/drawable/custom_background.png b/app/src/main/res/drawable/custom_background.png
new file mode 100644
index 000000000..ce5dfe236
Binary files /dev/null and b/app/src/main/res/drawable/custom_background.png differ
diff --git a/app/src/main/res/font/bagel_fat_one_regular.ttf b/app/src/main/res/font/bagel_fat_one_regular.ttf
new file mode 100644
index 000000000..9de4a2f78
Binary files /dev/null and b/app/src/main/res/font/bagel_fat_one_regular.ttf differ
diff --git a/app/src/main/res/font/onest_bold.ttf b/app/src/main/res/font/onest_bold.ttf
new file mode 100644
index 000000000..b0a3dd939
Binary files /dev/null and b/app/src/main/res/font/onest_bold.ttf differ
diff --git a/app/src/main/res/font/onest_medium.ttf b/app/src/main/res/font/onest_medium.ttf
new file mode 100644
index 000000000..2ff600481
Binary files /dev/null and b/app/src/main/res/font/onest_medium.ttf differ
diff --git a/app/src/main/res/font/onest_regular.ttf b/app/src/main/res/font/onest_regular.ttf
new file mode 100644
index 000000000..dec9f7a23
Binary files /dev/null and b/app/src/main/res/font/onest_regular.ttf differ
diff --git a/app/src/main/res/font/onest_semibold.ttf b/app/src/main/res/font/onest_semibold.ttf
new file mode 100644
index 000000000..c7e8a3d2e
Binary files /dev/null and b/app/src/main/res/font/onest_semibold.ttf differ
diff --git a/app/src/main/res/font/roboto_bold.ttf b/app/src/main/res/font/roboto_bold.ttf
new file mode 100644
index 000000000..651618564
Binary files /dev/null and b/app/src/main/res/font/roboto_bold.ttf differ
diff --git a/app/src/main/res/font/roboto_medium.ttf b/app/src/main/res/font/roboto_medium.ttf
new file mode 100644
index 000000000..bc5b17026
Binary files /dev/null and b/app/src/main/res/font/roboto_medium.ttf differ
diff --git a/app/src/main/res/font/roboto_regular.ttf b/app/src/main/res/font/roboto_regular.ttf
new file mode 100644
index 000000000..3db0d1fb0
Binary files /dev/null and b/app/src/main/res/font/roboto_regular.ttf differ
diff --git a/app/src/main/res/font/roboto_semibold.ttf b/app/src/main/res/font/roboto_semibold.ttf
new file mode 100644
index 000000000..7a8ef87d5
Binary files /dev/null and b/app/src/main/res/font/roboto_semibold.ttf differ
diff --git a/auth/README.md b/auth/README.md
index 2bb0ac31e..86eda98e8 100644
--- a/auth/README.md
+++ b/auth/README.md
@@ -827,7 +827,7 @@ FirebaseAuthScreen(
phoneContent = { state -> /* ... */ },
mfaEnrollmentContent = { state -> /* ... */ },
mfaChallengeContent = { state -> /* ... */ },
- reauthContent = { state, onDismiss -> /* ... */ },
+ reauthContent = { state -> /* ... */ },
) { authState, uiContext ->
// authenticated content
}
@@ -992,36 +992,39 @@ mfaChallengeContent = { state ->
#### Reauthentication (`reauthContent`)
-Replaces the default reauthentication bottom sheet shown when a sensitive operation requires the user to re-verify their identity. Receives the `AuthState.ReauthenticationRequired` state (including an optional `reason` string and the signed-in `user`) and an `onDismiss` callback that resets auth state to `Idle`.
+Replaces the default reauthentication bottom sheet shown when a sensitive operation requires the user to re-verify their identity. The `ReauthContentState` carries `user`, `reason`, the `providers` already filtered to those linked to that user, and callbacks to select a provider or dismiss.
+
+The library owns the credential exchange, so the slot only renders a provider chooser. Selecting a federated provider reauthenticates directly; selecting `AuthProvider.Email` or `AuthProvider.Phone` hands off to the library's own email/phone sub-flow, which honours your `emailContent` / `phoneContent` slots and replaces this slot while it is active. Password and OTP entry therefore never appear here.
```kotlin
-reauthContent = { state, onDismiss ->
+reauthContent = { state ->
AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text("Verify your identity") },
+ onDismissRequest = state.onDismiss,
+ title = { Text(state.reason ?: "Verify your identity") },
text = {
- Column {
- state.reason?.let { Text(it) }
- OutlinedTextField(
- value = password,
- onValueChange = { password = it },
- label = { Text("Password") },
- visualTransformation = PasswordVisualTransformation(),
- )
+ Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
+ state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
+ if (state.isLoading) CircularProgressIndicator()
+ state.providers.forEach { provider ->
+ Button(
+ onClick = { state.onProviderSelected(provider) },
+ enabled = !state.isLoading,
+ ) { Text("Continue with ${provider.providerName}") }
+ }
}
},
- confirmButton = {
- Button(onClick = {
- // Re-authenticate then update auth state on success
- }) { Text("Confirm") }
- },
+ confirmButton = {},
dismissButton = {
- TextButton(onClick = onDismiss) { Text("Cancel") }
+ TextButton(onClick = state.onDismiss) { Text("Cancel") }
},
)
}
```
+While this slot is shown the library suppresses its own loading and error dialogs, so render `state.isLoading` and `state.error` yourself. `state.error` is the same message the library's own error dialog would have shown, and `state.exception` carries the exception behind it when you need to branch on the failure type. On success the library resumes the operation that required reauthentication — there is nothing to retry. `state.onDismiss` abandons reauthentication and calls `onSignInCancelled`, so any pending operation will never run; backing out of a single provider attempt returns to the slot with the operation still pending and does *not* call `onSignInCancelled`. Render the slot so it blocks interaction with the content behind it — that content stays composed, and the library only makes its own affordances inert.
+
+An armed reauthentication survives Activity recreation: rotating keeps the pending operation, the latched `state.error`, and any active email/phone sub-flow. `state.exception` is not saveable, so after a recreation `state.error` still carries the message while `state.exception` is `null` — branch on the type only for a failure your own composition observed. The pending operation cannot survive process death, and if it is lost the flow emits an `AuthState.Error` explaining that identity confirmation was interrupted rather than dropping the operation silently.
+
For most cases, use [`withReauth`](#reauthentication) instead — it handles the full reauth cycle automatically and only shows the default bottom sheet. Use `reauthContent` when you need a custom design for the reauth UI.
### Reauthentication
@@ -1048,11 +1051,12 @@ lifecycleScope.launch {
3. `FirebaseAuthScreen` shows the reauth UI scoped to the user's linked providers.
4. On successful reauthentication, retries the operation automatically and emits `AuthState.Success` or `AuthState.Error`.
+The armed reauthentication lives on the process-cached `FirebaseAuthUI`, so it survives Activity recreation; it does not survive process death, and a lost operation is reported as an `AuthState.Error` rather than silently dropped.
+
**Activity-based alternative:** use `createReauthFlow` to start a standalone reauthentication activity scoped to the current user's linked providers, returning an `AuthFlowController`.
```kotlin
val reauth = authUI.createReauthFlow(
- context = context,
configuration = authUIConfiguration {
// Providers are automatically filtered to those linked to the current user
},
diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt
index 410107cdd..707f977ea 100644
--- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt
@@ -28,7 +28,8 @@ import com.google.firebase.auth.PhoneAuthProvider
* This class encapsulates all possible authentication states that can occur during
* the authentication flow, including success, error, and intermediate states.
*
- * Use the companion object factory methods or specific subclass constructors to create instances.
+ * Instances come from the companion object factory methods or a subclass constructor; states only
+ * the library may publish have an `internal` constructor.
*
* @since 10.0.0
*/
@@ -76,11 +77,14 @@ abstract class AuthState private constructor() {
* @property result The [AuthResult] containing the authenticated user, may be null if not available
* @property user The authenticated [FirebaseUser]
* @property isNewUser Whether this is a newly created user account
+ * @property reauthenticatedUid The uid this success re-proved, or `null` if it is not a
+ * reauthentication. Settable only from within the library.
*/
- class Success(
+ class Success internal constructor(
val result: AuthResult?,
val user: FirebaseUser,
- val isNewUser: Boolean = false
+ val isNewUser: Boolean = false,
+ val reauthenticatedUid: String? = null
) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
@@ -88,18 +92,21 @@ abstract class AuthState private constructor() {
if (other !is Success) return false
return result == other.result &&
user == other.user &&
- isNewUser == other.isNewUser
+ isNewUser == other.isNewUser &&
+ reauthenticatedUid == other.reauthenticatedUid
}
override fun hashCode(): Int {
var result1 = result?.hashCode() ?: 0
result1 = 31 * result1 + user.hashCode()
result1 = 31 * result1 + isNewUser.hashCode()
+ result1 = 31 * result1 + (reauthenticatedUid?.hashCode() ?: 0)
return result1
}
override fun toString(): String =
- "AuthState.Success(result=$result, user=$user, isNewUser=$isNewUser)"
+ "AuthState.Success(result=$result, user=$user, isNewUser=$isNewUser, " +
+ "reauthenticatedUid=$reauthenticatedUid)"
}
/**
@@ -257,21 +264,15 @@ abstract class AuthState private constructor() {
class ReauthenticationRequired(
val user: FirebaseUser,
val reason: String? = null,
- // Not included in equals/hashCode — lambdas have no meaningful equality.
val retryOperation: (suspend (android.content.Context) -> Unit)? = null,
) : AuthState() {
override val isNotification: Boolean = false
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (other !is ReauthenticationRequired) return false
- return user == other.user && reason == other.reason
- }
- override fun hashCode(): Int {
- var result = user.hashCode()
- result = 31 * result + (reason?.hashCode() ?: 0)
- return result
- }
+ // Identity, not value: arming a second sensitive operation for the same user must replace
+ // the first, and MutableStateFlow silently drops a write equal to the current value.
+ override fun equals(other: Any?): Boolean = this === other
+
+ override fun hashCode(): Int = System.identityHashCode(this)
override fun toString(): String =
"AuthState.ReauthenticationRequired(user=$user, reason=$reason)"
diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
index 432d25c82..84d24f1f2 100644
--- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
@@ -320,7 +320,8 @@ class FirebaseAuthUI private constructor(
val current = _authStateFlow.value
if (current is AuthState.Success ||
current is AuthState.RequiresEmailVerification ||
- current is AuthState.RequiresProfileCompletion
+ current is AuthState.RequiresProfileCompletion ||
+ current is AuthState.ReauthenticationRequired
) {
_authStateFlow.value = AuthState.Idle
}
@@ -722,4 +723,4 @@ class FirebaseAuthUI private constructor(
const val UNCONFIGURED_CONFIG_VALUE: String = "CHANGE-ME"
}
-}
\ No newline at end of file
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt
index 7eb92114e..a424bfed9 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt
@@ -248,6 +248,7 @@ class AuthUIConfiguration(
isCredentialManagerEnabled = this.isCredentialManagerEnabled,
isMfaEnabled = this.isMfaEnabled,
isAnonymousUpgradeEnabled = this.isAnonymousUpgradeEnabled,
+ isCredentialLinkingEnabled = this.isCredentialLinkingEnabled,
tosUrl = this.tosUrl,
privacyPolicyUrl = this.privacyPolicyUrl,
logo = this.logo,
@@ -255,6 +256,7 @@ class AuthUIConfiguration(
isNewEmailAccountsAllowed = isNewEmailAccountsAllowed,
isDisplayNameRequired = this.isDisplayNameRequired,
isProviderChoiceAlwaysShown = this.isProviderChoiceAlwaysShown,
+ legacyFetchSignInWithEmail = this.legacyFetchSignInWithEmail,
transitions = this.transitions,
isReauthenticationMode = isReauthenticationMode,
)
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt
index 53ca93660..6725929b8 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt
@@ -1005,6 +1005,9 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
internal fun canLinkCredential(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean {
val currentUser = auth.currentUser
return config.isCredentialLinkingEnabled
+ // Linking is not a proof of identity: diverting a reauthentication to
+ // linkWithCredential would yield an unstamped Success the guard must reject.
+ && !config.isReauthenticationMode
&& currentUser != null
&& !currentUser.isAnonymous
}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt
index 1e480eda9..8fab40ede 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt
@@ -153,8 +153,14 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
if (shouldLinkCredential) credentialProvider.getCredential(email, password) else null
try {
- // Check if new accounts are allowed (only for non-upgrade/non-linking flows)
- if (!shouldLinkCredential && !provider.isNewAccountsAllowed) {
+ if (config.isReauthenticationMode) {
+ throw AuthException.UnknownException(
+ message = context.getString(R.string.fui_error_reauth_sign_up_not_allowed)
+ )
+ }
+ if (!shouldLinkCredential &&
+ (!provider.isNewAccountsAllowed || !config.isNewEmailAccountsAllowed)
+ ) {
throw AuthException.UserNotFoundException(
message = context.getString(R.string.fui_error_email_does_not_exist)
)
@@ -654,9 +660,17 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential(
// signInOrReauth returns null in reauth mode (Task has no AuthResult).
// Reconstruct success state from the now-reauthenticated current user.
if (result == null && config.isReauthenticationMode) {
- auth.currentUser?.let {
- updateAuthState(AuthState.Success(result = null, user = it, isNewUser = false))
- }
+ val reauthenticatedUser = auth.currentUser
+ ?: throw AuthException.UserNotFoundException(
+ message = "No user is currently signed in for reauthentication"
+ )
+ updateAuthState(
+ AuthState.Success(
+ result = null,
+ user = reauthenticatedUser,
+ reauthenticatedUid = reauthenticatedUser.uid,
+ )
+ )
return null
}
result?.user?.let { mergeProfile(auth, displayName, photoUrl) }
@@ -1077,6 +1091,11 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink(
}
// Clear DataStore after success
persistenceManager.clear(context)
+ // In reauth mode the stamped Success is already published and there is no AuthResult, so
+ // updateAuthStateWithResult would overwrite the stamp with Idle and orphan the operation.
+ if (result == null && config.isReauthenticationMode) {
+ return null
+ }
updateAuthStateWithResult(result)
return result
} catch (e: CancellationException) {
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt
index 09736f190..ebaace91b 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt
@@ -162,6 +162,21 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle(
autoSelectEnabled = provider.autoSelectEnabled
)
} catch (fallbackException: NoCredentialException) {
+ // Credential Manager doesn't distinguish "no account on device" from
+ // developer-side misconfiguration, so log the possible causes for
+ // debugging. Never surfaced to end users: the overwhelming majority
+ // hitting this genuinely have no account, and Firebase Console
+ // guidance would just confuse them.
+ Log.w(
+ "GoogleAuthProvider",
+ "No credential returned from Credential Manager after trying both " +
+ "authorized and all accounts. Possible causes: (1) no Google " +
+ "account on this device, (2) no Android OAuth client / SHA-1 " +
+ "registered for this app's package + signing certificate in the " +
+ "Firebase console, or (3) the Credential Manager Google ID " +
+ "provider is unavailable on this device.",
+ fallbackException
+ )
// No Google accounts available on device at all
throw AuthException.UnknownException(
message = "No Google accounts available.\n\nPlease add a Google account to your device and try again.",
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt
index e85c4fea4..69e7bd135 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt
@@ -202,7 +202,21 @@ internal suspend fun FirebaseAuthUI.signInWithProvider(
android.util.Log.w("OAuthProvider", "Failed to save sign-in preference", e)
}
- updateAuthStateWithResult(authResult)
+ if (config.isReauthenticationMode) {
+ val reauthenticatedUser = auth.currentUser
+ ?: throw AuthException.UserNotFoundException(
+ message = "No user is currently signed in for reauthentication"
+ )
+ updateAuthState(
+ AuthState.Success(
+ result = authResult,
+ user = reauthenticatedUser,
+ reauthenticatedUid = reauthenticatedUser.uid,
+ )
+ )
+ } else {
+ updateAuthStateWithResult(authResult)
+ }
} else {
throw AuthException.UnknownException(
message = "OAuth sign-in did not return a valid credential"
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt
index 253a6e260..38f7f10f3 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt
@@ -42,10 +42,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.R
import com.firebase.ui.auth.configuration.PasswordRule
import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
import com.firebase.ui.auth.configuration.validators.EmailValidator
@@ -86,6 +89,7 @@ import com.firebase.ui.auth.configuration.validators.PasswordValidator
* @param visualTransformation Visual transformation for the input (e.g., password).
* @param leadingIcon An optional icon to display at the start of the field.
* @param trailingIcon An optional icon to display at the start of the field.
+ * @param readOnly If the value cannot be edited by the user.
*/
@Composable
fun AuthTextField(
@@ -103,8 +107,10 @@ fun AuthTextField(
visualTransformation: VisualTransformation = VisualTransformation.None,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
+ readOnly: Boolean = false,
) {
var passwordVisible by remember { mutableStateOf(false) }
+ val localContext = LocalContext.current
// Automatically set the correct keyboard type based on validator or field type
val resolvedKeyboardOptions = remember(validator, isSecureTextField, keyboardOptions) {
@@ -124,7 +130,17 @@ fun AuthTextField(
TextField(
modifier = modifier
- .fillMaxWidth(),
+ .fillMaxWidth()
+ // A read-only field looks identical to an editable one, so state it semantically.
+ .then(
+ if (readOnly) {
+ Modifier.semantics {
+ stateDescription = localContext.getString(R.string.fui_text_field_read_only)
+ }
+ } else {
+ Modifier
+ }
+ ),
value = value,
onValueChange = { newValue ->
onValueChange(newValue)
@@ -133,6 +149,7 @@ fun AuthTextField(
label = label,
singleLine = true,
enabled = enabled,
+ readOnly = readOnly,
isError = isError ?: validator?.hasError ?: false,
supportingText = {
if (validator?.hasError ?: false) {
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt
index a3e216ebe..cd78974af 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt
@@ -22,6 +22,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.window.DialogProperties
import com.firebase.ui.auth.AuthException
@@ -33,6 +34,9 @@ import com.google.firebase.auth.PhoneAuthProvider
import com.google.firebase.auth.TwitterAuthProvider
import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+/** Test tag on the dialog's recovery/retry action button, which only renders when it has an action. */
+internal const val ERROR_DIALOG_ACTION_TEST_TAG = "ErrorRecoveryDialogAction"
+
/**
* A composable dialog for displaying authentication errors with recovery options.
*
@@ -61,7 +65,8 @@ import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
*
* @param error The [AuthException] to display recovery information for
* @param stringProvider The [AuthUIStringProvider] for localized strings
- * @param onRetry Callback invoked when the user taps the retry action
+ * @param onRetry Callback invoked when the user taps the retry action, or `null` when there is
+ * nothing to retry — the action button is then not rendered at all
* @param onDismiss Callback invoked when the user dismisses the dialog
* @param modifier Optional [Modifier] for the dialog
* @param onRecover Optional callback for custom recovery actions based on the exception type
@@ -73,7 +78,7 @@ import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
fun ErrorRecoveryDialog(
error: AuthException,
stringProvider: AuthUIStringProvider,
- onRetry: (AuthException) -> Unit,
+ onRetry: ((AuthException) -> Unit)?,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
onRecover: ((AuthException) -> Unit)? = null,
@@ -97,11 +102,12 @@ fun ErrorRecoveryDialog(
)
},
confirmButton = {
- if (isRecoverable(error)) {
+ // No callback means no action to take, so an action button would be a no-op.
+ val action = onRecover ?: onRetry
+ if (action != null && isRecoverable(error)) {
TextButton(
- onClick = {
- onRecover?.invoke(error) ?: onRetry(error)
- }
+ onClick = { action(error) },
+ modifier = Modifier.testTag(ERROR_DIALOG_ACTION_TEST_TAG),
) {
Text(
text = getRecoveryActionText(error, stringProvider),
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt
index e5d22c1a8..a5b73917e 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt
@@ -82,14 +82,15 @@ class TopLevelDialogController(
* for de-duplication. Pass this explicitly when the caller might not be the only observer of
* the same error: by the time this runs, another observer may have already reset the live
* auth state to `Idle`, so falling back to [currentAuthState] alone would miss the dedup.
- * @param onRetry Callback when user clicks retry button
+ * @param onRetry Callback when user clicks retry button, or `null` when there is nothing to
+ * retry — [ErrorRecoveryDialog] then renders no action button at all
* @param onRecover Callback when user clicks recover button (e.g., navigate to different screen)
* @param onDismiss Callback when dialog is dismissed
*/
fun showErrorDialog(
exception: AuthException,
errorState: AuthState.Error? = null,
- onRetry: (AuthException) -> Unit = {},
+ onRetry: ((AuthException) -> Unit)? = null,
onRecover: ((AuthException) -> Unit)? = null,
onDismiss: () -> Unit = {}
) {
@@ -135,9 +136,11 @@ class TopLevelDialogController(
ErrorRecoveryDialog(
error = state.exception,
stringProvider = stringProvider,
- onRetry = { exception ->
- state.onRetry(exception)
- state.onDismiss()
+ onRetry = state.onRetry?.let { onRetry ->
+ { exception: AuthException ->
+ onRetry(exception)
+ state.onDismiss()
+ }
},
onRecover = state.onRecover?.let { onRecover ->
{ exception ->
@@ -157,7 +160,7 @@ class TopLevelDialogController(
private sealed class DialogState {
data class ErrorDialog(
val exception: AuthException,
- val onRetry: (AuthException) -> Unit,
+ val onRetry: ((AuthException) -> Unit)?,
val onRecover: ((AuthException) -> Unit)?,
val onDismiss: () -> Unit
) : DialogState()
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 14e0965f7..9084872c3 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
@@ -49,6 +49,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -64,6 +67,7 @@ import com.firebase.ui.auth.AuthState
import com.firebase.ui.auth.BuildConfig
import com.firebase.ui.auth.FirebaseAuthActivity
import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.R
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.MfaConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
@@ -78,6 +82,7 @@ import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringPro
import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
import com.firebase.ui.auth.configuration.theme.LocalAuthUITheme
import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController
+import com.firebase.ui.auth.ui.components.getRecoveryMessage
import com.firebase.ui.auth.ui.components.rememberTopLevelDialogController
import com.firebase.ui.auth.mfa.MfaChallengeContentState
import com.firebase.ui.auth.mfa.MfaEnrollmentContentState
@@ -114,6 +119,10 @@ import kotlinx.coroutines.tasks.await
* @param customMethodPickerTermsConfiguration Optional custom Terms of Service/Privacy Policy
* footer for the *default* method-picker layout. Ignored when [customMethodPickerLayout] is
* provided, since that slot takes over the whole screen.
+ * @param reauthContent Optional slot that replaces the default reauthentication bottom sheet,
+ * receiving a [ReauthContentState]. The library owns the credential exchange. An armed
+ * reauthentication survives Activity recreation (rotation) but not process death; if it is lost
+ * the flow surfaces an error rather than dropping the pending operation silently.
*
* @since 10.0.0
*/
@@ -134,7 +143,7 @@ fun FirebaseAuthScreen(
phoneContent: (@Composable (PhoneAuthContentState) -> Unit)? = null,
mfaEnrollmentContent: (@Composable (MfaEnrollmentContentState) -> Unit)? = null,
mfaChallengeContent: (@Composable (MfaChallengeContentState) -> Unit)? = null,
- reauthContent: (@Composable (state: AuthState.ReauthenticationRequired, onDismiss: () -> Unit) -> Unit)? = null,
+ reauthContent: (@Composable (ReauthContentState) -> Unit)? = null,
authenticatedContent: (@Composable (state: AuthState, uiContext: AuthSuccessUiContext) -> Unit)? = null,
) {
// Set FirebaseUI version
@@ -156,13 +165,46 @@ fun FirebaseAuthScreen(
val pendingReauthConfig = remember { mutableStateOf(null) }
val pendingReauthState = remember { mutableStateOf(null) }
val pendingReauthOperation = remember { mutableStateOf<(suspend (android.content.Context) -> Unit)?>(null) }
+ // Saved so a re-derived arming (rotation) can be told apart from a genuinely new one, and so a
+ // recreation that cannot re-derive it is reported rather than dropped silently.
+ val reauthArmedUid = rememberSaveable { mutableStateOf(null) }
+ // The exception is not saveable, its already-localized message is. Written only together, so
+ // the slot's `error` and `exception` can never disagree.
+ val reauthFailure = remember { mutableStateOf(null) }
+ val reauthErrorMessage = rememberSaveable { mutableStateOf(null) }
+ val reauthSubRoute =
+ rememberSaveable(stateSaver = ReauthSubRouteSaver) { mutableStateOf(null) }
+ val setReauthFailure: (AuthException?) -> Unit = remember(stringProvider) {
+ { failure ->
+ reauthFailure.value = failure
+ reauthErrorMessage.value = failure?.let { getRecoveryMessage(it, stringProvider) }
+ }
+ }
+ val clearPendingReauth: () -> Unit = remember(setReauthFailure) {
+ {
+ pendingReauthOperation.value = null
+ pendingReauthConfig.value = null
+ pendingReauthState.value = null
+ reauthArmedUid.value = null
+ reauthSubRoute.value = null
+ setReauthFailure(null)
+ }
+ }
+ // Idle would drop the arming from the process-cached FirebaseAuthUI, so an Activity recreation
+ // could no longer re-derive the pending operation. Re-emit the arming instead.
+ val resetTransientAuthState: () -> Unit = remember(authUI) {
+ { authUI.updateAuthState(pendingReauthState.value ?: AuthState.Idle) }
+ }
val emailLinkFromDifferentDevice = remember { mutableStateOf(null) }
val prefillEmail = remember { mutableStateOf(null) }
+ val reauthPrefillEmail = remember(authUI, configuration.isReauthenticationMode) {
+ if (configuration.isReauthenticationMode) authUI.auth.currentUser?.email else null
+ }
val lastSignInPreference =
remember { mutableStateOf(null) }
- // Last-processed AuthState, so the Idle branch below can tell a genuine reset apart from
- // Idle-as-a-side-effect of consuming a notification (see AuthState.isNotification).
- val previousAuthState = remember { mutableStateOf(AuthState.Idle) }
+ // Lets the Idle branch below tell a genuine reset apart from consuming a notification
+ // (AuthState.isNotification) and from collectAsState's placeholder Idle (null == none yet).
+ val previousAuthState = remember { mutableStateOf(null) }
val startRoute = remember(configuration.providers, configuration.isProviderChoiceAlwaysShown) {
getStartRoute(configuration)
}
@@ -175,7 +217,7 @@ fun FirebaseAuthScreen(
val emailProvider = configuration.providers.filterIsInstance().firstOrNull()
val logoAsset = configuration.logo
- val onProviderSelected = authUI.rememberOnProviderSelected(
+ val onOuterProviderSelected = authUI.rememberOnProviderSelected(
context = context,
activity = activity,
config = configuration,
@@ -192,6 +234,16 @@ fun FirebaseAuthScreen(
},
onSignInFailure = onSignInFailure,
)
+ // Remembered so the method picker is not recomposed on every parent recomposition;
+ // rememberOnProviderSelected returns a fresh lambda each time, so read it through a holder.
+ val currentOuterProviderSelected = rememberUpdatedState(onOuterProviderSelected)
+ val onProviderSelected: (AuthProvider) -> Unit = remember {
+ { provider ->
+ if (pendingReauthState.value == null) {
+ currentOuterProviderSelected.value(provider)
+ }
+ }
+ }
val continueWithProvider: (String) -> Unit = { providerId ->
configuration.providers.find { it.providerId == providerId }?.let { onProviderSelected(it) }
}
@@ -223,6 +275,8 @@ fun FirebaseAuthScreen(
) {
composable(AuthRoute.MethodPicker.route) {
if (customMethodPickerLayout != null) {
+ // Takes over the entire screen — no logo, no ToS/Privacy footer, and no
+ // automatic inset handling. See the KDoc on customMethodPickerLayout.
Box(modifier = modifier.fillMaxSize()) {
customMethodPickerLayout(configuration.providers, onProviderSelected)
}
@@ -255,7 +309,9 @@ fun FirebaseAuthScreen(
context = context,
configuration = configuration,
authUI = authUI,
- prefillEmail = prefillEmail.value,
+ // The reauth user's own address wins: a stale "Continue as" identifier
+ // would lock the field to an account that cannot be re-proved here.
+ prefillEmail = reauthPrefillEmail ?: prefillEmail.value,
credentialForLinking = pendingLinkingCredential.value,
emailLinkFromDifferentDevice = emailLinkFromDifferentDevice.value,
onContinueWithProvider = continueWithProvider,
@@ -319,14 +375,17 @@ fun FirebaseAuthScreen(
}
},
onManageMfa = {
- if (configuration.isMfaEnabled) {
- navController.navigate(AuthRoute.MfaEnrollment.route)
- } else {
- val exception = AuthException.AuthCancelledException(
- message = "Multi-factor authentication is disabled in the configuration. " +
- "Enable MFA in AuthUIConfiguration to use this feature."
- )
- authUI.updateAuthState(AuthState.Error(exception))
+ // Inert while armed: this content stays composed beneath the slot.
+ if (pendingReauthState.value == null) {
+ if (configuration.isMfaEnabled) {
+ navController.navigate(AuthRoute.MfaEnrollment.route)
+ } else {
+ val exception = AuthException.AuthCancelledException(
+ message = "Multi-factor authentication is disabled in the configuration. " +
+ "Enable MFA in AuthUIConfiguration to use this feature."
+ )
+ authUI.updateAuthState(AuthState.Error(exception))
+ }
}
},
onReloadUser = {
@@ -359,7 +418,10 @@ fun FirebaseAuthScreen(
}
},
onNavigate = { route ->
- navController.navigate(route.route)
+ // Inert while armed: this content stays composed beneath the slot.
+ if (pendingReauthState.value == null) {
+ navController.navigate(route.route)
+ }
}
)
}
@@ -425,7 +487,9 @@ fun FirebaseAuthScreen(
// Handle email link sign-in (deep links)
LaunchedEffect(emailLink) {
- if (emailLink != null && emailProvider != null) {
+ // A link arriving while armed would sign in on the non-reauth configuration, and
+ // could sign in a different user the armed operation can then never match.
+ if (emailLink != null && emailProvider != null && pendingReauthState.value == null) {
try {
// Try to retrieve saved email from DataStore (same-device flow)
val savedEmail =
@@ -464,31 +528,68 @@ fun FirebaseAuthScreen(
val previous = previousAuthState.value
previousAuthState.value = state
val currentRoute = navController.currentBackStackEntry?.destination?.route
+ // Armed before this composition existed, but the attempt in flight died with it:
+ // the operation can no longer run, so report it instead of dropping it silently.
+ if (reauthArmedUid.value != null &&
+ pendingReauthState.value == null &&
+ state is AuthState.Loading
+ ) {
+ clearPendingReauth()
+ authUI.updateAuthState(
+ AuthState.Error(
+ AuthException.UnknownException(
+ context.getString(R.string.fui_error_reauth_interrupted)
+ )
+ )
+ )
+ return@LaunchedEffect
+ }
when (state) {
is AuthState.Success -> {
pendingResolver.value = null
pendingLinkingCredential.value = null
- // If reauth just completed, execute the pending retry and skip normal success handling.
- // Guarded on !previous.isNotification: a wrong-password Error masks back into
- // Success while signed in, and that must not be mistaken for a completed reauth.
- if (!previous.isNotification) {
- pendingReauthOperation.value?.let { retry ->
- pendingReauthOperation.value = null
- pendingReauthConfig.value = null
- pendingReauthState.value = null
- // Lock the state to Loading before launching the retry so no
- // intermediate Success emission can navigate to AuthRoute.Success.
- authUI.updateAuthState(AuthState.Loading())
- coroutineScope.launch {
- try {
- retry(context)
- } catch (e: kotlinx.coroutines.CancellationException) {
- throw e
- } catch (e: Exception) {
- authUI.updateAuthState(AuthState.Error(e))
+ val expectedReauthUid = pendingReauthState.value?.user?.uid
+ if (expectedReauthUid != null) {
+ if (state.reauthenticatedUid == expectedReauthUid) {
+ val retry = pendingReauthOperation.value
+ clearPendingReauth()
+ if (retry != null) {
+ authUI.updateAuthState(AuthState.Loading())
+ coroutineScope.launch {
+ try {
+ retry(context)
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ authUI.updateAuthState(AuthState.Error(e))
+ }
+ }
+ } else if (currentRoute != AuthRoute.Success.route) {
+ // Nothing to resume, but the slot is gone: land on Success
+ // rather than whatever route it was covering.
+ navController.navigate(AuthRoute.Success.route) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ inclusive = true
+ }
+ launchSingleTop = true
}
}
+ // A reauthentication is never a sign-in: onSignInSuccess must not
+ // fire for it, whether or not an operation was attached.
+ return@LaunchedEffect
+ } else {
+ // Only the ambient re-emission for the armed user is benign; a
+ // stamp for another account leaves the slot inert, unexplained.
+ if (state.reauthenticatedUid != null ||
+ authUI.auth.currentUser?.uid != expectedReauthUid
+ ) {
+ setReauthFailure(
+ AuthException.UnknownException(
+ context.getString(R.string.fui_error_reauth_incomplete)
+ )
+ )
+ }
return@LaunchedEffect
}
}
@@ -515,32 +616,43 @@ fun FirebaseAuthScreen(
}
is AuthState.ReauthenticationRequired -> {
- pendingReauthOperation.value = state.retryOperation
val linked = configuration.providers.filterToLinkedProviders(state.user)
if (linked.isEmpty()) {
+ clearPendingReauth()
authUI.updateAuthState(
AuthState.Error(
AuthException.UnknownException(
- "No configured providers are linked to the current user"
+ context.getString(R.string.fui_error_reauth_no_linked_providers)
)
)
)
return@LaunchedEffect
}
- if (reauthContent != null) {
- pendingReauthState.value = state
- } else {
- pendingReauthConfig.value = configuration.copy(
- providers = linked,
- isNewEmailAccountsAllowed = false,
- isReauthenticationMode = true,
- )
+ // The durability re-emit and a post-recreation re-derivation are the same
+ // arming: keep the latched error and sub-flow. A new one clears both.
+ val sameArming = pendingReauthState.value === state ||
+ (pendingReauthState.value == null &&
+ reauthArmedUid.value == state.user.uid)
+ if (!sameArming) {
+ reauthSubRoute.value = null
+ setReauthFailure(null)
}
+ reauthArmedUid.value = state.user.uid
+ pendingReauthOperation.value = state.retryOperation
+ pendingReauthState.value = state
+ pendingReauthConfig.value = configuration.copy(
+ providers = linked,
+ isNewEmailAccountsAllowed = false,
+ isReauthenticationMode = true,
+ )
}
is AuthState.RequiresEmailVerification,
is AuthState.RequiresProfileCompletion,
-> {
+ // Reachable while armed (a wrong password in the sub-flow falls back to
+ // this): navigating would wipe the back stack out from under the slot.
+ if (pendingReauthState.value != null) return@LaunchedEffect
pendingResolver.value = null
pendingLinkingCredential.value = null
if (currentRoute != AuthRoute.Success.route) {
@@ -552,6 +664,18 @@ fun FirebaseAuthScreen(
}
is AuthState.RequiresMfa -> {
+ // An MFA-enrolled account cannot complete reauthentication today; pushing
+ // the challenge under the slot would leave a dead UI with no explanation.
+ if (pendingReauthState.value != null) {
+ authUI.updateAuthState(
+ AuthState.Error(
+ AuthException.UnknownException(
+ context.getString(R.string.fui_error_reauth_mfa_unsupported)
+ )
+ )
+ )
+ return@LaunchedEffect
+ }
pendingResolver.value = state.resolver
if (currentRoute != AuthRoute.MfaChallenge.route) {
navController.navigate(AuthRoute.MfaChallenge.route) {
@@ -561,9 +685,11 @@ fun FirebaseAuthScreen(
}
is AuthState.Cancelled -> {
- pendingReauthOperation.value = null
- pendingReauthConfig.value = null
- pendingReauthState.value = null
+ if (pendingReauthState.value != null) {
+ resetTransientAuthState()
+ return@LaunchedEffect
+ }
+ clearPendingReauth()
pendingResolver.value = null
pendingLinkingCredential.value = null
lastSuccessfulUserId.value = null
@@ -581,9 +707,7 @@ fun FirebaseAuthScreen(
// Hosted by FirebaseAuthActivity: its own authStateFlow collector
// independently finishes the activity and resets state on Aborted.
if (activity !is FirebaseAuthActivity) {
- pendingReauthOperation.value = null
- pendingReauthConfig.value = null
- pendingReauthState.value = null
+ clearPendingReauth()
pendingResolver.value = null
pendingLinkingCredential.value = null
lastSuccessfulUserId.value = null
@@ -594,10 +718,8 @@ fun FirebaseAuthScreen(
is AuthState.Idle -> {
// A notification resets to Idle purely to avoid leaking to a freshly
// created screen — that's not a request to leave the current one.
- if (!previous.isNotification) {
- pendingReauthOperation.value = null
- pendingReauthConfig.value = null
- pendingReauthState.value = null
+ if (previous != null && !previous.isNotification) {
+ clearPendingReauth()
pendingResolver.value = null
pendingLinkingCredential.value = null
lastSuccessfulUserId.value = null
@@ -614,6 +736,10 @@ fun FirebaseAuthScreen(
}
}
+ val reauthSlotActive = reauthContent != null &&
+ pendingReauthState.value != null &&
+ reauthSubRoute.value == null
+
// Handle errors using top-level dialog controller
val errorState = authState as? AuthState.Error
if (errorState != null) {
@@ -623,13 +749,20 @@ fun FirebaseAuthScreen(
else -> AuthException.from(throwable, stringProvider)
}
+ if (reauthSlotActive) {
+ if (exception !is AuthException.AuthCancelledException) {
+ setReauthFailure(exception)
+ }
+ resetTransientAuthState()
+ return@LaunchedEffect
+ }
+
dialogController.showErrorDialog(
exception = exception,
errorState = errorState,
- onRetry = { _ ->
- // Child screens handle their own retry logic
- },
- onRecover = when (exception) {
+ // Child screens own their retry logic, so there is nothing to retry here.
+ onRetry = null,
+ onRecover = if (pendingReauthState.value != null) null else when (exception) {
is AuthException.EmailAlreadyInUseException -> {
{
navController.navigate(AuthRoute.Email.route) {
@@ -685,7 +818,7 @@ fun FirebaseAuthScreen(
}
)
// Consumed immediately so this doesn't leak to a freshly created screen.
- authUI.updateAuthState(AuthState.Idle)
+ resetTransientAuthState()
}
}
@@ -693,51 +826,85 @@ fun FirebaseAuthScreen(
dialogController.CurrentDialog()
val loadingState = authState as? AuthState.Loading
- if (loadingState != null) {
+ if (loadingState != null && !reauthSlotActive) {
LoadingDialog(loadingState.message ?: stringProvider.progressDialogLoading)
}
- // Custom reauth UI — rendered when the caller provides reauthContent.
- val pendingReauth = pendingReauthState.value
- if (pendingReauth != null && reauthContent != null) {
- reauthContent(pendingReauth) {
- pendingReauthOperation.value = null
- pendingReauthState.value = null
+ // Keyed on authUI only: onSignInCancelled is a caller lambda that is typically not
+ // remembered, so keying on it would defeat the remember entirely.
+ val currentOnSignInCancelled = rememberUpdatedState(onSignInCancelled)
+ val onReauthDismiss: () -> Unit = remember(authUI, clearPendingReauth) {
+ {
+ clearPendingReauth()
authUI.updateAuthState(AuthState.Idle)
+ // Abandoning reauthentication drops the pending operation for good, so the
+ // host has to learn it will never run. A cancelled provider attempt does not.
+ currentOnSignInCancelled.value()
}
}
+ val onReauthAttemptStarted: () -> Unit =
+ remember(setReauthFailure) { { setReauthFailure(null) } }
+ val onReauthSubRouteChange: (AuthRoute?) -> Unit =
+ remember { { route -> reauthSubRoute.value = route } }
- // Default reauth bottom sheet — used when reauthContent is not provided.
val reauthConfig = pendingReauthConfig.value
- if (reauthConfig != null) {
- ModalBottomSheet(
- onDismissRequest = {
- pendingReauthOperation.value = null
- pendingReauthConfig.value = null
- authUI.updateAuthState(AuthState.Idle)
- },
- sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
- ) {
- ReauthSheetContent(
+ val pendingReauth = pendingReauthState.value
+ if (reauthConfig != null && pendingReauth != null) {
+ if (reauthContent != null) {
+ CustomReauthContent(
authUI = authUI,
reauthConfig = reauthConfig,
+ reauthState = pendingReauth,
activity = activity,
context = context,
emailContent = emailContent,
phoneContent = phoneContent,
- customMethodPickerLayout = customMethodPickerLayout,
- onDismiss = {
- pendingReauthOperation.value = null
- pendingReauthConfig.value = null
- authUI.updateAuthState(AuthState.Idle)
- },
+ isLoading = loadingState != null,
+ // The same string ErrorRecoveryDialog would have shown for this failure.
+ error = reauthErrorMessage.value,
+ exception = reauthFailure.value,
+ activeSubRoute = reauthSubRoute.value,
+ onActiveSubRouteChange = onReauthSubRouteChange,
+ onAttemptStarted = onReauthAttemptStarted,
+ onDismiss = onReauthDismiss,
+ content = reauthContent,
)
+ } else {
+ ModalBottomSheet(
+ onDismissRequest = onReauthDismiss,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ ) {
+ ReauthSheetContent(
+ authUI = authUI,
+ reauthConfig = reauthConfig,
+ activity = activity,
+ context = context,
+ prefillEmail = pendingReauth.user.email,
+ emailContent = emailContent,
+ phoneContent = phoneContent,
+ customMethodPickerLayout = customMethodPickerLayout,
+ onDismiss = onReauthDismiss,
+ )
+ }
}
}
}
}
}
+// Saved as its route String — AuthRoute is not Parcelable, and Email/Phone are the only reauth
+// sub-flows, so any other saved route restores as "no sub-flow" rather than a dead branch.
+private val ReauthSubRouteSaver: Saver = Saver(
+ save = { it?.route },
+ restore = { route ->
+ when (route) {
+ AuthRoute.Email.route -> AuthRoute.Email
+ AuthRoute.Phone.route -> AuthRoute.Phone
+ else -> null
+ }
+ },
+)
+
sealed class AuthRoute(val route: String) {
object MethodPicker : AuthRoute("auth_method_picker")
object Email : AuthRoute("auth_email")
@@ -958,6 +1125,7 @@ private fun ReauthSheetContent(
reauthConfig: AuthUIConfiguration,
activity: android.app.Activity?,
context: android.content.Context,
+ prefillEmail: String?,
emailContent: (@Composable (EmailAuthContentState) -> Unit)?,
phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?,
customMethodPickerLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)?,
@@ -1002,6 +1170,7 @@ private fun ReauthSheetContent(
context = context,
configuration = reauthConfig,
authUI = authUI,
+ prefillEmail = prefillEmail,
content = emailContent,
onSuccess = {},
onError = {},
@@ -1027,6 +1196,124 @@ private fun ReauthSheetContent(
}
}
+/**
+ * Custom reauth UI — renders the caller's [content] slot, and *replaces* it with the library's own
+ * email/phone sub-flow while the user is in one, i.e. after selecting [AuthProvider.Email] or
+ * [AuthProvider.Phone]. Cancelling the sub-flow composes [content] again from scratch, so any state
+ * the caller `remember`ed inside the slot is lost — the slot is a stateless provider chooser by
+ * design. Every other provider runs the library credential exchange in place, which routes to
+ * `reauthenticateWithCredential` because [reauthConfig] is in reauthentication mode.
+ *
+ * Only [onDismiss] abandons reauthentication; cancelling a sub-flow merely returns to [content].
+ *
+ * @param activeSubRoute Which sub-flow, if any, currently replaces [content].
+ * @param onActiveSubRouteChange Invoked when the active sub-flow opens or closes.
+ * @param onAttemptStarted Invoked just before an in-place credential attempt begins.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CustomReauthContent(
+ authUI: FirebaseAuthUI,
+ reauthConfig: AuthUIConfiguration,
+ reauthState: AuthState.ReauthenticationRequired,
+ activity: android.app.Activity?,
+ context: android.content.Context,
+ emailContent: (@Composable (EmailAuthContentState) -> Unit)?,
+ phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?,
+ isLoading: Boolean,
+ error: String?,
+ exception: Exception?,
+ activeSubRoute: AuthRoute?,
+ onActiveSubRouteChange: (AuthRoute?) -> Unit,
+ onAttemptStarted: () -> Unit,
+ onDismiss: () -> Unit,
+ content: @Composable (ReauthContentState) -> Unit,
+) {
+ val openSubFlow: (AuthRoute) -> Unit = remember(onActiveSubRouteChange) {
+ { route -> onActiveSubRouteChange(route) }
+ }
+ val onProviderSelected = authUI.rememberOnProviderSelected(
+ context = context,
+ activity = activity,
+ config = reauthConfig,
+ onNavigate = openSubFlow,
+ )
+ // rememberOnProviderSelected returns a fresh lambda per recomposition, so read it through a
+ // holder rather than keying on it — otherwise this remember would never hit.
+ val currentOnProviderSelected = rememberUpdatedState(onProviderSelected)
+ val onProviderSelectedFromSlot: (AuthProvider) -> Unit = remember(onAttemptStarted) {
+ { provider ->
+ // Email and Phone only open a sub-flow; clearing the latched error there would wipe a
+ // real failure on a mis-tap, and `error` is documented to survive backing out.
+ if (provider !is AuthProvider.Email && provider !is AuthProvider.Phone) {
+ onAttemptStarted()
+ }
+ currentOnProviderSelected.value(provider)
+ }
+ }
+ val closeSubFlow: () -> Unit =
+ remember(onActiveSubRouteChange) { { onActiveSubRouteChange(null) } }
+
+ val slotState = ReauthContentState(
+ user = reauthState.user,
+ reason = reauthState.reason,
+ providers = reauthConfig.providers,
+ onProviderSelected = onProviderSelectedFromSlot,
+ isLoading = isLoading,
+ error = error,
+ onDismiss = onDismiss,
+ exception = exception,
+ )
+
+ when (val subRoute = activeSubRoute) {
+ null -> content(slotState)
+
+ AuthRoute.Email -> ModalBottomSheet(
+ onDismissRequest = closeSubFlow,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ ) {
+ EmailAuthScreen(
+ context = context,
+ configuration = reauthConfig,
+ authUI = authUI,
+ prefillEmail = reauthState.user.email,
+ content = emailContent,
+ onSuccess = {},
+ onError = {},
+ onCancel = closeSubFlow,
+ )
+ }
+
+ AuthRoute.Phone -> ModalBottomSheet(
+ onDismissRequest = closeSubFlow,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ ) {
+ PhoneAuthScreen(
+ context = context,
+ configuration = reauthConfig,
+ authUI = authUI,
+ content = phoneContent,
+ onSuccess = {},
+ onError = {},
+ onCancel = closeSubFlow,
+ )
+ }
+
+ else -> {
+ // No sub-flow for this route: keep the caller's slot rather than crashing
+ // composition. Add a branch when a new provider gains its own screen.
+ LaunchedEffect(subRoute) {
+ Log.w(
+ "FirebaseAuthScreen",
+ "No reauth sub-flow for ${subRoute?.route}; staying on the slot"
+ )
+ onActiveSubRouteChange(null)
+ }
+ content(slotState)
+ }
+ }
+}
+
@Composable
private fun FirebaseAuthUI.rememberOnProviderSelected(
context: android.content.Context,
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/ReauthContentState.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/ReauthContentState.kt
new file mode 100644
index 000000000..87a16a0c9
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/ReauthContentState.kt
@@ -0,0 +1,97 @@
+/*
+ * 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
+
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.google.firebase.auth.FirebaseUser
+
+/**
+ * State class containing all the necessary information to render a custom UI for the
+ * reauthentication flow triggered by a sensitive operation (account deletion, password change,
+ * email change).
+ *
+ * This class is passed to the `reauthContent` slot of [FirebaseAuthScreen]. The caller renders a
+ * provider chooser; the library owns the credential exchange. [AuthProvider.Email] and
+ * [AuthProvider.Phone] hand off to the library's own sub-flow, which replaces this slot while
+ * active, so keep the slot stateless. On success the library resumes the pending operation.
+ *
+ * Render the slot so it blocks interaction with the content behind it (a dialog or modal sheet):
+ * that content stays composed, and the library only makes its own affordances inert.
+ *
+ * ```kotlin
+ * FirebaseAuthScreen(
+ * configuration = configuration,
+ * onSignInSuccess = { },
+ * onSignInFailure = { },
+ * onSignInCancelled = { },
+ * reauthContent = { state ->
+ * AlertDialog(
+ * onDismissRequest = state.onDismiss,
+ * title = { Text(state.reason ?: "Verify your identity") },
+ * text = {
+ * Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
+ * state.error?.let { Text(it) }
+ * if (state.isLoading) CircularProgressIndicator()
+ * state.providers.forEach { provider ->
+ * Button(
+ * onClick = { state.onProviderSelected(provider) },
+ * enabled = !state.isLoading,
+ * ) { Text("Continue with ${provider.providerName}") }
+ * }
+ * }
+ * },
+ * confirmButton = {},
+ * dismissButton = { TextButton(onClick = state.onDismiss) { Text("Cancel") } },
+ * )
+ * },
+ * )
+ * ```
+ *
+ * @property user The [FirebaseUser] that needs to reauthenticate.
+ * @property reason An optional human-readable reason to show the user, as supplied by the caller of the sensitive operation. Will be `null` when no reason was given.
+ * @property providers The providers the user may reauthenticate with, already filtered by the library to those both configured and linked to [user].
+ * @property onProviderSelected Callback invoked with the provider the user chose. Receives the selected [AuthProvider]; the library owns what happens next.
+ * @property isLoading `true` while a reauthentication attempt is in progress. Use this to show loading indicators and disable the provider buttons. The library's own loading dialog is suppressed while this slot is shown.
+ * @property error A localized error message for the last failed attempt, or `null` if it did not fail. Persists until the next credential attempt starts, so it can be rendered inline. Backing out of an attempt is not a failure and leaves this unchanged. Survives Activity recreation.
+ * @property onDismiss Callback to abandon reauthentication and drop the pending operation. This is the only way to abandon it — backing out of a single provider attempt returns to this slot with the operation still pending.
+ * @property exception The exception behind [error], or `null` if the last attempt did not fail. Branch on its type when a message alone is not enough. Not retained across Activity recreation, which leaves [error] set with this `null`.
+ *
+ * @since 10.0.0
+ */
+data class ReauthContentState(
+ /** The [FirebaseUser] that needs to reauthenticate. */
+ val user: FirebaseUser,
+
+ /** Optional human-readable reason to show the user. `null` when none was given. */
+ val reason: String? = null,
+
+ /** Configured providers linked to [user]. Already filtered by the library. */
+ val providers: List = emptyList(),
+
+ /** Callback invoked with the provider the user chose. The library owns the credential path. */
+ val onProviderSelected: (AuthProvider) -> Unit = {},
+
+ /** `true` while a reauthentication attempt is in progress. */
+ val isLoading: Boolean = false,
+
+ /** Localized error message for the last failed attempt. `null` if it did not fail. */
+ val error: String? = null,
+
+ /** Callback to abandon reauthentication and drop the pending operation. */
+ val onDismiss: () -> Unit = {},
+
+ /** The exception behind [error], if the last attempt failed. Dropped on recreation. */
+ val exception: Exception? = null,
+)
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt
index adf17afc5..155f60fa8 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt
@@ -88,6 +88,8 @@ enum class EmailAuthMode {
* @param onGoToSignUp A callback to switch the UI to the SignUp mode.
* @param onGoToSignIn A callback to switch the UI to the SignIn mode.
* @param onGoToResetPassword A callback to switch the UI to the ResetPassword mode.
+ * @param isEmailLocked true when the library fixed [email] and it must not be edited. Render the
+ * email field read-only while it is true.
*/
class EmailAuthContentState(
val mode: EmailAuthMode,
@@ -112,6 +114,7 @@ class EmailAuthContentState(
val onGoToSignIn: () -> Unit,
val onGoToResetPassword: () -> Unit,
val onGoToEmailLinkSignIn: () -> Unit,
+ val isEmailLocked: Boolean = false,
)
/**
@@ -156,13 +159,32 @@ fun EmailAuthScreen(
val passwordTextValue = rememberSaveable { mutableStateOf("") }
val confirmPasswordTextValue = rememberSaveable { mutableStateOf("") }
+ val isEmailLocked = remember(prefillEmail, configuration.isReauthenticationMode) {
+ configuration.isReauthenticationMode && !prefillEmail.isNullOrEmpty()
+ }
+
+ val isSignUpOffered = provider.isNewAccountsAllowed &&
+ configuration.isNewEmailAccountsAllowed &&
+ !configuration.isReauthenticationMode
+
// Used for clearing text fields when switching EmailAuthMode changes
- val textValues = listOf(
- displayNameValue,
- emailTextValue,
- passwordTextValue,
- confirmPasswordTextValue
- )
+ val textValues = remember {
+ listOf(
+ displayNameValue,
+ emailTextValue,
+ passwordTextValue,
+ confirmPasswordTextValue
+ )
+ }
+
+ val resetTextValues: () -> Unit = remember(textValues, isEmailLocked, prefillEmail) {
+ {
+ textValues.forEach { it.value = "" }
+ if (isEmailLocked) {
+ emailTextValue.value = prefillEmail.orEmpty()
+ }
+ }
+ }
val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle)
val isLoading = authState is AuthState.Loading
@@ -193,28 +215,31 @@ fun EmailAuthScreen(
dialogController?.showErrorDialog(
exception = exception,
errorState = state,
- onRetry = { ex ->
- when (ex) {
- is AuthException.UserNotFoundException -> {
- val provider = configuration.providers
- .filterIsInstance()
- .first()
- if (provider.isNewAccountsAllowed) {
- // User not found, but new accounts are allowed, switch to sign-up
- mode.value = EmailAuthMode.SignUp
+ // Every branch below is inert while reauthenticating, so an action button
+ // would only dismiss the dialog — leave it without one.
+ onRetry = if (configuration.isReauthenticationMode) {
+ null
+ } else {
+ { ex: AuthException ->
+ when (ex) {
+ is AuthException.UserNotFoundException -> {
+ if (isSignUpOffered) {
+ // User not found, but new accounts are allowed, switch to sign-up
+ mode.value = EmailAuthMode.SignUp
+ }
}
- }
- is AuthException.InvalidCredentialsException -> {
- // User can retry sign in with corrected credentials
- }
+ is AuthException.InvalidCredentialsException -> {
+ // User can retry sign in with corrected credentials
+ }
- is AuthException.EmailAlreadyInUseException -> {
- // Switch to sign-in mode
- mode.value = EmailAuthMode.SignIn
- }
+ is AuthException.EmailAlreadyInUseException -> {
+ // Switch to sign-in mode
+ mode.value = EmailAuthMode.SignIn
+ }
- else -> Unit
+ else -> Unit
+ }
}
},
onRecover = if (exception is AuthException.DifferentSignInMethodRequiredException) {
@@ -263,6 +288,7 @@ fun EmailAuthScreen(
mode = mode.value,
displayName = displayNameValue.value,
email = emailTextValue.value,
+ isEmailLocked = isEmailLocked,
password = passwordTextValue.value,
confirmPassword = confirmPasswordTextValue.value,
isLoading = isLoading,
@@ -270,7 +296,9 @@ fun EmailAuthScreen(
resetLinkSent = resetLinkSentLocal,
emailSignInLinkSent = emailSignInLinkSentLocal,
onEmailChange = { email ->
- emailTextValue.value = email
+ if (!isEmailLocked) {
+ emailTextValue.value = email
+ }
},
onPasswordChange = { password ->
passwordTextValue.value = password
@@ -362,23 +390,31 @@ fun EmailAuthScreen(
}
},
onGoToSignUp = {
- textValues.forEach { it.value = "" }
- mode.value = EmailAuthMode.SignUp
+ if (isSignUpOffered) {
+ resetTextValues()
+ mode.value = EmailAuthMode.SignUp
+ }
},
onGoToSignIn = {
- textValues.forEach { it.value = "" }
+ resetTextValues()
mode.value = EmailAuthMode.SignIn
emailSignInLinkSentLocal = false
},
onGoToResetPassword = {
- textValues.forEach { it.value = "" }
- mode.value = EmailAuthMode.ResetPassword
- resetLinkSentLocal = false
+ // Reauthentication is a modal confirmation of the signed-in account: diverting it to
+ // an out-of-band email step strands the pending operation behind something it can't see.
+ if (!configuration.isReauthenticationMode) {
+ resetTextValues()
+ mode.value = EmailAuthMode.ResetPassword
+ resetLinkSentLocal = false
+ }
},
onGoToEmailLinkSignIn = {
- textValues.forEach { it.value = "" }
- mode.value = EmailAuthMode.EmailLinkSignIn
- emailSignInLinkSentLocal = false
+ if (!configuration.isReauthenticationMode) {
+ resetTextValues()
+ mode.value = EmailAuthMode.EmailLinkSignIn
+ emailSignInLinkSentLocal = false
+ }
},
)
@@ -414,7 +450,8 @@ private fun DefaultEmailAuthContent(
onGoToSignUp = state.onGoToSignUp,
onGoToResetPassword = state.onGoToResetPassword,
onGoToEmailLinkSignIn = state.onGoToEmailLinkSignIn,
- onNavigateBack = onCancel
+ onNavigateBack = onCancel,
+ isEmailLocked = state.isEmailLocked,
)
}
@@ -422,6 +459,7 @@ private fun DefaultEmailAuthContent(
SignInEmailLinkUI(
configuration = configuration,
email = state.email,
+ isEmailLocked = state.isEmailLocked,
isLoading = state.isLoading,
emailSignInLinkSent = state.emailSignInLinkSent,
onEmailChange = state.onEmailChange,
@@ -446,7 +484,8 @@ private fun DefaultEmailAuthContent(
onConfirmPasswordChange = state.onConfirmPasswordChange,
onSignUpClick = state.onSignUpClick,
onGoToSignIn = state.onGoToSignIn,
- onNavigateBack = onCancel
+ onNavigateBack = onCancel,
+ isEmailLocked = state.isEmailLocked,
)
}
@@ -455,6 +494,7 @@ private fun DefaultEmailAuthContent(
configuration = configuration,
isLoading = state.isLoading,
email = state.email,
+ isEmailLocked = state.isEmailLocked,
resetLinkSent = state.resetLinkSent,
onEmailChange = state.onEmailChange,
onSendResetLink = state.onSendResetLinkClick,
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt
index 7d1de8a23..3e687ca9f 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt
@@ -67,6 +67,7 @@ fun ResetPasswordUI(
onSendResetLink: () -> Unit,
onGoToSignIn: () -> Unit,
onNavigateBack: (() -> Unit)? = null,
+ isEmailLocked: Boolean = false,
) {
val context = LocalContext.current
@@ -143,6 +144,7 @@ fun ResetPasswordUI(
value = email,
validator = emailValidator,
enabled = !isLoading,
+ readOnly = isEmailLocked,
label = {
Text(stringProvider.emailHint)
},
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt
index f2ec55fa3..fdbad6696 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt
@@ -74,6 +74,7 @@ fun SignInEmailLinkUI(
onGoToSignIn: () -> Unit,
onGoToResetPassword: () -> Unit,
onNavigateBack: (() -> Unit)? = null,
+ isEmailLocked: Boolean = false,
) {
val provider = configuration.providers.filterIsInstance().first()
val stringProvider = LocalAuthUIStringProvider.current
@@ -154,6 +155,7 @@ fun SignInEmailLinkUI(
value = email,
validator = emailValidator,
enabled = !isLoading,
+ readOnly = isEmailLocked,
label = {
Text(stringProvider.emailHint)
},
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt
index eb8b50159..4c18d21de 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt
@@ -48,12 +48,14 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.R
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.authUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
@@ -70,6 +72,9 @@ import com.firebase.ui.auth.ui.components.AuthTextField
import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController
import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm
+/** Test tag on the notice explaining that reauthentication here needs the account's password. */
+internal const val REAUTH_PASSWORD_NOTICE_TEST_TAG = "ReauthPasswordRequiredNotice"
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SignInUI(
@@ -87,6 +92,7 @@ fun SignInUI(
onGoToResetPassword: () -> Unit,
onGoToEmailLinkSignIn: () -> Unit,
onNavigateBack: (() -> Unit)? = null,
+ isEmailLocked: Boolean = false,
) {
val context = LocalContext.current
val provider = configuration.providers.filterIsInstance().first()
@@ -105,11 +111,22 @@ fun SignInUI(
}
}
+ val isSignUpOffered = provider.isNewAccountsAllowed &&
+ configuration.isNewEmailAccountsAllowed &&
+ !configuration.isReauthenticationMode
+
+ // Both routes leave this screen for an out-of-band email step, which a reauthentication sheet
+ // cannot observe — and an email link reopens the app with no pending operation left to resume.
+ val isPasswordRecoveryOffered = !configuration.isReauthenticationMode
+ val isEmailLinkSignInOffered =
+ provider.isEmailLinkSignInEnabled && !configuration.isReauthenticationMode
+
// Retrieve saved credentials when in SignIn mode
val credentialRetrievalAttempted = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (configuration.isCredentialManagerEnabled &&
+ !configuration.isReauthenticationMode &&
!credentialRetrievalAttempted.value &&
PasswordCredentialHandler.hasSavedCredentials(context)) {
credentialRetrievalAttempted.value = true
@@ -156,7 +173,10 @@ fun SignInUI(
},
navigationIcon = {
if (onNavigateBack != null) {
- IconButton(onClick = onNavigateBack) {
+ IconButton(
+ onClick = onNavigateBack,
+ modifier = Modifier.testTag("SignInBackButton"),
+ ) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringProvider.backAction
@@ -178,6 +198,7 @@ fun SignInUI(
value = email,
validator = emailValidator,
enabled = !isLoading,
+ readOnly = isEmailLocked,
label = {
Text(stringProvider.emailHint)
},
@@ -199,29 +220,43 @@ fun SignInUI(
}
)
Spacer(modifier = Modifier.height(8.dp))
- TextButton(
- modifier = Modifier
- .align(Alignment.Start),
- onClick = {
- onGoToResetPassword()
- },
- enabled = !isLoading,
- contentPadding = PaddingValues.Zero
- ) {
+ if (isPasswordRecoveryOffered) {
+ TextButton(
+ modifier = Modifier
+ .align(Alignment.Start),
+ onClick = {
+ onGoToResetPassword()
+ },
+ enabled = !isLoading,
+ contentPadding = PaddingValues.Zero
+ ) {
+ Text(
+ modifier = modifier,
+ text = stringProvider.troubleSigningIn,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ textDecoration = TextDecoration.Underline
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ if (configuration.isReauthenticationMode) {
+ // Firebase reports "password" for passwordless email-link accounts too, so such a
+ // user is offered a password field they can never fill. Say so instead of stalling.
Text(
- modifier = modifier,
- text = stringProvider.troubleSigningIn,
- style = MaterialTheme.typography.bodyMedium,
- textAlign = TextAlign.Center,
- textDecoration = TextDecoration.Underline
+ modifier = Modifier
+ .align(Alignment.Start)
+ .testTag(REAUTH_PASSWORD_NOTICE_TEST_TAG),
+ text = context.getString(R.string.fui_reauth_password_required_notice),
+ style = MaterialTheme.typography.bodySmall,
)
+ Spacer(modifier = Modifier.height(8.dp))
}
- Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.align(Alignment.End),
) {
- if (provider.isNewAccountsAllowed) {
+ if (isSignUpOffered) {
Button(
onClick = {
onGoToSignUp()
@@ -250,7 +285,7 @@ fun SignInUI(
}
// Show toggle to email link sign-in
- if (provider.isEmailLinkSignInEnabled) {
+ if (isEmailLinkSignInOffered) {
Spacer(modifier = Modifier.height(64.dp))
Row(
modifier = Modifier.fillMaxWidth(),
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt
index 7b6ba03c5..611ed0dcc 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt
@@ -69,6 +69,7 @@ fun SignUpUI(
onGoToSignIn: () -> Unit,
onSignUpClick: () -> Unit,
onNavigateBack: (() -> Unit)? = null,
+ isEmailLocked: Boolean = false,
) {
val provider = configuration.providers.filterIsInstance().first()
val context = LocalContext.current
@@ -147,6 +148,7 @@ fun SignUpUI(
value = email,
validator = emailValidator,
enabled = !isLoading,
+ readOnly = isEmailLocked,
label = {
Text(stringProvider.emailHint)
},
diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml
index 52cd8b87d..20191fd41 100644
--- a/auth/src/main/res/values/strings.xml
+++ b/auth/src/main/res/values/strings.xml
@@ -178,6 +178,15 @@
Sending...
That email address doesn\'t match an existing account
+
+ None of the available sign-in methods is linked to your account.
+ That did not confirm your identity for this account. Please try again.
+ Confirming your identity was interrupted. Please try that action again.
+ You cannot create a new account while confirming your identity.
+ This account uses two-step verification, which cannot be used to confirm your identity here.
+ Confirming your identity here needs this account\'s password. If you sign in with an email link instead of a password, this account cannot be confirmed with a password.
+ Read-only
+
An unknown error occurred.
Incorrect password.
diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt
index 78e7f0dd3..d025157a7 100644
--- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt
@@ -261,6 +261,43 @@ class FirebaseAuthUIAuthStateTest {
assertThat(states[2]).isEqualTo(AuthState.Idle) // After sign-out
}
+ /**
+ * A host calling raw `auth.signOut()` while a reauthentication is armed used to leave the
+ * internal state at ReauthenticationRequired: the combine keeps preferring it, so the reauth UI
+ * stays up over a signed-out session and every provider fails with an untranslated "no user".
+ */
+ @Test
+ fun `authStateFlow() clears an armed ReauthenticationRequired when the user signs out`() =
+ runBlocking {
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser)
+ `when`(mockFirebaseUser.isEmailVerified).thenReturn(true)
+ `when`(mockFirebaseUser.providerData).thenReturn(emptyList())
+
+ val listenerCaptor = ArgumentCaptor.forClass(AuthStateListener::class.java)
+ val states = mutableListOf()
+ // Collected open-endedly and cancelled below: a fixed `take` would hang rather than
+ // fail when the sign-out emission never arrives.
+ val job = launch { authUI.authStateFlow().toList(states) }
+
+ delay(100)
+ verify(mockFirebaseAuth).addAuthStateListener(listenerCaptor.capture())
+
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(mockFirebaseUser, reason = "Confirm it is you")
+ )
+ delay(100)
+ assertThat(states.last())
+ .isInstanceOf(AuthState.ReauthenticationRequired::class.java)
+
+ // The host signs out behind the library's back, e.g. authUI.auth.signOut().
+ `when`(mockFirebaseAuth.currentUser).thenReturn(null)
+ listenerCaptor.value.onAuthStateChanged(mockFirebaseAuth)
+ delay(200)
+ job.cancel()
+
+ assertThat(states.last()).isEqualTo(AuthState.Idle)
+ }
+
@Test
fun `authStateFlow() removes listener when flow is cancelled`() = runBlocking {
// Given auth state flow
diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt
index b06489e3d..b1c03621d 100644
--- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt
@@ -27,6 +27,7 @@ import com.firebase.ui.auth.util.EmailLinkPersistenceManager
import com.firebase.ui.auth.util.MockPersistenceManager
import com.google.android.gms.tasks.TaskCompletionSource
import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
import com.google.firebase.FirebaseApp
import com.google.firebase.FirebaseOptions
import com.google.firebase.auth.ActionCodeSettings
@@ -267,6 +268,82 @@ class EmailAuthProviderFirebaseAuthUITest {
}
}
+ /**
+ * Creating an account cannot re-prove an existing session — it *replaces* it. Left open, the
+ * reauthentication email sub-flow could route to sign-up, mint a brand new user, and have the
+ * resulting library-published success consume the pending sensitive operation, which would then
+ * run against a different, never-reauthenticated account.
+ */
+ @Test
+ fun `createOrLinkUserWithEmailAndPassword - rejects reauthentication mode outright`() = runTest {
+ val user = mock(FirebaseUser::class.java)
+ `when`(user.uid).thenReturn("existing-uid")
+ `when`(mockFirebaseAuth.currentUser).thenReturn(user)
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val emailProvider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList(),
+ isNewAccountsAllowed = true
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers { provider(emailProvider) }
+ }.copy(isReauthenticationMode = true)
+
+ try {
+ instance.createOrLinkUserWithEmailAndPassword(
+ context = applicationContext,
+ config = config,
+ provider = emailProvider,
+ name = null,
+ email = "brand-new@example.com",
+ password = "Pass@123"
+ )
+ assertWithMessage("expected reauthentication mode to reject account creation").fail()
+ } catch (e: Exception) {
+ assertThat(e.message)
+ .isEqualTo(
+ applicationContext.getString(R.string.fui_error_reauth_sign_up_not_allowed)
+ )
+ }
+ verify(mockFirebaseAuth, never()).createUserWithEmailAndPassword(anyString(), anyString())
+ }
+
+ /**
+ * `isNewEmailAccountsAllowed` is the configuration-level veto the reauthentication config sets;
+ * it had no consumer at all, so it vetoed nothing.
+ */
+ @Test
+ fun `createOrLinkUserWithEmailAndPassword - respects isNewEmailAccountsAllowed setting`() = runTest {
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val emailProvider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList(),
+ isNewAccountsAllowed = true
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers { provider(emailProvider) }
+ }.copy(isNewEmailAccountsAllowed = false)
+
+ try {
+ instance.createOrLinkUserWithEmailAndPassword(
+ context = applicationContext,
+ config = config,
+ provider = emailProvider,
+ name = null,
+ email = "test@example.com",
+ password = "Pass@123"
+ )
+ assertWithMessage("expected isNewEmailAccountsAllowed=false to veto account creation")
+ .fail()
+ } catch (e: Exception) {
+ assertThat(e.message)
+ .isEqualTo(applicationContext.getString(R.string.fui_error_email_does_not_exist))
+ }
+ verify(mockFirebaseAuth, never()).createUserWithEmailAndPassword(anyString(), anyString())
+ }
+
@Test
fun `createOrLinkUserWithEmailAndPassword - respects isNewAccountsAllowed setting`() = runTest {
val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
@@ -688,6 +765,128 @@ class EmailAuthProviderFirebaseAuthUITest {
verify(mockFirebaseAuth).signInWithCredential(credential)
}
+ /**
+ * Only the null-`currentUser` failure was covered, so the *value* of the stamp was free: a
+ * `reauthenticatedUid = null` would still have published a Success, which the screen accepts
+ * as a completed sign-in while refusing to resume the operation it was armed for.
+ */
+ @Test
+ fun `signInAndLinkWithCredential - reauth success stamps the reauthenticated uid`() = runTest {
+ val user = mock(FirebaseUser::class.java)
+ `when`(user.uid).thenReturn("existing-uid")
+ `when`(user.isAnonymous).thenReturn(false)
+ `when`(user.isEmailVerified).thenReturn(true)
+ `when`(mockFirebaseAuth.currentUser).thenReturn(user)
+
+ val credential = GoogleAuthProvider.getCredential("google-id-token", null)
+ val reauthTask = TaskCompletionSource()
+ reauthTask.setResult(null)
+ `when`(user.reauthenticate(credential)).thenReturn(reauthTask.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val emailProvider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers { provider(emailProvider) }
+ }.copy(isReauthenticationMode = true)
+
+ val result = instance.signInAndLinkWithCredential(config = config, credential = credential)
+
+ assertThat(result).isNull()
+ verify(user).reauthenticate(credential)
+ verify(mockFirebaseAuth, never()).signInWithCredential(any())
+ val state = instance.authStateFlow().first { it !is AuthState.Loading }
+ assertThat(state).isInstanceOf(AuthState.Success::class.java)
+ val success = state as AuthState.Success
+ assertThat(success.reauthenticatedUid).isEqualTo("existing-uid")
+ assertThat(success.result).isNull()
+ assertThat(success.user).isSameInstanceAs(user)
+ }
+
+ /**
+ * With `isCredentialLinkingEnabled` forwarded by `copy()`, a reauthentication would otherwise
+ * divert to `linkWithCredential` — which proves no identity and yields an unstamped Success.
+ */
+ @Test
+ fun `signInAndLinkWithCredential - credential linking never diverts a reauthentication`() =
+ runTest {
+ val user = mock(FirebaseUser::class.java)
+ `when`(user.uid).thenReturn("existing-uid")
+ `when`(user.isAnonymous).thenReturn(false)
+ `when`(user.isEmailVerified).thenReturn(true)
+ `when`(mockFirebaseAuth.currentUser).thenReturn(user)
+
+ val credential = GoogleAuthProvider.getCredential("google-id-token", null)
+ val reauthTask = TaskCompletionSource()
+ reauthTask.setResult(null)
+ `when`(user.reauthenticate(credential)).thenReturn(reauthTask.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val emailProvider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ isCredentialLinkingEnabled = true
+ providers { provider(emailProvider) }
+ }.copy(isReauthenticationMode = true)
+ assertThat(config.isCredentialLinkingEnabled).isTrue()
+
+ instance.signInAndLinkWithCredential(config = config, credential = credential)
+
+ verify(user).reauthenticate(credential)
+ verify(user, never()).linkWithCredential(any())
+ val state = instance.authStateFlow().first { it !is AuthState.Loading }
+ assertThat((state as AuthState.Success).reauthenticatedUid).isEqualTo("existing-uid")
+ }
+
+ /**
+ * A successful `reauthenticate` whose `currentUser` has since gone null must surface an error
+ * rather than publishing nothing: the reauth UI would otherwise sit on its last Loading state
+ * forever, with no Success and no Error to act on.
+ */
+ @Test
+ fun `signInAndLinkWithCredential - reauth with a null currentUser reports an error`() = runTest {
+ val user = mock(FirebaseUser::class.java)
+ `when`(user.uid).thenReturn("existing-uid")
+ `when`(user.isAnonymous).thenReturn(false)
+
+ // Non-null while reauthenticating, then gone by the time the success is built.
+ var currentUser: FirebaseUser? = user
+ `when`(mockFirebaseAuth.currentUser).thenAnswer { currentUser }
+
+ val credential = GoogleAuthProvider.getCredential("google-id-token", null)
+ `when`(user.reauthenticate(credential)).thenAnswer {
+ currentUser = null
+ val source = TaskCompletionSource()
+ source.setResult(null)
+ source.task
+ }
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val emailProvider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers { provider(emailProvider) }
+ }.copy(isReauthenticationMode = true)
+
+ try {
+ instance.signInAndLinkWithCredential(config = config, credential = credential)
+ assertWithMessage("expected a null currentUser after reauth to throw").fail()
+ } catch (e: Exception) {
+ assertThat(e).isInstanceOf(AuthException.UserNotFoundException::class.java)
+ }
+ assertThat(instance.authStateFlow().first())
+ .isInstanceOf(AuthState.Error::class.java)
+ }
+
@Test
fun `signInAndLinkWithCredential - handles anonymous upgrade`() = runTest {
val anonymousUser = mock(FirebaseUser::class.java)
@@ -1745,6 +1944,73 @@ class EmailAuthProviderFirebaseAuthUITest {
assertThat(state).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = true))
}
+ /**
+ * In reauthentication mode the email-link path has no [AuthResult] — `signInOrReauth` returns
+ * null after publishing the stamped Success itself. Falling through to
+ * `updateAuthStateWithResult(null)` publishes [AuthState.Idle] over that stamp in the same
+ * coroutine, so a conflated collector can see only Idle: the proof of identity is lost and the
+ * pending sensitive operation is orphaned with no error anywhere.
+ */
+ @Test
+ fun `signInWithEmailLink - reauth keeps the stamped Success instead of resetting to Idle`() =
+ runTest {
+ val mockUser = mock(FirebaseUser::class.java)
+ `when`(mockUser.uid).thenReturn("reauth-uid")
+ `when`(mockUser.email).thenReturn("test@example.com")
+ `when`(mockUser.isAnonymous).thenReturn(false)
+ `when`(mockUser.isEmailVerified).thenReturn(true)
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser)
+ `when`(mockFirebaseAuth.isSignInWithEmailLink(anyString())).thenReturn(true)
+
+ val reauthTask = TaskCompletionSource()
+ reauthTask.setResult(null)
+ `when`(mockUser.reauthenticate(any())).thenReturn(reauthTask.task)
+
+ val provider = AuthProvider.Email(
+ isEmailLinkSignInEnabled = true,
+ emailLinkActionCodeSettings = ActionCodeSettings.newBuilder()
+ .setUrl("https://example.com")
+ .setHandleCodeInApp(true)
+ .build(),
+ passwordValidationRules = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ }.copy(isReauthenticationMode = true)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+
+ val mockPersistence = MockPersistenceManager()
+ mockPersistence.setSessionRecord(
+ EmailLinkPersistenceManager.SessionRecord(
+ sessionId = "session123",
+ email = "test@example.com",
+ anonymousUserId = null,
+ credentialForLinking = null
+ )
+ )
+
+ val emailLink =
+ "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code" +
+ "&continueUrl=https://example.com?ui_sid=session123"
+
+ val result = instance.signInWithEmailLink(
+ context = applicationContext,
+ config = config,
+ provider = provider,
+ email = "test@example.com",
+ emailLink = emailLink,
+ persistenceManager = mockPersistence
+ )
+
+ assertThat(result).isNull()
+ verify(mockUser).reauthenticate(any())
+ val state = instance.authStateFlow().first { it !is AuthState.Loading }
+ assertThat(state).isInstanceOf(AuthState.Success::class.java)
+ assertThat((state as AuthState.Success).reauthenticatedUid).isEqualTo("reauth-uid")
+ }
+
@Test
fun `signInWithEmailLink - emits AuthState Success with non-null result`() = runTest {
val mockUser = mock(FirebaseUser::class.java)
diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt
index ce65580d5..b82f010ac 100644
--- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt
@@ -15,10 +15,12 @@
package com.firebase.ui.auth.configuration.auth_provider
import android.content.Context
+import android.util.Log
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.core.net.toUri
import androidx.credentials.CredentialManager
import androidx.credentials.exceptions.GetCredentialCancellationException
+import androidx.credentials.exceptions.NoCredentialException
import androidx.test.core.app.ApplicationProvider
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
@@ -55,6 +57,7 @@ import org.mockito.kotlin.eq
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowLog
/**
* Comprehensive unit tests for Google Sign-In provider methods in FirebaseAuthUI.
@@ -445,6 +448,65 @@ class GoogleAuthProviderFirebaseAuthUITest {
assertThat(errorState.exception).isInstanceOf(AuthException.UnknownException::class.java)
}
+ @Test
+ fun `Sign in with Google when both credential attempts throw NoCredentialException logs diagnostic warning`() = runTest {
+ ShadowLog.clear()
+ val noCredentialException = NoCredentialException("No credential available")
+
+ `when`(
+ mockCredentialManagerProvider.getGoogleCredential(
+ context = eq(applicationContext),
+ credentialManager = any(),
+ serverClientId = eq("test-client-id"),
+ filterByAuthorizedAccounts = eq(true),
+ autoSelectEnabled = eq(false)
+ )
+ ).thenAnswer { throw noCredentialException }
+
+ `when`(
+ mockCredentialManagerProvider.getGoogleCredential(
+ context = eq(applicationContext),
+ credentialManager = any(),
+ serverClientId = eq("test-client-id"),
+ filterByAuthorizedAccounts = eq(false),
+ autoSelectEnabled = eq(false)
+ )
+ ).thenAnswer { throw noCredentialException }
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val googleProvider = AuthProvider.Google(
+ serverClientId = "test-client-id",
+ scopes = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(googleProvider)
+ }
+ }
+
+ try {
+ instance.signInWithGoogle(
+ context = applicationContext,
+ config = config,
+ provider = googleProvider,
+ authorizationProvider = mockAuthorizationProvider,
+ credentialManagerProvider = mockCredentialManagerProvider
+ )
+ throw AssertionError("Expected exception to be thrown")
+ } catch (e: AuthException) {
+ // User-facing message stays generic - never mentions Firebase Console/SHA-1
+ assertThat(e).isInstanceOf(AuthException.UnknownException::class.java)
+ assertThat(e.message).contains("No Google accounts available")
+ }
+
+ // Diagnostic detail goes to Logcat only, for developers
+ val diagnosticLog = ShadowLog.getLogs().firstOrNull {
+ it.type == Log.WARN && it.tag == "GoogleAuthProvider" && it.msg.contains("SHA-1")
+ }
+ assertThat(diagnosticLog).isNotNull()
+ }
+
@Test
fun `Sign in with Google when Firebase sign-in fails should throw AuthException`() = runTest {
val mockCredential = mock(AuthCredential::class.java)
diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt
index 893f34540..054c75245 100644
--- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt
@@ -25,6 +25,7 @@ import com.firebase.ui.auth.configuration.authUIConfiguration
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.TaskCompletionSource
import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
import com.google.firebase.FirebaseApp
import com.google.firebase.FirebaseOptions
import com.google.firebase.auth.AuthCredential
@@ -154,6 +155,123 @@ class OAuthProviderFirebaseAuthUITest {
assertThat(finalState).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false))
}
+ // =============================================================================================
+ // signInWithProvider - Reauthentication
+ // =============================================================================================
+
+ /**
+ * The stamp is the *only* proof `FirebaseAuthScreen` accepts before resuming a pending
+ * sensitive operation, and this is where it is applied for Apple, GitHub, Microsoft, Yahoo,
+ * Twitter and generic OAuth. Publishing a plain success here (or a null uid) would make every
+ * federated reauthentication fail closed with an "incomplete" error and strand the operation.
+ */
+ @Test
+ fun `Reauthenticating with an OAuth provider stamps the reauthenticated uid`() = runTest {
+ val mockOAuthCredential = mock(OAuthCredential::class.java)
+ val mockUser = mock(FirebaseUser::class.java)
+ `when`(mockUser.isAnonymous).thenReturn(false)
+ `when`(mockUser.uid).thenReturn("reauth-uid")
+ `when`(mockUser.email).thenReturn(null)
+
+ val mockAuthResult = mock(AuthResult::class.java)
+ `when`(mockAuthResult.user).thenReturn(mockUser)
+ `when`(mockAuthResult.credential).thenReturn(mockOAuthCredential)
+
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setResult(mockAuthResult)
+
+ `when`(mockFirebaseAuth.pendingAuthResult).thenReturn(null)
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser)
+ `when`(
+ mockUser.startActivityForReauthenticateWithProvider(
+ any(),
+ any()
+ )
+ ).thenReturn(taskCompletionSource.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val appleProvider = AuthProvider.Apple(locale = null, customParameters = emptyMap())
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers { provider(appleProvider) }
+ }.copy(isReauthenticationMode = true)
+
+ instance.signInWithProvider(
+ applicationContext,
+ config = config,
+ activity = mockActivity,
+ provider = appleProvider,
+ )
+
+ verify(mockUser).startActivityForReauthenticateWithProvider(
+ eq(mockActivity),
+ any()
+ )
+ verify(mockFirebaseAuth, never())
+ .startActivityForSignInWithProvider(any(), any())
+
+ val finalState = instance.authStateFlow().first { it !is AuthState.Loading }
+ assertThat(finalState).isInstanceOf(AuthState.Success::class.java)
+ val success = finalState as AuthState.Success
+ assertThat(success.reauthenticatedUid).isEqualTo("reauth-uid")
+ assertThat(success.user).isSameInstanceAs(mockUser)
+ assertThat(success.isNewUser).isFalse()
+ }
+
+ /**
+ * A successful reauthenticate whose `currentUser` has since gone must surface an error rather
+ * than an unstamped success: the reauth UI would otherwise sit on Loading with nothing to act
+ * on, or worse accept a success that proves nothing.
+ */
+ @Test
+ fun `Reauthenticating with an OAuth provider errors when the user is gone`() = runTest {
+ val mockOAuthCredential = mock(OAuthCredential::class.java)
+ val mockUser = mock(FirebaseUser::class.java)
+ `when`(mockUser.isAnonymous).thenReturn(false)
+ `when`(mockUser.uid).thenReturn("reauth-uid")
+
+ val mockAuthResult = mock(AuthResult::class.java)
+ `when`(mockAuthResult.user).thenReturn(mockUser)
+ `when`(mockAuthResult.credential).thenReturn(mockOAuthCredential)
+
+ // Non-null while reauthenticating, then gone by the time the success is built.
+ var currentUser: FirebaseUser? = mockUser
+ `when`(mockFirebaseAuth.pendingAuthResult).thenReturn(null)
+ `when`(mockFirebaseAuth.currentUser).thenAnswer { currentUser }
+ `when`(
+ mockUser.startActivityForReauthenticateWithProvider(
+ any(),
+ any()
+ )
+ ).thenAnswer {
+ currentUser = null
+ val source = TaskCompletionSource()
+ source.setResult(mockAuthResult)
+ source.task
+ }
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val githubProvider = AuthProvider.Github(customParameters = emptyMap())
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers { provider(githubProvider) }
+ }.copy(isReauthenticationMode = true)
+
+ try {
+ instance.signInWithProvider(
+ applicationContext,
+ config = config,
+ activity = mockActivity,
+ provider = githubProvider,
+ )
+ assertWithMessage("expected a null currentUser after reauth to throw").fail()
+ } catch (e: Exception) {
+ assertThat(e).isInstanceOf(AuthException.UserNotFoundException::class.java)
+ }
+
+ assertThat(instance.authStateFlow().first()).isInstanceOf(AuthState.Error::class.java)
+ }
+
// =============================================================================================
// signInWithProvider - Anonymous Upgrade
// =============================================================================================
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt
new file mode 100644
index 000000000..8129e0731
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt
@@ -0,0 +1,1241 @@
+/*
+ * 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
+
+import android.content.Context
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+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.StateRestorationTester
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+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.authUIConfiguration
+import com.firebase.ui.auth.R
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.ui.components.ERROR_DIALOG_ACTION_TEST_TAG
+import com.google.common.truth.Truth.assertThat
+import com.google.firebase.FirebaseApp
+import com.google.firebase.FirebaseOptions
+import com.google.firebase.auth.AuthResult
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.FirebaseAuthInvalidUserException
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.MultiFactorResolver
+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
+
+/**
+ * Contract tests for the [ReauthContentState] handed to [FirebaseAuthScreen]'s `reauthContent`
+ * slot: the slot only ever chooses a provider, and the library owns every credential path —
+ * including temporarily presenting its own email sub-flow (prefilled with the reauthenticating
+ * user's address) for [AuthProvider.Email].
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE, sdk = [34])
+class FirebaseAuthScreenReauthContentStateTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private lateinit var context: Context
+ private lateinit var authUI: FirebaseAuthUI
+ private lateinit var stringProvider: DefaultAuthUIStringProvider
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ FirebaseAuthUI.clearInstanceCache()
+ FirebaseApp.getApps(context).forEach { it.delete() }
+ FirebaseApp.initializeApp(
+ context,
+ FirebaseOptions.Builder()
+ .setApiKey("fake-api-key")
+ .setApplicationId("fake-app-id")
+ .setProjectId("fake-project-id")
+ .build()
+ )
+ authUI = FirebaseAuthUI.getInstance()
+ stringProvider = DefaultAuthUIStringProvider(context)
+ }
+
+ @After
+ fun tearDown() {
+ FirebaseAuthUI.clearInstanceCache()
+ FirebaseApp.getApps(context).forEach {
+ try {
+ it.delete()
+ } catch (_: Exception) {
+ }
+ }
+ }
+
+ /** A user linked to the password provider only — phone must be filtered out of the slot. */
+ private fun passwordOnlyUser(email: String?): FirebaseUser = userLinkedTo("password", email)
+
+ /** A user linked only to a provider that is *not* configured, so nothing can be offered. */
+ private fun googleOnlyUser(email: String?): FirebaseUser = userLinkedTo("google.com", email)
+
+ private fun userLinkedTo(providerId: String, email: String?): FirebaseUser {
+ val providerInfo = mock(UserInfo::class.java)
+ `when`(providerInfo.providerId).thenReturn(providerId)
+ val user = mock(FirebaseUser::class.java)
+ `when`(user.providerData).thenReturn(listOf(providerInfo))
+ `when`(user.email).thenReturn(email)
+ `when`(user.uid).thenReturn("uid-$providerId")
+ return user
+ }
+
+ private fun emailAndPhoneConfiguration(): AuthUIConfiguration = authUIConfiguration {
+ context = this@FirebaseAuthScreenReauthContentStateTest.context
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null
+ )
+ )
+ }
+ isCredentialManagerEnabled = false
+ }
+
+ @Test
+ fun `reauthContent receives only the providers linked to the user`() {
+ val user = passwordOnlyUser("linked@example.com")
+ var captured: ReauthContentState? = null
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = { state ->
+ captured = state
+ Text(
+ text = "REAUTH:${state.reason}",
+ modifier = Modifier.testTag("reauth_slot")
+ )
+ }
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(user, reason = "Confirm it is you")
+ )
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+ composeTestRule.onNodeWithText("REAUTH:Confirm it is you").assertIsDisplayed()
+
+ val state = requireNotNull(captured) { "reauthContent was never composed" }
+ assertThat(state.providers.map { it.providerId }).containsExactly("password")
+ assertThat(state.user).isSameInstanceAs(user)
+ assertThat(state.reason).isEqualTo("Confirm it is you")
+ assertThat(state.error).isNull()
+ assertThat(state.isLoading).isFalse()
+ }
+
+ @Test
+ fun `selecting email from the reauth slot presents the library email sub-flow prefilled`() {
+ val user = passwordOnlyUser("linked@example.com")
+ // The sub-flow starts its own authStateFlow() collector, and a fresh AuthStateListener
+ // fires immediately: over a signed-out session that legitimately disarms the reauth.
+ val signedInAuthUI = signedInAuthUI(user)
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = signedInAuthUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ emailContent = { state ->
+ Text(
+ text = "EMAIL_SUBFLOW:${state.email}",
+ modifier = Modifier.testTag("email_subflow")
+ )
+ },
+ reauthContent = { state ->
+ Button(
+ onClick = { state.onProviderSelected(state.providers.first()) },
+ modifier = Modifier.testTag("pick_provider")
+ ) {
+ Text("Continue")
+ }
+ }
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(AuthState.ReauthenticationRequired(user))
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("pick_provider").performClick()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("email_subflow").assertIsDisplayed()
+ composeTestRule.onNodeWithText("EMAIL_SUBFLOW:linked@example.com").assertIsDisplayed()
+ }
+
+ @Test
+ fun `cancelling the email sub-flow returns to the reauth slot`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val signedInAuthUI = signedInAuthUI(user)
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = signedInAuthUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = { state ->
+ Button(
+ onClick = { state.onProviderSelected(state.providers.first()) },
+ modifier = Modifier.testTag("pick_provider")
+ ) {
+ Text("Continue")
+ }
+ }
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(AuthState.ReauthenticationRequired(user))
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("pick_provider").performClick()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithContentDescription(stringProvider.backAction).performClick()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("pick_provider").assertIsDisplayed()
+ }
+
+ /**
+ * A dismissed provider sheet (Credential Manager, an OAuth web flow, …) emits
+ * [AuthState.Cancelled]. While reauthentication is armed that only cancels *that attempt*: the
+ * slot must stay up, the flow must not report itself cancelled, and the pending sensitive
+ * operation must survive so a later successful reauthentication still runs it.
+ */
+ @Test
+ fun `cancelling a provider attempt keeps the reauth slot armed`() {
+ val user = passwordOnlyUser("linked@example.com")
+ var cancelledCount = 0
+ var retryRan = false
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = { cancelledCount++ },
+ reauthContent = {
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ }
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+
+ composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Cancelled()) }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+ assertThat(cancelledCount).isEqualTo(0)
+ assertThat(retryRan).isFalse()
+
+ composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Loading()) }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid))
+ }
+ composeTestRule.waitUntil(timeoutMillis = 5_000) { retryRan }
+
+ assertThat(retryRan).isTrue()
+ }
+
+ /**
+ * The same contract on the default bottom-sheet path: a cancelled provider attempt must not
+ * report the flow as cancelled nor drop the pending operation.
+ */
+ @Test
+ fun `cancelling a provider attempt in the default reauth sheet keeps it armed`() {
+ val phoneInfo = mock(UserInfo::class.java)
+ `when`(phoneInfo.providerId).thenReturn("phone")
+ val passwordInfo = mock(UserInfo::class.java)
+ `when`(passwordInfo.providerId).thenReturn("password")
+ val user = mock(FirebaseUser::class.java)
+ `when`(user.providerData).thenReturn(listOf(passwordInfo, phoneInfo))
+ `when`(user.email).thenReturn("linked@example.com")
+ `when`(user.uid).thenReturn("uid-multi")
+
+ var cancelledCount = 0
+ var retryRan = false
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = { cancelledCount++ },
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true })
+ )
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Cancelled()) }
+ composeTestRule.waitForIdle()
+
+ assertThat(cancelledCount).isEqualTo(0)
+ assertThat(retryRan).isFalse()
+
+ composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Loading()) }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid))
+ }
+ composeTestRule.waitUntil(timeoutMillis = 5_000) { retryRan }
+
+ assertThat(retryRan).isTrue()
+ }
+
+ /**
+ * [ReauthContentState.error] has to outlive the reset-to-Idle that consumes [AuthState.Error],
+ * carry the *localized* message rather than the raw throwable message, and be suppressed from
+ * the library's own error dialog so the failure surfaces exactly once — in the slot.
+ */
+ @Test
+ fun `a failed attempt latches a localized error and exception into the slot`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val signedInAuthUI = signedInAuthUI(user)
+ var captured: ReauthContentState? = null
+ val rawMessage = "RAW-BACKEND-CODE-17"
+ val thrown = FirebaseAuthInvalidUserException("ERROR_USER_DISABLED", rawMessage)
+ val expectedMessage = requireNotNull(AuthException.from(thrown, stringProvider).message)
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = signedInAuthUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = { state ->
+ captured = state
+ Button(
+ onClick = { state.onProviderSelected(state.providers.first()) },
+ modifier = Modifier.testTag("pick_provider")
+ ) {
+ Text("SLOT_ERROR=${state.error}")
+ }
+ }
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(AuthState.ReauthenticationRequired(user))
+ }
+ composeTestRule.waitForIdle()
+ assertThat(requireNotNull(captured).error).isNull()
+
+ composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Error(thrown)) }
+ composeTestRule.waitForIdle()
+
+ assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage)
+ assertThat(requireNotNull(captured).error).doesNotContain(rawMessage)
+ assertThat(requireNotNull(captured).exception)
+ .isInstanceOf(AuthException.InvalidCredentialsException::class.java)
+ assertThat(requireNotNull(captured).exception?.cause).isSameInstanceAs(thrown)
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("SLOT_ERROR=$expectedMessage").assertIsDisplayed()
+ assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage)
+
+ assertThat(
+ composeTestRule.onAllNodesWithText(expectedMessage).fetchSemanticsNodes()
+ ).isEmpty()
+
+ // Opening and backing out of the email sub-flow is not an attempt, so the latched
+ // failure survives it — otherwise a mis-tap would silently erase a real error.
+ composeTestRule.onNodeWithTag("pick_provider").performClick()
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithContentDescription(stringProvider.backAction).performClick()
+ composeTestRule.waitForIdle()
+
+ assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage)
+ assertThat(requireNotNull(captured).exception).isNotNull()
+ }
+
+ /**
+ * When no configured provider is linked to the user there is no reauth UI to show, so nothing
+ * may stay armed — otherwise a later Loading → Success would consume the pending operation and
+ * run the sensitive action with no reauthentication at all.
+ */
+ @Test
+ fun `no linked providers leaves nothing armed`() {
+ val user = googleOnlyUser("federated@example.com")
+ var slotComposed = false
+ var retryRan = false
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = {
+ slotComposed = true
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ }
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true })
+ )
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist()
+ assertThat(slotComposed).isFalse()
+
+ composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Loading()) }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(AuthState.Success(result = null, user = user))
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ assertThat(retryRan).isFalse()
+ }
+
+ /**
+ * A [FirebaseAuthUI] over a mocked, *signed-in* [com.google.firebase.auth.FirebaseAuth] — the
+ * only state reauthentication can happen in, and the one the rest of this suite cannot reach
+ * (with no current user `authStateFlow()` falls back to [AuthState.Idle] instead).
+ */
+ private fun signedInAuthUI(user: FirebaseUser): FirebaseAuthUI {
+ `when`(user.isEmailVerified).thenReturn(true)
+ val auth = mock(FirebaseAuth::class.java)
+ `when`(auth.currentUser).thenReturn(user)
+ `when`(auth.app).thenReturn(FirebaseApp.getInstance())
+ return FirebaseAuthUI.create(FirebaseApp.getInstance(), auth)
+ }
+
+ /**
+ * The sensitive operation must never run without an actual credential exchange.
+ *
+ * `authStateFlow()` prefers the internal state and otherwise falls back to the live Firebase
+ * session, so for the (necessarily signed-in) user being reauthenticated *every* reset to
+ * [AuthState.Idle] re-emits an [AuthState.Success] for the session that already existed —
+ * after a cancelled provider attempt, after a latched error, and whenever a provider retracts
+ * its own [AuthState.Loading] (`clearLoadingState`, e.g. cancelled phone verification). None of
+ * those is evidence of reauthentication, and no one-step lookback at the previous state can
+ * tell them apart: this sequence ends on `Loading -> Success`, exactly the shape a genuine
+ * reauthentication has.
+ */
+ @Test
+ fun `an ambient Success from the signed-in session does not run the pending operation`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val signedInAuthUI = signedInAuthUI(user)
+ var retryRan = false
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = signedInAuthUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = {
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ },
+ authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") },
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(
+ AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+
+ composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Cancelled()) }
+ composeTestRule.waitForIdle()
+ assertThat(retryRan).isFalse()
+
+ composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Loading()) }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Idle) }
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ assertThat(retryRan).isFalse()
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+ }
+
+ /**
+ * The other half of the contract above: an [AuthState.Success] the library published itself —
+ * what every provider's credential exchange ends with — does consume the operation, exactly
+ * once, even though the ambient session is emitting Successes of its own.
+ */
+ @Test
+ fun `a library-published Success runs the pending operation exactly once`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val signedInAuthUI = signedInAuthUI(user)
+ var retryCount = 0
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = signedInAuthUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = {
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ },
+ authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") },
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(
+ AuthState.ReauthenticationRequired(user, retryOperation = { retryCount++ })
+ )
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Loading()) }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid))
+ }
+ composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount > 0 }
+
+ composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Idle) }
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ assertThat(retryCount).isEqualTo(1)
+ }
+
+ /**
+ * The error dialog's recovery actions navigate the *outer* NavHost to the non-reauth email
+ * screen. While a reauthentication is armed both `onRecover` and `onRetry` are withheld, so the
+ * dialog has no action to offer and must not render an action button that silently dismisses
+ * instead of recovering. This is the default-sheet path — with a custom slot the error latches
+ * into the slot and no dialog is shown at all.
+ */
+ @Test
+ fun `a recoverable error offers no action while reauthentication is armed`() {
+ val phoneInfo = mock(UserInfo::class.java)
+ `when`(phoneInfo.providerId).thenReturn("phone")
+ val passwordInfo = mock(UserInfo::class.java)
+ `when`(passwordInfo.providerId).thenReturn("password")
+ val user = mock(FirebaseUser::class.java)
+ `when`(user.providerData).thenReturn(listOf(passwordInfo, phoneInfo))
+ `when`(user.email).thenReturn("linked@example.com")
+ `when`(user.uid).thenReturn("uid-multi")
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(user, retryOperation = {})
+ )
+ }
+ composeTestRule.waitForIdle()
+
+ // The sheet opens on its method picker (two linked providers), so no password field is on
+ // screen yet. The outer NavHost is still on the method-picker route behind it.
+ composeTestRule.onAllNodesWithText(stringProvider.passwordHint).assertCountEquals(0)
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.Error(
+ AuthException.EmailAlreadyInUseException(
+ message = "already in use",
+ email = "linked@example.com",
+ )
+ )
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists()
+
+ // Ungated, onRecover would navigate the outer NavHost to the non-reauth email screen.
+ // With both callbacks withheld the button has nothing to do, so it must not render.
+ composeTestRule.onNodeWithTag(ERROR_DIALOG_ACTION_TEST_TAG).assertDoesNotExist()
+ composeTestRule.onNodeWithText(stringProvider.dismissAction).assertExists()
+ composeTestRule.onAllNodesWithText(stringProvider.passwordHint).assertCountEquals(0)
+ }
+
+ /**
+ * The control for the test above: outside reauthentication the same error still offers its
+ * recovery action, and it still navigates to the email screen.
+ */
+ @Test
+ fun `a recoverable error still offers its recovery action outside reauthentication`() {
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ )
+ }
+
+ composeTestRule.onAllNodesWithText(stringProvider.passwordHint).assertCountEquals(0)
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.Error(
+ AuthException.EmailAlreadyInUseException(
+ message = "already in use",
+ email = "linked@example.com",
+ )
+ )
+ )
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag(ERROR_DIALOG_ACTION_TEST_TAG).performClick()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText(stringProvider.passwordHint).assertExists()
+ }
+
+ /**
+ * The method picker stays composed underneath a custom reauth slot, wired to the *non-reauth*
+ * configuration. A tap reaching it would start an ordinary sign-in while a sensitive operation
+ * is pending, so provider selection has to be inert.
+ */
+ @Test
+ fun `provider selection is inert while reauthentication is armed`() {
+ val user = passwordOnlyUser("linked@example.com")
+ var retryRan = false
+ var captured: ReauthContentState? = null
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ customMethodPickerLayout = { providers, onProviderSelected ->
+ Column {
+ providers.forEach { provider ->
+ Button(
+ onClick = { onProviderSelected(provider) },
+ modifier = Modifier.testTag("pick_${provider.providerId}"),
+ ) { Text(provider.providerId) }
+ }
+ }
+ },
+ reauthContent = { state ->
+ captured = state
+ Text("reauth_slot", modifier = Modifier.testTag("reauth_slot"))
+ },
+ )
+ }
+
+ composeTestRule.onNodeWithTag("pick_password").assertExists()
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("reauth_slot").assertExists()
+ assertThat(captured).isNotNull()
+
+ composeTestRule.onAllNodesWithText(stringProvider.passwordHint).assertCountEquals(0)
+
+ // Ungated, selecting email navigates the outer NavHost to its non-reauth email screen,
+ // surfacing a password field behind the slot.
+ composeTestRule.onNodeWithTag("pick_password").performClick()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onAllNodesWithText(stringProvider.passwordHint).assertCountEquals(0)
+ composeTestRule.onNodeWithTag("reauth_slot").assertExists()
+ assertThat(retryRan).isFalse()
+ }
+
+ /**
+ * Arming a second sensitive operation while the first is still pending must replace it. Value
+ * equality on [AuthState.ReauthenticationRequired] made the second write equal to the current
+ * one, which [kotlinx.coroutines.flow.MutableStateFlow] silently drops — so the screen kept the
+ * *first* lambda and ran the wrong sensitive operation after reauthentication.
+ */
+ @Test
+ fun `arming a second operation for the same user replaces the first`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val ran = mutableListOf()
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = {
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ }
+ )
+ }
+
+ // Same user, same (absent) reason: the two states differ only in the attached operation.
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(user, retryOperation = { ran.add("first") })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(user, retryOperation = { ran.add("second") })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+
+ composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Loading()) }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)
+ )
+ }
+ composeTestRule.waitUntil(timeoutMillis = 5_000) { ran.isNotEmpty() }
+ composeTestRule.waitForIdle()
+
+ assertThat(ran).containsExactly("second")
+ }
+
+ /**
+ * Reauthentication is not a sign-in. With no operation attached the library still has to consume
+ * the matched stamp and stop there — falling through published the reauthentication's
+ * [com.google.firebase.auth.AuthResult] to `onSignInSuccess`, which federated providers stamp
+ * and the email provider does not, so the same public callback behaved differently by provider.
+ */
+ @Test
+ fun `a matched reauthentication with no pending operation does not report a sign-in`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val authResult = mock(AuthResult::class.java)
+ `when`(authResult.user).thenReturn(user)
+ var signInSuccessCount = 0
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = authUI,
+ onSignInSuccess = { signInSuccessCount++ },
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = {
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ },
+ authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") },
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(AuthState.ReauthenticationRequired(user, retryOperation = null))
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+
+ // The federated stamp shape: a non-null AuthResult alongside the reauthenticated uid.
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.Success(
+ result = authResult,
+ user = user,
+ reauthenticatedUid = user.uid,
+ )
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ assertThat(signInSuccessCount).isEqualTo(0)
+ composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist()
+ composeTestRule.onNodeWithText("AUTHENTICATED").assertExists()
+ }
+
+ /**
+ * The uid comparison is the whole guarantee: a stamped success for *another* account is not
+ * evidence that the armed user re-proved anything, so the operation must not run and the slot
+ * must stay up. Without this the comparison could be weakened to a null check unnoticed.
+ */
+ @Test
+ fun `a stamped Success for a different uid does not run the pending operation`() {
+ val armedUser = passwordOnlyUser("armed@example.com")
+ val otherUser = userLinkedTo("google.com", "other@example.com")
+ var retryRan = false
+ var captured: ReauthContentState? = null
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = { state ->
+ captured = state
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ }
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(armedUser, retryOperation = { retryRan = true })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+ assertThat(armedUser.uid).isNotEqualTo(otherUser.uid)
+
+ composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Loading()) }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.Success(
+ result = null,
+ user = otherUser,
+ reauthenticatedUid = otherUser.uid,
+ )
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ assertThat(retryRan).isFalse()
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+ assertThat(requireNotNull(captured).error)
+ .isEqualTo(context.getString(R.string.fui_error_reauth_incomplete))
+ }
+
+ /**
+ * A wrong password for an unverified account ends up here: the consumed Error resets to Idle,
+ * the combine falls back to the live session, and that yields RequiresEmailVerification. It
+ * navigates with `popUpTo(inclusive = true)`, which would wipe the stack under the armed slot.
+ */
+ @Test
+ fun `RequiresEmailVerification does not navigate while reauthentication is armed`() {
+ val user = passwordOnlyUser("linked@example.com")
+ var retryRan = false
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = {
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ },
+ authenticatedContent = { _, _ ->
+ Text(text = "AUTHENTICATED", modifier = Modifier.testTag("authenticated"))
+ },
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.RequiresEmailVerification(user = user, email = "linked@example.com")
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("authenticated").assertDoesNotExist()
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+ assertThat(retryRan).isFalse()
+ }
+
+ /**
+ * An MFA-enrolled account cannot complete reauthentication at all (a known, separate defect).
+ * Unguarded, RequiresMfa pushed AuthRoute.MfaChallenge *beneath* the armed slot, which then
+ * showed neither loading nor an error — a dead UI with the operation still pending.
+ */
+ @Test
+ fun `RequiresMfa does not navigate while reauthentication is armed and latches an error`() {
+ val user = passwordOnlyUser("linked@example.com")
+ var retryRan = false
+ var captured: ReauthContentState? = null
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = { state ->
+ captured = state
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ },
+ authenticatedContent = { _, _ ->
+ Text(text = "AUTHENTICATED", modifier = Modifier.testTag("authenticated"))
+ },
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(AuthState.RequiresMfa(mock(MultiFactorResolver::class.java)))
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+ assertThat(retryRan).isFalse()
+ val state = requireNotNull(captured)
+ assertThat(state.error)
+ .isEqualTo(context.getString(R.string.fui_error_reauth_mfa_unsupported))
+ assertThat(state.exception).isInstanceOf(AuthException.UnknownException::class.java)
+ }
+
+ /**
+ * Dismissing abandons the operation for good, and `withReauth` has already returned normally —
+ * so the host has no other way to learn its sensitive operation will never run.
+ */
+ @Test
+ fun `dismissing the reauth slot reports the flow as cancelled exactly once`() {
+ val user = passwordOnlyUser("linked@example.com")
+ var cancelledCount = 0
+ var retryRan = false
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = { cancelledCount++ },
+ reauthContent = { state ->
+ Button(
+ onClick = state.onDismiss,
+ modifier = Modifier.testTag("dismiss_reauth")
+ ) {
+ Text("Cancel")
+ }
+ }
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true })
+ )
+ }
+ composeTestRule.waitForIdle()
+ assertThat(cancelledCount).isEqualTo(0)
+
+ composeTestRule.onNodeWithTag("dismiss_reauth").performClick()
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ assertThat(cancelledCount).isEqualTo(1)
+ assertThat(retryRan).isFalse()
+ composeTestRule.onNodeWithTag("dismiss_reauth").assertDoesNotExist()
+ }
+
+ /**
+ * Rotating while the slot shows a latched failure must not silently erase it. The message is
+ * saveable and survives; the [AuthException] behind it is not, so `exception` comes back null
+ * (documented on [ReauthContentState.exception]) rather than disagreeing with `error`.
+ */
+ @Test
+ fun `a latched slot error survives Activity recreation`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val signedInAuthUI = signedInAuthUI(user)
+ var captured: ReauthContentState? = null
+ val thrown = FirebaseAuthInvalidUserException("ERROR_USER_DISABLED", "RAW-BACKEND-CODE-17")
+ val expectedMessage = requireNotNull(AuthException.from(thrown, stringProvider).message)
+ val restorationTester = StateRestorationTester(composeTestRule)
+
+ restorationTester.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = signedInAuthUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = { state ->
+ captured = state
+ Text(text = "SLOT_ERROR=${state.error}", modifier = Modifier.testTag("slot"))
+ }
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(AuthState.ReauthenticationRequired(user))
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Error(thrown)) }
+ composeTestRule.waitForIdle()
+ assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage)
+
+ captured = null
+ restorationTester.emulateSavedInstanceStateRestore()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText("SLOT_ERROR=$expectedMessage").assertIsDisplayed()
+ assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage)
+ assertThat(requireNotNull(captured).exception).isNull()
+ }
+
+ /**
+ * Rotating part-way through the library's own email sub-flow must not bounce the user back to
+ * the provider chooser: the active sub-route is saved alongside the arming.
+ */
+ @Test
+ fun `an active email sub-flow survives Activity recreation`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val signedInAuthUI = signedInAuthUI(user)
+ val restorationTester = StateRestorationTester(composeTestRule)
+
+ restorationTester.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = signedInAuthUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ emailContent = { state ->
+ Text(
+ text = "EMAIL_SUBFLOW:${state.email}",
+ modifier = Modifier.testTag("email_subflow")
+ )
+ },
+ reauthContent = { state ->
+ Button(
+ onClick = { state.onProviderSelected(state.providers.first()) },
+ modifier = Modifier.testTag("pick_provider")
+ ) {
+ Text("Continue")
+ }
+ }
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(AuthState.ReauthenticationRequired(user))
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("pick_provider").performClick()
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("email_subflow").assertIsDisplayed()
+
+ restorationTester.emulateSavedInstanceStateRestore()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("email_subflow").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("pick_provider").assertDoesNotExist()
+ }
+
+ /**
+ * The likeliest moment to rotate is right after a cancelled or failed attempt. Resetting the
+ * flow to [AuthState.Idle] there would drop the arming from the process-cached [FirebaseAuthUI]
+ * and lose the pending operation silently; the arming is re-emitted instead, so a recreation
+ * re-derives both it and the operation, and a later genuine reauthentication still runs it.
+ */
+ @Test
+ fun `the pending operation survives recreation after a cancelled attempt`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val signedInAuthUI = signedInAuthUI(user)
+ var retryCount = 0
+ val restorationTester = StateRestorationTester(composeTestRule)
+
+ restorationTester.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = signedInAuthUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = {
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ }
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(
+ AuthState.ReauthenticationRequired(user, retryOperation = { retryCount++ })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Cancelled()) }
+ composeTestRule.waitForIdle()
+
+ restorationTester.emulateSavedInstanceStateRestore()
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+
+ composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Loading()) }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(
+ AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)
+ )
+ }
+ composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount > 0 }
+
+ assertThat(retryCount).isEqualTo(1)
+ }
+
+ /**
+ * The one arming a recreation genuinely cannot re-derive: the credential exchange in flight
+ * died with the composition, so the flow still reads [AuthState.Loading] and the suspend
+ * operation is gone. That must be reported, not dropped silently, and must not later be
+ * consumed by an unrelated success.
+ */
+ @Test
+ fun `an attempt interrupted by recreation is reported rather than dropped`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val signedInAuthUI = signedInAuthUI(user)
+ var retryCount = 0
+ val restorationTester = StateRestorationTester(composeTestRule)
+
+ restorationTester.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = signedInAuthUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = {
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ }
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(
+ AuthState.ReauthenticationRequired(user, retryOperation = { retryCount++ })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Loading()) }
+ composeTestRule.waitForIdle()
+
+ restorationTester.emulateSavedInstanceStateRestore()
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist()
+ composeTestRule
+ .onNodeWithText(context.getString(R.string.fui_error_reauth_interrupted))
+ .assertExists()
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(
+ AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ assertThat(retryCount).isEqualTo(0)
+ }
+}
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt
index 3bd40b643..2da188a6a 100644
--- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt
@@ -116,7 +116,7 @@ class FirebaseAuthScreenReauthIdleResetTest {
onSignInSuccess = {},
onSignInFailure = {},
onSignInCancelled = {},
- reauthContent = { _, _ ->
+ reauthContent = {
Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker"))
}
)
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenReauthEmailLockTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenReauthEmailLockTest.kt
new file mode 100644
index 000000000..65196c744
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenReauthEmailLockTest.kt
@@ -0,0 +1,456 @@
+/*
+ * 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.email
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+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.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import androidx.compose.runtime.CompositionLocalProvider
+import com.firebase.ui.auth.ui.components.ERROR_DIALOG_ACTION_TEST_TAG
+import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController
+import com.firebase.ui.auth.ui.components.rememberTopLevelDialogController
+import com.google.common.truth.Truth.assertThat
+import com.google.firebase.FirebaseApp
+import com.google.firebase.auth.ActionCodeSettings
+import com.google.firebase.FirebaseOptions
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.FirebaseUser
+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 reauthentication email lock has to survive an [EmailAuthMode] round-trip.
+ *
+ * [DefaultEmailAuthContent] dispatches modes with a `when`, so leaving [EmailAuthMode.SignIn]
+ * *disposes* the [SignInUI] composition group and coming back creates a fresh one. Any lock
+ * [SignInUI] inferred from its own (mutable) field value was therefore re-decided on every return —
+ * either dropping the lock, or locking an address the library never prefilled.
+ *
+ * @suppress Internal test class
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE, sdk = [34])
+class EmailAuthScreenReauthEmailLockTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private lateinit var applicationContext: Context
+ private lateinit var stringProvider: AuthUIStringProvider
+ private lateinit var authUI: FirebaseAuthUI
+
+ private val prefillEmail = "linked@example.com"
+
+ @Before
+ fun setUp() {
+ applicationContext = ApplicationProvider.getApplicationContext()
+ stringProvider = DefaultAuthUIStringProvider(applicationContext)
+ FirebaseAuthUI.clearInstanceCache()
+ 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()
+ )
+ val providerInfo = mock(UserInfo::class.java)
+ `when`(providerInfo.providerId).thenReturn("password")
+ val user = mock(FirebaseUser::class.java)
+ `when`(user.providerData).thenReturn(listOf(providerInfo))
+ `when`(user.email).thenReturn(prefillEmail)
+ `when`(user.uid).thenReturn("uid-password")
+ val auth = mock(FirebaseAuth::class.java)
+ `when`(auth.currentUser).thenReturn(user)
+ authUI = FirebaseAuthUI.create(app, auth)
+ }
+
+ @After
+ fun tearDown() {
+ FirebaseAuthUI.clearInstanceCache()
+ FirebaseApp.getApps(applicationContext).forEach {
+ try {
+ it.delete()
+ } catch (_: Exception) {
+ }
+ }
+ }
+
+ /** The configuration `FirebaseAuthUI.createReauthFlow` actually produces. */
+ private fun reauthConfiguration(): AuthUIConfiguration {
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ isCredentialManagerEnabled = false
+ }
+ return authUI.createReauthFlow(configuration).configuration
+ }
+
+ /** The same reauth configuration, but with email-link sign-in available. */
+ private fun reauthConfigurationWithEmailLink(): AuthUIConfiguration {
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Email(
+ isEmailLinkSignInEnabled = true,
+ emailLinkActionCodeSettings = ActionCodeSettings.newBuilder()
+ .setUrl("https://example.com")
+ .setHandleCodeInApp(true)
+ .setAndroidPackageName("com.test", true, null)
+ .build(),
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ isCredentialManagerEnabled = false
+ }
+ return authUI.createReauthFlow(configuration).configuration
+ }
+
+ @Composable
+ private fun EmailAuthScreenUnderTest(
+ configuration: AuthUIConfiguration,
+ prefill: String?,
+ ) {
+ CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) {
+ EmailAuthScreen(
+ context = applicationContext,
+ configuration = configuration,
+ authUI = authUI,
+ prefillEmail = prefill,
+ onSuccess = {},
+ onError = {},
+ onCancel = {},
+ )
+ }
+ }
+
+ /**
+ * Every branch of this screen's `onRetry` is inert in reauthentication mode (sign-up and
+ * mode switches are all vetoed), so an action button on the error dialog could only dismiss —
+ * and it raced the outer screen's `onRetry = null` for the same error.
+ */
+ @Test
+ fun `the reauth sub-flow error dialog offers no action button`() {
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) {
+ val controller = rememberTopLevelDialogController(
+ stringProvider = stringProvider,
+ authState = { AuthState.Idle },
+ )
+ CompositionLocalProvider(LocalTopLevelDialogController provides controller) {
+ EmailAuthScreenUnderTest(reauthConfiguration(), prefillEmail)
+ controller.CurrentDialog()
+ }
+ }
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.Error(AuthException.UserNotFoundException(message = "nope"))
+ )
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText(stringProvider.dismissAction).assertExists()
+ composeTestRule.onNodeWithTag(ERROR_DIALOG_ACTION_TEST_TAG).assertDoesNotExist()
+ }
+
+ /**
+ * Resetting the text fields (the mode switches all do it) must put the locked address back,
+ * not leave the user on an empty read-only field.
+ */
+ @Test
+ fun `the locked email is restored when the text fields are reset`() {
+ var email: String? = null
+ var isEmailLocked: Boolean? = null
+ var goToSignIn: (() -> Unit)? = null
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) {
+ EmailAuthScreen(
+ context = applicationContext,
+ configuration = reauthConfiguration(),
+ authUI = authUI,
+ prefillEmail = prefillEmail,
+ onSuccess = {},
+ onError = {},
+ onCancel = {},
+ content = { state ->
+ email = state.email
+ isEmailLocked = state.isEmailLocked
+ goToSignIn = state.onGoToSignIn
+ },
+ )
+ }
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.runOnIdle { requireNotNull(goToSignIn).invoke() }
+ composeTestRule.waitForIdle()
+
+ assertThat(email).isEqualTo(prefillEmail)
+ assertThat(isEmailLocked).isTrue()
+ }
+
+ /**
+ * The lock is wired into every mode that shows the address, not only SignIn — reauthentication
+ * cannot reach those modes any more, but a custom `emailContent` slot and a future route can.
+ */
+ @Test
+ fun `ResetPasswordUI renders a locked email read-only`() {
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) {
+ ResetPasswordUI(
+ configuration = reauthConfiguration(),
+ isLoading = false,
+ email = prefillEmail,
+ resetLinkSent = false,
+ onEmailChange = {},
+ onSendResetLink = {},
+ onGoToSignIn = {},
+ isEmailLocked = true,
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithText(stringProvider.recoverPasswordPageTitle).assertExists()
+ composeTestRule.onNodeWithText(prefillEmail)
+ .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.SetText))
+ }
+
+ /** The same for the email-link route, the other mode that shows the address. */
+ @Test
+ fun `SignInEmailLinkUI renders a locked email read-only`() {
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) {
+ SignInEmailLinkUI(
+ configuration = reauthConfigurationWithEmailLink(),
+ isLoading = false,
+ emailSignInLinkSent = false,
+ email = prefillEmail,
+ onEmailChange = {},
+ onSignInWithEmailLink = {},
+ onGoToSignIn = {},
+ onGoToResetPassword = {},
+ isEmailLocked = true,
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithText(stringProvider.passwordHint).assertDoesNotExist()
+ composeTestRule.onNodeWithText(prefillEmail)
+ .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.SetText))
+ }
+
+ /**
+ * Both routes hand off to an out-of-band email step the reauthentication sheet cannot observe,
+ * and an email link reopens the app with no pending operation left to resume.
+ */
+ @Test
+ fun `neither password recovery nor email-link sign-in is offered while reauthenticating`() {
+ composeTestRule.setContent {
+ EmailAuthScreenUnderTest(reauthConfigurationWithEmailLink(), prefill = prefillEmail)
+ }
+
+ // The password field proves this is the reauth SignIn screen, still usable as intended.
+ composeTestRule.onNodeWithText(stringProvider.passwordHint).assertExists()
+ composeTestRule.onNodeWithText(stringProvider.troubleSigningIn).assertDoesNotExist()
+ composeTestRule.onNodeWithText(stringProvider.signInWithEmailLink, ignoreCase = true)
+ .assertDoesNotExist()
+ }
+
+ /** Defence in depth: a custom `emailContent` slot cannot reach those modes either. */
+ @Test
+ fun `the reauth mode switches to ResetPassword and EmailLink are inert`() {
+ val observed = mutableListOf()
+ var goToResetPassword: (() -> Unit)? = null
+ var goToEmailLinkSignIn: (() -> Unit)? = null
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) {
+ EmailAuthScreen(
+ context = applicationContext,
+ configuration = reauthConfigurationWithEmailLink(),
+ authUI = authUI,
+ prefillEmail = prefillEmail,
+ onSuccess = {},
+ onError = {},
+ onCancel = {},
+ content = { state ->
+ observed.add(state.mode)
+ goToResetPassword = state.onGoToResetPassword
+ goToEmailLinkSignIn = state.onGoToEmailLinkSignIn
+ },
+ )
+ }
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.runOnIdle { requireNotNull(goToResetPassword).invoke() }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle { requireNotNull(goToEmailLinkSignIn).invoke() }
+ composeTestRule.waitForIdle()
+
+ assertThat(observed.toSet()).containsExactly(EmailAuthMode.SignIn)
+ }
+
+ /**
+ * The mirror case: outside reauthentication nothing is locked, so a round-trip must leave the
+ * field editable (and the "sign in" mode switch keeps clearing it as it always did).
+ */
+ @Test
+ fun `the email field stays editable across a round-trip outside reauthentication`() {
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ isCredentialManagerEnabled = false
+ }
+
+ composeTestRule.setContent {
+ EmailAuthScreenUnderTest(configuration, prefill = prefillEmail)
+ }
+
+ composeTestRule.onNodeWithText(stringProvider.troubleSigningIn).performClick()
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText(stringProvider.signInDefault, ignoreCase = true)
+ .performClick()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText(stringProvider.emailHint)
+ .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.SetText))
+ }
+
+ /**
+ * With nothing prefilled there is nothing to lock, so the standalone `createReauthFlow` entry
+ * point must not strand the user on a blank read-only field.
+ */
+ @Test
+ fun `nothing is locked in reauthentication mode when nothing was prefilled`() {
+ composeTestRule.setContent {
+ EmailAuthScreenUnderTest(reauthConfiguration(), prefill = null)
+ }
+
+ composeTestRule.onNodeWithText(stringProvider.emailHint)
+ .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.SetText))
+ }
+
+ /**
+ * `EmailAuthContentState.isEmailLocked` is the signal a custom `emailContent` slot needs in
+ * order to render the field read-only itself, and it must not flip as the user moves modes.
+ */
+ @Test
+ fun `isEmailLocked is reported to a custom content slot and is stable across modes`() {
+ val observed = mutableListOf>()
+ var goToSignIn: (() -> Unit)? = null
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) {
+ EmailAuthScreen(
+ context = applicationContext,
+ configuration = reauthConfiguration(),
+ authUI = authUI,
+ prefillEmail = prefillEmail,
+ onSuccess = {},
+ onError = {},
+ onCancel = {},
+ content = { state ->
+ observed.add(state.mode to state.isEmailLocked)
+ goToSignIn = state.onGoToSignIn
+ },
+ )
+ }
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.runOnIdle { requireNotNull(goToSignIn).invoke() }
+ composeTestRule.waitForIdle()
+
+ assertThat(observed.map { it.first }.last()).isEqualTo(EmailAuthMode.SignIn)
+ assertThat(observed.map { it.second }.toSet()).containsExactly(true)
+ }
+
+ /** A locked address is inert: nothing may substitute another account for the one being re-proved. */
+ @Test
+ fun `onEmailChange cannot replace a locked address`() {
+ var email: String? = null
+ var onEmailChange: ((String) -> Unit)? = null
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) {
+ EmailAuthScreen(
+ context = applicationContext,
+ configuration = reauthConfiguration(),
+ authUI = authUI,
+ prefillEmail = prefillEmail,
+ onSuccess = {},
+ onError = {},
+ onCancel = {},
+ content = { state ->
+ email = state.email
+ onEmailChange = state.onEmailChange
+ },
+ )
+ }
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.runOnIdle { requireNotNull(onEmailChange).invoke("attacker@example.com") }
+ composeTestRule.waitForIdle()
+
+ assertThat(email).isEqualTo(prefillEmail)
+ }
+}
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt
index 6a3775287..49faaa135 100644
--- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt
@@ -16,26 +16,83 @@ package com.firebase.ui.auth.ui.screens.email
import android.content.Context
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performTextInput
+import androidx.credentials.CredentialManager
+import androidx.credentials.GetCredentialResponse
+import androidx.credentials.PasswordCredential
import androidx.test.core.app.ApplicationProvider
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.R
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.authUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.credentialmanager.CredentialManagerProvider
+import com.firebase.ui.auth.credentialmanager.PasswordCredentialHandler
+import com.firebase.ui.auth.util.CredentialPersistenceManager
+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.UserInfo
+import kotlinx.coroutines.runBlocking
+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.mockito.kotlin.doReturn
+import org.mockito.kotlin.any
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
+/** Address of the (different) account the fake Credential Manager offers. */
+private const val SAVED_CREDENTIAL_USERNAME = "saved-other@example.com"
+
/**
- * Unit tests for [SignInUI], covering the sign-up button's visibility and email pre-fill.
+ * A Credential Manager that always offers a saved password for an account *other* than the one
+ * being reauthenticated — the case that used to strand the user on a locked, wrong address.
+ */
+private object FakeCredentialManagerProvider : CredentialManagerProvider {
+ /** Set the moment the screen reaches for a saved credential at all. */
+ @Volatile
+ var wasQueried: Boolean = false
+
+ override fun getCredentialManager(context: Context): CredentialManager {
+ wasQueried = true
+ val response = GetCredentialResponse(
+ PasswordCredential(SAVED_CREDENTIAL_USERNAME, "saved-password")
+ )
+ return org.mockito.kotlin.mock {
+ onBlocking {
+ getCredential(any(), any())
+ } doReturn response
+ }
+ }
+}
+
+/**
+ * Unit tests for [SignInUI], covering the sign-up button's visibility, email pre-fill, and the
+ * reauthentication-mode restrictions on the email field and Credential Manager autofill.
*
* @suppress Internal test class
*/
@@ -53,6 +110,23 @@ class SignInUITest {
fun setUp() {
applicationContext = ApplicationProvider.getApplicationContext()
stringProvider = DefaultAuthUIStringProvider(applicationContext)
+ runBlocking { CredentialPersistenceManager.clearSavedCredentialsFlag(applicationContext) }
+ FakeCredentialManagerProvider.wasQueried = false
+ FirebaseAuthUI.clearInstanceCache()
+ FirebaseApp.getApps(applicationContext).forEach { it.delete() }
+ }
+
+ @After
+ fun tearDown() {
+ PasswordCredentialHandler.testCredentialManagerProvider = null
+ runBlocking { CredentialPersistenceManager.clearSavedCredentialsFlag(applicationContext) }
+ FirebaseAuthUI.clearInstanceCache()
+ FirebaseApp.getApps(applicationContext).forEach {
+ try {
+ it.delete()
+ } catch (_: Exception) {
+ }
+ }
}
private fun setSignInUIContent(isNewAccountsAllowed: Boolean) {
@@ -170,4 +244,264 @@ class SignInUITest {
composeTestRule.onNodeWithText("user@example.com").assertDoesNotExist()
}
+
+ /**
+ * The configuration [FirebaseAuthUI.createReauthFlow] actually produces, so these tests
+ * exercise the public standalone-reauthentication entry point rather than a hand-rolled copy.
+ */
+ private fun createReauthFlowConfiguration(): AuthUIConfiguration {
+ val providerInfo = mock(UserInfo::class.java)
+ `when`(providerInfo.providerId).thenReturn("password")
+ val user = mock(FirebaseUser::class.java)
+ `when`(user.providerData).thenReturn(listOf(providerInfo))
+ val auth = mock(FirebaseAuth::class.java)
+ `when`(auth.currentUser).thenReturn(user)
+
+ val app = FirebaseApp.initializeApp(
+ applicationContext,
+ FirebaseOptions.Builder()
+ .setApiKey("fake-api-key")
+ .setApplicationId("fake-app-id")
+ .setProjectId("fake-project-id")
+ .build()
+ )
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ isCredentialManagerEnabled = credentialManagerEnabled
+ }
+ return FirebaseAuthUI.create(app, auth).createReauthFlow(configuration).configuration
+ }
+
+ /**
+ * Set before [createReauthFlowConfiguration] to build a Credential-Manager-enabled config.
+ *
+ * Safe as mutable per-instance state only because JUnit4 constructs a *fresh* instance of this
+ * class for every `@Test` method, so it cannot leak from one test to the next. It would need
+ * resetting in [setUp] under a runner that reuses the instance.
+ */
+ private var credentialManagerEnabled = false
+
+ private fun setStatefulSignInUIContent(
+ configuration: AuthUIConfiguration,
+ initialEmail: String,
+ isEmailLocked: Boolean = false,
+ onSignInClicked: () -> Unit = {},
+ onCredentialProbeDone: (() -> Unit)? = null,
+ ) {
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) {
+ var email by remember { mutableStateOf(initialEmail) }
+ var password by remember { mutableStateOf("") }
+ SignInUI(
+ configuration = configuration,
+ isLoading = false,
+ emailSignInLinkSent = false,
+ email = email,
+ password = password,
+ onEmailChange = { email = it },
+ onPasswordChange = { password = it },
+ onRetrievedCredential = { },
+ onSignInClick = onSignInClicked,
+ onGoToSignUp = { },
+ onGoToResetPassword = { },
+ onGoToEmailLinkSignIn = { },
+ isEmailLocked = isEmailLocked,
+ )
+ if (onCredentialProbeDone != null) {
+ // Mirrors the suspend read SignInUI's own autofill effect makes first, and is
+ // launched after it, so completing here means that effect already decided.
+ LaunchedEffect(Unit) {
+ PasswordCredentialHandler.hasSavedCredentials(applicationContext)
+ onCredentialProbeDone()
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Reauthentication can only ever re-prove the signed-in user's own account, so when the library
+ * says the address is locked the field is read-only: a different one would only produce an
+ * opaque credential mismatch. The lock is an explicit input rather than something this screen
+ * infers from the current field value — see the round-trip test in
+ * [com.firebase.ui.auth.ui.screens.email.EmailAuthScreenReauthEmailLockTest].
+ */
+ @Test
+ fun `email field is read-only when the address is locked`() {
+ val prefillEmail = "linked@example.com"
+
+ setStatefulSignInUIContent(
+ createReauthFlowConfiguration(),
+ initialEmail = prefillEmail,
+ isEmailLocked = true,
+ )
+
+ composeTestRule.onNodeWithText(prefillEmail)
+ .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.SetText))
+ composeTestRule.onNodeWithText(prefillEmail).assertExists()
+ }
+
+ /**
+ * Regression guard: locking on the *mode* rather than on an actual prefill left the standalone
+ * `createReauthFlow` path with a blank field the user could not type into, because nothing
+ * prefills it unless a "Continue as" chip was tapped. An unlocked field must stay editable — and
+ * must not flip to read-only on the first keystroke either.
+ */
+ @Test
+ fun `email field stays editable in reauthentication mode when nothing was prefilled`() {
+ setStatefulSignInUIContent(createReauthFlowConfiguration(), initialEmail = "")
+
+ composeTestRule.onNodeWithText(stringProvider.emailHint)
+ .performTextInput("typed@example.com")
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText("typed@example.com").assertExists()
+ composeTestRule.onNodeWithText("typed@example.com")
+ .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.SetText))
+ }
+
+ /**
+ * SIGN UP creates a brand new account, which cannot re-prove an existing session — it replaces
+ * it. The button was still offered during reauthentication because it is gated on
+ * `AuthProvider.Email.isNewAccountsAllowed` (default `true`), which the reauthentication config
+ * never touches.
+ */
+ @Test
+ fun `sign up button is hidden in reauthentication mode`() {
+ setStatefulSignInUIContent(
+ createReauthFlowConfiguration(),
+ initialEmail = "linked@example.com",
+ isEmailLocked = true,
+ )
+
+ composeTestRule.onNode(hasText(stringProvider.signupPageTitle.uppercase()) and hasClickAction())
+ .assertDoesNotExist()
+ }
+
+ /** The configuration-level veto has to work on its own, independently of the provider flag. */
+ @Test
+ fun `sign up button is hidden when new email accounts are not allowed by the configuration`() {
+ val provider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ isNewAccountsAllowed = true,
+ passwordValidationRules = emptyList()
+ )
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ }.copy(isNewEmailAccountsAllowed = false)
+
+ setStatefulSignInUIContent(configuration, initialEmail = "")
+
+ composeTestRule.onNode(hasText(stringProvider.signupPageTitle.uppercase()) and hasClickAction())
+ .assertDoesNotExist()
+ }
+
+ /**
+ * `isCredentialManagerEnabled` defaults to true and the reauthentication config preserves it,
+ * so this effect used to fire during reauthentication too — writing a saved credential straight
+ * into the form and auto-submitting it. A saved password for a *different* account would then
+ * silently submit the wrong credential (a read-only field does not stop a programmatic write),
+ * stranding the user. The control test below proves the harness really does autofill.
+ */
+ @Test
+ fun `credential manager autofill is skipped in reauthentication mode`() {
+ credentialManagerEnabled = true
+ runBlocking { CredentialPersistenceManager.setCredentialsSaved(applicationContext) }
+ PasswordCredentialHandler.testCredentialManagerProvider = FakeCredentialManagerProvider
+ var signInClicks = 0
+
+ var probeDone = false
+ setStatefulSignInUIContent(
+ createReauthFlowConfiguration(),
+ initialEmail = "linked@example.com",
+ onSignInClicked = { signInClicks++ },
+ onCredentialProbeDone = { probeDone = true },
+ )
+ awaitOrTimeout { probeDone || FakeCredentialManagerProvider.wasQueried }
+
+ assertThat(FakeCredentialManagerProvider.wasQueried).isFalse()
+ composeTestRule.onNodeWithText(SAVED_CREDENTIAL_USERNAME).assertDoesNotExist()
+ composeTestRule.onNodeWithText("linked@example.com").assertExists()
+ assertThat(signInClicks).isEqualTo(0)
+ }
+
+ /**
+ * Polls [condition] and returns as soon as it holds, idling composition in between. The two
+ * second cap is only a safety net — the caller supplies a condition that really does settle.
+ */
+ private fun awaitOrTimeout(condition: () -> Boolean) {
+ val deadline = System.currentTimeMillis() + 2_000
+ while (System.currentTimeMillis() < deadline && !condition()) {
+ composeTestRule.waitForIdle()
+ Thread.sleep(25)
+ }
+ }
+
+ /**
+ * Firebase reports the provider id `"password"` for passwordless email-link accounts too, so
+ * such a user is offered the Email method and lands on a password field they can never fill.
+ * Reauthentication mode has also removed the email-link toggle and "trouble signing in?", so
+ * without this notice the screen is a silent dead end. The provider cannot be filtered out
+ * instead: `providerData` cannot tell a password account from an email-link one.
+ */
+ @Test
+ fun `a password requirement notice is shown while reauthenticating`() {
+ setStatefulSignInUIContent(
+ createReauthFlowConfiguration(),
+ initialEmail = "linked@example.com",
+ isEmailLocked = true,
+ )
+
+ composeTestRule.onNodeWithTag(REAUTH_PASSWORD_NOTICE_TEST_TAG).assertExists()
+ composeTestRule
+ .onNodeWithText(applicationContext.getString(R.string.fui_reauth_password_required_notice))
+ .assertExists()
+ }
+
+ /** The notice is specific to reauthentication and must not appear in a normal sign-in. */
+ @Test
+ fun `no password requirement notice outside reauthentication`() {
+ setSignInUIContent(isNewAccountsAllowed = true)
+
+ composeTestRule.onNodeWithTag(REAUTH_PASSWORD_NOTICE_TEST_TAG).assertDoesNotExist()
+ }
+
+ /** Control for the test above: outside reauthentication mode the autofill still happens. */
+ @Test
+ fun `credential manager autofill still happens outside reauthentication mode`() {
+ runBlocking { CredentialPersistenceManager.setCredentialsSaved(applicationContext) }
+ PasswordCredentialHandler.testCredentialManagerProvider = FakeCredentialManagerProvider
+ var signInClicks = 0
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ isCredentialManagerEnabled = true
+ }
+
+ setStatefulSignInUIContent(
+ configuration,
+ initialEmail = "",
+ onSignInClicked = { signInClicks++ },
+ )
+ composeTestRule.waitUntil(timeoutMillis = 5_000) { signInClicks > 0 }
+
+ composeTestRule.onNodeWithText(SAVED_CREDENTIAL_USERNAME).assertExists()
+ assertThat(signInClicks).isEqualTo(1)
+ }
}
diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/testutil/EmulatorApi.kt b/e2eTest/src/test/java/com/firebase/ui/auth/testutil/EmulatorApi.kt
index 4430c37a7..6a2c60e8a 100644
--- a/e2eTest/src/test/java/com/firebase/ui/auth/testutil/EmulatorApi.kt
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/testutil/EmulatorApi.kt
@@ -18,23 +18,48 @@ class EmulatorAuthApi(
* This function calls the emulator's clear data endpoint to remove all accounts,
* OOB codes, and other authentication data. This ensures test isolation by providing
* a clean slate for each test.
+ *
+ * Retries on transient failures (e.g. a loaded CI runner momentarily failing to respond)
+ * and throws if the emulator still can't be cleared, so a broken reset fails the test
+ * loudly instead of silently leaking stale accounts into the next test.
*/
fun clearEmulatorData() {
- try {
- clearAccounts()
- } catch (e: Exception) {
- println("WARNING: Exception while clearing emulator data: ${e.message}")
+ val maxRetries = 3
+ var lastError: Exception? = null
+ for (attempt in 1..maxRetries) {
+ try {
+ clearAccounts()
+ return
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ throw e
+ } catch (e: Exception) {
+ lastError = e
+ println("WARNING: Failed to clear emulator data (attempt $attempt/$maxRetries): ${e.message}")
+ if (attempt < maxRetries) {
+ try {
+ Thread.sleep(500L * attempt)
+ } catch (ie: InterruptedException) {
+ Thread.currentThread().interrupt()
+ throw ie
+ }
+ }
+ }
}
+ throw IllegalStateException(
+ "Failed to clear Firebase Auth Emulator data after $maxRetries attempts. " +
+ "Aborting test to avoid running against stale emulator state.",
+ lastError
+ )
}
fun clearAccounts() {
httpClient.delete("/emulator/v1/projects/$projectId/accounts") { connection ->
val responseCode = connection.responseCode
if (responseCode !in 200..299) {
- println("WARNING: Failed to clear emulator data: HTTP $responseCode")
- } else {
- println("TEST: Cleared emulator data")
+ throw IllegalStateException("Failed to clear emulator data: HTTP $responseCode")
}
+ println("TEST: Cleared emulator data")
}
}
diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt
index 80a456ac7..86af8ea24 100644
--- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt
@@ -9,7 +9,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
@@ -184,10 +186,9 @@ class ReauthFlowTest {
.fetchSemanticsNodes().isNotEmpty()
}
- // Step 3: Enter credentials in the reauth bottom sheet.
composeAndroidTestRule.onNodeWithText(stringProvider.emailHint)
.performScrollTo()
- .performTextInput(email)
+ .assertTextContains(email)
composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint)
.performScrollTo()
.performTextInput(password)
@@ -207,22 +208,37 @@ class ReauthFlowTest {
}
/**
- * Verifies that when reauthContent is provided, it receives the ReauthenticationRequired state
- * and calling onDismiss resets the auth state to Idle.
+ * Verifies the [ReauthContentState] contract for the custom reauthContent slot: it receives the
+ * reauthenticating user, the reason, and the configured providers already filtered to the ones
+ * linked to that user; dismissing it drops the pending retry operation without firing it.
+ *
+ * The user stays signed in, as they always are during reauthentication. That is why dismissing
+ * does *not* leave the state on [AuthState.Idle]: `onDismiss` resets the library's internal
+ * state, and `authStateFlow()` then falls back to the live session, which is an
+ * [AuthState.Success] for the session that already existed.
*/
@Test
- fun `custom reauthContent receives ReauthenticationRequired state and dismisses to Idle`() {
+ fun `custom reauthContent receives linked providers and dismisses without retrying`() {
val email = "reauth-custom-${System.currentTimeMillis()}@example.com"
val password = "test123"
val user = ensureFreshUser(authUI, email, password)
requireNotNull(user) { "Failed to create user" }
+ try {
+ verifyEmailInEmulator(authUI, emulatorApi, user)
+ } catch (e: Exception) {
+ Assume.assumeTrue(
+ "Skipping: Firebase Auth Emulator OOB codes not available. Error: ${e.message}",
+ false
+ )
+ }
+
val capturedUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in after creation" }
- authUI.auth.signOut()
- shadowOf(Looper.getMainLooper()).idle()
var currentAuthState: AuthState = AuthState.Idle
+ var retryOperationCalled = false
+ var capturedState: ReauthContentState? = null
val expectedReason = "Sensitive operation requires sign-in"
val configuration = authUIConfiguration {
@@ -234,6 +250,13 @@ class ReauthFlowTest {
passwordValidationRules = emptyList()
)
)
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null
+ )
+ )
}
isCredentialManagerEnabled = false
}
@@ -248,10 +271,11 @@ class ReauthFlowTest {
onSignInSuccess = {},
onSignInFailure = {},
onSignInCancelled = {},
- reauthContent = { reauthState, onDismiss ->
+ reauthContent = { reauthState ->
+ capturedState = reauthState
Column {
Text("REAUTH REQUIRED - ${reauthState.reason}")
- Button(onClick = onDismiss) { Text("DISMISS REAUTH") }
+ Button(onClick = reauthState.onDismiss) { Text("DISMISS REAUTH") }
}
},
) { _, _ ->
@@ -269,6 +293,7 @@ class ReauthFlowTest {
AuthState.ReauthenticationRequired(
user = capturedUser,
reason = expectedReason,
+ retryOperation = { retryOperationCalled = true },
)
)
@@ -284,18 +309,135 @@ class ReauthFlowTest {
composeAndroidTestRule.onNodeWithText("REAUTH REQUIRED - $expectedReason")
.assertIsDisplayed()
- // Dismiss the custom reauth UI via the onDismiss callback.
+ val state = requireNotNull(capturedState) { "reauthContent was never composed" }
+ assertThat(state.user.uid).isEqualTo(capturedUser.uid)
+ assertThat(state.reason).isEqualTo(expectedReason)
+ assertThat(state.providers.map { it.providerId }).containsExactly("password")
+
composeAndroidTestRule.onNodeWithText("DISMISS REAUTH").performClick()
shadowOf(Looper.getMainLooper()).idle()
- // Verify that dismissing resets auth state to Idle.
composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
shadowOf(Looper.getMainLooper()).idle()
- currentAuthState is AuthState.Idle
+ composeAndroidTestRule.onAllNodesWithText("CONTENT").fetchSemanticsNodes().isNotEmpty()
}
- assertThat(currentAuthState).isInstanceOf(AuthState.Idle::class.java)
+ composeAndroidTestRule.onAllNodesWithText("REAUTH REQUIRED - $expectedReason")
+ .assertCountEquals(0)
+ val observedState = currentAuthState
+ assertThat(observedState).isInstanceOf(AuthState.Success::class.java)
+ assertThat((observedState as AuthState.Success).user.uid).isEqualTo(capturedUser.uid)
+ assertThat(observedState.result).isNull()
+ assertThat(retryOperationCalled).isFalse()
+ }
+
+ /**
+ * The custom slot only picks a provider: selecting email makes the library present its own
+ * email sub-flow (prefilled with the user's address), and completing it fires the pending
+ * retry operation — mirroring the default bottom sheet path.
+ */
+ @Test
+ fun `reauth through the custom slot email sub-flow triggers the retry operation`() {
+ val email = "reauth-slot-email-${System.currentTimeMillis()}@example.com"
+ val password = "test123"
+
+ val user = ensureFreshUser(authUI, email, password)
+ requireNotNull(user) { "Failed to create user" }
+
+ try {
+ verifyEmailInEmulator(authUI, emulatorApi, user)
+ } catch (e: Exception) {
+ Assume.assumeTrue(
+ "Skipping: Firebase Auth Emulator OOB codes not available. Error: ${e.message}",
+ false
+ )
+ }
+
+ val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" }
+
+ var retryOperationCalled = false
+
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ isCredentialManagerEnabled = false
+ }
+
+ composeAndroidTestRule.setContent {
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides DefaultAuthUIStringProvider(applicationContext)
+ ) {
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = { reauthState ->
+ Column {
+ Text("PICK A PROVIDER")
+ reauthState.providers.forEach { provider ->
+ Button(
+ onClick = { reauthState.onProviderSelected(provider) }
+ ) { Text("USE ${provider.providerId}") }
+ }
+ }
+ },
+ ) { _, _ ->
+ Text("AUTHENTICATED")
+ }
+ }
+ }
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(
+ user = signedInUser,
+ reason = "Please verify your identity to continue",
+ retryOperation = { retryOperationCalled = true },
+ )
+ )
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
+ shadowOf(Looper.getMainLooper()).idle()
+ composeAndroidTestRule.onAllNodesWithText("USE password")
+ .fetchSemanticsNodes().isNotEmpty()
+ }
+
+ composeAndroidTestRule.onNodeWithText("USE password").performClick()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
+ shadowOf(Looper.getMainLooper()).idle()
+ composeAndroidTestRule.onAllNodesWithText(email).fetchSemanticsNodes().isNotEmpty()
+ }
+
+ composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint)
+ .performScrollTo()
+ .performTextInput(password)
+ composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault.uppercase())
+ .performScrollTo()
+ .performClick()
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
+ shadowOf(Looper.getMainLooper()).idle()
+ retryOperationCalled
+ }
+
+ assertThat(retryOperationCalled).isTrue()
}
@Test
@@ -393,10 +535,9 @@ class ReauthFlowTest {
.fetchSemanticsNodes().isNotEmpty()
}
- // Step 3: enter the WRONG password in the reauth sheet.
composeAndroidTestRule.onNodeWithText(stringProvider.emailHint)
.performScrollTo()
- .performTextInput(email)
+ .assertTextContains(email)
composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint)
.performScrollTo()
.performTextInput(wrongPassword)
diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts
index 7ac13aeba..d3cfc2d40 100644
--- a/storage/build.gradle.kts
+++ b/storage/build.gradle.kts
@@ -57,4 +57,4 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockito.core)
-}
\ No newline at end of file
+}