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..7b4f732aa 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.reauth.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)
}
@@ -333,7 +330,7 @@ private fun AppAuthenticatedContent(
try {
uiContext.authUI.delete(context)
} catch (e: AuthException.InvalidCredentialsException) {
- // ReauthenticationRequired state was emitted —
+ // Reauthentication.Required state was emitted —
// FirebaseAuthScreen navigates to the reauth flow automatically.
Log.d("HighLevelApiDemoActivity", "Reauth required before delete")
} catch (e: AuthException) {
@@ -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..69addb092
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationDemoActivity.kt
@@ -0,0 +1,168 @@
+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.email.EmailAuthUI
+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,
+ )
+ },
+ // The picker hosts its own email entry; this slot covers the email
+ // flows the library navigates to itself (reauth, linking, recovery), which
+ // would otherwise render its stock screen.
+ emailContent = { state -> EmailAuthUI(state) },
+ 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,
+ 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..a5d3ca7ff
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt
@@ -0,0 +1,124 @@
+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"
+ // Email only reaches this button during reauthentication: the sign-in sheet filters it out,
+ // since the picker screen already has its own email field.
+ is AuthProvider.Email -> "Continue with email"
+ 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..6a09802f7
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthMethodPickerUI.kt
@@ -0,0 +1,95 @@
+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.saveable.rememberSaveable
+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.firebase.ui.auth.ui.screens.email.EmailAuthMode
+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 kotlinx.coroutines.tasks.await
+
+@Composable
+fun AuthMethodPickerUI(
+ state: EmailAuthContentState,
+ otherProviders: List,
+ onProviderSelected: (AuthProvider) -> Unit,
+ tosUrl: String?,
+ ppUrl: String?,
+) {
+ // Only "has the user chosen yet" is local; which form to show is state.mode, so the library's
+ // own corrections (EmailAlreadyInUse -> SignIn, UserNotFound -> SignUp) actually move the UI.
+ var chosen by rememberSaveable { mutableStateOf(false) }
+ var showOtherMethods by remember { mutableStateOf(false) }
+
+ // 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("")
+ chosen = false
+ }
+
+ // 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 = chosen) { onUseDifferentEmail() }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ if (!chosen) {
+ EmailEntryStep(
+ email = state.email,
+ onEmailChange = state.onEmailChange,
+ isLoading = state.isLoading,
+ // onGoToSignIn/onGoToSignUp call the library's resetTextValues(), which clears
+ // every field including the address just typed (it only restores one when the
+ // email is locked for reauthentication) — so put it back afterwards.
+ onSignIn = {
+ val typed = state.email
+ state.onGoToSignIn()
+ state.onEmailChange(typed)
+ chosen = true
+ },
+ onCreateAccount = {
+ val typed = state.email
+ state.onGoToSignUp()
+ state.onEmailChange(typed)
+ chosen = true
+ },
+ onShowOtherMethods = { showOtherMethods = true },
+ )
+ } else {
+ when (state.mode) {
+ EmailAuthMode.SignUp -> SignUpStep(state, onUseDifferentEmail)
+ // Reset-password and email-link are offered inline on the login form, which also
+ // reports their "sent" states, so every mode has a screen and none can blank out.
+ EmailAuthMode.SignIn,
+ EmailAuthMode.ResetPassword,
+ EmailAuthMode.EmailLinkSignIn -> LoginStep(state, onUseDifferentEmail)
+ }
+ }
+ }
+
+ if (showOtherMethods) {
+ OtherSignInMethodsSheet(
+ otherProviders = otherProviders,
+ onProviderSelected = onProviderSelected,
+ onDismissRequest = { showOtherMethods = false },
+ tosUrl = tosUrl,
+ ppUrl = ppUrl,
+ )
+ }
+}
+
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..ce7fcff51
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthenticatedUI.kt
@@ -0,0 +1,288 @@
+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.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.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: the two-factor button 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()
+
+ // enrolledFactors reads the cached user, so it still shows the pre-enrollment list when we come
+ // back from the MFA flow. This destination is disposed while that flow is on screen, so the
+ // effect re-runs on return and refreshes it; keyed on Unit, it can't loop on its own update.
+ LaunchedEffect(Unit) { uiContext.onReloadUser() }
+ val enrolledFactors = authUI.getCurrentUser()?.multiFactor?.enrolledFactors.orEmpty()
+
+ 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(
+ // Relabelled rather than disabled: SelectFactorStep is the only place a factor can
+ // be removed, so greying this out once one exists would strand the user with it.
+ text = if (enrolledFactors.isEmpty()) "Set up two-factor" else "Manage 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/EmailAuthUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/EmailAuthUI.kt
new file mode 100644
index 000000000..d8e2996bc
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/EmailAuthUI.kt
@@ -0,0 +1,84 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.email
+
+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.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState
+import com.firebase.ui.auth.ui.screens.email.EmailAuthMode
+import com.firebaseui.android.demo.R
+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
+
+/**
+ * Custom UI for `FirebaseAuthScreen.emailContent`.
+ *
+ * The method picker hosts its own email entry, so this slot only renders for email flows the
+ * *library* navigates to: reauthentication, account linking, and email-already-in-use recovery.
+ * Without it those flows fall back to the library's stock email screen, which is jarring inside a
+ * demo whose whole premise is that nothing looks stock.
+ *
+ * An address supplied by the library (as reauthentication does) skips the choice entirely — the
+ * caller already knows who is signing in.
+ */
+@Composable
+fun EmailAuthUI(state: EmailAuthContentState) {
+ var chosen by rememberSaveable { mutableStateOf(state.email.isNotBlank()) }
+
+ val onUseDifferentEmail: () -> Unit = {
+ state.onPasswordChange("")
+ state.onConfirmPasswordChange("")
+ chosen = false
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ // The email pages don't paint their own background — MainUI and PhoneSignInUI do it for
+ // theirs — so this slot has to, or the screen renders on bare surface colour.
+ Image(
+ painter = painterResource(id = R.drawable.custom_background),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+
+ if (!chosen) {
+ EmailEntryStep(
+ email = state.email,
+ onEmailChange = state.onEmailChange,
+ isLoading = state.isLoading,
+ // onGoToSignIn/onGoToSignUp call the library's resetTextValues(), which clears
+ // every field including the address just typed (it only restores one when the
+ // email is locked for reauthentication) — so put it back afterwards.
+ onSignIn = {
+ val typed = state.email
+ state.onGoToSignIn()
+ state.onEmailChange(typed)
+ chosen = true
+ },
+ onCreateAccount = {
+ val typed = state.email
+ state.onGoToSignUp()
+ state.onEmailChange(typed)
+ chosen = true
+ },
+ // No provider sheet in this slot — the caller already committed to email.
+ onShowOtherMethods = {},
+ )
+ } else {
+ when (state.mode) {
+ EmailAuthMode.SignUp -> SignUpStep(state, onUseDifferentEmail)
+ EmailAuthMode.SignIn,
+ EmailAuthMode.ResetPassword,
+ EmailAuthMode.EmailLinkSignIn -> LoginStep(state, onUseDifferentEmail)
+ }
+ }
+ }
+}
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..a4fd2eb4d
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/EmailEntryStep.kt
@@ -0,0 +1,204 @@
+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.ButtonDefaults
+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,
+ onSignIn: () -> Unit,
+ onCreateAccount: () -> 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))
+
+ // Two explicit choices rather than one "Continue" that guesses: with email
+ // enumeration protection enabled, Firebase deliberately withholds whether an
+ // address is registered, so asking is the only reliable route.
+ CtaButton(
+ text = "Sign in",
+ onClick = onSignIn,
+ enabled = isEmailValid && !isLoading,
+ isLoading = isLoading,
+ modifier = Modifier.semantics { contentDescription = "button - sign in" },
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ CtaButton(
+ text = "Create account",
+ onClick = onCreateAccount,
+ // Not gated on the address: the sign-up form collects and confirms it, so
+ // there is nothing to validate here first.
+ enabled = !isLoading,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ modifier = Modifier.semantics { contentDescription = "button - create account" },
+ )
+ }
+
+ 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..d90243179
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/SignUpStep.kt
@@ -0,0 +1,244 @@
+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,
+ // Editable here, unlike the login form: the account doesn't
+ // exist yet, and "Create account" can be reached without
+ // having typed an address on the previous screen.
+ onValueChange = state.onEmailChange,
+ label = "Email",
+ enabled = !state.isLoading,
+ leadingIcon = { EmailFieldIcon() },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Email,
+ ),
+ 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..f104c1bb0
--- /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 every enrollment step, 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..b3cd6f8a3
--- /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.reauth.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 e2e1daab2..ee30e41b3 100644
--- a/auth/README.md
+++ b/auth/README.md
@@ -6,7 +6,7 @@ Built entirely with **Jetpack Compose** and **Material Design 3**, FirebaseUI Au
- **Simple API** - Choose between high-level screens or low-level controllers for maximum flexibility
- **12+ Authentication Methods** - Email/Password, Phone, Google, Facebook, Twitter, GitHub, Microsoft, Yahoo, Apple, Anonymous, and custom OAuth providers
-- **Multi-Factor Authentication** - SMS and TOTP (Time-based One-Time Password) with recovery codes
+- **Multi-Factor Authentication** - SMS and TOTP (Time-based One-Time Password)
- **Android Credential Manager** - Automatic credential saving and one-tap sign-in
- **Material Design 3** - Beautiful, themeable UI components that integrate seamlessly with your app
- **Localization Support** - Customizable strings for internationalization
@@ -827,7 +827,7 @@ FirebaseAuthScreen(
phoneContent = { state -> /* ... */ },
mfaEnrollmentContent = { state -> /* ... */ },
mfaChallengeContent = { state -> /* ... */ },
- reauthContent = { state, onDismiss -> /* ... */ },
+ reauthContent = { state -> /* ... */ },
) { authState, uiContext ->
// authenticated content
}
@@ -992,43 +992,48 @@ 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.
+
+If the account has multi-factor authentication enrolled, Firebase needs the second factor to complete the reauthentication too. The library presents the MFA challenge as another sub-flow over this slot, honouring your `mfaChallengeContent` slot; resolving it completes the reauthentication and the pending operation resumes. Backing out of the challenge returns to this slot with the operation still pending, and a failed challenge latches into `state.error` like any other failed attempt.
```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`, its `state.exception`, and any active email/phone sub-flow. 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
Firebase requires the user to have signed in recently before performing sensitive operations like deleting their account or changing their password. If the session is too old, Firebase throws `FirebaseAuthRecentLoginRequiredException`.
-`withReauth` wraps any sensitive operation. If the exception is thrown, it automatically emits `AuthState.ReauthenticationRequired` and — once the user reauthenticates via the default bottom sheet or your `reauthContent` slot — retries the original operation.
+`withReauth` wraps any sensitive operation. If the exception is thrown, it automatically emits `AuthState.Reauthentication.Required` and — once the user reauthenticates via the default bottom sheet or your `reauthContent` slot — retries the original operation.
```kotlin
lifecycleScope.launch {
@@ -1044,15 +1049,18 @@ lifecycleScope.launch {
`withReauth` handles the full cycle:
1. Runs the operation.
-2. If `FirebaseAuthRecentLoginRequiredException` is thrown, emits `AuthState.ReauthenticationRequired` with the retry attached.
-3. `FirebaseAuthScreen` shows the reauth UI scoped to the user's linked providers.
+2. If `FirebaseAuthRecentLoginRequiredException` is thrown, emits `AuthState.Reauthentication.Required` with the retry attached.
+3. `FirebaseAuthScreen` shows the reauth UI scoped to the user's linked providers, including the MFA challenge when the account has a second factor enrolled.
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. The operation runs at most once: if a recreation interrupts it mid-flight the flow reports the interruption instead of starting it again, because the first attempt may already have committed.
+
+**What `authStateFlow()` emits while this is running.** From the moment `FirebaseAuthScreen` picks the request up until it ends, every state is published as an `AuthState.Reauthentication` — the phases of that one request, each carrying its `requestId` and `userUid`. The ordinary `AuthState.Loading` / `AuthState.Error` / `AuthState.Cancelled` of the credential exchange are folded into those phases, so `is AuthState.Error` and `is AuthState.Loading` do **not** match for the duration and app-side error dialogs and spinners stay quiet: the library owns the UI for that window. Match `is AuthState.Reauthentication` if you need to know it is happening. The final outcome — `AuthState.Success`, `AuthState.Error` or `AuthState.Idle` — is published as an ordinary state once the request ends. Arming a request with no `FirebaseAuthScreen` composed (catching `withReauth`/`delete`'s exception and showing your own UI) folds nothing: states are published normally, and the next one simply replaces the arming.
+
**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
},
@@ -1073,10 +1081,7 @@ val mfaConfig = MfaConfiguration(
allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp),
// Optional: Require MFA enrollment (default: false)
- requireEnrollment = false,
-
- // Optional: Enable recovery codes (default: true)
- enableRecoveryCodes = true
+ requireEnrollment = false
)
val configuration = authUIConfiguration {
@@ -1138,9 +1143,6 @@ MfaEnrollmentScreen(
MfaEnrollmentStep.VerifyFactor -> {
CustomVerificationUI(state)
}
- MfaEnrollmentStep.ShowRecoveryCodes -> {
- CustomRecoveryCodesUI(state)
- }
}
}
```
diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt b/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt
index f5c0bd6e3..87f1ae1bd 100644
--- a/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt
@@ -129,6 +129,8 @@ class AuthFlowController internal constructor(
* - [AuthState.Aborted] - The whole flow was ended via [cancel]
* - [AuthState.RequiresMfa] - Multi-factor authentication required
* - [AuthState.RequiresEmailVerification] - Email verification required
+ * - [AuthState.Reauthentication] - A reauthentication [FirebaseAuthScreen] is driving; the
+ * states above are reported as its library-owned phases until it ends
*/
val authStateFlow: Flow
get() {
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..cfb6f4634 100644
--- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt
@@ -21,6 +21,7 @@ import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.MultiFactorResolver
import com.google.firebase.auth.PhoneAuthCredential
import com.google.firebase.auth.PhoneAuthProvider
+import java.util.UUID
/**
* Represents the authentication state in Firebase Auth UI.
@@ -28,7 +29,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 +78,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 +93,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)"
}
/**
@@ -248,33 +256,239 @@ abstract class AuthState private constructor() {
}
/**
- * Reauthentication is required before a sensitive operation (e.g. delete account, change email)
- * can proceed. Use [FirebaseAuthUI.createReauthFlow] to launch the reauthentication flow.
+ * A state in the lifecycle of one reauthentication request.
*
- * @property user The [FirebaseUser] that needs to reauthenticate
- * @property reason Optional human-readable reason to show the user
+ * Every state carries a stable [requestId], so Activity recreation can distinguish a
+ * continuation of the same sensitive operation from a new operation for the same user. The
+ * request itself is process-local because its retry callback cannot be serialized.
*/
- 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() {
+ sealed class Reauthentication : AuthState() {
+ abstract val requestId: String
+ abstract val userUid: String
+ internal abstract val request: Request?
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
+
+ /** Process-local data shared by every resumable state of one reauthentication request. */
+ internal class Request(
+ val requestId: String,
+ val user: FirebaseUser,
+ val reason: String?,
+ retryOperation: (suspend (android.content.Context) -> Unit)?,
+ ) {
+ /** Null once [claimRetryOperation] consumed it, so no recreation can re-run it. */
+ var retryOperation: (suspend (android.content.Context) -> Unit)? = retryOperation
+ private set
+
+ /** Whether this request ever carried an operation, even after it was claimed. */
+ val hasRetryOperation: Boolean = retryOperation != null
+
+ /**
+ * Hands the operation out exactly once. A second claim means the first run was lost,
+ * which must be reported rather than retried: the operation may have committed already.
+ */
+ fun claimRetryOperation(): (suspend (android.content.Context) -> Unit)? =
+ retryOperation.also { retryOperation = null }
}
- override fun hashCode(): Int {
- var result = user.hashCode()
- result = 31 * result + (reason?.hashCode() ?: 0)
- return result
+ /**
+ * Reauthentication is required before a sensitive operation (e.g. delete account, change
+ * email) can proceed. Use [FirebaseAuthUI.createReauthFlow] to launch a standalone
+ * reauthentication flow.
+ *
+ * @property requestId Stable identifier for this sensitive operation
+ * @property user The [FirebaseUser] that needs to reauthenticate
+ * @property reason Optional human-readable reason to show the user
+ */
+ class Required internal constructor(
+ override val request: Request,
+ ) : Reauthentication() {
+ constructor(
+ user: FirebaseUser,
+ reason: String? = null,
+ retryOperation: (suspend (android.content.Context) -> Unit)? = null,
+ ) : this(
+ Request(
+ requestId = UUID.randomUUID().toString(),
+ user = user,
+ reason = reason,
+ retryOperation = retryOperation,
+ )
+ )
+
+ override val requestId: String get() = request.requestId
+ override val userUid: String get() = request.user.uid
+ val user: FirebaseUser get() = request.user
+ val reason: String? get() = request.reason
+ val retryOperation: (suspend (android.content.Context) -> Unit)?
+ get() = request.retryOperation
+
+ override fun equals(other: Any?): Boolean =
+ other is Required && requestId == other.requestId
+
+ override fun hashCode(): Int = requestId.hashCode()
+
+ override fun toString(): String =
+ "AuthState.Reauthentication.Required(requestId=$requestId, " +
+ "user=$user, reason=$reason)"
}
- override fun toString(): String =
- "AuthState.ReauthenticationRequired(user=$user, reason=$reason)"
+ /** The user has selected a provider and the library is exchanging credentials. */
+ internal class Authenticating(
+ override val request: Request,
+ val message: String? = null,
+ ) : Reauthentication() {
+ override val requestId: String get() = request.requestId
+ override val userUid: String get() = request.user.uid
+ }
+
+ /** The most recent credential attempt failed, but the request remains armed. */
+ internal class AttemptFailed(
+ override val request: Request,
+ val exception: Exception,
+ ) : Reauthentication() {
+ override val requestId: String get() = request.requestId
+ override val userUid: String get() = request.user.uid
+ }
+
+ /** A credential attempt requires MFA, which reauthentication UI does not yet support. */
+ internal class RequiresMfa(
+ override val request: Request,
+ val resolver: MultiFactorResolver,
+ val hint: String? = null,
+ ) : Reauthentication() {
+ override val requestId: String get() = request.requestId
+ override val userUid: String get() = request.user.uid
+ }
+
+ /** Phone verification sent a code and is waiting for the user to enter it. */
+ internal class PhoneNumberVerificationRequired(
+ override val request: Request,
+ val verificationId: String,
+ val forceResendingToken: PhoneAuthProvider.ForceResendingToken,
+ ) : Reauthentication() {
+ override val requestId: String get() = request.requestId
+ override val userUid: String get() = request.user.uid
+ }
+
+ /** Phone verification obtained a credential automatically. */
+ internal class SmsAutoVerified(
+ override val request: Request,
+ val credential: PhoneAuthCredential,
+ ) : Reauthentication() {
+ override val requestId: String get() = request.requestId
+ override val userUid: String get() = request.user.uid
+ }
+
+ /** A password-reset email was sent from the reauthentication email sub-flow. */
+ internal class PasswordResetLinkSent(
+ override val request: Request,
+ ) : Reauthentication() {
+ override val requestId: String get() = request.requestId
+ override val userUid: String get() = request.user.uid
+ }
+
+ /** A sign-in link was sent from the reauthentication email sub-flow. */
+ internal class EmailSignInLinkSent(
+ override val request: Request,
+ ) : Reauthentication() {
+ override val requestId: String get() = request.requestId
+ override val userUid: String get() = request.user.uid
+ }
+
+ /** Credentials were accepted for the request's user. */
+ internal class Succeeded(
+ override val request: Request,
+ val success: Success,
+ ) : Reauthentication() {
+ override val requestId: String get() = request.requestId
+ override val userUid: String get() = request.user.uid
+ }
+
+ /** The sensitive operation is being retried after credentials were accepted. */
+ internal class RetryingOperation(
+ override val request: Request,
+ ) : Reauthentication() {
+ override val requestId: String get() = request.requestId
+ override val userUid: String get() = request.user.uid
+ }
+
+ /** The retry completed and [outcome] is ready to become the ordinary auth state. */
+ internal class OperationFinished(
+ override val request: Request,
+ val outcome: AuthState,
+ ) : Reauthentication() {
+ override val requestId: String get() = request.requestId
+ override val userUid: String get() = request.user.uid
+ }
+
+ /**
+ * Saved UI state proved a request existed, but its process-local retry callback was lost.
+ */
+ internal class Interrupted(
+ override val requestId: String,
+ override val userUid: String,
+ ) : Reauthentication() {
+ override val request: Request? = null
+ }
+
+ /**
+ * Whether this request's reauthentication already succeeded. A sign-out must not clear such
+ * a phase, because the pending operation succeeding can be what signed the user out.
+ */
+ internal val isReauthenticated: Boolean
+ get() = this is Succeeded || this is RetryingOperation || this is OperationFinished
+
+ /**
+ * A provider attempt is about to run, clearing any previously surfaced failure. Null once
+ * credentials were accepted, so a late attempt cannot rewind a running operation.
+ */
+ internal fun attemptStarted(): AuthState? = when (this) {
+ is Required,
+ is Authenticating,
+ is AttemptFailed,
+ is RequiresMfa,
+ is PhoneNumberVerificationRequired,
+ is SmsAutoVerified,
+ is PasswordResetLinkSent,
+ is EmailSignInLinkSent,
+ -> request?.let { Authenticating(it) }
+
+ else -> null
+ }
+
+ /**
+ * The active sub-flow was consumed, so the request returns to provider selection. Null from
+ * a surfaced failure: only [attemptStarted] clears one, when a real attempt replaces it.
+ */
+ internal fun returnedToProviderSelection(): AuthState? = when (this) {
+ is Authenticating,
+ is PhoneNumberVerificationRequired,
+ is SmsAutoVerified,
+ is PasswordResetLinkSent,
+ is EmailSignInLinkSent,
+ -> request?.let { Required(it) }
+
+ else -> null
+ }
+
+ /**
+ * The user backed out of an in-flight provider sub-flow. Null in every other phase, so a
+ * surfaced failure or a finished request is never rewound to provider selection.
+ */
+ internal fun attemptCancelled(): AuthState? = when (this) {
+ is Authenticating,
+ is RequiresMfa,
+ is PhoneNumberVerificationRequired,
+ is SmsAutoVerified,
+ -> request?.let { Required(it) }
+
+ else -> null
+ }
+
+ /** The retried sensitive operation produced [outcome]. Null unless a retry is in flight. */
+ internal fun operationFinished(outcome: AuthState): AuthState? =
+ (this as? RetryingOperation)
+ ?.let { OperationFinished(it.request, outcome) }
}
/**
diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
index 972b1786b..c7f675a17 100644
--- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
@@ -41,7 +41,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.tasks.await
import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.atomic.AtomicLong
/**
* The central class that coordinates all authentication operations for Firebase Auth UI Compose.
@@ -80,7 +79,9 @@ class FirebaseAuthUI private constructor(
) {
private val _authStateFlow = MutableStateFlow(AuthState.Idle)
- private val authStateRevision = AtomicLong(0)
+
+ /** How many composed [FirebaseAuthScreen]s can currently drive a reauthentication request. */
+ private var reauthenticationDrainers = 0
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
var testCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null
@@ -249,6 +250,10 @@ class FirebaseAuthUI private constructor(
}
val reauthConfig = configuration.copy(
providers = linked,
+ // Belt and braces with the canLinkCredential/canUpgradeAnonymous guards: a linked
+ // credential is not a proof of identity, so a reauth config never enables either.
+ isAnonymousUpgradeEnabled = false,
+ isCredentialLinkingEnabled = false,
isNewEmailAccountsAllowed = false,
isReauthenticationMode = true,
)
@@ -267,6 +272,8 @@ class FirebaseAuthUI private constructor(
* - [AuthState.Cancelled] when authentication is cancelled
* - [AuthState.RequiresMfa] when multi-factor authentication is needed
* - [AuthState.RequiresEmailVerification] when email verification is needed
+ * - [AuthState.Reauthentication] for the whole of a reauthentication [FirebaseAuthScreen] is
+ * driving: the states above are then reported as its library-owned phases instead
*
* The flow automatically emits [AuthState.Success] or [AuthState.Idle] based on
* the current authentication state when collection starts.
@@ -320,12 +327,19 @@ class FirebaseAuthUI private constructor(
// doesn't return Success/RequiresEmailVerification after the user is gone.
if (firebaseAuth.currentUser == null) {
val current = _authStateFlow.value
- if (current is AuthState.Success ||
- current is AuthState.RequiresEmailVerification ||
- current is AuthState.RequiresProfileCompletion
- ) {
- _authStateFlow.value = AuthState.Idle
+ val isStale = when (current) {
+ is AuthState.Success,
+ is AuthState.RequiresEmailVerification,
+ is AuthState.RequiresProfileCompletion,
+ -> true
+ // A sensitive operation such as delete() signs the user out as its own
+ // success condition, so phases owning that operation must survive this.
+ is AuthState.Reauthentication -> !current.isReauthenticated
+ else -> false
}
+ // Via the session helper so a cleared request leaves the state machine outright
+ // instead of being written past contextualizeReauthenticationState().
+ if (isStale) finishReauthentication(AuthState.Idle)
}
trySend(buildState(firebaseAuth.currentUser))
}
@@ -365,28 +379,146 @@ class FirebaseAuthUI private constructor(
*/
@MainThread
fun updateAuthState(state: AuthState) {
- authStateRevision.incrementAndGet()
+ _authStateFlow.value = contextualizeReauthenticationState(state)
+ }
+
+ /** Ends the current reauthentication session without preserving its request context. */
+ @MainThread
+ internal fun finishReauthentication(state: AuthState) {
_authStateFlow.value = state
}
/**
- * Retracts a pending [AuthState.Loading] by resetting to [AuthState.Idle], but only while
- * [revision] is still the most recent write. Any state emitted since is left untouched.
- *
- * The revision is what makes this precise: [AuthState.Loading] compares equal whenever the
- * message matches, and [MutableStateFlow] drops a write equal to the current value without
- * replacing the stored reference - so neither equality nor identity can tell a concurrent
- * operation's Loading apart from the caller's.
- *
- * @param revision The value [currentAuthStateRevision] returned right after the caller emitted
- * the [AuthState.Loading] it now wants to retract
+ * Registers a screen that can drive an armed reauthentication request to completion.
+ * Call [removeReauthenticationDrainer] when it leaves the composition.
*/
- internal fun clearLoadingState(revision: Long) {
- if (authStateRevision.get() == revision) updateAuthState(AuthState.Idle)
+ @MainThread
+ internal fun addReauthenticationDrainer() {
+ reauthenticationDrainers++
}
- /** Identifies the most recent [updateAuthState] write. See [clearLoadingState]. */
- internal fun currentAuthStateRevision(): Long = authStateRevision.get()
+ /** Unregisters a drainer added by [addReauthenticationDrainer]. */
+ @MainThread
+ internal fun removeReauthenticationDrainer() {
+ if (reauthenticationDrainers > 0) reauthenticationDrainers--
+ }
+
+ /**
+ * Applies a reauthentication [transition] only while [requestId] is still the armed request.
+ * A null transition result is a no-op, which is how phases reject a transition they disallow.
+ */
+ @MainThread
+ internal fun updateReauthentication(
+ requestId: String,
+ transition: (AuthState.Reauthentication) -> AuthState?,
+ ) {
+ val current = _authStateFlow.value as? AuthState.Reauthentication ?: return
+ if (current.requestId != requestId) return
+ transition(current)?.let { updateAuthState(it) }
+ }
+
+ /**
+ * Publishes the [AuthState.Success] that proves a genuine reauthentication of the signed-in
+ * user, for the one exchange no provider owns: a resolved second factor. Call only on success.
+ */
+ @MainThread
+ internal fun publishReauthenticationSuccess() {
+ // Matches the provider stamp sites: no current user means nothing was re-proved, so the
+ // attempt is reported as a failure rather than published as an unstamped Success.
+ val reauthenticatedUser = auth.currentUser
+ if (reauthenticatedUser == null) {
+ updateAuthState(
+ AuthState.Error(
+ AuthException.UserNotFoundException(
+ message = "No user is currently signed in for reauthentication"
+ )
+ )
+ )
+ return
+ }
+ updateAuthState(
+ AuthState.Success(
+ result = null,
+ user = reauthenticatedUser,
+ reauthenticatedUid = reauthenticatedUser.uid,
+ )
+ )
+ }
+
+ /**
+ * Keeps one reauthentication request attached while provider code publishes ordinary auth
+ * states. Provider implementations therefore do not need their own parallel session storage.
+ *
+ * Scoped to a registered drainer: with no screen to end a request, an arming from public API
+ * alone stays inert rather than swallowing every later state and capturing [authStateFlow].
+ */
+ private fun contextualizeReauthenticationState(state: AuthState): AuthState {
+ if (state is AuthState.Reauthentication) return state
+ if (reauthenticationDrainers == 0) return state
+
+ val current = _authStateFlow.value as? AuthState.Reauthentication ?: return state
+ val request = current.request ?: return state
+
+ if (current is AuthState.Reauthentication.RetryingOperation) {
+ return when (state) {
+ // Sensitive operations such as delete() publish their own Loading before the
+ // final result. Keep the retry phase and its callback attached in the meantime.
+ is AuthState.Loading -> current
+ else -> AuthState.Reauthentication.OperationFinished(request, state)
+ }
+ }
+
+ return when (state) {
+ is AuthState.Loading ->
+ AuthState.Reauthentication.Authenticating(request, state.message)
+
+ is AuthState.Error -> {
+ if (state.exception is AuthException.AuthCancelledException) {
+ AuthState.Reauthentication.Required(request)
+ } else {
+ AuthState.Reauthentication.AttemptFailed(request, state.exception)
+ }
+ }
+
+ is AuthState.Cancelled -> AuthState.Reauthentication.Required(request)
+
+ is AuthState.RequiresMfa ->
+ AuthState.Reauthentication.RequiresMfa(request, state.resolver, state.hint)
+
+ is AuthState.PhoneNumberVerificationRequired ->
+ AuthState.Reauthentication.PhoneNumberVerificationRequired(
+ request = request,
+ verificationId = state.verificationId,
+ forceResendingToken = state.forceResendingToken,
+ )
+
+ is AuthState.SMSAutoVerified ->
+ AuthState.Reauthentication.SmsAutoVerified(request, state.credential)
+
+ is AuthState.PasswordResetLinkSent ->
+ AuthState.Reauthentication.PasswordResetLinkSent(request)
+
+ is AuthState.EmailSignInLinkSent ->
+ AuthState.Reauthentication.EmailSignInLinkSent(request)
+
+ is AuthState.Success -> {
+ if (state.reauthenticatedUid != null) {
+ AuthState.Reauthentication.Succeeded(request, state)
+ } else {
+ current
+ }
+ }
+
+ // These states can be ambient FirebaseAuth emissions or notification cleanup while a
+ // request is armed. They must not detach the process-local retry callback.
+ is AuthState.Idle,
+ is AuthState.RequiresEmailVerification,
+ is AuthState.RequiresProfileCompletion,
+ -> current
+
+ else -> state
+ }
+ }
internal fun updateAuthStateWithResult(result: AuthResult?, defaultIsNewUser: Boolean = false) {
val user = result?.user
@@ -516,7 +648,8 @@ class FirebaseAuthUI private constructor(
* Executes a sensitive operation, automatically handling reauthentication if required.
*
* If the [operation] throws [FirebaseAuthRecentLoginRequiredException], this method emits
- * [AuthState.ReauthenticationRequired] with the operation attached as [AuthState.ReauthenticationRequired.retryOperation].
+ * [AuthState.Reauthentication.Required] with the operation attached as its
+ * [AuthState.Reauthentication.Required.retryOperation].
* [FirebaseAuthScreen] observes this state and presents a reauthentication sheet; on success
* the operation is retried automatically without any further action from the caller.
*
@@ -547,7 +680,7 @@ class FirebaseAuthUI private constructor(
val user = auth.currentUser
?: throw AuthException.UserNotFoundException(message = "No user is currently signed in")
updateAuthState(
- AuthState.ReauthenticationRequired(
+ AuthState.Reauthentication.Required(
user = user,
reason = reason,
retryOperation = {
@@ -557,7 +690,7 @@ class FirebaseAuthUI private constructor(
throw e
} catch (e: Exception) {
updateAuthState(AuthState.Error(e))
- return@ReauthenticationRequired
+ return@Required
}
val currentUser = auth.currentUser
if (currentUser != null) {
@@ -590,7 +723,7 @@ class FirebaseAuthUI private constructor(
} catch (e: FirebaseAuthRecentLoginRequiredException) {
auth.currentUser?.let {
updateAuthState(
- AuthState.ReauthenticationRequired(
+ AuthState.Reauthentication.Required(
user = it,
retryOperation = { ctx -> delete(ctx) },
)
@@ -744,4 +877,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..d39120915 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
@@ -237,6 +237,8 @@ class AuthUIConfiguration(
) {
internal fun copy(
providers: List = this.providers,
+ isAnonymousUpgradeEnabled: Boolean = this.isAnonymousUpgradeEnabled,
+ isCredentialLinkingEnabled: Boolean = this.isCredentialLinkingEnabled,
isNewEmailAccountsAllowed: Boolean = this.isNewEmailAccountsAllowed,
isReauthenticationMode: Boolean = this.isReauthenticationMode,
): AuthUIConfiguration = AuthUIConfiguration(
@@ -247,7 +249,8 @@ class AuthUIConfiguration(
stringProvider = this.stringProvider,
isCredentialManagerEnabled = this.isCredentialManagerEnabled,
isMfaEnabled = this.isMfaEnabled,
- isAnonymousUpgradeEnabled = this.isAnonymousUpgradeEnabled,
+ isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled,
+ isCredentialLinkingEnabled = isCredentialLinkingEnabled,
tosUrl = this.tosUrl,
privacyPolicyUrl = this.privacyPolicyUrl,
logo = this.logo,
@@ -255,6 +258,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/MfaConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/MfaConfiguration.kt
index ed748bfe0..28dba024f 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/MfaConfiguration.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/MfaConfiguration.kt
@@ -17,22 +17,18 @@ package com.firebase.ui.auth.configuration
/**
* Configuration class for Multi-Factor Authentication (MFA) enrollment and verification behavior.
*
- * This class controls which MFA factors are available to users, whether enrollment is mandatory,
- * and whether recovery codes are generated.
+ * This class controls which MFA factors are available to users and whether enrollment is
+ * mandatory.
*
* @property allowedFactors List of MFA factors that users are permitted to enroll in.
* Defaults to [MfaFactor.Sms, MfaFactor.Totp].
* @property requireEnrollment Whether MFA enrollment is mandatory for all users.
* When true, users must enroll in at least one MFA factor.
* Defaults to false.
- * @property enableRecoveryCodes Whether to generate and provide recovery codes to users
- * after successful MFA enrollment. These codes can be used
- * as a backup authentication method. Defaults to true.
*/
class MfaConfiguration(
val allowedFactors: List = listOf(MfaFactor.Sms, MfaFactor.Totp),
- val requireEnrollment: Boolean = false,
- val enableRecoveryCodes: Boolean = true
+ val requireEnrollment: Boolean = false
) {
init {
require(allowedFactors.isNotEmpty()) {
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..956fe95d2 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
@@ -998,6 +998,9 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
internal fun canUpgradeAnonymous(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean {
val currentUser = auth.currentUser
return config.isAnonymousUpgradeEnabled
+ // Same reason as canLinkCredential: an upgrade link is not a proof of
+ // identity, so it must never be stamped as a reauthentication.
+ && !config.isReauthenticationMode
&& currentUser != null
&& currentUser.isAnonymous
}
@@ -1005,6 +1008,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/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt
index 1ee3dc72f..8cf9fa7c2 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt
@@ -119,11 +119,8 @@ internal suspend fun FirebaseAuthUI.verifyPhoneNumber(
forceResendingToken: PhoneAuthProvider.ForceResendingToken? = null,
verifier: AuthProvider.Phone.Verifier = AuthProvider.Phone.DefaultVerifier(),
) {
- // -1 never matches a real revision, so a cancellation before the Loading lands clears nothing.
- var loadingRevision = -1L
try {
updateAuthState(AuthState.Loading(config.stringProvider.loadingVerifyingPhoneNumber))
- loadingRevision = currentAuthStateRevision()
provider.verifyPhoneNumberFlow(
auth = auth,
activity = activity,
@@ -148,9 +145,8 @@ internal suspend fun FirebaseAuthUI.verifyPhoneNumber(
}
}
} catch (e: CancellationException) {
- // Cancellation here is the screen's own bookkeeping, not a failure: retract only the
- // Loading this call emitted, then rethrow so no spurious Error reaches authStateFlow.
- clearLoadingState(loadingRevision)
+ // Writes nothing: the caller cancelling this attempt owns whatever state replaces it, and
+ // a retraction from here would race the replacement's own Loading.
throw e
} catch (e: AuthException) {
updateAuthState(AuthState.Error(e))
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt
index 9af38935b..f528aaf1a 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt
@@ -410,9 +410,6 @@ interface AuthUIStringProvider {
/** Action text for choosing a different factor during MFA challenge. */
val useDifferentMethodAction: String
- /** Action text for confirming recovery codes have been saved. */
- val recoveryCodesSavedAction: String
-
/** Label for secret key text displayed during TOTP setup. */
val secretKeyLabel: String
@@ -511,9 +508,6 @@ interface AuthUIStringProvider {
/** Title for MFA verification step */
val mfaStepVerifyFactorTitle: String
- /** Title for recovery codes step */
- val mfaStepShowRecoveryCodesTitle: String
-
// MFA Enrollment Helper Text
/** Helper text for selecting MFA factor */
val mfaStepSelectFactorHelper: String
@@ -533,9 +527,6 @@ interface AuthUIStringProvider {
/** Generic helper text for factor verification */
val mfaStepVerifyFactorGenericHelper: String
- /** Helper text for recovery codes */
- val mfaStepShowRecoveryCodesHelper: String
-
// MFA Enrollment Screen Titles
/** Title for MFA phone number enrollment screen (top app bar) */
val mfaEnrollmentEnterPhoneNumber: String
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt
index aec4a83cc..a609ddcce 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt
@@ -373,9 +373,6 @@ class DefaultAuthUIStringProvider(
override val useDifferentMethodAction: String
get() = localizedContext.getString(R.string.fui_use_different_method_action)
- override val recoveryCodesSavedAction: String
- get() = localizedContext.getString(R.string.fui_recovery_codes_saved_action)
-
override val secretKeyLabel: String
get() = localizedContext.getString(R.string.fui_secret_key_label)
@@ -462,8 +459,6 @@ class DefaultAuthUIStringProvider(
get() = localizedContext.getString(R.string.fui_mfa_step_configure_totp_title)
override val mfaStepVerifyFactorTitle: String
get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_title)
- override val mfaStepShowRecoveryCodesTitle: String
- get() = localizedContext.getString(R.string.fui_mfa_step_show_recovery_codes_title)
/**
* MFA Enrollment Helper Text
@@ -480,8 +475,6 @@ class DefaultAuthUIStringProvider(
get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_totp_helper)
override val mfaStepVerifyFactorGenericHelper: String
get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_generic_helper)
- override val mfaStepShowRecoveryCodesHelper: String
- get() = localizedContext.getString(R.string.fui_mfa_step_show_recovery_codes_helper)
// MFA Enrollment Screen Titles
override val mfaEnrollmentEnterPhoneNumber: String
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentState.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentState.kt
index 674cb42e6..be8758ba9 100644
--- a/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentState.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentState.kt
@@ -70,9 +70,6 @@ import com.google.firebase.auth.MultiFactorInfo
* @property resendTimer (Step: [MfaEnrollmentStep.VerifyFactor], SMS only) The number of seconds remaining before the "Resend" action is available. Will be 0 when resend is allowed.
* @property onResendCodeClick (Step: [MfaEnrollmentStep.VerifyFactor], SMS only) Callback to resend the SMS verification code. Will be `null` for TOTP verification.
*
- * @property recoveryCodes (Step: [MfaEnrollmentStep.ShowRecoveryCodes]) A list of one-time backup codes the user should save. Only present if [com.firebase.ui.auth.configuration.MfaConfiguration.enableRecoveryCodes] is `true`.
- * @property onCodesSavedClick (Step: [MfaEnrollmentStep.ShowRecoveryCodes]) Callback invoked when the user confirms they have saved their recovery codes. Completes the enrollment flow.
- *
* @since 10.0.0
*/
data class MfaEnrollmentContentState(
@@ -131,12 +128,7 @@ data class MfaEnrollmentContentState(
val resendTimer: Int = 0,
- val onResendCodeClick: (() -> Unit)? = null,
-
- // ShowRecoveryCodes step
- val recoveryCodes: List? = null,
-
- val onCodesSavedClick: () -> Unit = {}
+ val onResendCodeClick: (() -> Unit)? = null
) {
/**
* Returns true if the current state is valid for the current step.
@@ -149,7 +141,6 @@ data class MfaEnrollmentContentState(
MfaEnrollmentStep.ConfigureSms -> phoneNumber.isNotBlank()
MfaEnrollmentStep.ConfigureTotp -> totpSecret != null && totpQrCodeUrl != null
MfaEnrollmentStep.VerifyFactor -> verificationCode.length == 6
- MfaEnrollmentStep.ShowRecoveryCodes -> !recoveryCodes.isNullOrEmpty()
}
/**
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentStep.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentStep.kt
index 8d64da620..e76c1b40e 100644
--- a/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentStep.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentStep.kt
@@ -21,7 +21,7 @@ import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
* Represents the different steps in the Multi-Factor Authentication (MFA) enrollment flow.
*
* This enum defines the sequence of UI states that users progress through when enrolling
- * in MFA, from selecting a factor to completing the setup with recovery codes.
+ * in MFA, from selecting a factor to verifying it.
*
* @since 10.0.0
*/
@@ -50,14 +50,7 @@ enum class MfaEnrollmentStep {
* For SMS, this is the code received via text message.
* For TOTP, this is the code generated by their authenticator app.
*/
- VerifyFactor,
-
- /**
- * The enrollment is complete and recovery codes are displayed to the user.
- * These backup codes can be used to sign in if the primary MFA method is unavailable.
- * This step only appears if recovery codes are enabled in the configuration.
- */
- ShowRecoveryCodes
+ VerifyFactor
}
/**
@@ -71,7 +64,6 @@ fun MfaEnrollmentStep.getTitle(stringProvider: AuthUIStringProvider): String = w
MfaEnrollmentStep.ConfigureSms -> stringProvider.mfaStepConfigureSmsTitle
MfaEnrollmentStep.ConfigureTotp -> stringProvider.mfaStepConfigureTotpTitle
MfaEnrollmentStep.VerifyFactor -> stringProvider.mfaStepVerifyFactorTitle
- MfaEnrollmentStep.ShowRecoveryCodes -> stringProvider.mfaStepShowRecoveryCodesTitle
}
/**
@@ -94,5 +86,4 @@ fun MfaEnrollmentStep.getHelperText(
MfaFactor.Totp -> stringProvider.mfaStepVerifyFactorTotpHelper
null -> stringProvider.mfaStepVerifyFactorGenericHelper
}
- MfaEnrollmentStep.ShowRecoveryCodes -> stringProvider.mfaStepShowRecoveryCodesHelper
}
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..b8a52eac1 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
@@ -43,12 +43,15 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.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
@@ -85,8 +90,15 @@ import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker
import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration
import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState
import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen
+import com.firebase.ui.auth.ui.screens.mfa.MfaChallengeScreen
+import com.firebase.ui.auth.ui.screens.mfa.MfaEnrollmentScreen
import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState
import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen
+import com.firebase.ui.auth.ui.screens.reauth.CustomReauthContent
+import com.firebase.ui.auth.ui.screens.reauth.ReauthContentState
+import com.firebase.ui.auth.ui.screens.reauth.ReauthPresentationState
+import com.firebase.ui.auth.ui.screens.reauth.ReauthPresentationStateSaver
+import com.firebase.ui.auth.ui.screens.reauth.ReauthSheetContent
import com.firebase.ui.auth.util.EmailLinkPersistenceManager
import com.firebase.ui.auth.util.SignInPreferenceManager
import com.firebase.ui.auth.util.displayIdentifier
@@ -114,6 +126,11 @@ 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. An enrolled
+ * second factor is challenged over the slot, honouring [mfaChallengeContent].
*
* @since 10.0.0
*/
@@ -134,7 +151,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
@@ -148,21 +165,66 @@ fun FirebaseAuthScreen(
val stringProvider = remember(context) { DefaultAuthUIStringProvider(context) }
val navController = rememberNavController()
- val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle)
+ val observedAuthState by remember(authUI) { authUI.authStateFlow() }
+ .collectAsState(initial = null as AuthState?)
+ val authState = observedAuthState ?: AuthState.Idle
val dialogController = rememberTopLevelDialogController(stringProvider) { authState }
val lastSuccessfulUserId = remember { mutableStateOf(null) }
val pendingLinkingCredential = remember { mutableStateOf(null) }
val pendingResolver = remember { mutableStateOf(null) }
- val pendingReauthConfig = remember { mutableStateOf(null) }
- val pendingReauthState = remember { mutableStateOf(null) }
- val pendingReauthOperation = remember { mutableStateOf<(suspend (android.content.Context) -> Unit)?>(null) }
+ // This screen is the only thing that can drive a reauthentication request to completion, so
+ // FirebaseAuthUI only folds ordinary states into an armed request while one is registered.
+ DisposableEffect(authUI) {
+ authUI.addReauthenticationDrainer()
+ onDispose { authUI.removeReauthenticationDrainer() }
+ }
+ val reauthPresentation = rememberSaveable(stateSaver = ReauthPresentationStateSaver) {
+ mutableStateOf(null)
+ }
+ val clearReauthPresentation: () -> Unit = remember {
+ { reauthPresentation.value = null }
+ }
+ val reauthState = authState as? AuthState.Reauthentication
+ val reauthRequest = reauthState?.request
+ val reauthRequired = reauthRequest?.let { AuthState.Reauthentication.Required(it) }
+ val reauthConfig = reauthRequest?.let { request ->
+ configuration.providers.filterToLinkedProviders(request.user)
+ .takeIf { it.isNotEmpty() }
+ ?.let { linkedProviders ->
+ configuration.copy(
+ providers = linkedProviders,
+ // Belt and braces with the canLinkCredential/canUpgradeAnonymous guards: a
+ // linked credential is not a proof of identity, so neither is ever enabled.
+ isAnonymousUpgradeEnabled = false,
+ isCredentialLinkingEnabled = false,
+ isNewEmailAccountsAllowed = false,
+ isReauthenticationMode = true,
+ )
+ }
+ }
+ val reauthException = (reauthState as? AuthState.Reauthentication.AttemptFailed)
+ ?.exception
+ ?.let { throwable ->
+ when (throwable) {
+ is AuthException -> throwable
+ else -> AuthException.from(throwable, stringProvider)
+ }
+ }
+ val reauthErrorMessage = reauthException?.let { getRecoveryMessage(it, stringProvider) }
+ // Firebase requires the second factor to complete the reauthentication too, so the challenge
+ // is presented as a reauth sub-flow instead of on the outer NavHost under the modal.
+ val reauthMfa = reauthState as? AuthState.Reauthentication.RequiresMfa
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.
+ // collectAsState uses null until the first real flow emission, so process restoration can
+ // distinguish that placeholder from FirebaseAuthUI's actual Idle/Success state.
+ val previousAuthState = remember { mutableStateOf(null) }
val startRoute = remember(configuration.providers, configuration.isProviderChoiceAlwaysShown) {
getStartRoute(configuration)
}
@@ -175,7 +237,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 +254,17 @@ 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 currentReauthState = rememberUpdatedState(reauthState)
+ val onProviderSelected: (AuthProvider) -> Unit = remember {
+ { provider ->
+ if (currentReauthState.value == null) {
+ currentOuterProviderSelected.value(provider)
+ }
+ }
+ }
val continueWithProvider: (String) -> Unit = { providerId ->
configuration.providers.find { it.providerId == providerId }?.let { onProviderSelected(it) }
}
@@ -223,6 +296,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 +330,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 +396,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 (reauthState == 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 +439,10 @@ fun FirebaseAuthScreen(
}
},
onNavigate = { route ->
- navController.navigate(route.route)
+ // Inert while armed: this content stays composed beneath the slot.
+ if (reauthState == null) {
+ navController.navigate(route.route)
+ }
}
)
}
@@ -397,16 +480,19 @@ fun FirebaseAuthScreen(
}
composable(AuthRoute.MfaChallenge.route) {
- val resolver = pendingResolver.value
+ // Retained for this back-stack entry: onSuccess clears pendingResolver, and
+ // reading it directly would blank the screen through the exit transition.
+ val resolver = remember { pendingResolver.value }
if (resolver != null) {
MfaChallengeScreen(
resolver = resolver,
auth = authUI.auth,
content = mfaChallengeContent,
- onSuccess = {
+ onSuccess = { result ->
pendingResolver.value = null
- // Reset auth state to Idle so the firebaseAuthFlow Success state takes over
- authUI.updateAuthState(AuthState.Idle)
+ // Route through the same path every other provider uses so
+ // onSignInSuccess receives the resolved AuthResult.
+ authUI.updateAuthStateWithResult(result)
},
onCancel = {
pendingResolver.value = null
@@ -425,7 +511,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 && reauthState == null) {
try {
// Try to retrieve saved email from DataStore (same-device flow)
val savedEmail =
@@ -459,40 +547,35 @@ fun FirebaseAuthScreen(
}
// Synchronise auth state changes with navigation stack.
- LaunchedEffect(authState) {
- val state = authState
+ LaunchedEffect(observedAuthState) {
+ val state = observedAuthState ?: return@LaunchedEffect
val previous = previousAuthState.value
previousAuthState.value = state
val currentRoute = navController.currentBackStackEntry?.destination?.route
+ val savedPresentation = reauthPresentation.value
+
+ // A saveable marker without its matching process-local AuthState means process
+ // death discarded the retry callback. Report that loss for every real first
+ // emission, not only Loading; FirebaseAuth commonly restores as Success.
+ if (savedPresentation != null &&
+ state !is AuthState.Reauthentication &&
+ state !is AuthState.Aborted
+ ) {
+ clearReauthPresentation()
+ authUI.updateAuthState(
+ AuthState.Reauthentication.Interrupted(
+ requestId = savedPresentation.requestId,
+ userUid = savedPresentation.userUid,
+ )
+ )
+ 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))
- }
- }
- return@LaunchedEffect
- }
- }
-
state.result?.let { result ->
if (state.user.uid != lastSuccessfulUserId.value) {
onSignInSuccess(result)
@@ -514,26 +597,114 @@ fun FirebaseAuthScreen(
}
}
- is AuthState.ReauthenticationRequired -> {
- pendingReauthOperation.value = state.retryOperation
+ is AuthState.Reauthentication.Required -> {
val linked = configuration.providers.filterToLinkedProviders(state.user)
if (linked.isEmpty()) {
- authUI.updateAuthState(
+ clearReauthPresentation()
+ authUI.finishReauthentication(
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
+ val currentPresentation = reauthPresentation.value
+ if (currentPresentation?.requestId != state.requestId) {
+ reauthPresentation.value = ReauthPresentationState(
+ requestId = state.requestId,
+ userUid = state.userUid,
+ )
+ }
+ }
+
+ is AuthState.Reauthentication.Succeeded -> {
+ val success = state.success
+ if (success.reauthenticatedUid != state.userUid ||
+ success.user.uid != state.userUid
+ ) {
+ authUI.updateAuthState(
+ AuthState.Error(
+ AuthException.UnknownException(
+ context.getString(R.string.fui_error_reauth_incomplete)
+ )
+ )
+ )
} else {
- pendingReauthConfig.value = configuration.copy(
- providers = linked,
- isNewEmailAccountsAllowed = false,
- isReauthenticationMode = true,
+ authUI.updateAuthState(
+ AuthState.Reauthentication.RetryingOperation(state.request)
+ )
+ }
+ }
+
+ is AuthState.Reauthentication.RetryingOperation -> {
+ val request = state.request
+ if (!request.hasRetryOperation) {
+ clearReauthPresentation()
+ authUI.finishReauthentication(
+ AuthState.Success(
+ result = null,
+ user = request.user,
+ )
+ )
+ return@LaunchedEffect
+ }
+ // Claimed before the first suspension point, so a recreation that resumes
+ // this phase reports the interruption instead of running the operation again.
+ val retry = request.claimRetryOperation()
+ if (retry == null) {
+ clearReauthPresentation()
+ authUI.finishReauthentication(
+ AuthState.Error(
+ AuthException.UnknownException(
+ context.getString(R.string.fui_error_reauth_interrupted)
+ )
+ )
+ )
+ return@LaunchedEffect
+ }
+ try {
+ retry(context)
+ val currentUser = authUI.auth.currentUser
+ val outcome = if (currentUser != null) {
+ AuthState.Success(result = null, user = currentUser)
+ } else {
+ AuthState.Idle
+ }
+ authUI.updateReauthentication(state.requestId) {
+ it.operationFinished(outcome)
+ }
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ authUI.updateAuthState(AuthState.Error(e))
+ }
+ }
+
+ is AuthState.Reauthentication.OperationFinished -> {
+ clearReauthPresentation()
+ authUI.finishReauthentication(state.outcome)
+ }
+
+ is AuthState.Reauthentication.Interrupted -> {
+ clearReauthPresentation()
+ authUI.finishReauthentication(
+ AuthState.Error(
+ AuthException.UnknownException(
+ context.getString(R.string.fui_error_reauth_interrupted)
+ )
+ )
+ )
+ }
+
+ is AuthState.Reauthentication -> {
+ // Activity recreation may resume in any in-flight reauthentication phase.
+ val currentPresentation = reauthPresentation.value
+ if (currentPresentation?.requestId != state.requestId) {
+ reauthPresentation.value = ReauthPresentationState(
+ requestId = state.requestId,
+ userUid = state.userUid,
)
}
}
@@ -561,9 +732,7 @@ fun FirebaseAuthScreen(
}
is AuthState.Cancelled -> {
- pendingReauthOperation.value = null
- pendingReauthConfig.value = null
- pendingReauthState.value = null
+ clearReauthPresentation()
pendingResolver.value = null
pendingLinkingCredential.value = null
lastSuccessfulUserId.value = null
@@ -581,9 +750,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
+ clearReauthPresentation()
pendingResolver.value = null
pendingLinkingCredential.value = null
lastSuccessfulUserId.value = null
@@ -594,10 +761,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) {
+ clearReauthPresentation()
pendingResolver.value = null
pendingLinkingCredential.value = null
lastSuccessfulUserId.value = null
@@ -614,6 +779,46 @@ fun FirebaseAuthScreen(
}
}
+ val reauthUiVisible = when (reauthState) {
+ is AuthState.Reauthentication.Required,
+ is AuthState.Reauthentication.Authenticating,
+ is AuthState.Reauthentication.AttemptFailed,
+ is AuthState.Reauthentication.RequiresMfa,
+ is AuthState.Reauthentication.PhoneNumberVerificationRequired,
+ is AuthState.Reauthentication.SmsAutoVerified,
+ is AuthState.Reauthentication.PasswordResetLinkSent,
+ is AuthState.Reauthentication.EmailSignInLinkSent,
+ -> true
+
+ else -> false
+ }
+ // Derived from the state rather than the saveable marker, because the resolver lives
+ // only in the state: this sub-route can never outlive the challenge it presents.
+ val reauthSubRoute = if (reauthMfa != null) {
+ AuthRoute.MfaChallenge
+ } else {
+ reauthPresentation.value?.subRoute
+ }
+ val reauthSlotActive = reauthContent != null &&
+ reauthUiVisible &&
+ reauthSubRoute == null
+
+ val reauthAttemptFailure =
+ reauthState as? AuthState.Reauthentication.AttemptFailed
+ if (reauthAttemptFailure != null && !reauthSlotActive) {
+ LaunchedEffect(reauthAttemptFailure) {
+ val exception = reauthException ?: return@LaunchedEffect
+ dialogController.showErrorDialog(
+ exception = exception,
+ // The latched failure is never the live Error, so without an explicit key
+ // reopening a sub-flow re-adds this effect and re-shows a stale dialog.
+ errorState = AuthState.Error(reauthAttemptFailure.exception),
+ onRetry = null,
+ onRecover = null,
+ )
+ }
+ }
+
// Handle errors using top-level dialog controller
val errorState = authState as? AuthState.Error
if (errorState != null) {
@@ -626,9 +831,8 @@ fun FirebaseAuthScreen(
dialogController.showErrorDialog(
exception = exception,
errorState = errorState,
- onRetry = { _ ->
- // Child screens handle their own retry logic
- },
+ // Child screens own their retry logic, so there is nothing to retry here.
+ onRetry = null,
onRecover = when (exception) {
is AuthException.EmailAlreadyInUseException -> {
{
@@ -692,46 +896,96 @@ fun FirebaseAuthScreen(
// Render the top-level dialog (only one instance)
dialogController.CurrentDialog()
- val loadingState = authState as? AuthState.Loading
- if (loadingState != null) {
- LoadingDialog(loadingState.message ?: stringProvider.progressDialogLoading)
+ val loadingMessage = when (val state = authState) {
+ is AuthState.Loading -> state.message
+ is AuthState.Reauthentication.Authenticating -> state.message
+ is AuthState.Reauthentication.RetryingOperation -> null
+ else -> null
+ }
+ val isLoading = authState is AuthState.Loading ||
+ authState is AuthState.Reauthentication.Authenticating ||
+ authState is AuthState.Reauthentication.RetryingOperation
+ if (isLoading && !reauthSlotActive) {
+ LoadingDialog(loadingMessage ?: 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
- authUI.updateAuthState(AuthState.Idle)
+ // 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, clearReauthPresentation) {
+ {
+ clearReauthPresentation()
+ authUI.finishReauthentication(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(authUI, reauthState?.requestId) {
+ {
+ reauthState?.requestId?.let { requestId ->
+ authUI.updateReauthentication(requestId) { it.attemptStarted() }
+ }
+ }
+ }
+ val onReauthSubRouteChange: (AuthRoute?) -> Unit =
+ remember {
+ { route ->
+ reauthPresentation.value = reauthPresentation.value?.copy(subRoute = route)
+ }
+ }
+ val onReauthMfaError: (Exception) -> Unit = remember(authUI) {
+ { exception ->
+ // Clear the sub-flow that triggered the challenge so the failure lands on the
+ // slot, where the caller renders `error`, rather than back inside that sub-flow.
+ reauthPresentation.value = reauthPresentation.value?.copy(subRoute = null)
+ authUI.updateAuthState(AuthState.Error(exception))
}
}
- // 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(
+ if (reauthConfig != null && reauthRequired != null && reauthUiVisible) {
+ if (reauthContent != null) {
+ CustomReauthContent(
authUI = authUI,
reauthConfig = reauthConfig,
+ reauthState = reauthRequired,
activity = activity,
context = context,
emailContent = emailContent,
phoneContent = phoneContent,
- customMethodPickerLayout = customMethodPickerLayout,
- onDismiss = {
- pendingReauthOperation.value = null
- pendingReauthConfig.value = null
- authUI.updateAuthState(AuthState.Idle)
- },
+ mfaChallengeContent = mfaChallengeContent,
+ mfaResolver = reauthMfa?.resolver,
+ isLoading = authState is AuthState.Reauthentication.Authenticating,
+ // The same string ErrorRecoveryDialog would have shown for this failure.
+ error = reauthErrorMessage,
+ exception = reauthException,
+ activeSubRoute = reauthSubRoute,
+ onActiveSubRouteChange = onReauthSubRouteChange,
+ onAttemptStarted = onReauthAttemptStarted,
+ onMfaError = onReauthMfaError,
+ onDismiss = onReauthDismiss,
+ content = reauthContent,
)
+ } else {
+ ModalBottomSheet(
+ onDismissRequest = onReauthDismiss,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ ) {
+ ReauthSheetContent(
+ authUI = authUI,
+ reauthConfig = reauthConfig,
+ requestId = reauthRequired.requestId,
+ activity = activity,
+ context = context,
+ prefillEmail = reauthRequired.user.email,
+ emailContent = emailContent,
+ phoneContent = phoneContent,
+ mfaChallengeContent = mfaChallengeContent,
+ mfaResolver = reauthMfa?.resolver,
+ customMethodPickerLayout = customMethodPickerLayout,
+ onDismiss = onReauthDismiss,
+ )
+ }
}
}
}
@@ -951,84 +1205,9 @@ private fun LoadingDialog(message: String) {
}
)
}
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-private fun ReauthSheetContent(
- authUI: FirebaseAuthUI,
- reauthConfig: AuthUIConfiguration,
- activity: android.app.Activity?,
- context: android.content.Context,
- emailContent: (@Composable (EmailAuthContentState) -> Unit)?,
- phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?,
- customMethodPickerLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)?,
- onDismiss: () -> Unit,
-) {
- val sheetNavController = rememberNavController()
- val startRoute = remember(reauthConfig) { getStartRoute(reauthConfig) }
- val skipsMethodPicker = startRoute != AuthRoute.MethodPicker
- val onProviderSelected = authUI.rememberOnProviderSelected(
- context = context,
- activity = activity,
- config = reauthConfig,
- onNavigate = { route -> sheetNavController.navigate(route.route) },
- )
-
- NavHost(
- navController = sheetNavController,
- startDestination = startRoute.route,
- enterTransition = { fadeIn(animationSpec = tween(700)) },
- exitTransition = { fadeOut(animationSpec = tween(700)) },
- popEnterTransition = { fadeIn(animationSpec = tween(700)) },
- popExitTransition = { fadeOut(animationSpec = tween(700)) },
- ) {
- composable(AuthRoute.MethodPicker.route) {
- if (customMethodPickerLayout != null) {
- Box(modifier = Modifier.fillMaxSize()) {
- customMethodPickerLayout(reauthConfig.providers, onProviderSelected)
- }
- } else {
- Scaffold { innerPadding ->
- AuthMethodPicker(
- modifier = Modifier.padding(innerPadding),
- providers = reauthConfig.providers,
- onProviderSelected = onProviderSelected,
- )
- }
- }
- }
-
- composable(AuthRoute.Email.route) {
- com.firebase.ui.auth.ui.screens.email.EmailAuthScreen(
- context = context,
- configuration = reauthConfig,
- authUI = authUI,
- content = emailContent,
- onSuccess = {},
- onError = {},
- onCancel = {
- if (skipsMethodPicker || !sheetNavController.popBackStack()) onDismiss()
- }
- )
- }
-
- composable(AuthRoute.Phone.route) {
- com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen(
- context = context,
- configuration = reauthConfig,
- authUI = authUI,
- content = phoneContent,
- onSuccess = {},
- onError = {},
- onCancel = {
- if (skipsMethodPicker || !sheetNavController.popBackStack()) onDismiss()
- }
- )
- }
- }
-}
@Composable
-private fun FirebaseAuthUI.rememberOnProviderSelected(
+internal fun FirebaseAuthUI.rememberOnProviderSelected(
context: android.content.Context,
activity: android.app.Activity?,
config: AuthUIConfiguration,
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..f7671ec9e 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,19 +159,42 @@ 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
+ val isLoading = authState is AuthState.Loading ||
+ authState is AuthState.Reauthentication.Authenticating
val authCredentialForLinking = remember { credentialForLinking }
- val errorMessage =
- if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null
+ val errorMessage = when (val state = authState) {
+ is AuthState.Error -> state.exception.message
+ is AuthState.Reauthentication.AttemptFailed -> state.exception.message
+ else -> null
+ }
// Latched locally since these get consumed (reset to Idle) below — deriving directly from
// authState would close ResetPasswordUI/SignInEmailLinkUI's dialogs as soon as it resets.
@@ -193,28 +219,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) {
@@ -250,11 +279,21 @@ fun EmailAuthScreen(
authUI.updateAuthState(AuthState.Idle)
}
+ is AuthState.Reauthentication.PasswordResetLinkSent -> {
+ resetLinkSentLocal = true
+ authUI.updateReauthentication(state.requestId) { it.returnedToProviderSelection() }
+ }
+
is AuthState.EmailSignInLinkSent -> {
emailSignInLinkSentLocal = true
authUI.updateAuthState(AuthState.Idle)
}
+ is AuthState.Reauthentication.EmailSignInLinkSent -> {
+ emailSignInLinkSentLocal = true
+ authUI.updateReauthentication(state.requestId) { it.returnedToProviderSelection() }
+ }
+
else -> Unit
}
}
@@ -263,6 +302,7 @@ fun EmailAuthScreen(
mode = mode.value,
displayName = displayNameValue.value,
email = emailTextValue.value,
+ isEmailLocked = isEmailLocked,
password = passwordTextValue.value,
confirmPassword = confirmPasswordTextValue.value,
isLoading = isLoading,
@@ -270,7 +310,9 @@ fun EmailAuthScreen(
resetLinkSent = resetLinkSentLocal,
emailSignInLinkSent = emailSignInLinkSentLocal,
onEmailChange = { email ->
- emailTextValue.value = email
+ if (!isEmailLocked) {
+ emailTextValue.value = email
+ }
},
onPasswordChange = { password ->
passwordTextValue.value = password
@@ -362,23 +404,29 @@ 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 = "" }
+ // Offered during reauthentication too: a reset email leaves the sheet up and the
+ // request armed, and blocking it strands a user who has forgotten their password.
+ 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 +462,8 @@ private fun DefaultEmailAuthContent(
onGoToSignUp = state.onGoToSignUp,
onGoToResetPassword = state.onGoToResetPassword,
onGoToEmailLinkSignIn = state.onGoToEmailLinkSignIn,
- onNavigateBack = onCancel
+ onNavigateBack = onCancel,
+ isEmailLocked = state.isEmailLocked,
)
}
@@ -422,6 +471,7 @@ private fun DefaultEmailAuthContent(
SignInEmailLinkUI(
configuration = configuration,
email = state.email,
+ isEmailLocked = state.isEmailLocked,
isLoading = state.isLoading,
emailSignInLinkSent = state.emailSignInLinkSent,
onEmailChange = state.onEmailChange,
@@ -446,7 +496,8 @@ private fun DefaultEmailAuthContent(
onConfirmPasswordChange = state.onConfirmPasswordChange,
onSignUpClick = state.onSignUpClick,
onGoToSignIn = state.onGoToSignIn,
- onNavigateBack = onCancel
+ onNavigateBack = onCancel,
+ isEmailLocked = state.isEmailLocked,
)
}
@@ -455,6 +506,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..19b831d28 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,21 @@ fun SignInUI(
}
}
+ val isSignUpOffered = provider.isNewAccountsAllowed &&
+ configuration.isNewEmailAccountsAllowed &&
+ !configuration.isReauthenticationMode
+
+ // An email link reopens the app with nothing armed, so completing it reports an interruption
+ // instead of the operation; a reset email leaves the reauth sheet and its request intact.
+ val isEmailLinkSignInOffered =
+ provider.isEmailLinkSignInEnabled && !configuration.isReauthenticationMode
+
// 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 +172,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 +197,7 @@ fun SignInUI(
value = email,
validator = emailValidator,
enabled = !isLoading,
+ readOnly = isEmailLocked,
label = {
Text(stringProvider.emailHint)
},
@@ -217,11 +237,23 @@ fun SignInUI(
)
}
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
+ .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))
+ }
Row(
modifier = Modifier
.align(Alignment.End),
) {
- if (provider.isNewAccountsAllowed) {
+ if (isSignUpOffered) {
Button(
onClick = {
onGoToSignUp()
@@ -250,7 +282,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/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeDefaults.kt
similarity index 99%
rename from auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt
rename to auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeDefaults.kt
index 0780348ee..7cc1561e3 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeDefaults.kt
@@ -12,7 +12,7 @@
* limitations under the License.
*/
-package com.firebase.ui.auth.ui.screens
+package com.firebase.ui.auth.ui.screens.mfa
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeScreen.kt
similarity index 99%
rename from auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreen.kt
rename to auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeScreen.kt
index 2dab06adb..7cc20534c 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreen.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeScreen.kt
@@ -12,7 +12,7 @@
* limitations under the License.
*/
-package com.firebase.ui.auth.ui.screens
+package com.firebase.ui.auth.ui.screens.mfa
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt
similarity index 89%
rename from auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt
rename to auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt
index 1cbb45949..4d5cf66af 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt
@@ -12,7 +12,7 @@
* limitations under the License.
*/
-package com.firebase.ui.auth.ui.screens
+package com.firebase.ui.auth.ui.screens.mfa
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -208,16 +208,6 @@ internal fun DefaultMfaEnrollmentContent(
null -> Unit
}
}
-
- MfaEnrollmentStep.ShowRecoveryCodes -> {
- ShowRecoveryCodesUI(
- recoveryCodes = state.recoveryCodes.orEmpty(),
- onDoneClick = state.onCodesSavedClick,
- isLoading = state.isLoading,
- error = state.error,
- stringProvider = stringProvider
- )
- }
}
SnackbarHost(
@@ -571,70 +561,3 @@ private fun VerifyTotpUI(
}
}
}
-
-@Composable
-private fun ShowRecoveryCodesUI(
- recoveryCodes: List,
- onDoneClick: () -> Unit,
- isLoading: Boolean,
- error: String?,
- stringProvider: AuthUIStringProvider
-) {
- Scaffold { innerPadding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding)
- .padding(16.dp)
- .verticalScroll(rememberScrollState()),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Text(
- text = stringProvider.mfaStepShowRecoveryCodesTitle,
- style = MaterialTheme.typography.headlineMedium,
- textAlign = TextAlign.Center
- )
-
- Text(
- text = stringProvider.mfaStepShowRecoveryCodesHelper,
- style = MaterialTheme.typography.bodyMedium,
- textAlign = TextAlign.Center,
- color = MaterialTheme.colorScheme.error
- )
-
- error?.let {
- Text(
- text = it,
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.bodySmall,
- textAlign = TextAlign.Center
- )
- }
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- recoveryCodes.forEach { code ->
- Text(
- text = code,
- style = MaterialTheme.typography.bodyMedium,
- modifier = Modifier.fillMaxWidth(),
- textAlign = TextAlign.Center
- )
- }
- }
-
- Button(
- onClick = onDoneClick,
- enabled = !isLoading,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(stringProvider.recoveryCodesSavedAction)
- }
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt
similarity index 92%
rename from auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreen.kt
rename to auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt
index 29f6827c9..9031d4b0d 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreen.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt
@@ -12,7 +12,7 @@
* limitations under the License.
*/
-package com.firebase.ui.auth.ui.screens
+package com.firebase.ui.auth.ui.screens.mfa
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
@@ -45,14 +45,13 @@ import kotlinx.coroutines.launch
* A stateful composable that manages the Multi-Factor Authentication (MFA) enrollment flow.
*
* This screen handles all steps of MFA enrollment including factor selection, configuration,
- * verification, and recovery code display. It uses the provided handlers to communicate with
- * Firebase Authentication and exposes state through a content slot for custom UI rendering.
+ * and verification. It uses the provided handlers to communicate with Firebase Authentication
+ * and exposes state through a content slot for custom UI rendering.
*
* **Enrollment Flow:**
* 1. **SelectFactor** - User chooses between SMS or TOTP
* 2. **ConfigureSms** or **ConfigureTotp** - User sets up their chosen factor
* 3. **VerifyFactor** - User verifies with a code
- * 4. **ShowRecoveryCodes** - (Optional) User receives backup codes
*
* @param user The currently authenticated [FirebaseUser] to enroll in MFA
* @param auth The [FirebaseAuth] instance
@@ -100,8 +99,6 @@ fun MfaEnrollmentScreen(
val verificationCode = rememberSaveable { mutableStateOf("") }
- val recoveryCodes = remember { mutableStateOf?>(null) }
-
val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) }
val phoneAuthConfiguration = remember(authConfiguration, applicationContext) {
@@ -180,9 +177,6 @@ fun MfaEnrollmentScreen(
null -> currentStep.value = MfaEnrollmentStep.SelectFactor
}
}
- MfaEnrollmentStep.ShowRecoveryCodes -> {
- currentStep.value = MfaEnrollmentStep.VerifyFactor
- }
}
error.value = null
lastException.value = null
@@ -322,12 +316,7 @@ fun MfaEnrollmentScreen(
// Refresh enrolled factors after successful enrollment
enrolledFactors.value = user.multiFactor.enrolledFactors
- if (configuration.enableRecoveryCodes) {
- recoveryCodes.value = generateRecoveryCodes()
- currentStep.value = MfaEnrollmentStep.ShowRecoveryCodes
- } else {
- onComplete()
- }
+ onComplete()
error.value = null
lastException.value = null
} catch (e: Exception) {
@@ -365,11 +354,7 @@ fun MfaEnrollmentScreen(
}
}
}
- } else null,
- recoveryCodes = recoveryCodes.value,
- onCodesSavedClick = {
- onComplete()
- }
+ } else null
)
if (content != null) {
@@ -382,15 +367,3 @@ fun MfaEnrollmentScreen(
)
}
}
-
-/**
- * Generates placeholder recovery codes.
- * In a production implementation, these would come from Firebase or a backend service.
- */
-private fun generateRecoveryCodes(): List {
- return List(10) { index ->
- List(4) { (0..9).random() }
- .joinToString("")
- .let { if (index % 2 == 0) "$it-${(1000..9999).random()}" else it }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt
index 2406da779..83126ee56 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt
@@ -18,6 +18,7 @@ import android.content.Context
import android.util.Log
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -178,10 +179,30 @@ fun PhoneAuthScreen(
}
}
- val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle)
- val isLoading = authState is AuthState.Loading
- val errorMessage =
- if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null
+ val currentAuthState = remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle)
+ val authState by currentAuthState
+ val isLoading = authState is AuthState.Loading ||
+ authState is AuthState.Reauthentication.Authenticating
+
+ // A cancelled Loading outlives this composition on the process-scoped FirebaseAuthUI, and
+ // currentAuthState is re-remembered per authUI, so onDispose reads the right instance.
+ //
+ // Only an ordinary Loading is retracted here. Under a reauthentication request the pending
+ // Loading is published as Reauthentication.Authenticating, which the reauth flow's own teardown
+ // owns; and were this to write anyway, updateAuthState folds Idle back into the armed request
+ // rather than dropping it.
+ DisposableEffect(authUI) {
+ onDispose {
+ if (currentAuthState.value is AuthState.Loading) {
+ authUI.updateAuthState(AuthState.Idle)
+ }
+ }
+ }
+ val errorMessage = when (val state = authState) {
+ is AuthState.Error -> state.exception.message
+ is AuthState.Reauthentication.AttemptFailed -> state.exception.message
+ else -> null
+ }
// Handle resend timer countdown
LaunchedEffect(resendTimerSeconds.intValue) {
@@ -203,14 +224,33 @@ fun PhoneAuthScreen(
}
}
- is AuthState.PhoneNumberVerificationRequired -> {
- verificationId.value = state.verificationId
- forceResendingToken.value = state.forceResendingToken
+ is AuthState.PhoneNumberVerificationRequired,
+ is AuthState.Reauthentication.PhoneNumberVerificationRequired -> {
+ verificationId.value = when (state) {
+ is AuthState.PhoneNumberVerificationRequired -> state.verificationId
+ is AuthState.Reauthentication.PhoneNumberVerificationRequired -> {
+ state.verificationId
+ }
+ else -> error("Unreachable phone verification state")
+ }
+ forceResendingToken.value = when (state) {
+ is AuthState.PhoneNumberVerificationRequired -> state.forceResendingToken
+ is AuthState.Reauthentication.PhoneNumberVerificationRequired -> {
+ state.forceResendingToken
+ }
+ else -> error("Unreachable phone verification state")
+ }
step.value = PhoneAuthStep.EnterVerificationCode
resendTimerSeconds.intValue = provider.timeout.toInt() // Start 60-second countdown
}
- is AuthState.SMSAutoVerified -> {
+ is AuthState.SMSAutoVerified,
+ is AuthState.Reauthentication.SmsAutoVerified -> {
+ val credential = when (state) {
+ is AuthState.SMSAutoVerified -> state.credential
+ is AuthState.Reauthentication.SmsAutoVerified -> state.credential
+ else -> error("Unreachable SMS verification state")
+ }
// Auto-verification succeeded, sign in with the credential
// and clear pending verification tracking
pendingVerificationPhoneNumber.value = null
@@ -228,13 +268,17 @@ fun PhoneAuthScreen(
} else {
// Consumed before the async sign-in call so it can't be clobbered by that
// call's own state.
- authUI.updateAuthState(AuthState.Idle)
+ if (state is AuthState.Reauthentication.SmsAutoVerified) {
+ authUI.updateReauthentication(state.requestId) { it.attemptStarted() }
+ } else {
+ authUI.updateAuthState(AuthState.Idle)
+ }
coroutineScope.launch {
try {
authUI.signInWithPhoneAuthCredential(
context = context,
config = configuration,
- credential = state.credential
+ credential = credential
)
} catch (e: Exception) {
// Error will be handled by authState flow
@@ -282,6 +326,16 @@ fun PhoneAuthScreen(
authUI.updateAuthState(AuthState.Idle)
}
+ is AuthState.Reauthentication.AttemptFailed -> {
+ // Same teardown as the ordinary Error branch above: the attempt is over, so stop
+ // holding Firebase's callbacks. The phase itself is left latched for the reauth UI
+ // to render, so nothing is consumed here.
+ val exception = AuthException.from(state.exception, stringProvider)
+ if (exception !is AuthException.PhoneVerificationCooldownException) {
+ cancelVerification("reauthentication attempt failed")
+ }
+ }
+
else -> Unit
}
}
@@ -399,6 +453,16 @@ fun PhoneAuthScreen(
resendTimer = resendTimerSeconds.intValue,
onChangeNumberClick = {
cancelVerification("changing phone number")
+ // Nothing replaces the cancelled attempt here, so this handler retracts its Loading -
+ // as the armed request's provider-selection phase when one is running, Idle otherwise.
+ val currentReauthentication = authState as? AuthState.Reauthentication
+ if (currentReauthentication != null) {
+ authUI.updateReauthentication(currentReauthentication.requestId) {
+ it.returnedToProviderSelection()
+ }
+ } else {
+ authUI.updateAuthState(AuthState.Idle)
+ }
verificationJob.value = null
isSubmittingCode.value = false
step.value = PhoneAuthStep.EnterPhoneNumber
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/CustomReauthContent.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/CustomReauthContent.kt
new file mode 100644
index 000000000..dcf29f193
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/CustomReauthContent.kt
@@ -0,0 +1,188 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.screens.reauth
+
+import android.util.Log
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.mfa.MfaChallengeContentState
+import com.firebase.ui.auth.ui.screens.AuthRoute
+import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState
+import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen
+import com.firebase.ui.auth.ui.screens.mfa.MfaChallengeScreen
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen
+import com.firebase.ui.auth.ui.screens.rememberOnProviderSelected
+import com.google.firebase.auth.MultiFactorResolver
+
+/**
+ * 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.
+ * @param mfaResolver Non-null while the reauthentication needs a second factor resolved.
+ * @param onMfaError Invoked when resolving the second factor fails.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun CustomReauthContent(
+ authUI: FirebaseAuthUI,
+ reauthConfig: AuthUIConfiguration,
+ reauthState: AuthState.Reauthentication.Required,
+ activity: android.app.Activity?,
+ context: android.content.Context,
+ emailContent: (@Composable (EmailAuthContentState) -> Unit)?,
+ phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?,
+ mfaChallengeContent: (@Composable (MfaChallengeContentState) -> Unit)?,
+ mfaResolver: MultiFactorResolver?,
+ isLoading: Boolean,
+ error: String?,
+ exception: Exception?,
+ activeSubRoute: AuthRoute?,
+ onActiveSubRouteChange: (AuthRoute?) -> Unit,
+ onAttemptStarted: () -> Unit,
+ onMfaError: (Exception) -> 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(
+ authUI,
+ reauthState.requestId,
+ onActiveSubRouteChange,
+ ) {
+ {
+ authUI.updateReauthentication(reauthState.requestId) { it.attemptCancelled() }
+ 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,
+ )
+ }
+
+ AuthRoute.MfaChallenge -> if (mfaResolver != null) {
+ ModalBottomSheet(
+ onDismissRequest = closeSubFlow,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ ) {
+ MfaChallengeScreen(
+ resolver = mfaResolver,
+ auth = authUI.auth,
+ content = mfaChallengeContent,
+ // Resolving the challenge is what completed the reauthentication, so this is
+ // where the stamped Success for it is published.
+ onSuccess = { authUI.publishReauthenticationSuccess() },
+ onCancel = closeSubFlow,
+ onError = onMfaError,
+ )
+ }
+ } else {
+ content(slotState)
+ }
+
+ 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)
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthContentState.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthContentState.kt
new file mode 100644
index 000000000..cdbf54cd4
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthContentState.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.screens.reauth
+
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen
+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. Survives Activity recreation with the
+ * active reauthentication request.
+ *
+ * @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. */
+ val exception: Exception? = null,
+)
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthPresentationState.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthPresentationState.kt
new file mode 100644
index 000000000..8f31bdd1a
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthPresentationState.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.screens.reauth
+
+import androidx.compose.runtime.saveable.Saver
+import com.firebase.ui.auth.ui.screens.AuthRoute
+
+internal data class ReauthPresentationState(
+ val requestId: String,
+ val userUid: String,
+ val subRoute: AuthRoute? = null,
+)
+
+// The process-local request and retry callback live in AuthState.Reauthentication. Only the marker
+// and presentation route needed for Activity/process restoration are saveable here.
+internal val ReauthPresentationStateSaver: Saver> = Saver(
+ save = { state ->
+ state?.let { listOf(it.requestId, it.userUid, it.subRoute?.route) }
+ },
+ restore = { saved ->
+ ReauthPresentationState(
+ requestId = requireNotNull(saved[0]),
+ userUid = requireNotNull(saved[1]),
+ subRoute = when (saved.getOrNull(2)) {
+ AuthRoute.Email.route -> AuthRoute.Email
+ AuthRoute.Phone.route -> AuthRoute.Phone
+ else -> null
+ },
+ )
+ },
+)
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSheetContent.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSheetContent.kt
new file mode 100644
index 000000000..615ea38db
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSheetContent.kt
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.screens.reauth
+
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.mfa.MfaChallengeContentState
+import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker
+import com.firebase.ui.auth.ui.screens.AuthRoute
+import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState
+import com.firebase.ui.auth.ui.screens.getStartRoute
+import com.firebase.ui.auth.ui.screens.mfa.MfaChallengeScreen
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState
+import com.firebase.ui.auth.ui.screens.rememberOnProviderSelected
+import com.google.firebase.auth.MultiFactorResolver
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun ReauthSheetContent(
+ authUI: FirebaseAuthUI,
+ reauthConfig: AuthUIConfiguration,
+ requestId: String,
+ activity: android.app.Activity?,
+ context: android.content.Context,
+ prefillEmail: String?,
+ emailContent: (@Composable (EmailAuthContentState) -> Unit)?,
+ phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?,
+ mfaChallengeContent: (@Composable (MfaChallengeContentState) -> Unit)?,
+ mfaResolver: MultiFactorResolver?,
+ customMethodPickerLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)?,
+ onDismiss: () -> Unit,
+) {
+ val sheetNavController = rememberNavController()
+ val startRoute = remember(reauthConfig) { getStartRoute(reauthConfig) }
+ val skipsMethodPicker = startRoute != AuthRoute.MethodPicker
+ val onProviderSelected = authUI.rememberOnProviderSelected(
+ context = context,
+ activity = activity,
+ config = reauthConfig,
+ onNavigate = { route -> sheetNavController.navigate(route.route) },
+ )
+ // Provider selection for this sheet, which is where a consumed challenge returns to. With a
+ // single provider that is its own screen, so the credential attempt can simply be repeated.
+ val returnToProviderSelection: () -> Unit = {
+ sheetNavController.navigate(startRoute.route) {
+ popUpTo(startRoute.route) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ // Inside the sheet's own NavHost: on the outer one the challenge would render underneath
+ // this modal, where the user cannot reach it.
+ LaunchedEffect(mfaResolver) {
+ if (mfaResolver != null) {
+ sheetNavController.navigate(AuthRoute.MfaChallenge.route) { launchSingleTop = true }
+ }
+ }
+
+ NavHost(
+ navController = sheetNavController,
+ startDestination = startRoute.route,
+ enterTransition = { fadeIn(animationSpec = tween(700)) },
+ exitTransition = { fadeOut(animationSpec = tween(700)) },
+ popEnterTransition = { fadeIn(animationSpec = tween(700)) },
+ popExitTransition = { fadeOut(animationSpec = tween(700)) },
+ ) {
+ composable(AuthRoute.MethodPicker.route) {
+ if (customMethodPickerLayout != null) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ customMethodPickerLayout(reauthConfig.providers, onProviderSelected)
+ }
+ } else {
+ Scaffold { innerPadding ->
+ AuthMethodPicker(
+ modifier = Modifier.padding(innerPadding),
+ providers = reauthConfig.providers,
+ onProviderSelected = onProviderSelected,
+ )
+ }
+ }
+ }
+
+ composable(AuthRoute.Email.route) {
+ com.firebase.ui.auth.ui.screens.email.EmailAuthScreen(
+ context = context,
+ configuration = reauthConfig,
+ authUI = authUI,
+ prefillEmail = prefillEmail,
+ content = emailContent,
+ onSuccess = {},
+ onError = {},
+ onCancel = {
+ if (skipsMethodPicker || !sheetNavController.popBackStack()) {
+ onDismiss()
+ } else {
+ authUI.updateReauthentication(requestId) { it.attemptCancelled() }
+ }
+ }
+ )
+ }
+
+ composable(AuthRoute.Phone.route) {
+ com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen(
+ context = context,
+ configuration = reauthConfig,
+ authUI = authUI,
+ content = phoneContent,
+ onSuccess = {},
+ onError = {},
+ onCancel = {
+ if (skipsMethodPicker || !sheetNavController.popBackStack()) {
+ onDismiss()
+ } else {
+ authUI.updateReauthentication(requestId) { it.attemptCancelled() }
+ }
+ }
+ )
+ }
+
+ composable(AuthRoute.MfaChallenge.route) {
+ if (mfaResolver != null) {
+ MfaChallengeScreen(
+ resolver = mfaResolver,
+ auth = authUI.auth,
+ content = mfaChallengeContent,
+ // Resolving the challenge is what completed the reauthentication, so this is
+ // where the stamped Success for it is published.
+ onSuccess = { authUI.publishReauthenticationSuccess() },
+ onCancel = {
+ returnToProviderSelection()
+ authUI.updateReauthentication(requestId) { it.attemptCancelled() }
+ },
+ onError = { exception ->
+ returnToProviderSelection()
+ authUI.updateAuthState(AuthState.Error(exception))
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/auth/src/main/res/values-ar/strings.xml b/auth/src/main/res/values-ar/strings.xml
index f888d1d64..c1c80d58f 100755
--- a/auth/src/main/res/values-ar/strings.xml
+++ b/auth/src/main/res/values-ar/strings.xml
@@ -115,7 +115,6 @@
إعداد التحقق بالرسائل القصيرة
إعداد تطبيق المصادقة
تحقق من الرمز
- احفظ رموز الاسترداد
اختر طريقة مصادقة ثانية لتأمين حسابك
أدخل رقم هاتفك لتلقي رموز التحقق
@@ -123,7 +122,6 @@
أدخل الرمز المرسل إلى هاتفك
أدخل الرمز من تطبيق المصادقة
أدخل رمز التحقق
- احفظ هذه الرموز في مكان آمن. يمكنك استخدامها لتسجيل الدخول إذا فقدت الوصول إلى طريقة المصادقة.
تأكيد كلمة المرور
كلمتا المرور غير متطابقتين
@@ -164,7 +162,6 @@
إعادة المصادقة مطلوبة
نجحت إعادة المصادقة
إعادة المصادقة
- حفظت رموز الاسترداد
إزالة
إعادة إرسال بريد التحقق
المفتاح السري
diff --git a/auth/src/main/res/values-b+es+419/strings.xml b/auth/src/main/res/values-b+es+419/strings.xml
index 2f0530751..58830d9c4 100755
--- a/auth/src/main/res/values-b+es+419/strings.xml
+++ b/auth/src/main/res/values-b+es+419/strings.xml
@@ -115,7 +115,6 @@
Set Up SMS Verification
Set Up Authenticator App
Verify Your Code
- Save Your Recovery Codes
Select a second authentication method to secure your account
Enter your phone number to receive verification codes
@@ -123,7 +122,6 @@
Enter the code sent to your phone
Enter the code from your authenticator app
Enter your verification code
- Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method.
Confirmar contraseña
Las contraseñas no coinciden
@@ -163,7 +161,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-bg/strings.xml b/auth/src/main/res/values-bg/strings.xml
index 344e45c44..cc1343f80 100755
--- a/auth/src/main/res/values-bg/strings.xml
+++ b/auth/src/main/res/values-bg/strings.xml
@@ -115,7 +115,6 @@
Настройте SMS проверка
Настройте приложение за удостоверяване
Потвърдете кода си
- Запазете кодовете за възстановяване
Изберете втори метод за удостоверяване, за да защитите акаунта си
Въведете телефонния си номер, за да получавате кодове за проверка
@@ -123,7 +122,6 @@
Въведете кода, изпратен на телефона ви
Въведете кода от приложението си за удостоверяване
Въведете кода си за проверка
- Съхранявайте тези кодове на сигурно място. Можете да ги използвате за влизане, ако загубите достъп до метода си за удостоверяване.
Потвърдете паролата
Паролите не съвпадат
@@ -164,7 +162,6 @@
Необходимо е повторно удостоверяване
Повторното удостоверяване е успешно
Повторно удостоверяване
- Запазих кодовете за възстановяване
Премахване
Изпращане на имейл за потвърждение отново
Таен ключ
diff --git a/auth/src/main/res/values-bn/strings.xml b/auth/src/main/res/values-bn/strings.xml
index e0fd8e00b..769cb8568 100755
--- a/auth/src/main/res/values-bn/strings.xml
+++ b/auth/src/main/res/values-bn/strings.xml
@@ -116,7 +116,6 @@
SMS যাচাইকরণ সেট আপ করুন
প্রমাণীকরণকারী অ্যাপ সেট আপ করুন
আপনার কোড যাচাই করুন
- আপনার পুনরুদ্ধার কোড সংরক্ষণ করুন
আপনার অ্যাকাউন্ট সুরক্ষিত করতে দ্বিতীয় প্রমাণীকরণ পদ্ধতি নির্বাচন করুন
যাচাইকরণ কোড পেতে আপনার ফোন নম্বর লিখুন
@@ -124,7 +123,6 @@
আপনার ফোনে পাঠানো কোড লিখুন
আপনার প্রমাণীকরণকারী অ্যাপ থেকে কোড লিখুন
আপনার যাচাইকরণ কোড লিখুন
- এই কোডগুলি একটি নিরাপদ স্থানে সংরক্ষণ করুন। আপনি যদি আপনার প্রমাণীকরণ পদ্ধতিতে অ্যাক্সেস হারান তবে সাইন ইন করতে এগুলি ব্যবহার করতে পারেন।
পাসওয়ার্ড নিশ্চিত করুন
পাসওয়ার্ড মিলছে না
@@ -165,7 +163,6 @@
পুনরায় প্রমাণীকরণ প্রয়োজন
পুনরায় প্রমাণীকরণ সফল
পুনরায় প্রমাণীকরণ
- আমি আমার পুনরুদ্ধার কোড সংরক্ষণ করেছি
সরান
যাচাইকরণ ইমেল পুনরায় পাঠান
গোপন কী
diff --git a/auth/src/main/res/values-ca/strings.xml b/auth/src/main/res/values-ca/strings.xml
index 2fb311bfb..ea5a3970e 100755
--- a/auth/src/main/res/values-ca/strings.xml
+++ b/auth/src/main/res/values-ca/strings.xml
@@ -116,7 +116,6 @@
Configureu la verificació per SMS
Configureu l\'aplicació d\'autenticació
Verifiqueu el codi
- Deseu els codis de recuperació
Seleccioneu un segon mètode d\'autenticació per protegir el vostre compte
Introduïu el vostre número de telèfon per rebre codis de verificació
@@ -124,7 +123,6 @@
Introduïu el codi enviat al vostre telèfon
Introduïu el codi de la vostra aplicació d\'autenticació
Introduïu el vostre codi de verificació
- Deseu aquests codis en un lloc segur. Podeu utilitzar-los per iniciar sessió si perdeu l\'accés al vostre mètode d\'autenticació.
Confirma la contrasenya
Les contrasenyes no coincideixen
@@ -165,7 +163,6 @@
Es requereix reautenticació
Reautenticació correcta
Reautentica
- He desat els meus codis de recuperació
Elimina
Torna a enviar el correu de verificació
Clau secreta
diff --git a/auth/src/main/res/values-cs/strings.xml b/auth/src/main/res/values-cs/strings.xml
index 1d1dab36e..536d3a5ab 100755
--- a/auth/src/main/res/values-cs/strings.xml
+++ b/auth/src/main/res/values-cs/strings.xml
@@ -115,7 +115,6 @@
Nastavit ověření SMS
Nastavit ověřovací aplikaci
Ověřte svůj kód
- Uložte obnovací kódy
Vyberte druhou metodu ověření pro zabezpečení účtu
Zadejte telefonní číslo pro příjem ověřovacích kódů
@@ -123,7 +122,6 @@
Zadejte kód odeslaný na váš telefon
Zadejte kód z ověřovací aplikace
Zadejte ověřovací kód
- Uložte tyto kódy na bezpečném místě. Můžete je použít k přihlášení, pokud ztratíte přístup k metodě ověření.
Potvrďte heslo
Hesla se neshodují
@@ -164,7 +162,6 @@
Vyžaduje se opětovné ověření
Opětovné ověření proběhlo úspěšně
Znovu ověřit
- Uložil jsem kódy pro obnovení
Odstranit
Znovu odeslat ověřovací e-mail
Tajný klíč
diff --git a/auth/src/main/res/values-da/strings.xml b/auth/src/main/res/values-da/strings.xml
index 8096a7d84..dc69df85e 100755
--- a/auth/src/main/res/values-da/strings.xml
+++ b/auth/src/main/res/values-da/strings.xml
@@ -115,7 +115,6 @@
Konfigurer SMS-bekræftelse
Konfigurer godkendelsesapp
Bekræft din kode
- Gem dine gendannelseskoder
Vælg en anden godkendelsesmetode for at beskytte din konto
Indtast dit telefonnummer for at modtage bekræftelseskoder
@@ -123,7 +122,6 @@
Indtast koden sendt til din telefon
Indtast koden fra din godkendelsesapp
Indtast din bekræftelseskode
- Gem disse koder et sikkert sted. Du kan bruge dem til at logge ind, hvis du mister adgang til din godkendelsesmetode.
Bekræft adgangskode
Adgangskoderne stemmer ikke overens
@@ -164,7 +162,6 @@
Gengodkendelse påkrævet
Gengodkendelse lykkedes
Gengodkend
- Jeg har gemt mine gendannelseskoder
Fjern
Send bekræftelsesemail igen
Hemmelig nøgle
diff --git a/auth/src/main/res/values-de-rAT/strings.xml b/auth/src/main/res/values-de-rAT/strings.xml
index 21dbe9cb6..c0b9e9822 100755
--- a/auth/src/main/res/values-de-rAT/strings.xml
+++ b/auth/src/main/res/values-de-rAT/strings.xml
@@ -115,7 +115,6 @@
SMS-Bestätigung einrichten
Authenticator-App einrichten
Code bestätigen
- Wiederherstellungscodes speichern
Wählen Sie eine zweite Authentifizierungsmethode aus, um Ihr Konto zu schützen
Geben Sie Ihre Telefonnummer ein, um Bestätigungscodes zu erhalten
@@ -123,7 +122,6 @@
Geben Sie den an Ihr Telefon gesendeten Code ein
Geben Sie den Code aus Ihrer Authenticator-App ein
Geben Sie Ihren Bestätigungscode ein
- Bewahren Sie diese Codes an einem sicheren Ort auf. Sie können sie zum Anmelden verwenden, wenn Sie den Zugriff auf Ihre Authentifizierungsmethode verlieren.
Passwort bestätigen
Passwörter stimmen nicht überein
@@ -163,7 +161,6 @@
Zu Ihrer Sicherheit geben Sie bitte Ihr Passwort erneut ein, um fortzufahren.
Verifizieren Sie Ihre Identität
Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.
- Ich habe diese Codes gespeichert
Entfernen
Bestätigungs-E-Mail erneut senden
Geheimer Schlüssel
diff --git a/auth/src/main/res/values-de-rCH/strings.xml b/auth/src/main/res/values-de-rCH/strings.xml
index f18d40f5b..6ced5d337 100755
--- a/auth/src/main/res/values-de-rCH/strings.xml
+++ b/auth/src/main/res/values-de-rCH/strings.xml
@@ -116,7 +116,6 @@
SMS-Bestätigung einrichten
Authenticator-App einrichten
Code bestätigen
- Wiederherstellungscodes speichern
Wählen Sie eine zweite Authentifizierungsmethode aus, um Ihr Konto zu schützen
Geben Sie Ihre Telefonnummer ein, um Bestätigungscodes zu erhalten
@@ -124,7 +123,6 @@
Geben Sie den an Ihr Telefon gesendeten Code ein
Geben Sie den Code aus Ihrer Authenticator-App ein
Geben Sie Ihren Bestätigungscode ein
- Bewahren Sie diese Codes an einem sicheren Ort auf. Sie können sie zum Anmelden verwenden, wenn Sie den Zugriff auf Ihre Authentifizierungsmethode verlieren.
Passwort bestätigen
Passwörter stimmen nicht überein
@@ -164,7 +162,6 @@
Zu Ihrer Sicherheit geben Sie bitte Ihr Passwort erneut ein, um fortzufahren.
Verifizieren Sie Ihre Identität
Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.
- Ich habe diese Codes gespeichert
Entfernen
Bestätigungs-E-Mail erneut senden
Geheimer Schlüssel
diff --git a/auth/src/main/res/values-de/strings.xml b/auth/src/main/res/values-de/strings.xml
index da405144b..8d089b07b 100755
--- a/auth/src/main/res/values-de/strings.xml
+++ b/auth/src/main/res/values-de/strings.xml
@@ -115,7 +115,6 @@
SMS-Bestätigung einrichten
Authenticator-App einrichten
Code bestätigen
- Wiederherstellungscodes speichern
Wählen Sie eine zweite Authentifizierungsmethode aus, um Ihr Konto zu schützen
Geben Sie Ihre Telefonnummer ein, um Bestätigungscodes zu erhalten
@@ -123,7 +122,6 @@
Geben Sie den an Ihr Telefon gesendeten Code ein
Geben Sie den Code aus Ihrer Authenticator-App ein
Geben Sie Ihren Bestätigungscode ein
- Bewahren Sie diese Codes an einem sicheren Ort auf. Sie können sie zum Anmelden verwenden, wenn Sie den Zugriff auf Ihre Authentifizierungsmethode verlieren.
Passwort bestätigen
Passwörter stimmen nicht überein
@@ -163,7 +161,6 @@
Zu Ihrer Sicherheit geben Sie bitte Ihr Passwort erneut ein, um fortzufahren.
Verifizieren Sie Ihre Identität
Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.
- Ich habe diese Codes gespeichert
Entfernen
Bestätigungs-E-Mail erneut senden
Geheimer Schlüssel
diff --git a/auth/src/main/res/values-el/strings.xml b/auth/src/main/res/values-el/strings.xml
index 19f5daef9..839dce07b 100755
--- a/auth/src/main/res/values-el/strings.xml
+++ b/auth/src/main/res/values-el/strings.xml
@@ -116,7 +116,6 @@
Ρύθμιση επαλήθευσης SMS
Ρύθμιση εφαρμογής ελέγχου ταυτότητας
Επαληθεύστε τον κωδικό σας
- Αποθηκεύστε τους κωδικούς ανάκτησης
Επιλέξτε μια δεύτερη μέθοδο ελέγχου ταυτότητας για να προστατεύσετε τον λογαριασμό σας
Εισαγάγετε τον αριθμό τηλεφώνου σας για να λαμβάνετε κωδικούς επαλήθευσης
@@ -124,7 +123,6 @@
Εισαγάγετε τον κωδικό που στάλθηκε στο τηλέφωνό σας
Εισαγάγετε τον κωδικό από την εφαρμογή ελέγχου ταυτότητας
Εισαγάγετε τον κωδικό επαλήθευσης
- Αποθηκεύστε αυτούς τους κωδικούς σε ασφαλές μέρος. Μπορείτε να τους χρησιμοποιήσετε για σύνδεση εάν χάσετε την πρόσβαση στη μέθοδο ελέγχου ταυτότητας.
Επιβεβαίωση κωδικού πρόσβασης
Οι κωδικοί πρόσβασης δεν ταιριάζουν
@@ -165,7 +163,6 @@
Απαιτείται επανέλεγχος ταυτότητας
Ο επανέλεγχος ταυτότητας ολοκληρώθηκε
Επανέλεγχος
- Έχω αποθηκεύσει τους κωδικούς ανάκτησης
Κατάργηση
Επαναποστολή email επαλήθευσης
Μυστικό κλειδί
diff --git a/auth/src/main/res/values-en-rAU/strings.xml b/auth/src/main/res/values-en-rAU/strings.xml
index fe9f1a0f4..1c48724b3 100755
--- a/auth/src/main/res/values-en-rAU/strings.xml
+++ b/auth/src/main/res/values-en-rAU/strings.xml
@@ -115,7 +115,6 @@
Set Up SMS Verification
Set Up Authenticator App
Verify Your Code
- Save Your Recovery Codes
Select a second authentication method to secure your account
Enter your phone number to receive verification codes
@@ -123,7 +122,6 @@
Enter the code sent to your phone
Enter the code from your authenticator app
Enter your verification code
- Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method.
Confirm password
Passwords do not match
@@ -164,7 +162,6 @@
Re-authentication required
Re-authentication successful
Re-authenticate
- I have saved my recovery codes
Remove
Resend verification email
Secret key
diff --git a/auth/src/main/res/values-en-rCA/strings.xml b/auth/src/main/res/values-en-rCA/strings.xml
index 2c53e3eb5..07f5929af 100755
--- a/auth/src/main/res/values-en-rCA/strings.xml
+++ b/auth/src/main/res/values-en-rCA/strings.xml
@@ -115,7 +115,6 @@
Set Up SMS Verification
Set Up Authenticator App
Verify Your Code
- Save Your Recovery Codes
Select a second authentication method to secure your account
Enter your phone number to receive verification codes
@@ -123,7 +122,6 @@
Enter the code sent to your phone
Enter the code from your authenticator app
Enter your verification code
- Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method.
Confirm password
Passwords do not match
@@ -164,7 +162,6 @@
Re-authentication required
Re-authentication successful
Re-authenticate
- I have saved my recovery codes
Remove
Resend verification email
Secret key
diff --git a/auth/src/main/res/values-en-rGB/strings.xml b/auth/src/main/res/values-en-rGB/strings.xml
index cb667e4c5..da17ad525 100755
--- a/auth/src/main/res/values-en-rGB/strings.xml
+++ b/auth/src/main/res/values-en-rGB/strings.xml
@@ -115,7 +115,6 @@
Set Up SMS Verification
Set Up Authenticator App
Verify Your Code
- Save Your Recovery Codes
Select a second authentication method to secure your account
Enter your phone number to receive verification codes
@@ -123,7 +122,6 @@
Enter the code sent to your phone
Enter the code from your authenticator app
Enter your verification code
- Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method.
Confirm password
Passwords do not match
@@ -164,7 +162,6 @@
Re-authentication required
Re-authentication successful
Re-authenticate
- I have saved my recovery codes
Remove
Resend verification email
Secret key
diff --git a/auth/src/main/res/values-en-rIE/strings.xml b/auth/src/main/res/values-en-rIE/strings.xml
index f73f72711..c9c92620e 100755
--- a/auth/src/main/res/values-en-rIE/strings.xml
+++ b/auth/src/main/res/values-en-rIE/strings.xml
@@ -115,7 +115,6 @@
Set Up SMS Verification
Set Up Authenticator App
Verify Your Code
- Save Your Recovery Codes
Select a second authentication method to secure your account
Enter your phone number to receive verification codes
@@ -123,7 +122,6 @@
Enter the code sent to your phone
Enter the code from your authenticator app
Enter your verification code
- Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method.
@@ -157,7 +155,6 @@
Re-authentication required
Re-authentication successful
Re-authenticate
- I have saved my recovery codes
Remove
Resend verification email
Secret key
diff --git a/auth/src/main/res/values-en-rIN/strings.xml b/auth/src/main/res/values-en-rIN/strings.xml
index f73f72711..c9c92620e 100755
--- a/auth/src/main/res/values-en-rIN/strings.xml
+++ b/auth/src/main/res/values-en-rIN/strings.xml
@@ -115,7 +115,6 @@
Set Up SMS Verification
Set Up Authenticator App
Verify Your Code
- Save Your Recovery Codes
Select a second authentication method to secure your account
Enter your phone number to receive verification codes
@@ -123,7 +122,6 @@
Enter the code sent to your phone
Enter the code from your authenticator app
Enter your verification code
- Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method.
@@ -157,7 +155,6 @@
Re-authentication required
Re-authentication successful
Re-authenticate
- I have saved my recovery codes
Remove
Resend verification email
Secret key
diff --git a/auth/src/main/res/values-en-rSG/strings.xml b/auth/src/main/res/values-en-rSG/strings.xml
index f73f72711..c9c92620e 100755
--- a/auth/src/main/res/values-en-rSG/strings.xml
+++ b/auth/src/main/res/values-en-rSG/strings.xml
@@ -115,7 +115,6 @@
Set Up SMS Verification
Set Up Authenticator App
Verify Your Code
- Save Your Recovery Codes
Select a second authentication method to secure your account
Enter your phone number to receive verification codes
@@ -123,7 +122,6 @@
Enter the code sent to your phone
Enter the code from your authenticator app
Enter your verification code
- Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method.
@@ -157,7 +155,6 @@
Re-authentication required
Re-authentication successful
Re-authenticate
- I have saved my recovery codes
Remove
Resend verification email
Secret key
diff --git a/auth/src/main/res/values-en-rZA/strings.xml b/auth/src/main/res/values-en-rZA/strings.xml
index f73f72711..c9c92620e 100755
--- a/auth/src/main/res/values-en-rZA/strings.xml
+++ b/auth/src/main/res/values-en-rZA/strings.xml
@@ -115,7 +115,6 @@
Set Up SMS Verification
Set Up Authenticator App
Verify Your Code
- Save Your Recovery Codes
Select a second authentication method to secure your account
Enter your phone number to receive verification codes
@@ -123,7 +122,6 @@
Enter the code sent to your phone
Enter the code from your authenticator app
Enter your verification code
- Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method.
@@ -157,7 +155,6 @@
Re-authentication required
Re-authentication successful
Re-authenticate
- I have saved my recovery codes
Remove
Resend verification email
Secret key
diff --git a/auth/src/main/res/values-es-rAR/strings.xml b/auth/src/main/res/values-es-rAR/strings.xml
index f20ebae00..74c21e414 100755
--- a/auth/src/main/res/values-es-rAR/strings.xml
+++ b/auth/src/main/res/values-es-rAR/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rBO/strings.xml b/auth/src/main/res/values-es-rBO/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rBO/strings.xml
+++ b/auth/src/main/res/values-es-rBO/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rCL/strings.xml b/auth/src/main/res/values-es-rCL/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rCL/strings.xml
+++ b/auth/src/main/res/values-es-rCL/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rCO/strings.xml b/auth/src/main/res/values-es-rCO/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rCO/strings.xml
+++ b/auth/src/main/res/values-es-rCO/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rCR/strings.xml b/auth/src/main/res/values-es-rCR/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rCR/strings.xml
+++ b/auth/src/main/res/values-es-rCR/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rDO/strings.xml b/auth/src/main/res/values-es-rDO/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rDO/strings.xml
+++ b/auth/src/main/res/values-es-rDO/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rEC/strings.xml b/auth/src/main/res/values-es-rEC/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rEC/strings.xml
+++ b/auth/src/main/res/values-es-rEC/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rGT/strings.xml b/auth/src/main/res/values-es-rGT/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rGT/strings.xml
+++ b/auth/src/main/res/values-es-rGT/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rHN/strings.xml b/auth/src/main/res/values-es-rHN/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rHN/strings.xml
+++ b/auth/src/main/res/values-es-rHN/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rMX/strings.xml b/auth/src/main/res/values-es-rMX/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rMX/strings.xml
+++ b/auth/src/main/res/values-es-rMX/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rNI/strings.xml b/auth/src/main/res/values-es-rNI/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rNI/strings.xml
+++ b/auth/src/main/res/values-es-rNI/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rPA/strings.xml b/auth/src/main/res/values-es-rPA/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rPA/strings.xml
+++ b/auth/src/main/res/values-es-rPA/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rPE/strings.xml b/auth/src/main/res/values-es-rPE/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rPE/strings.xml
+++ b/auth/src/main/res/values-es-rPE/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rPR/strings.xml b/auth/src/main/res/values-es-rPR/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rPR/strings.xml
+++ b/auth/src/main/res/values-es-rPR/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rPY/strings.xml b/auth/src/main/res/values-es-rPY/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rPY/strings.xml
+++ b/auth/src/main/res/values-es-rPY/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rSV/strings.xml b/auth/src/main/res/values-es-rSV/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rSV/strings.xml
+++ b/auth/src/main/res/values-es-rSV/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rUS/strings.xml b/auth/src/main/res/values-es-rUS/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rUS/strings.xml
+++ b/auth/src/main/res/values-es-rUS/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rUY/strings.xml b/auth/src/main/res/values-es-rUY/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rUY/strings.xml
+++ b/auth/src/main/res/values-es-rUY/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es-rVE/strings.xml b/auth/src/main/res/values-es-rVE/strings.xml
index 87af2ee76..61f829cac 100755
--- a/auth/src/main/res/values-es-rVE/strings.xml
+++ b/auth/src/main/res/values-es-rVE/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
@@ -156,7 +154,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-es/strings.xml b/auth/src/main/res/values-es/strings.xml
index f937887cc..2ecc78b4e 100755
--- a/auth/src/main/res/values-es/strings.xml
+++ b/auth/src/main/res/values-es/strings.xml
@@ -115,7 +115,6 @@
Configurar verificación por SMS
Configurar aplicación de autenticación
Verifica tu código
- Guarda tus códigos de recuperación
Selecciona un segundo método de autenticación para proteger tu cuenta
Introduce tu número de teléfono para recibir códigos de verificación
@@ -123,7 +122,6 @@
Introduce el código enviado a tu teléfono
Introduce el código de tu aplicación de autenticación
Introduce tu código de verificación
- Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación.
Confirmar contraseña
Las contraseñas no coinciden
@@ -163,7 +161,6 @@
Por tu seguridad, vuelve a ingresar tu contraseña para continuar.
Verifica tu identidad
Error de autenticación. Inténtalo de nuevo.
- He guardado estos códigos
Eliminar
Reenviar correo de verificación
Clave secreta
diff --git a/auth/src/main/res/values-fa/strings.xml b/auth/src/main/res/values-fa/strings.xml
index 5d21a20e3..35949e0d1 100755
--- a/auth/src/main/res/values-fa/strings.xml
+++ b/auth/src/main/res/values-fa/strings.xml
@@ -116,7 +116,6 @@
تأیید پیامک را راهاندازی کنید
برنامه احراز هویت را راهاندازی کنید
کد خود را تأیید کنید
- کدهای بازیابی خود را ذخیره کنید
برای ایمن کردن حساب خود، روش دوم احراز هویت را انتخاب کنید
شماره تلفن خود را وارد کنید تا کدهای تأیید دریافت کنید
@@ -124,7 +123,6 @@
کد ارسال شده به تلفن خود را وارد کنید
کد برنامه احراز هویت خود را وارد کنید
کد تأیید خود را وارد کنید
- این کدها را در مکانی امن ذخیره کنید. اگر دسترسی به روش احراز هویت خود را از دست دادید، میتوانید از آنها برای ورود استفاده کنید.
تأیید گذرواژه
گذرواژهها مطابقت ندارند
@@ -165,7 +163,6 @@
احراز هویت مجدد لازم است
احراز هویت مجدد موفق بود
احراز هویت مجدد
- کدهای بازیابی خود را ذخیره کردم
حذف
ارسال مجدد ایمیل تأیید
کلید مخفی
diff --git a/auth/src/main/res/values-fi/strings.xml b/auth/src/main/res/values-fi/strings.xml
index c12c36c37..e70fb8198 100755
--- a/auth/src/main/res/values-fi/strings.xml
+++ b/auth/src/main/res/values-fi/strings.xml
@@ -115,7 +115,6 @@
Määritä tekstiviestivahvistus
Määritä todennussovellus
Vahvista koodisi
- Tallenna palautuskoodisi
Valitse toinen todennusmenetelmä tilisi suojaamiseksi
Anna puhelinnumerosi vastaanottaaksesi vahvistuskoodit
@@ -123,7 +122,6 @@
Anna puhelimeesi lähetetty koodi
Anna todennussovelluksesi koodi
Anna vahvistuskoodisi
- Tallenna nämä koodit turvalliseen paikkaan. Voit käyttää niitä kirjautumiseen, jos menetät pääsyn todennusmenetelmääsi.
Vahvista salasana
Salasanat eivät täsmää
@@ -164,7 +162,6 @@
Uudelleentodennus vaaditaan
Uudelleentodennus onnistui
Todenna uudelleen
- Olen tallentanut palautuskoodini
Poista
Lähetä vahvistussähköposti uudelleen
Salainen avain
diff --git a/auth/src/main/res/values-fil/strings.xml b/auth/src/main/res/values-fil/strings.xml
index 8e28c1bd9..6f4abe0dd 100755
--- a/auth/src/main/res/values-fil/strings.xml
+++ b/auth/src/main/res/values-fil/strings.xml
@@ -115,7 +115,6 @@
I-set Up ang SMS Verification
I-set Up ang Authenticator App
I-verify ang Iyong Code
- I-save ang Iyong Mga Recovery Code
Pumili ng pangalawang paraan ng authentication para protektahan ang iyong account
Ilagay ang iyong numero ng telepono para makatanggap ng mga verification code
@@ -123,7 +122,6 @@
Ilagay ang code na ipinadala sa iyong telepono
Ilagay ang code mula sa iyong authenticator app
Ilagay ang iyong verification code
- I-imbak ang mga code na ito sa ligtas na lugar. Magagamit mo ang mga ito para mag-sign in kung mawawala ang access sa iyong paraan ng authentication.
Kumpirmahin ang password
Hindi tugma ang mga password
@@ -164,7 +162,6 @@
Kinakailangan ang muling pag-authenticate
Matagumpay ang muling pag-authenticate
Mag-authenticate muli
- Na-save ko ang aking mga recovery code
Alisin
Ipadala muli ang verification email
Secret key
diff --git a/auth/src/main/res/values-fr-rCH/strings.xml b/auth/src/main/res/values-fr-rCH/strings.xml
index 178fb4e2b..5bf9e2989 100755
--- a/auth/src/main/res/values-fr-rCH/strings.xml
+++ b/auth/src/main/res/values-fr-rCH/strings.xml
@@ -116,7 +116,6 @@
Configurer la vérification par SMS
Configurer l\'application d\'authentification
Vérifiez votre code
- Enregistrez vos codes de récupération
Sélectionnez une deuxième méthode d\'authentification pour sécuriser votre compte
Saisissez votre numéro de téléphone pour recevoir les codes de vérification
@@ -124,7 +123,6 @@
Saisissez le code envoyé à votre téléphone
Saisissez le code de votre application d\'authentification
Saisissez votre code de vérification
- Conservez ces codes en lieu sûr. Vous pouvez les utiliser pour vous connecter si vous perdez l\'accès à votre méthode d\'authentification.
@@ -157,7 +155,6 @@
Pour votre sécurité, veuillez saisir à nouveau votre mot de passe pour continuer.
Vérifiez votre identité
Échec de l\'authentification. Veuillez réessayer.
- J\'ai sauvegardé ces codes
Supprimer
Renvoyer l\'e-mail de vérification
Clé secrète
diff --git a/auth/src/main/res/values-fr/strings.xml b/auth/src/main/res/values-fr/strings.xml
index 22f509157..c7a77de6f 100755
--- a/auth/src/main/res/values-fr/strings.xml
+++ b/auth/src/main/res/values-fr/strings.xml
@@ -115,7 +115,6 @@
Configurer la vérification par SMS
Configurer l\'application d\'authentification
Vérifiez votre code
- Enregistrez vos codes de récupération
Sélectionnez une deuxième méthode d\'authentification pour sécuriser votre compte
Saisissez votre numéro de téléphone pour recevoir les codes de vérification
@@ -123,7 +122,6 @@
Saisissez le code envoyé à votre téléphone
Saisissez le code de votre application d\'authentification
Saisissez votre code de vérification
- Conservez ces codes en lieu sûr. Vous pouvez les utiliser pour vous connecter si vous perdez l\'accès à votre méthode d\'authentification.
Confirmer le mot de passe
Les mots de passe ne correspondent pas
@@ -163,7 +161,6 @@
Pour votre sécurité, veuillez saisir à nouveau votre mot de passe pour continuer.
Vérifiez votre identité
Échec de l\'authentification. Veuillez réessayer.
- J\'ai sauvegardé ces codes
Supprimer
Renvoyer l\'e-mail de vérification
Clé secrète
diff --git a/auth/src/main/res/values-gsw/strings.xml b/auth/src/main/res/values-gsw/strings.xml
index 6accbd34c..86c217974 100755
--- a/auth/src/main/res/values-gsw/strings.xml
+++ b/auth/src/main/res/values-gsw/strings.xml
@@ -115,7 +115,6 @@
SMS-Bestätigung iirichte
Authenticator-App iirichte
Code bestätige
- Wiederherstelligscodes speichere
Wähle Sie e zweiti Authentifizierungsmethode us, zum Ihres Konto z schütze
Gäbe Sie Ihri Telefonnummer ii, zum Bestätigungscodes z erhalte
@@ -123,7 +122,6 @@
Gäbe Sie de Code ii, wo an Ihres Telefon gschickt worde isch
Gäbe Sie de Code us Ihrer Authenticator-App ii
Gäbe Sie Ihre Bestätigungscode ii
- Bewahre Sie die Codes a eme sichere Ort uuf. Sie chönd si zum Aamälde benutze, falls Sie de Zuegriff uf Ihri Authentifizierungsmethode verliere.
Passwort bestätige
Passwörter stimmed nöd überiin
@@ -164,7 +162,6 @@
Erneuti Authentifizierig erforderlech
Erneuti Authentifizierig erfolgriich
Erneut authentifiziere
- Ich ha mini Wiederherstelligscodes gspicheret
Entferne
Verifizierigs-E-Mail erneut sende
Gheime Schlüssel
diff --git a/auth/src/main/res/values-gu/strings.xml b/auth/src/main/res/values-gu/strings.xml
index f80face1b..e147fad95 100755
--- a/auth/src/main/res/values-gu/strings.xml
+++ b/auth/src/main/res/values-gu/strings.xml
@@ -116,7 +116,6 @@
SMS ચકાસણી સેટ કરો
પ્રમાણીકરણ ઍપ સેટ કરો
તમારો કોડ ચકાસો
- તમારા પુનઃપ્રાપ્તિ કોડ સાચવો
તમારા એકાઉન્ટને સુરક્ષિત કરવા બીજી પ્રમાણીકરણ પદ્ધતિ પસંદ કરો
ચકાસણી કોડ મેળવવા તમારો ફોન નંબર દાખલ કરો
@@ -124,7 +123,6 @@
તમારા ફોન પર મોકલવામાં આવેલો કોડ દાખલ કરો
તમારી પ્રમાણીકરણ ઍપનો કોડ દાખલ કરો
તમારો ચકાસણી કોડ દાખલ કરો
- આ કોડને સુરક્ષિત સ્થળે સંગ્રહિત કરો. જો તમે તમારી પ્રમાણીકરણ પદ્ધતિની ઍક્સેસ ગુમાવો છો, તો તમે સાઇન ઇન કરવા માટે તેનો ઉપયોગ કરી શકો છો.
પાસવર્ડ કન્ફર્મ કરો
પાસવર્ડ મેળ ખાતા નથી
@@ -165,7 +163,6 @@
ફરીથી પ્રમાણીકરણ આવશ્યક છે
ફરીથી પ્રમાણીકરણ સફળ
ફરીથી પ્રમાણીકરણ
- મેં મારા પુનઃપ્રાપ્તિ કોડ સાચવ્યા છે
દૂર કરો
ચકાસણી ઇમેઇલ ફરી મોકલો
ગુપ્ત કી
diff --git a/auth/src/main/res/values-hi/strings.xml b/auth/src/main/res/values-hi/strings.xml
index 361aaf69a..4a43ec97b 100755
--- a/auth/src/main/res/values-hi/strings.xml
+++ b/auth/src/main/res/values-hi/strings.xml
@@ -116,7 +116,6 @@
SMS सत्यापन सेट करें
प्रमाणक ऐप सेट करें
अपना कोड सत्यापित करें
- अपने पुनर्प्राप्ति कोड सहेजें
अपने खाते को सुरक्षित करने के लिए दूसरी प्रमाणीकरण विधि चुनें
सत्यापन कोड प्राप्त करने के लिए अपना फ़ोन नंबर दर्ज करें
@@ -124,7 +123,6 @@
अपने फ़ोन पर भेजा गया कोड दर्ज करें
अपने प्रमाणक ऐप से कोड दर्ज करें
अपना सत्यापन कोड दर्ज करें
- इन कोड को सुरक्षित स्थान पर संग्रहीत करें। यदि आप अपनी प्रमाणीकरण विधि तक पहुंच खो देते हैं, तो आप साइन इन करने के लिए इनका उपयोग कर सकते हैं।
पासवर्ड की पुष्टि करें
पासवर्ड मेल नहीं खाते
@@ -165,7 +163,6 @@
पुनः प्रमाणीकरण आवश्यक है
पुनः प्रमाणीकरण सफल
पुनः प्रमाणीकरण
- मैंने अपने पुनर्प्राप्ति कोड सहेज लिए हैं
हटाएं
सत्यापन ईमेल फिर से भेजें
गुप्त कुंजी
diff --git a/auth/src/main/res/values-hr/strings.xml b/auth/src/main/res/values-hr/strings.xml
index d3db464d1..be0c62a41 100755
--- a/auth/src/main/res/values-hr/strings.xml
+++ b/auth/src/main/res/values-hr/strings.xml
@@ -115,7 +115,6 @@
Postavite SMS provjeru
Postavite aplikaciju za provjeru autentičnosti
Potvrdite svoju šifru
- Spremite šifre za oporavak
Odaberite drugu metodu provjere autentičnosti kako biste zaštitili svoj račun
Unesite svoj telefonski broj kako biste primali šifre za potvrdu
@@ -123,7 +122,6 @@
Unesite šifru poslanu na vaš telefon
Unesite šifru iz svoje aplikacije za provjeru autentičnosti
Unesite svoju šifru za potvrdu
- Pohranite ove šifre na sigurno mjesto. Možete ih koristiti za prijavu ako izgubite pristup svojoj metodi provjere autentičnosti.
Potvrdite zaporku
Zaporke se ne podudaraju
@@ -164,7 +162,6 @@
Potrebna je ponovna autentifikacija
Ponovna autentifikacija uspješna
Ponovno autentificiraj
- Spremio sam kodove za oporavak
Ukloni
Ponovno pošalji e-poštu za provjeru
Tajni ključ
diff --git a/auth/src/main/res/values-hu/strings.xml b/auth/src/main/res/values-hu/strings.xml
index 7e0f61ceb..3f3991552 100755
--- a/auth/src/main/res/values-hu/strings.xml
+++ b/auth/src/main/res/values-hu/strings.xml
@@ -115,7 +115,6 @@
SMS-ellenőrzés beállítása
Hitelesítő alkalmazás beállítása
Kód ellenőrzése
- Helyreállítási kódok mentése
Válasszon második hitelesítési módszert fiókja védelme érdekében
Adja meg telefonszámát az ellenőrző kódok fogadásához
@@ -123,7 +122,6 @@
Adja meg a telefonjára küldött kódot
Adja meg a hitelesítő alkalmazásából származó kódot
Adja meg ellenőrző kódját
- Tárolja ezeket a kódokat biztonságos helyen. Ezekkel jelentkezhet be, ha elveszti a hitelesítési módszeréhez való hozzáférést.
Jelszó megerősítése
A jelszavak nem egyeznek
@@ -164,7 +162,6 @@
Újrahitelesítés szükséges
Újrahitelesítés sikeres
Újrahitelesítés
- Elmentettem a helyreállítási kódokat
Eltávolítás
Ellenőrző e-mail újraküldése
Titkos kulcs
diff --git a/auth/src/main/res/values-in/strings.xml b/auth/src/main/res/values-in/strings.xml
index de450ae52..d80f8bd88 100755
--- a/auth/src/main/res/values-in/strings.xml
+++ b/auth/src/main/res/values-in/strings.xml
@@ -116,7 +116,6 @@
Siapkan Verifikasi SMS
Siapkan Aplikasi Autentikator
Verifikasi Kode Anda
- Simpan Kode Pemulihan Anda
Pilih metode autentikasi kedua untuk mengamankan akun Anda
Masukkan nomor telepon Anda untuk menerima kode verifikasi
@@ -124,7 +123,6 @@
Masukkan kode yang dikirim ke ponsel Anda
Masukkan kode dari aplikasi autentikator Anda
Masukkan kode verifikasi Anda
- Simpan kode ini di tempat yang aman. Anda dapat menggunakannya untuk masuk jika kehilangan akses ke metode autentikasi Anda.
Konfirmasi sandi
Sandi tidak cocok
@@ -165,7 +163,6 @@
Autentikasi ulang diperlukan
Autentikasi ulang berhasil
Autentikasi ulang
- Saya telah menyimpan kode pemulihan saya
Hapus
Kirim ulang email verifikasi
Kunci rahasia
diff --git a/auth/src/main/res/values-it/strings.xml b/auth/src/main/res/values-it/strings.xml
index 5f39e4d28..1b2050f62 100755
--- a/auth/src/main/res/values-it/strings.xml
+++ b/auth/src/main/res/values-it/strings.xml
@@ -115,7 +115,6 @@
Configura verifica SMS
Configura app di autenticazione
Verifica il codice
- Salva i codici di recupero
Seleziona un secondo metodo di autenticazione per proteggere il tuo account
Inserisci il tuo numero di telefono per ricevere i codici di verifica
@@ -123,7 +122,6 @@
Inserisci il codice inviato al tuo telefono
Inserisci il codice dalla tua app di autenticazione
Inserisci il codice di verifica
- Conserva questi codici in un luogo sicuro. Puoi usarli per accedere se perdi l\'accesso al tuo metodo di autenticazione.
Conferma password
Le password non corrispondono
@@ -164,7 +162,6 @@
Riautenticazione richiesta
Riautenticazione riuscita
Riautentica
- Ho salvato i miei codici di recupero
Rimuovi
Invia nuovamente email di verifica
Chiave segreta
diff --git a/auth/src/main/res/values-iw/strings.xml b/auth/src/main/res/values-iw/strings.xml
index 5d2864c7f..408025859 100755
--- a/auth/src/main/res/values-iw/strings.xml
+++ b/auth/src/main/res/values-iw/strings.xml
@@ -116,7 +116,6 @@
הגדר אימות SMS
הגדר אפליקציית מאמת
אמת את הקוד שלך
- שמור את קודי השחזור שלך
בחר שיטת אימות שנייה כדי לאבטח את החשבון שלך
הזן את מספר הטלפון שלך כדי לקבל קודי אימות
@@ -124,7 +123,6 @@
הזן את הקוד שנשלח לטלפון שלך
הזן את הקוד מאפליקציית המאמת שלך
הזן את קוד האימות שלך
- שמור קודים אלה במקום בטוח. תוכל להשתמש בהם כדי להיכנס אם תאבד גישה לשיטת האימות שלך.
אישור סיסמה
הסיסמאות אינן תואמות
@@ -165,7 +163,6 @@
נדרש אימות מחדש
אימות מחדש בוצע בהצלחה
אמת מחדש
- שמרתי את קודי השחזור
הסר
שלח שוב אימייל אימות
מפתח סודי
diff --git a/auth/src/main/res/values-ja/strings.xml b/auth/src/main/res/values-ja/strings.xml
index 9faa6db47..21a458824 100755
--- a/auth/src/main/res/values-ja/strings.xml
+++ b/auth/src/main/res/values-ja/strings.xml
@@ -115,7 +115,6 @@
SMS認証を設定
認証アプリを設定
コードを確認
- 復元コードを保存
アカウントを保護するため、2つ目の認証方法を選択してください
確認コードを受け取るために電話番号を入力してください
@@ -123,7 +122,6 @@
電話に送信されたコードを入力してください
認証アプリのコードを入力してください
確認コードを入力してください
- これらのコードを安全な場所に保管してください。認証方法にアクセスできなくなった場合、これらを使用してログインできます。
パスワードの確認
パスワードが一致しません
@@ -164,7 +162,6 @@
再認証が必要です
再認証に成功しました
再認証
- 復旧コードを保存しました
削除
確認メールを再送信
シークレットキー
diff --git a/auth/src/main/res/values-kn/strings.xml b/auth/src/main/res/values-kn/strings.xml
index fd3730c06..53052796d 100755
--- a/auth/src/main/res/values-kn/strings.xml
+++ b/auth/src/main/res/values-kn/strings.xml
@@ -116,7 +116,6 @@
SMS ಪರಿಶೀಲನೆಯನ್ನು ಹೊಂದಿಸಿ
ದೃಢೀಕರಣ ಅಪ್ಲಿಕೇಶನ್ ಹೊಂದಿಸಿ
ನಿಮ್ಮ ಕೋಡ್ ಅನ್ನು ಪರಿಶೀಲಿಸಿ
- ನಿಮ್ಮ ಮರುಪಡೆಯುವಿಕೆ ಕೋಡ್ಗಳನ್ನು ಉಳಿಸಿ
ನಿಮ್ಮ ಖಾತೆಯನ್ನು ಸುರಕ್ಷಿತವಾಗಿರಿಸಲು ಎರಡನೇ ದೃಢೀಕರಣ ವಿಧಾನವನ್ನು ಆಯ್ಕೆಮಾಡಿ
ಪರಿಶೀಲನೆ ಕೋಡ್ಗಳನ್ನು ಸ್ವೀಕರಿಸಲು ನಿಮ್ಮ ಫೋನ್ ಸಂಖ್ಯೆಯನ್ನು ನಮೂದಿಸಿ
@@ -124,7 +123,6 @@
ನಿಮ್ಮ ಫೋನ್ಗೆ ಕಳುಹಿಸಿದ ಕೋಡ್ ಅನ್ನು ನಮೂದಿಸಿ
ನಿಮ್ಮ ದೃಢೀಕರಣ ಅಪ್ಲಿಕೇಶನ್ನಿಂದ ಕೋಡ್ ಅನ್ನು ನಮೂದಿಸಿ
ನಿಮ್ಮ ಪರಿಶೀಲನೆ ಕೋಡ್ ಅನ್ನು ನಮೂದಿಸಿ
- ಈ ಕೋಡ್ಗಳನ್ನು ಸುರಕ್ಷಿತ ಸ್ಥಳದಲ್ಲಿ ಸಂಗ್ರಹಿಸಿ. ನಿಮ್ಮ ದೃಢೀಕರಣ ವಿಧಾನಕ್ಕೆ ಪ್ರವೇಶವನ್ನು ಕಳೆದುಕೊಂಡರೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಅವುಗಳನ್ನು ಬಳಸಬಹುದು.
ಪಾಸ್ವರ್ಡ್ ದೃಢೀಕರಿಸಿ
ಪಾಸ್ವರ್ಡ್ಗಳು ಹೊಂದಿಕೆಯಾಗುತ್ತಿಲ್ಲ
@@ -165,7 +163,6 @@
ಮರು-ಪ್ರಮಾಣೀಕರಣ ಅಗತ್ಯವಿದೆ
ಮರು-ಪ್ರಮಾಣೀಕರಣ ಯಶಸ್ವಿಯಾಗಿದೆ
ಮರು-ಪ್ರಮಾಣೀಕರಣ
- ನಾನು ನನ್ನ ಮರುಪಡೆಯುವ ಕೋಡ್ಗಳನ್ನು ಉಳಿಸಿದ್ದೇನೆ
ತೆಗೆದುಹಾಕಿ
ಪರಿಶೀಲನೆ ಇಮೇಲ್ ಮರುಕಳುಹಿಸಿ
ರಹಸ್ಯ ಕೀ
diff --git a/auth/src/main/res/values-ko/strings.xml b/auth/src/main/res/values-ko/strings.xml
index 574d81fe7..a89f44731 100755
--- a/auth/src/main/res/values-ko/strings.xml
+++ b/auth/src/main/res/values-ko/strings.xml
@@ -114,7 +114,6 @@
SMS 인증 설정
인증 앱 설정
코드 확인
- 복구 코드 저장
계정을 보호하기 위해 두 번째 인증 방법을 선택하세요
인증 코드를 받을 전화번호를 입력하세요
@@ -122,7 +121,6 @@
휴대전화로 전송된 코드를 입력하세요
인증 앱의 코드를 입력하세요
인증 코드를 입력하세요
- 이 코드를 안전한 곳에 보관하세요. 인증 방법에 액세스할 수 없게 되면 이 코드를 사용하여 로그인할 수 있습니다.
비밀번호 확인
비밀번호가 일치하지 않습니다
@@ -163,7 +161,6 @@
재인증이 필요합니다
재인증에 성공했습니다
재인증
- 복구 코드를 저장했습니다
삭제
확인 이메일 다시 보내기
비밀 키
diff --git a/auth/src/main/res/values-ln/strings.xml b/auth/src/main/res/values-ln/strings.xml
index 832335a0d..df5a4325f 100755
--- a/auth/src/main/res/values-ln/strings.xml
+++ b/auth/src/main/res/values-ln/strings.xml
@@ -116,7 +116,6 @@
Bongisa bondimi ya SMS
Bongisa aplikasyo ya bondimi
Talela kode na yo
- Bomba ba kode ya bozongisi
Pona lolenge ya mibale ya bondimi po na kobatela konte na yo
Kota nimero ya telefone na yo po na kozwa ba kode ya bondimi
@@ -124,7 +123,6 @@
Kota kode oyo etindaki na telefone na yo
Kota kode oyo euti na aplikasyo na yo ya bondimi
Kota kode na yo ya bondimi
- Bomba ba kode oyo na esika moko ya libateli. Okoki kosalela yango po na kokota soki obungi nzela ya bondimi na yo.
Ndima mot de passe
Ba mot de passe ekokani te
@@ -165,7 +163,6 @@
Re-authentification esengeli
Re-authentification elongi
Re-authentifier
- Nabombi ba codes ya récupération na ngai
Longola
Tinda lisusu e-mail ya vérification
Clé secrète
diff --git a/auth/src/main/res/values-lt/strings.xml b/auth/src/main/res/values-lt/strings.xml
index 1c5270c7b..4959139a0 100755
--- a/auth/src/main/res/values-lt/strings.xml
+++ b/auth/src/main/res/values-lt/strings.xml
@@ -116,7 +116,6 @@
Nustatyti SMS patvirtinimą
Nustatyti autentifikatoriaus programą
Patvirtinkite kodą
- Išsaugokite atkūrimo kodus
Pasirinkite antrą autentifikavimo metodą, kad apsaugotumėte paskyrą
Įveskite telefono numerį, kad gautumėte patvirtinimo kodus
@@ -124,7 +123,6 @@
Įveskite kodą, išsiųstą į telefoną
Įveskite kodą iš autentifikatoriaus programos
Įveskite patvirtinimo kodą
- Išsaugokite šiuos kodus saugioje vietoje. Galite juos naudoti prisijungti, jei prarasite prieigą prie autentifikavimo metodo.
Patvirtinti slaptažodį
Slaptažodžiai nesutampa
@@ -165,7 +163,6 @@
Reikalingas pakartotinis autentifikavimas
Pakartotinis autentifikavimas sėkmingas
Autentifikuoti iš naujo
- Išsaugojau atkūrimo kodus
Pašalinti
Siųsti patvirtinimo el. laišką iš naujo
Slaptasis raktas
diff --git a/auth/src/main/res/values-lv/strings.xml b/auth/src/main/res/values-lv/strings.xml
index 6e2fa6097..527e22493 100755
--- a/auth/src/main/res/values-lv/strings.xml
+++ b/auth/src/main/res/values-lv/strings.xml
@@ -116,7 +116,6 @@
Iestatīt SMS verifikāciju
Iestatīt autentifikatora lietotni
Verificējiet savu kodu
- Saglabājiet atgūšanas kodus
Izvēlieties otro autentifikācijas metodi, lai aizsargātu savu kontu
Ievadiet savu tālruņa numuru, lai saņemtu verificēšanas kodus
@@ -124,7 +123,6 @@
Ievadiet uz tālruni nosūtīto kodu
Ievadiet kodu no savas autentifikatora lietotnes
Ievadiet verificēšanas kodu
- Glabājiet šos kodus drošā vietā. Varat tos izmantot, lai pierakstītos, ja zaudējat piekļuvi autentifikācijas metodei.
Apstipriniet paroli
Paroles nesakrīt
@@ -165,7 +163,6 @@
Nepieciešama atkārtota autentifikācija
Atkārtota autentifikācija veiksmīga
Autentificēt atkārtoti
- Esmu saglabājis atgūšanas kodus
Noņemt
Atkārtoti nosūtīt verifikācijas e-pastu
Slepenā atslēga
diff --git a/auth/src/main/res/values-mo/strings.xml b/auth/src/main/res/values-mo/strings.xml
index 4d9f9fef4..541681a04 100755
--- a/auth/src/main/res/values-mo/strings.xml
+++ b/auth/src/main/res/values-mo/strings.xml
@@ -116,7 +116,6 @@
Configurați verificarea prin SMS
Configurați aplicația de autentificare
Verificați codul
- Salvați codurile de recuperare
Selectați o a doua metodă de autentificare pentru a vă securiza contul
Introduceți numărul de telefon pentru a primi coduri de verificare
@@ -124,7 +123,6 @@
Introduceți codul trimis pe telefon
Introduceți codul din aplicația dvs. de autentificare
Introduceți codul de verificare
- Stocați aceste coduri într-un loc sigur. Le puteți folosi pentru a vă conecta dacă pierdeți accesul la metoda de autentificare.
Confirmați parola
Parolele nu se potrivesc
@@ -165,7 +163,6 @@
Este necesară reautentificarea
Reautentificare reușită
Reautentificare
- Am salvat codurile de recuperare
Eliminare
Retrimite e-mailul de verificare
Cheie secretă
diff --git a/auth/src/main/res/values-mr/strings.xml b/auth/src/main/res/values-mr/strings.xml
index d4075f456..f5219814b 100755
--- a/auth/src/main/res/values-mr/strings.xml
+++ b/auth/src/main/res/values-mr/strings.xml
@@ -116,7 +116,6 @@
SMS पडताळणी सेट करा
प्रमाणीकरणकर्ता अॅप सेट करा
तुमचा कोड पडताळा
- तुमचे पुनर्प्राप्ती कोड जतन करा
तुमचे खाते सुरक्षित करण्यासाठी दुसरी प्रमाणीकरण पद्धत निवडा
पडताळणी कोड प्राप्त करण्यासाठी तुमचा फोन नंबर प्रविष्ट करा
@@ -124,7 +123,6 @@
तुमच्या फोनवर पाठवलेला कोड प्रविष्ट करा
तुमच्या प्रमाणीकरणकर्ता अॅपमधील कोड प्रविष्ट करा
तुमचा पडताळणी कोड प्रविष्ट करा
- हे कोड सुरक्षित ठिकाणी संग्रहित करा. तुम्ही तुमच्या प्रमाणीकरण पद्धतीचा प्रवेश गमावल्यास साइन इन करण्यासाठी त्यांचा वापर करू शकता.
पासवर्डची पुष्टी करा
पासवर्ड जुळत नाहीत
@@ -165,7 +163,6 @@
पुन्हा प्रमाणीकरण आवश्यक आहे
पुन्हा प्रमाणीकरण यशस्वी
पुन्हा प्रमाणीकरण
- मी माझे पुनर्प्राप्ती कोड सुरक्षित केले आहेत
काढा
सत्यापन ईमेल पुन्हा पाठवा
गुप्त की
diff --git a/auth/src/main/res/values-ms/strings.xml b/auth/src/main/res/values-ms/strings.xml
index 55876519c..32e8056d8 100755
--- a/auth/src/main/res/values-ms/strings.xml
+++ b/auth/src/main/res/values-ms/strings.xml
@@ -116,7 +116,6 @@
Sediakan Pengesahan SMS
Sediakan Aplikasi Pengesah
Sahkan Kod Anda
- Simpan Kod Pemulihan Anda
Pilih kaedah pengesahan kedua untuk melindungi akaun anda
Masukkan nombor telefon anda untuk menerima kod pengesahan
@@ -124,7 +123,6 @@
Masukkan kod yang dihantar ke telefon anda
Masukkan kod dari aplikasi pengesah anda
Masukkan kod pengesahan anda
- Simpan kod ini di tempat yang selamat. Anda boleh menggunakannya untuk log masuk jika anda kehilangan akses kepada kaedah pengesahan anda.
Sahkan Kata Laluan
Kata laluan tidak sepadan
@@ -165,7 +163,6 @@
Pengesahan semula diperlukan
Pengesahan semula berjaya
Sahkan semula
- Saya telah menyimpan kod pemulihan saya
Buang
Hantar semula e-mel pengesahan
Kunci rahsia
diff --git a/auth/src/main/res/values-nb/strings.xml b/auth/src/main/res/values-nb/strings.xml
index 4ea4f79c1..0c33d7cfb 100755
--- a/auth/src/main/res/values-nb/strings.xml
+++ b/auth/src/main/res/values-nb/strings.xml
@@ -115,7 +115,6 @@
Konfigurer SMS-bekreftelse
Konfigurer autentiseringsapp
Bekreft koden din
- Lagre gjenopprettingskodene dine
Velg en andre autentiseringsmetode for å sikre kontoen din
Skriv inn telefonnummeret ditt for å motta bekreftelseskoder
@@ -123,7 +122,6 @@
Skriv inn koden som ble sendt til telefonen din
Skriv inn koden fra autentiseringsappen din
Skriv inn bekreftelseskoden din
- Lagre disse kodene på et sikkert sted. Du kan bruke dem til å logge inn hvis du mister tilgang til autentiseringsmetoden din.
Bekreft passord
Passordene stemmer ikke overens
@@ -164,7 +162,6 @@
Ny autentisering påkrevd
Ny autentisering vellykket
Autentiser på nytt
- Jeg har lagret gjenopprettingskodene mine
Fjern
Send bekreftelsese-post på nytt
Hemmelig nøkkel
diff --git a/auth/src/main/res/values-nl/strings.xml b/auth/src/main/res/values-nl/strings.xml
index c71fc63c3..9fec937ae 100755
--- a/auth/src/main/res/values-nl/strings.xml
+++ b/auth/src/main/res/values-nl/strings.xml
@@ -115,7 +115,6 @@
SMS-verificatie instellen
Authenticator-app instellen
Code verifiëren
- Herstelcodes opslaan
Selecteer een tweede authenticatiemethode om je account te beveiligen
Voer je telefoonnummer in om verificatiecodes te ontvangen
@@ -123,7 +122,6 @@
Voer de code in die naar je telefoon is verzonden
Voer de code uit je authenticator-app in
Voer je verificatiecode in
- Bewaar deze codes op een veilige plek. Je kunt ze gebruiken om in te loggen als je geen toegang meer hebt tot je authenticatiemethode.
Bevestig wachtwoord
Wachtwoorden komen niet overeen
@@ -164,7 +162,6 @@
Herauthenticatie vereist
Herauthenticatie geslaagd
Opnieuw authenticeren
- Ik heb mijn herstelcodes opgeslagen
Verwijderen
Verificatie-e-mail opnieuw verzenden
Geheime sleutel
diff --git a/auth/src/main/res/values-no/strings.xml b/auth/src/main/res/values-no/strings.xml
index 3e3fe493a..68737a9e8 100755
--- a/auth/src/main/res/values-no/strings.xml
+++ b/auth/src/main/res/values-no/strings.xml
@@ -116,7 +116,6 @@
Konfigurer SMS-bekreftelse
Konfigurer autentiseringsapp
Bekreft koden din
- Lagre gjenopprettingskodene dine
Velg en andre autentiseringsmetode for å sikre kontoen din
Skriv inn telefonnummeret ditt for å motta bekreftelseskoder
@@ -124,7 +123,6 @@
Skriv inn koden som ble sendt til telefonen din
Skriv inn koden fra autentiseringsappen din
Skriv inn bekreftelseskoden din
- Lagre disse kodene på et sikkert sted. Du kan bruke dem til å logge inn hvis du mister tilgang til autentiseringsmetoden din.
Bekreft passord
Passordene stemmer ikke overens
@@ -165,7 +163,6 @@
Ny autentisering påkrevd
Ny autentisering vellykket
Autentiser på nytt
- Jeg har lagret gjenopprettingskodene mine
Fjern
Send bekreftelsese-post på nytt
Hemmelig nøkkel
diff --git a/auth/src/main/res/values-pl/strings.xml b/auth/src/main/res/values-pl/strings.xml
index 8bcb36d6a..951ca3527 100755
--- a/auth/src/main/res/values-pl/strings.xml
+++ b/auth/src/main/res/values-pl/strings.xml
@@ -115,7 +115,6 @@
Skonfiguruj weryfikację SMS
Skonfiguruj aplikację uwierzytelniającą
Zweryfikuj kod
- Zapisz kody odzyskiwania
Wybierz drugą metodę uwierzytelniania, aby zabezpieczyć swoje konto
Wprowadź numer telefonu, aby otrzymywać kody weryfikacyjne
@@ -123,7 +122,6 @@
Wprowadź kod wysłany na Twój telefon
Wprowadź kod z aplikacji uwierzytelniającej
Wprowadź kod weryfikacyjny
- Przechowuj te kody w bezpiecznym miejscu. Możesz ich użyć do zalogowania się, jeśli stracisz dostęp do metody uwierzytelniania.
Potwierdź hasło
Hasła nie są takie same
@@ -164,7 +162,6 @@
Wymagane ponowne uwierzytelnienie
Ponowne uwierzytelnienie zakończone sukcesem
Uwierzytelnij ponownie
- Zapisałem kody odzyskiwania
Usuń
Wyślij ponownie e-mail weryfikacyjny
Tajny klucz
diff --git a/auth/src/main/res/values-pt-rBR/strings.xml b/auth/src/main/res/values-pt-rBR/strings.xml
index 38a96c93f..8afc908e3 100755
--- a/auth/src/main/res/values-pt-rBR/strings.xml
+++ b/auth/src/main/res/values-pt-rBR/strings.xml
@@ -116,7 +116,6 @@
Configurar verificação por SMS
Configurar app de autenticação
Verificar código
- Salvar códigos de recuperação
Selecione um segundo método de autenticação para proteger sua conta
Digite seu número de telefone para receber códigos de verificação
@@ -124,7 +123,6 @@
Digite o código enviado para seu telefone
Digite o código do seu app de autenticação
Digite seu código de verificação
- Armazene esses códigos em um local seguro. Você pode usá-los para fazer login se perder o acesso ao seu método de autenticação.
Confirmar senha
As senhas não correspondem
@@ -164,7 +162,6 @@
Para sua segurança, insira sua senha novamente para continuar.
Verifique sua identidade
Falha na autenticação. Tente novamente.
- Salvei estes códigos
Remover
Reenviar e-mail de verificação
Chave secreta
diff --git a/auth/src/main/res/values-pt-rPT/strings.xml b/auth/src/main/res/values-pt-rPT/strings.xml
index 36f1ed714..0359a87a3 100755
--- a/auth/src/main/res/values-pt-rPT/strings.xml
+++ b/auth/src/main/res/values-pt-rPT/strings.xml
@@ -116,7 +116,6 @@
Configurar verificação por SMS
Configurar app de autenticação
Verificar código
- Salvar códigos de recuperação
Selecione um segundo método de autenticação para proteger sua conta
Digite seu número de telefone para receber códigos de verificação
@@ -124,7 +123,6 @@
Digite o código enviado para seu telefone
Digite o código do seu app de autenticação
Digite seu código de verificação
- Armazene esses códigos em um local seguro. Você pode usá-los para fazer login se perder o acesso ao seu método de autenticação.
Confirmar palavra-passe
As palavras-passe não correspondem
@@ -164,7 +162,6 @@
Para sua segurança, insira sua senha novamente para continuar.
Verifique sua identidade
Falha na autenticação. Tente novamente.
- Salvei estes códigos
Remover
Reenviar e-mail de verificação
Chave secreta
diff --git a/auth/src/main/res/values-pt/strings.xml b/auth/src/main/res/values-pt/strings.xml
index 0541291cc..1c3cf0220 100755
--- a/auth/src/main/res/values-pt/strings.xml
+++ b/auth/src/main/res/values-pt/strings.xml
@@ -115,7 +115,6 @@
Configurar verificação por SMS
Configurar app de autenticação
Verificar código
- Salvar códigos de recuperação
Selecione um segundo método de autenticação para proteger sua conta
Digite seu número de telefone para receber códigos de verificação
@@ -123,7 +122,6 @@
Digite o código enviado para seu telefone
Digite o código do seu app de autenticação
Digite seu código de verificação
- Armazene esses códigos em um local seguro. Você pode usá-los para fazer login se perder o acesso ao seu método de autenticação.
Confirmar senha
As senhas não correspondem
@@ -163,7 +161,6 @@
Para sua segurança, insira sua senha novamente para continuar.
Verifique sua identidade
Falha na autenticação. Tente novamente.
- Salvei estes códigos
Remover
Reenviar e-mail de verificação
Chave secreta
diff --git a/auth/src/main/res/values-ro/strings.xml b/auth/src/main/res/values-ro/strings.xml
index bdd7bece4..fb0dfc104 100755
--- a/auth/src/main/res/values-ro/strings.xml
+++ b/auth/src/main/res/values-ro/strings.xml
@@ -115,7 +115,6 @@
Configurați verificarea prin SMS
Configurați aplicația de autentificare
Verificați codul
- Salvați codurile de recuperare
Selectați o a doua metodă de autentificare pentru a vă securiza contul
Introduceți numărul de telefon pentru a primi coduri de verificare
@@ -123,7 +122,6 @@
Introduceți codul trimis pe telefon
Introduceți codul din aplicația dvs. de autentificare
Introduceți codul de verificare
- Stocați aceste coduri într-un loc sigur. Le puteți folosi pentru a vă conecta dacă pierdeți accesul la metoda de autentificare.
Confirmați parola
Parolele nu se potrivesc
@@ -164,7 +162,6 @@
Este necesară reautentificarea
Reautentificare reușită
Reautentificare
- Am salvat codurile de recuperare
Eliminare
Retrimite e-mailul de verificare
Cheie secretă
diff --git a/auth/src/main/res/values-ru/strings.xml b/auth/src/main/res/values-ru/strings.xml
index c68934e03..fb602de07 100755
--- a/auth/src/main/res/values-ru/strings.xml
+++ b/auth/src/main/res/values-ru/strings.xml
@@ -115,7 +115,6 @@
Настроить SMS-подтверждение
Настроить приложение-аутентификатор
Подтвердите код
- Сохраните коды восстановления
Выберите второй способ аутентификации для защиты аккаунта
Введите номер телефона для получения кодов подтверждения
@@ -123,7 +122,6 @@
Введите код, отправленный на ваш телефон
Введите код из приложения-аутентификатора
Введите код подтверждения
- Сохраните эти коды в безопасном месте. Вы можете использовать их для входа, если потеряете доступ к способу аутентификации.
Подтвердите пароль
Пароли не совпадают
@@ -164,7 +162,6 @@
Требуется повторная аутентификация
Повторная аутентификация выполнена
Повторная аутентификация
- Я сохранил коды восстановления
Удалить
Отправить письмо подтверждения повторно
Секретный ключ
diff --git a/auth/src/main/res/values-sk/strings.xml b/auth/src/main/res/values-sk/strings.xml
index 52f1b1845..8bfd24669 100755
--- a/auth/src/main/res/values-sk/strings.xml
+++ b/auth/src/main/res/values-sk/strings.xml
@@ -115,7 +115,6 @@
Nastaviť overenie SMS
Nastaviť overovaciu aplikáciu
Overte svoj kód
- Uložte obnovovacie kódy
Vyberte druhú metódu overenia na zabezpečenie účtu
Zadajte telefónne číslo na príjem overovacích kódov
@@ -123,7 +122,6 @@
Zadajte kód odoslaný na váš telefón
Zadajte kód z overovacej aplikácie
Zadajte overovací kód
- Uložte tieto kódy na bezpečnom mieste. Môžete ich použiť na prihlásenie, ak stratíte prístup k metóde overenia.
Potvrdiť heslo
Heslá sa nezhodujú
@@ -164,7 +162,6 @@
Vyžaduje sa opätovné overenie
Opätovné overenie bolo úspešné
Znova overiť
- Uložil som si kódy na obnovenie
Odstrániť
Znova poslať overovací e-mail
Tajný kľúč
diff --git a/auth/src/main/res/values-sl/strings.xml b/auth/src/main/res/values-sl/strings.xml
index fc81a8e4a..42c9539fd 100755
--- a/auth/src/main/res/values-sl/strings.xml
+++ b/auth/src/main/res/values-sl/strings.xml
@@ -116,7 +116,6 @@
Nastavite preverjanje prek SMS
Nastavite aplikacijo za preverjanje pristnosti
Preverite svojo kodo
- Shranite kode za obnovitev
Izberite drug način preverjanja pristnosti za zaščito računa
Vnesite telefonsko številko za prejemanje kod za preverjanje
@@ -124,7 +123,6 @@
Vnesite kodo, poslano na vaš telefon
Vnesite kodo iz aplikacije za preverjanje pristnosti
Vnesite kodo za preverjanje
- Shranite te kode na varno mesto. Uporabite jih lahko za prijavo, če izgubite dostop do načina preverjanja pristnosti.
Potrdite geslo
Gesli se ne ujemata
@@ -165,7 +163,6 @@
Zahtevano je ponovno preverjanje pristnosti
Ponovno preverjanje pristnosti uspešno
Ponovno preveri pristnost
- Shranil sem kode za obnovitev
Odstrani
Znova pošlji e-sporočilo za preverjanje
Skrivni ključ
diff --git a/auth/src/main/res/values-sr/strings.xml b/auth/src/main/res/values-sr/strings.xml
index 24ecfc208..64aad2f2b 100755
--- a/auth/src/main/res/values-sr/strings.xml
+++ b/auth/src/main/res/values-sr/strings.xml
@@ -116,7 +116,6 @@
Подесите SMS верификацију
Подесите апликацију за аутентификацију
Верификујте свој код
- Сачувајте кодове за опоравак
Изаберите други метод аутентификације да бисте заштитили свој налог
Унесите свој број телефона да бисте примали кодове за верификацију
@@ -124,7 +123,6 @@
Унесите код послат на ваш телефон
Унесите код из своје апликације за аутентификацију
Унесите свој код за верификацију
- Похраните ове кодове на сигурно место. Можете их користити за пријаву ако изгубите приступ методу аутентификације.
Потврди лозинку
Лозинке се не подударају
@@ -165,7 +163,6 @@
Потребна је поновна аутентификација
Поновна аутентификација је успешна
Поновна аутентификација
- Сачувао сам кодове за опоравак
Уклони
Поново пошаљи имејл за верификацију
Тајни кључ
diff --git a/auth/src/main/res/values-sv/strings.xml b/auth/src/main/res/values-sv/strings.xml
index b32888d05..1b47289dc 100755
--- a/auth/src/main/res/values-sv/strings.xml
+++ b/auth/src/main/res/values-sv/strings.xml
@@ -115,7 +115,6 @@
Konfigurera SMS-verifiering
Konfigurera autentiseringsapp
Verifiera din kod
- Spara dina återställningskoder
Välj en andra autentiseringsmetod för att skydda ditt konto
Ange ditt telefonnummer för att ta emot verifieringskoder
@@ -123,7 +122,6 @@
Ange koden som skickades till din telefon
Ange koden från din autentiseringsapp
Ange din verifieringskod
- Förvara dessa koder på en säker plats. Du kan använda dem för att logga in om du förlorar åtkomst till din autentiseringsmetod.
Bekräfta lösenord
Lösenorden matchar inte
@@ -164,7 +162,6 @@
Omautentisering krävs
Omautentisering lyckades
Omautentisera
- Jag har sparat mina återställningskoder
Ta bort
Skicka verifieringsmail igen
Hemlig nyckel
diff --git a/auth/src/main/res/values-ta/strings.xml b/auth/src/main/res/values-ta/strings.xml
index c81b3054c..d3639e450 100755
--- a/auth/src/main/res/values-ta/strings.xml
+++ b/auth/src/main/res/values-ta/strings.xml
@@ -116,7 +116,6 @@
SMS சரிபார்ப்பை அமைக்கவும்
அங்கீகரிப்பு ஆப்ஸை அமைக்கவும்
உங்கள் குறியீட்டைச் சரிபார்க்கவும்
- உங்கள் மீட்டெடுப்பு குறியீடுகளைச் சேமிக்கவும்
உங்கள் கணக்கைப் பாதுகாக்க இரண்டாவது அங்கீகார முறையைத் தேர்ந்தெடுக்கவும்
சரிபார்ப்புக் குறியீடுகளைப் பெற உங்கள் தொலைபேசி எண்ணை உள்ளிடவும்
@@ -124,7 +123,6 @@
உங்கள் தொலைபேசிக்கு அனுப்பப்பட்ட குறியீட்டை உள்ளிடவும்
உங்கள் அங்கீகரிப்பு ஆப்ஸிலிருந்து குறியீட்டை உள்ளிடவும்
உங்கள் சரிபார்ப்புக் குறியீட்டை உள்ளிடவும்
- இந்தக் குறியீடுகளைப் பாதுகாப்பான இடத்தில் சேமிக்கவும். உங்கள் அங்கீகார முறைக்கான அணுகலை இழந்தால் உள்நுழைய இவற்றைப் பயன்படுத்தலாம்.
கடவுச்சொல்லை உறுதிப்படுத்தவும்
கடவுச்சொற்கள் பொருந்தவில்லை
@@ -165,7 +163,6 @@
மீண்டும் அங்கீகாரம் தேவை
மீண்டும் அங்கீகாரம் வெற்றிகரமாக
மீண்டும் அங்கீகரி
- எனது மீட்பு குறியீடுகளைச் சேமித்துள்ளேன்
அகற்று
சரிபார்ப்பு மின்னஞ்சலை மீண்டும் அனுப்பு
ரகசிய திறவுகோல்
diff --git a/auth/src/main/res/values-th/strings.xml b/auth/src/main/res/values-th/strings.xml
index ec9196f9f..c22f1215d 100755
--- a/auth/src/main/res/values-th/strings.xml
+++ b/auth/src/main/res/values-th/strings.xml
@@ -116,7 +116,6 @@
ตั้งค่าการยืนยัน SMS
ตั้งค่าแอปตรวจสอบสิทธิ์
ยืนยันรหัสของคุณ
- บันทึกรหัสกู้คืนของคุณ
เลือกวิธีการตรวจสอบสิทธิ์ที่สองเพื่อรักษาความปลอดภัยบัญชีของคุณ
ป้อนหมายเลขโทรศัพท์ของคุณเพื่อรับรหัสยืนยัน
@@ -124,7 +123,6 @@
ป้อนรหัสที่ส่งไปยังโทรศัพท์ของคุณ
ป้อนรหัสจากแอปตรวจสอบสิทธิ์ของคุณ
ป้อนรหัสยืนยันของคุณ
- เก็บรหัสเหล่านี้ไว้ในที่ปลอดภัย คุณสามารถใช้รหัสเหล่านี้ลงชื่อเข้าใช้หากคุณไม่สามารถเข้าถึงวิธีการตรวจสอบสิทธิ์ของคุณ
ยืนยันรหัสผ่าน
รหัสผ่านไม่ตรงกัน
@@ -165,7 +163,6 @@
ต้องการการยืนยันตัวตนอีกครั้ง
ยืนยันตัวตนอีกครั้งสำเร็จ
ยืนยันตัวตนอีกครั้ง
- ฉันได้บันทึกรหัสกู้คืนแล้ว
ลบ
ส่งอีเมลยืนยันอีกครั้ง
คีย์ลับ
diff --git a/auth/src/main/res/values-tl/strings.xml b/auth/src/main/res/values-tl/strings.xml
index ccd438d35..788890c3d 100755
--- a/auth/src/main/res/values-tl/strings.xml
+++ b/auth/src/main/res/values-tl/strings.xml
@@ -115,7 +115,6 @@
I-set Up ang SMS Verification
I-set Up ang Authenticator App
I-verify ang Iyong Code
- I-save ang Iyong Mga Recovery Code
Pumili ng pangalawang paraan ng authentication para protektahan ang iyong account
Ilagay ang iyong numero ng telepono para makatanggap ng mga verification code
@@ -123,7 +122,6 @@
Ilagay ang code na ipinadala sa iyong telepono
Ilagay ang code mula sa iyong authenticator app
Ilagay ang iyong verification code
- I-imbak ang mga code na ito sa ligtas na lugar. Magagamit mo ang mga ito para mag-sign in kung mawawala ang access sa iyong paraan ng authentication.
Kumpirmahin ang password
Hindi magkatugma ang mga password
@@ -164,7 +162,6 @@
Kinakailangan ang muling pag-authenticate
Matagumpay ang muling pag-authenticate
Mag-authenticate muli
- Na-save ko ang aking mga recovery code
Alisin
Ipadala muli ang verification email
Secret key
diff --git a/auth/src/main/res/values-tr/strings.xml b/auth/src/main/res/values-tr/strings.xml
index bbefc6917..1754a2c56 100755
--- a/auth/src/main/res/values-tr/strings.xml
+++ b/auth/src/main/res/values-tr/strings.xml
@@ -116,7 +116,6 @@
SMS Doğrulamasını Ayarlayın
Kimlik Doğrulayıcı Uygulamayı Ayarlayın
Kodunuzu Doğrulayın
- Kurtarma Kodlarınızı Kaydedin
Hesabınızı güvence altına almak için ikinci bir kimlik doğrulama yöntemi seçin
Doğrulama kodları almak için telefon numaranızı girin
@@ -124,7 +123,6 @@
Telefonunuza gönderilen kodu girin
Kimlik doğrulayıcı uygulamanızdaki kodu girin
Doğrulama kodunuzu girin
- Bu kodları güvenli bir yerde saklayın. Kimlik doğrulama yönteminize erişimi kaybederseniz oturum açmak için bunları kullanabilirsiniz.
Şifreyi onayla
Şifreler eşleşmiyor
@@ -165,7 +163,6 @@
Yeniden kimlik doğrulama gerekli
Yeniden kimlik doğrulama başarılı
Yeniden kimlik doğrula
- Kurtarma kodlarımı kaydettim
Kaldır
Doğrulama e-postasını tekrar gönder
Gizli anahtar
diff --git a/auth/src/main/res/values-uk/strings.xml b/auth/src/main/res/values-uk/strings.xml
index 1fad8a98d..81efe4b86 100755
--- a/auth/src/main/res/values-uk/strings.xml
+++ b/auth/src/main/res/values-uk/strings.xml
@@ -116,7 +116,6 @@
Налаштувати підтвердження через SMS
Налаштувати додаток автентифікації
Підтвердіть свій код
- Збережіть коди відновлення
Виберіть другий спосіб автентифікації для захисту облікового запису
Введіть номер телефону для отримання кодів підтвердження
@@ -124,7 +123,6 @@
Введіть код, надісланий на ваш телефон
Введіть код із додатка автентифікації
Введіть код підтвердження
- Збережіть ці коди в безпечному місці. Ви можете використовувати їх для входу, якщо втратите доступ до способу автентифікації.
Підтвердьте пароль
Паролі не збігаються
@@ -165,7 +163,6 @@
Потрібна повторна автентифікація
Повторна автентифікація виконана
Повторна автентифікація
- Я зберіг коди відновлення
Видалити
Надіслати лист підтвердження повторно
Секретний ключ
diff --git a/auth/src/main/res/values-ur/strings.xml b/auth/src/main/res/values-ur/strings.xml
index 1394f2c1f..29f7c91d9 100755
--- a/auth/src/main/res/values-ur/strings.xml
+++ b/auth/src/main/res/values-ur/strings.xml
@@ -116,7 +116,6 @@
SMS تصدیق سیٹ اپ کریں
تصدیقی ایپ سیٹ اپ کریں
اپنا کوڈ تصدیق کریں
- اپنے بازیافتی کوڈز محفوظ کریں
اپنے اکاؤنٹ کو محفوظ بنانے کے لیے دوسرا تصدیقی طریقہ منتخب کریں
تصدیقی کوڈز وصول کرنے کے لیے اپنا فون نمبر درج کریں
@@ -124,7 +123,6 @@
اپنے فون پر بھیجا گیا کوڈ درج کریں
اپنی تصدیقی ایپ سے کوڈ درج کریں
اپنا تصدیقی کوڈ درج کریں
- ان کوڈز کو محفوظ جگہ پر اسٹور کریں۔ اگر آپ اپنے تصدیقی طریقے تک رسائی کھو دیتے ہیں تو آپ سائن ان کرنے کے لیے ان کا استعمال کر سکتے ہیں۔
پاس ورڈ کی تصدیق کریں
پاس ورڈز مماثل نہیں ہیں
@@ -165,7 +163,6 @@
دوبارہ توثیق درکار ہے
دوبارہ توثیق کامیاب
دوبارہ توثیق کریں
- میں نے اپنے بحالی کے کوڈز محفوظ کر لیے ہیں
ہٹائیں
تصدیقی ای میل دوبارہ بھیجیں
خفیہ کلید
diff --git a/auth/src/main/res/values-vi/strings.xml b/auth/src/main/res/values-vi/strings.xml
index 53266567b..3286e38b0 100755
--- a/auth/src/main/res/values-vi/strings.xml
+++ b/auth/src/main/res/values-vi/strings.xml
@@ -116,7 +116,6 @@
Thiết lập xác minh qua SMS
Thiết lập ứng dụng xác thực
Xác minh mã của bạn
- Lưu mã khôi phục của bạn
Chọn phương thức xác thực thứ hai để bảo mật tài khoản của bạn
Nhập số điện thoại của bạn để nhận mã xác minh
@@ -124,7 +123,6 @@
Nhập mã được gửi đến điện thoại của bạn
Nhập mã từ ứng dụng xác thực của bạn
Nhập mã xác minh của bạn
- Lưu trữ các mã này ở nơi an toàn. Bạn có thể sử dụng chúng để đăng nhập nếu mất quyền truy cập vào phương thức xác thực của mình.
Xác nhận mật khẩu
Mật khẩu không khớp
@@ -165,7 +163,6 @@
Yêu cầu xác thực lại
Xác thực lại thành công
Xác thực lại
- Tôi đã lưu mã khôi phục của mình
Xóa
Gửi lại email xác minh
Khóa bí mật
diff --git a/auth/src/main/res/values-zh-rCN/strings.xml b/auth/src/main/res/values-zh-rCN/strings.xml
index 61721caa5..a86e1b893 100755
--- a/auth/src/main/res/values-zh-rCN/strings.xml
+++ b/auth/src/main/res/values-zh-rCN/strings.xml
@@ -116,7 +116,6 @@
设置短信验证
设置身份验证器应用
验证代码
- 保存恢复代码
选择第二种身份验证方法以保护您的帐号
输入您的电话号码以接收验证码
@@ -124,7 +123,6 @@
输入发送到您手机的验证码
输入身份验证器应用中的验证码
输入您的验证码
- 将这些代码保存在安全的地方。如果您无法访问身份验证方法,可以使用这些代码登录。
确认密码
密码不匹配
@@ -165,7 +163,6 @@
需要重新验证
重新验证成功
重新验证
- 我已保存恢复代码
移除
重新发送验证邮件
密钥
diff --git a/auth/src/main/res/values-zh-rHK/strings.xml b/auth/src/main/res/values-zh-rHK/strings.xml
index 3cd99ad3b..9dd8ab580 100755
--- a/auth/src/main/res/values-zh-rHK/strings.xml
+++ b/auth/src/main/res/values-zh-rHK/strings.xml
@@ -116,7 +116,6 @@
設定短訊驗證
設定驗證器應用程式
驗證代碼
- 儲存復原代碼
選擇第二種驗證方法以保護您的帳戶
輸入您的電話號碼以接收驗證碼
@@ -124,7 +123,6 @@
輸入傳送至您手機的驗證碼
輸入驗證器應用程式中的驗證碼
輸入您的驗證碼
- 將這些代碼儲存在安全的地方。如果您無法存取驗證方法,可以使用這些代碼登入。
確認密碼
密碼不符
@@ -165,7 +163,6 @@
需要重新驗證
重新驗證成功
重新驗證
- 我已儲存復原碼
移除
重新發送驗證電郵
密鑰
diff --git a/auth/src/main/res/values-zh-rTW/strings.xml b/auth/src/main/res/values-zh-rTW/strings.xml
index fd247fb40..125473407 100755
--- a/auth/src/main/res/values-zh-rTW/strings.xml
+++ b/auth/src/main/res/values-zh-rTW/strings.xml
@@ -116,7 +116,6 @@
設定簡訊驗證
設定驗證器應用程式
驗證代碼
- 儲存復原代碼
選擇第二種驗證方法以保護您的帳戶
輸入您的電話號碼以接收驗證碼
@@ -124,7 +123,6 @@
輸入傳送至您手機的驗證碼
輸入驗證器應用程式中的驗證碼
輸入您的驗證碼
- 將這些代碼儲存在安全的地方。如果您無法存取驗證方法,可以使用這些代碼登入。
確認密碼
密碼不符
@@ -165,7 +163,6 @@
需要重新驗證
重新驗證成功
重新驗證
- 我已儲存復原代碼
移除
重新傳送驗證郵件
密鑰
diff --git a/auth/src/main/res/values-zh/strings.xml b/auth/src/main/res/values-zh/strings.xml
index a1f514f8f..d22819d4e 100755
--- a/auth/src/main/res/values-zh/strings.xml
+++ b/auth/src/main/res/values-zh/strings.xml
@@ -115,7 +115,6 @@
设置短信验证
设置身份验证器应用
验证代码
- 保存恢复代码
选择第二种身份验证方法以保护您的帐号
输入您的电话号码以接收验证码
@@ -123,7 +122,6 @@
输入发送到您手机的验证码
输入身份验证器应用中的验证码
输入您的验证码
- 将这些代码保存在安全的地方。如果您无法访问身份验证方法,可以使用这些代码登录。
确认密码
密码不匹配
@@ -164,7 +162,6 @@
需要重新验证
重新验证成功
重新验证
- 我已保存恢复代码
移除
重新发送验证邮件
密钥
diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml
index 6217412de..5b2ef2917 100644
--- a/auth/src/main/res/values/strings.xml
+++ b/auth/src/main/res/values/strings.xml
@@ -72,7 +72,6 @@
Back
Verify
Use a different method
- I\'ve saved these codes
Secret key
Verification code
Identity verified. Please try your action again.
@@ -179,6 +178,14 @@
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.
+ 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.
@@ -264,7 +271,6 @@
Set Up SMS Verification
Set Up Authenticator App
Verify Your Code
- Save Your Recovery Codes
Select a second authentication method to secure your account
Enter your phone number to receive verification codes
@@ -272,7 +278,6 @@
Enter the code sent to your phone
Enter the code from your authenticator app
Enter your verification code
- Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method.
Set Up SMS Authentication
Verify SMS Code
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..22738bd28 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 Reauthentication.Required: the combine keeps preferring it, so the reauth UI
+ * stays up over a signed-out session and every provider fails with an untranslated "no user".
+ */
+ @Test
+ fun `authStateFlow() clears an armed Reauthentication Required when the user signs out`() =
+ 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.Reauthentication.Required(mockFirebaseUser, reason = "Confirm it is you")
+ )
+ delay(100)
+ assertThat(states.last())
+ .isInstanceOf(AuthState.Reauthentication.Required::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
@@ -435,11 +472,11 @@ class FirebaseAuthUIAuthStateTest {
}
// =============================================================================================
- // delete() ReauthenticationRequired state Tests
+ // delete() Reauthentication.Required state Tests
// =============================================================================================
@Test
- fun `delete() emits ReauthenticationRequired state when recent login required`() = runTest {
+ fun `delete() emits Reauthentication Required state when recent login required`() = runTest {
val mockUser = mock(FirebaseUser::class.java)
val tcs = TaskCompletionSource()
tcs.setException(
@@ -458,13 +495,13 @@ class FirebaseAuthUIAuthStateTest {
// expected — existing contract preserved
}
- assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.ReauthenticationRequired::class.java)
- val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired
+ assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.Reauthentication.Required::class.java)
+ val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required
assertThat(state.user).isEqualTo(mockUser)
}
@Test
- fun `delete() attaches retryOperation to ReauthenticationRequired state`() = runTest {
+ fun `delete() attaches retryOperation to Reauthentication Required state`() = runTest {
val mockUser = mock(FirebaseUser::class.java)
val tcs = TaskCompletionSource()
tcs.setException(
@@ -478,11 +515,177 @@ class FirebaseAuthUIAuthStateTest {
val context = ApplicationProvider.getApplicationContext()
try { authUI.delete(context) } catch (_: AuthException.InvalidCredentialsException) {}
- val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired
+ val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required
// Fails until delete() passes retryOperation into the state
assertThat(state.retryOperation).isNotNull()
}
+ @Test
+ fun `reauthentication provider states retain the same request`() = runTest {
+ `when`(mockFirebaseUser.uid).thenReturn("uid-reauth")
+ // Stands in for the composed FirebaseAuthScreen that folding is scoped to.
+ authUI.addReauthenticationDrainer()
+ val required = AuthState.Reauthentication.Required(mockFirebaseUser)
+ authUI.updateAuthState(required)
+
+ authUI.updateAuthState(AuthState.Loading("Signing in"))
+ val authenticating = authUI.authStateFlow().first()
+ assertThat(authenticating)
+ .isInstanceOf(AuthState.Reauthentication.Authenticating::class.java)
+ assertThat((authenticating as AuthState.Reauthentication).requestId)
+ .isEqualTo(required.requestId)
+
+ authUI.updateAuthState(AuthState.Error(IllegalArgumentException("wrong password")))
+ val failed = authUI.authStateFlow().first()
+ assertThat(failed)
+ .isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java)
+ assertThat((failed as AuthState.Reauthentication).requestId)
+ .isEqualTo(required.requestId)
+
+ authUI.updateAuthState(AuthState.Cancelled)
+ val resumed = authUI.authStateFlow().first()
+ assertThat(resumed).isInstanceOf(AuthState.Reauthentication.Required::class.java)
+ assertThat((resumed as AuthState.Reauthentication.Required).requestId)
+ .isEqualTo(required.requestId)
+ }
+
+ @Test
+ fun `reauthentication email notifications retain the request until consumed`() = runTest {
+ `when`(mockFirebaseUser.uid).thenReturn("uid-reauth")
+ authUI.addReauthenticationDrainer()
+ val required = AuthState.Reauthentication.Required(mockFirebaseUser)
+ authUI.updateAuthState(required)
+
+ authUI.updateAuthState(AuthState.PasswordResetLinkSent())
+ val notification = authUI.authStateFlow().first()
+ assertThat(notification)
+ .isInstanceOf(AuthState.Reauthentication.PasswordResetLinkSent::class.java)
+ assertThat((notification as AuthState.Reauthentication).requestId)
+ .isEqualTo(required.requestId)
+
+ authUI.updateReauthentication(required.requestId) { it.returnedToProviderSelection() }
+ val resumed = authUI.authStateFlow().first()
+ assertThat(resumed).isInstanceOf(AuthState.Reauthentication.Required::class.java)
+ assertThat((resumed as AuthState.Reauthentication.Required).requestId)
+ .isEqualTo(required.requestId)
+ }
+
+ @Test
+ fun `updateReauthentication ignores a stale requestId`() = runTest {
+ `when`(mockFirebaseUser.uid).thenReturn("uid-reauth")
+ val required = AuthState.Reauthentication.Required(mockFirebaseUser)
+ authUI.updateAuthState(required)
+
+ authUI.updateReauthentication("stale-request-id") { it.attemptStarted() }
+
+ val unchanged = authUI.authStateFlow().first()
+ assertThat(unchanged).isInstanceOf(AuthState.Reauthentication.Required::class.java)
+ assertThat((unchanged as AuthState.Reauthentication.Required).requestId)
+ .isEqualTo(required.requestId)
+ }
+
+ @Test
+ fun `attemptCancelled does not rewind a surfaced attempt failure`() = runTest {
+ `when`(mockFirebaseUser.uid).thenReturn("uid-reauth")
+ authUI.addReauthenticationDrainer()
+ val required = AuthState.Reauthentication.Required(mockFirebaseUser)
+ authUI.updateAuthState(required)
+ authUI.updateAuthState(AuthState.Error(IllegalArgumentException("wrong password")))
+ assertThat(authUI.authStateFlow().first())
+ .isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java)
+
+ authUI.updateReauthentication(required.requestId) { it.attemptCancelled() }
+
+ val unchanged = authUI.authStateFlow().first()
+ assertThat(unchanged).isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java)
+ assertThat((unchanged as AuthState.Reauthentication).requestId)
+ .isEqualTo(required.requestId)
+ }
+
+ /**
+ * The phone sub-flow's "Change number" returns to provider selection, and by then a wrong SMS
+ * code has latched a failure. Clearing it there would erase the only report the user gets.
+ */
+ @Test
+ fun `returnedToProviderSelection does not wipe a surfaced attempt failure`() = runTest {
+ `when`(mockFirebaseUser.uid).thenReturn("uid-reauth")
+ authUI.addReauthenticationDrainer()
+ val required = AuthState.Reauthentication.Required(mockFirebaseUser)
+ authUI.updateAuthState(required)
+ authUI.updateAuthState(AuthState.Error(IllegalArgumentException("wrong sms code")))
+ assertThat(authUI.authStateFlow().first())
+ .isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java)
+
+ authUI.updateReauthentication(required.requestId) { it.returnedToProviderSelection() }
+
+ val unchanged = authUI.authStateFlow().first()
+ assertThat(unchanged).isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java)
+ assertThat((unchanged as AuthState.Reauthentication).requestId)
+ .isEqualTo(required.requestId)
+ }
+
+ /** Credentials were already accepted, so a stray attempt must not rewind the retry phase. */
+ @Test
+ fun `attemptStarted does not rewind a retry in flight`() = runTest {
+ `when`(mockFirebaseUser.uid).thenReturn("uid-reauth")
+ authUI.addReauthenticationDrainer()
+ val required = AuthState.Reauthentication.Required(mockFirebaseUser)
+ authUI.updateAuthState(required)
+ authUI.updateAuthState(AuthState.Reauthentication.RetryingOperation(required.request))
+
+ authUI.updateReauthentication(required.requestId) { it.attemptStarted() }
+
+ assertThat(authUI.authStateFlow().first())
+ .isInstanceOf(AuthState.Reauthentication.RetryingOperation::class.java)
+ }
+
+ /**
+ * `withReauth`/`delete` are public and arm a request with no [FirebaseAuthScreen] composed —
+ * the caller catches the exception and shows its own UI. Nothing can then drain the request,
+ * so folding must not apply: the app's own collector has to keep seeing ordinary states.
+ */
+ @Test
+ fun `a Success reaches collectors while an undrainable request is armed`() = runTest {
+ `when`(mockFirebaseUser.uid).thenReturn("uid-reauth")
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser)
+ authUI.updateAuthState(AuthState.Reauthentication.Required(mockFirebaseUser))
+ assertThat(authUI.authStateFlow().first())
+ .isInstanceOf(AuthState.Reauthentication.Required::class.java)
+
+ authUI.updateAuthState(AuthState.Success(result = null, user = mockFirebaseUser))
+
+ val observed = authUI.authStateFlow().first()
+ assertThat(observed).isInstanceOf(AuthState.Success::class.java)
+ assertThat(observed).isNotInstanceOf(AuthState.Reauthentication::class.java)
+ }
+
+ /** The same escape for Idle: an undrainable arming is replaced, not made permanent. */
+ @Test
+ fun `an Idle write clears an undrainable armed request`() = runTest {
+ `when`(mockFirebaseUser.uid).thenReturn("uid-reauth")
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser)
+ authUI.updateAuthState(AuthState.Reauthentication.Required(mockFirebaseUser))
+
+ authUI.updateAuthState(AuthState.Idle)
+
+ assertThat(authUI.authStateFlow().first())
+ .isNotInstanceOf(AuthState.Reauthentication::class.java)
+ }
+
+ @Test
+ fun `operationFinished only applies while a retry is in flight`() = runTest {
+ `when`(mockFirebaseUser.uid).thenReturn("uid-reauth")
+ val required = AuthState.Reauthentication.Required(mockFirebaseUser)
+ authUI.updateAuthState(required)
+
+ authUI.updateReauthentication(required.requestId) {
+ it.operationFinished(AuthState.Success(result = null, user = mockFirebaseUser))
+ }
+
+ assertThat(authUI.authStateFlow().first())
+ .isInstanceOf(AuthState.Reauthentication.Required::class.java)
+ }
+
// =============================================================================================
// withReauth() Tests
// =============================================================================================
@@ -499,7 +702,7 @@ class FirebaseAuthUIAuthStateTest {
}
@Test
- fun `withReauth() emits ReauthenticationRequired when FirebaseAuthRecentLoginRequiredException thrown`() = runTest {
+ fun `withReauth() emits Reauthentication Required when FirebaseAuthRecentLoginRequiredException thrown`() = runTest {
val context = ApplicationProvider.getApplicationContext()
`when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser)
@@ -507,13 +710,13 @@ class FirebaseAuthUIAuthStateTest {
throw FirebaseAuthRecentLoginRequiredException("ERROR_REQUIRES_RECENT_LOGIN", "Recent login required")
}
- assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.ReauthenticationRequired::class.java)
- val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired
+ assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.Reauthentication.Required::class.java)
+ val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required
assertThat(state.user).isEqualTo(mockFirebaseUser)
}
@Test
- fun `withReauth() forwards reason to ReauthenticationRequired state`() = runTest {
+ fun `withReauth() forwards reason to Reauthentication Required state`() = runTest {
val context = ApplicationProvider.getApplicationContext()
`when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser)
@@ -521,7 +724,7 @@ class FirebaseAuthUIAuthStateTest {
throw FirebaseAuthRecentLoginRequiredException("ERROR_REQUIRES_RECENT_LOGIN", "Recent login required")
}
- val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired
+ val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required
assertThat(state.reason).isEqualTo("Verify identity to change email")
}
@@ -538,7 +741,7 @@ class FirebaseAuthUIAuthStateTest {
)
}
- val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired
+ val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required
assertThat(state.retryOperation).isNotNull()
state.retryOperation!!(context)
assertThat(callCount).isEqualTo(2)
@@ -547,7 +750,9 @@ class FirebaseAuthUIAuthStateTest {
@Test
fun `withReauth() retryOperation restores auth state after successful retry`() = runTest {
val context = ApplicationProvider.getApplicationContext()
+ `when`(mockFirebaseUser.uid).thenReturn("uid-reauth")
`when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser)
+ authUI.addReauthenticationDrainer()
var callCount = 0
authUI.withReauth(context) {
@@ -557,16 +762,37 @@ class FirebaseAuthUIAuthStateTest {
)
}
- val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired
+ val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required
- // Simulate FirebaseAuthScreen: set Loading, then invoke the retry
- authUI.updateAuthState(AuthState.Loading())
- state.retryOperation!!(context)
+ // Reach the retry phase through the uid-gated credential success, not by hand: a Success
+ // stamped for this request's user is the only thing that may unlock the operation.
+ authUI.updateAuthState(
+ AuthState.Success(
+ result = null,
+ user = mockFirebaseUser,
+ reauthenticatedUid = mockFirebaseUser.uid,
+ )
+ )
+ val succeeded = authUI.authStateFlow().first()
+ assertThat(succeeded).isInstanceOf(AuthState.Reauthentication.Succeeded::class.java)
+ val request = (succeeded as AuthState.Reauthentication.Succeeded).request
+ assertThat(request.requestId).isEqualTo(state.requestId)
+
+ // What FirebaseAuthScreen does next: claim the operation once, then run it.
+ authUI.updateAuthState(AuthState.Reauthentication.RetryingOperation(request))
+ val retry = requireNotNull(request.claimRetryOperation())
+ retry(context)
+ assertThat(callCount).isEqualTo(2)
+ // Claimed for good: a second entry into the retry phase has nothing left to run.
+ assertThat(request.claimRetryOperation()).isNull()
- // Auth state must not be stuck on Loading — withReauth owns the state lifecycle
+ // The retry outcome remains attached to the request until the screen consumes it.
val authState = authUI.authStateFlow().first()
- assertThat(authState).isNotInstanceOf(AuthState.Loading::class.java)
- assertThat(authState).isInstanceOf(AuthState.Success::class.java)
+ assertThat(authState)
+ .isInstanceOf(AuthState.Reauthentication.OperationFinished::class.java)
+ val finished = authState as AuthState.Reauthentication.OperationFinished
+ assertThat(finished.requestId).isEqualTo(state.requestId)
+ assertThat(finished.outcome).isInstanceOf(AuthState.Success::class.java)
}
@Test
@@ -617,10 +843,10 @@ class FirebaseAuthUIAuthStateTest {
val context = ApplicationProvider.getApplicationContext()
try { authUI.delete(context) } catch (_: AuthException.InvalidCredentialsException) {}
- val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired
+ val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required
// Fails until delete() passes retryOperation into the state
state.retryOperation!!(context)
verify(mockUser, times(2)).delete()
}
-}
\ No newline at end of file
+}
diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt
index 51e9cca91..0bcfdecbd 100644
--- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt
@@ -794,6 +794,43 @@ class FirebaseAuthUITest {
assertThat(controller.configuration.isReauthenticationMode).isTrue()
}
+ /**
+ * Defence in depth alongside the `canLinkCredential` / `canUpgradeAnonymous` guards: forcing
+ * both flags off makes the reauthentication config self-describing, so nothing reading the
+ * configuration alone can conclude that linking a credential is allowed here.
+ */
+ @Test
+ fun `createReauthFlow resulting config forces credential linking and anonymous upgrade off`() {
+ val mockUser = mock(FirebaseUser::class.java)
+ val info = mock(UserInfo::class.java)
+ `when`(info.providerId).thenReturn("password")
+ `when`(mockUser.providerData).thenReturn(listOf(info))
+ val mockAuth = mock(FirebaseAuth::class.java)
+ `when`(mockAuth.currentUser).thenReturn(mockUser)
+ val authUI = FirebaseAuthUI.create(defaultApp, mockAuth)
+
+ val config = authUIConfiguration {
+ this.context = ApplicationProvider.getApplicationContext()
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ isAnonymousUpgradeEnabled = true
+ isCredentialLinkingEnabled = true
+ }
+ assertThat(config.isAnonymousUpgradeEnabled).isTrue()
+ assertThat(config.isCredentialLinkingEnabled).isTrue()
+
+ val controller = authUI.createReauthFlow(config)
+
+ assertThat(controller.configuration.isAnonymousUpgradeEnabled).isFalse()
+ assertThat(controller.configuration.isCredentialLinkingEnabled).isFalse()
+ }
+
@Test
fun `canHandleIntent returns true when auth validates email link`() {
diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/MfaConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/MfaConfigurationTest.kt
index 44802fc29..5cf78796d 100644
--- a/auth/src/test/java/com/firebase/ui/auth/configuration/MfaConfigurationTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/configuration/MfaConfigurationTest.kt
@@ -40,7 +40,6 @@ class MfaConfigurationTest {
assertThat(config.allowedFactors).containsExactly(MfaFactor.Sms, MfaFactor.Totp)
assertThat(config.requireEnrollment).isFalse()
- assertThat(config.enableRecoveryCodes).isTrue()
}
@Test
@@ -86,25 +85,14 @@ class MfaConfigurationTest {
}
@Test
- fun `MfaConfiguration with enableRecoveryCodes disabled`() {
- val config = MfaConfiguration(
- enableRecoveryCodes = false
- )
-
- assertThat(config.enableRecoveryCodes).isFalse()
- }
-
- @Test
- fun `MfaConfiguration with all custom values`() {
+ fun `MfaConfiguration with both parameters customized`() {
val config = MfaConfiguration(
allowedFactors = listOf(MfaFactor.Sms),
- requireEnrollment = true,
- enableRecoveryCodes = false
+ requireEnrollment = true
)
assertThat(config.allowedFactors).containsExactly(MfaFactor.Sms)
assertThat(config.requireEnrollment).isTrue()
- assertThat(config.enableRecoveryCodes).isFalse()
}
// =============================================================================================
diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt
index 747e03ff2..e021b85af 100644
--- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt
@@ -3,7 +3,10 @@ package com.firebase.ui.auth.configuration.auth_provider
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.firebase.ui.auth.R
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.authUIConfiguration
import com.google.common.truth.Truth.assertThat
+import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.UserInfo
import com.google.firebase.auth.actionCodeSettings
@@ -480,6 +483,65 @@ class AuthProviderTest {
assertThat(result.map { it.providerId }).containsExactly("password")
}
+ // =============================================================================================
+ // Reauthentication guards
+ // =============================================================================================
+
+ private fun anonymousUpgradeAuth(): FirebaseAuth {
+ val anonymousUser = mock(FirebaseUser::class.java)
+ `when`(anonymousUser.isAnonymous).thenReturn(true)
+ return mock(FirebaseAuth::class.java).also { `when`(it.currentUser).thenReturn(anonymousUser) }
+ }
+
+ private fun upgradeEnabledConfig(): AuthUIConfiguration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ isAnonymousUpgradeEnabled = true
+ isCredentialLinkingEnabled = true
+ }
+
+ /**
+ * `canUpgradeAnonymous` decides whether provider code calls `linkWithCredential` instead of
+ * `reauthenticate`, and OAuth tests it *before* `isReauthenticationMode`. Linking is not a
+ * proof of identity, so an upgrade taken in reauthentication mode would be stamped with
+ * `reauthenticatedUid` and forged into a reauthentication proof — the same hole
+ * `canLinkCredential` already closes for the non-anonymous case.
+ */
+ @Test
+ fun `canUpgradeAnonymous is false in reauthentication mode`() {
+ val auth = anonymousUpgradeAuth()
+ val config = upgradeEnabledConfig()
+
+ // Control: outside reauthentication an enabled upgrade is still taken.
+ assertThat(AuthProvider.canUpgradeAnonymous(config, auth)).isTrue()
+
+ assertThat(
+ AuthProvider.canUpgradeAnonymous(config.copy(isReauthenticationMode = true), auth)
+ ).isFalse()
+ }
+
+ /** The sibling guard, pinned alongside so the pair cannot drift apart again. */
+ @Test
+ fun `canLinkCredential is false in reauthentication mode`() {
+ val nonAnonymousUser = mock(FirebaseUser::class.java)
+ `when`(nonAnonymousUser.isAnonymous).thenReturn(false)
+ val auth = mock(FirebaseAuth::class.java)
+ `when`(auth.currentUser).thenReturn(nonAnonymousUser)
+ val config = upgradeEnabledConfig()
+
+ assertThat(AuthProvider.canLinkCredential(config, auth)).isTrue()
+ assertThat(
+ AuthProvider.canLinkCredential(config.copy(isReauthenticationMode = true), auth)
+ ).isFalse()
+ }
+
@Test
fun `generic oauth provider with blank button label should throw`() {
val provider = AuthProvider.GenericOAuth(
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/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt
index 18b7a7c22..df78205cb 100644
--- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt
@@ -411,23 +411,6 @@ class PhoneAuthProviderFirebaseAuthUITest {
.isNotInstanceOf(AuthState.Error::class.java)
}
- @Test
- fun `verifyPhoneNumber - cancellation clears the pending Loading state`() = runTest {
- val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
- val deferred = startNeverResolvingVerifyPhoneNumber(instance)
-
- deferred.cancel()
- try {
- deferred.await()
- } catch (_: CancellationException) {
- // Expected
- }
-
- val state = instance.authStateFlow().first()
- assertThat(state).isNotInstanceOf(AuthState.Loading::class.java)
- assertThat(state).isInstanceOf(AuthState.Idle::class.java)
- }
-
@Test
fun `verifyPhoneNumber - cancellation does not clobber a newer unrelated state`() = runTest {
val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
@@ -470,32 +453,6 @@ class PhoneAuthProviderFirebaseAuthUITest {
resend.cancel()
}
- @Test
- fun `clearLoadingState - equal but distinct Loading instances do not clear each other`() =
- runTest {
- val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
- val first = AuthState.Loading("verifying")
- val second = AuthState.Loading("verifying")
- assertThat(first).isEqualTo(second)
-
- instance.updateAuthState(first)
- val firstRevision = instance.currentAuthStateRevision()
- instance.updateAuthState(second)
- instance.clearLoadingState(firstRevision)
-
- assertThat(instance.authStateFlow().first()).isInstanceOf(AuthState.Loading::class.java)
- }
-
- @Test
- fun `clearLoadingState - clears when its Loading is still the latest state`() = runTest {
- val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
- instance.updateAuthState(AuthState.Loading("verifying"))
-
- instance.clearLoadingState(instance.currentAuthStateRevision())
-
- assertThat(instance.authStateFlow().first()).isInstanceOf(AuthState.Idle::class.java)
- }
-
// Starts verifyPhoneNumber against a flow that never emits, UNDISPATCHED so the call reaches
// its suspension point (past the Loading emission) before the caller can cancel it.
private fun CoroutineScope.startNeverResolvingVerifyPhoneNumber(
diff --git a/auth/src/test/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentStateTest.kt
index 739558cd6..3be018fc8 100644
--- a/auth/src/test/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentStateTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentStateTest.kt
@@ -130,30 +130,6 @@ class MfaEnrollmentContentStateTest {
assertFalse(state.isValid)
}
- @Test
- fun `isValid returns true for ShowRecoveryCodes with codes`() {
- // Given
- val state = MfaEnrollmentContentState(
- step = MfaEnrollmentStep.ShowRecoveryCodes,
- recoveryCodes = listOf("code1", "code2", "code3")
- )
-
- // When & Then
- assertTrue(state.isValid)
- }
-
- @Test
- fun `isValid returns false for ShowRecoveryCodes without codes`() {
- // Given
- val state = MfaEnrollmentContentState(
- step = MfaEnrollmentStep.ShowRecoveryCodes,
- recoveryCodes = null
- )
-
- // When & Then
- assertFalse(state.isValid)
- }
-
@Test
fun `hasError returns true when error is present`() {
// Given
@@ -243,8 +219,7 @@ class MfaEnrollmentContentStateTest {
val steps = listOf(
MfaEnrollmentStep.ConfigureSms,
MfaEnrollmentStep.ConfigureTotp,
- MfaEnrollmentStep.VerifyFactor,
- MfaEnrollmentStep.ShowRecoveryCodes
+ MfaEnrollmentStep.VerifyFactor
)
// When & Then
@@ -271,7 +246,6 @@ class MfaEnrollmentContentStateTest {
totpQrCodeUrl = "otpauth://totp/test",
verificationCode = "123456",
selectedFactor = MfaFactor.Totp,
- recoveryCodes = listOf("code1", "code2"),
availableFactors = listOf(MfaFactor.Sms, MfaFactor.Totp)
)
@@ -285,7 +259,6 @@ class MfaEnrollmentContentStateTest {
assertEquals("otpauth://totp/test", state.totpQrCodeUrl)
assertEquals("123456", state.verificationCode)
assertEquals(MfaFactor.Totp, state.selectedFactor)
- assertEquals(listOf("code1", "code2"), state.recoveryCodes)
assertEquals(listOf(MfaFactor.Sms, MfaFactor.Totp), state.availableFactors)
}
}
diff --git a/auth/src/test/java/com/firebase/ui/auth/mfa/MfaEnrollmentStepTest.kt b/auth/src/test/java/com/firebase/ui/auth/mfa/MfaEnrollmentStepTest.kt
index a3cc7e469..df3d6b5f3 100644
--- a/auth/src/test/java/com/firebase/ui/auth/mfa/MfaEnrollmentStepTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/mfa/MfaEnrollmentStepTest.kt
@@ -19,7 +19,6 @@ import androidx.test.core.app.ApplicationProvider
import com.firebase.ui.auth.configuration.MfaFactor
import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -42,12 +41,11 @@ class MfaEnrollmentStepTest {
fun `enum has all expected values`() {
val values = MfaEnrollmentStep.entries.toTypedArray()
- assertEquals(5, values.size)
+ assertEquals(4, values.size)
assertEquals(MfaEnrollmentStep.SelectFactor, values[0])
assertEquals(MfaEnrollmentStep.ConfigureSms, values[1])
assertEquals(MfaEnrollmentStep.ConfigureTotp, values[2])
assertEquals(MfaEnrollmentStep.VerifyFactor, values[3])
- assertEquals(MfaEnrollmentStep.ShowRecoveryCodes, values[4])
}
@Test
@@ -56,7 +54,6 @@ class MfaEnrollmentStepTest {
assertEquals(MfaEnrollmentStep.ConfigureSms, MfaEnrollmentStep.valueOf("ConfigureSms"))
assertEquals(MfaEnrollmentStep.ConfigureTotp, MfaEnrollmentStep.valueOf("ConfigureTotp"))
assertEquals(MfaEnrollmentStep.VerifyFactor, MfaEnrollmentStep.valueOf("VerifyFactor"))
- assertEquals(MfaEnrollmentStep.ShowRecoveryCodes, MfaEnrollmentStep.valueOf("ShowRecoveryCodes"))
}
@Test
@@ -65,7 +62,6 @@ class MfaEnrollmentStepTest {
assertEquals(1, MfaEnrollmentStep.ConfigureSms.ordinal)
assertEquals(2, MfaEnrollmentStep.ConfigureTotp.ordinal)
assertEquals(3, MfaEnrollmentStep.VerifyFactor.ordinal)
- assertEquals(4, MfaEnrollmentStep.ShowRecoveryCodes.ordinal)
}
@Test
@@ -74,7 +70,6 @@ class MfaEnrollmentStepTest {
assertEquals("Set Up SMS Verification", MfaEnrollmentStep.ConfigureSms.getTitle(stringProvider))
assertEquals("Set Up Authenticator App", MfaEnrollmentStep.ConfigureTotp.getTitle(stringProvider))
assertEquals("Verify Your Code", MfaEnrollmentStep.VerifyFactor.getTitle(stringProvider))
- assertEquals("Save Your Recovery Codes", MfaEnrollmentStep.ShowRecoveryCodes.getTitle(stringProvider))
}
@Test
@@ -133,12 +128,6 @@ class MfaEnrollmentStepTest {
)
}
- @Test
- fun `getHelperText returns correct text for ShowRecoveryCodes`() {
- val helperText = MfaEnrollmentStep.ShowRecoveryCodes.getHelperText(stringProvider)
- assertTrue(helperText.contains("Store these codes in a safe place"))
- }
-
@Test
fun `getHelperText ignores factor parameter for non-VerifyFactor steps`() {
// These should return the same result regardless of the factor parameter
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenMfaSuccessTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenMfaSuccessTest.kt
new file mode 100644
index 000000000..5e6a0f5d7
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenMfaSuccessTest.kt
@@ -0,0 +1,209 @@
+package com.firebase.ui.auth.ui.screens
+
+import androidx.compose.material3.Text
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.test.core.app.ApplicationProvider
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.mfa.MfaChallengeContentState
+import com.google.android.gms.tasks.Tasks
+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.FirebaseUser
+import com.google.firebase.auth.MultiFactorInfo
+import com.google.firebase.auth.MultiFactorResolver
+import com.google.firebase.auth.MultiFactorSession
+import com.google.firebase.auth.TotpMultiFactorInfo
+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.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.MockitoAnnotations
+import org.mockito.kotlin.any
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Regression tests for the MFA challenge success path in [FirebaseAuthScreen].
+ *
+ * A successful challenge used to clear `pendingResolver` while the route was still composed,
+ * which emptied the back stack and left a blank screen, and it reset the auth state to
+ * [AuthState.Idle], which discarded the resolved [AuthResult] so `onSignInSuccess` never fired.
+ *
+ * The emulator cannot perform a real MFA resolve, so the resolver is mocked - the same approach
+ * the `:e2eTest` MFA tests take.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE, sdk = [34])
+class FirebaseAuthScreenMfaSuccessTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Mock
+ private lateinit var mockFirebaseAuth: FirebaseAuth
+
+ @Mock
+ private lateinit var mockResolver: MultiFactorResolver
+
+ @Mock
+ private lateinit var mockSession: MultiFactorSession
+
+ @Mock
+ private lateinit var mockTotpHint: TotpMultiFactorInfo
+
+ @Mock
+ private lateinit var mockAuthResult: AuthResult
+
+ @Mock
+ private lateinit var mockUser: FirebaseUser
+
+ @Mock
+ private lateinit var mockPasswordProvider: UserInfo
+
+ private lateinit var authUI: FirebaseAuthUI
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.openMocks(this)
+
+ FirebaseAuthUI.clearInstanceCache()
+
+ val context = ApplicationProvider.getApplicationContext()
+ FirebaseApp.getApps(context).forEach { app ->
+ app.delete()
+ }
+
+ val defaultApp = FirebaseApp.initializeApp(
+ context,
+ FirebaseOptions.Builder()
+ .setApiKey("fake-api-key")
+ .setApplicationId("fake-app-id")
+ .setProjectId("fake-project-id")
+ .build()
+ )!!
+
+ `when`(mockFirebaseAuth.app).thenReturn(defaultApp)
+
+ authUI = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth)
+
+ `when`(mockResolver.session).thenReturn(mockSession)
+ `when`(mockResolver.hints).thenReturn(listOf(mockTotpHint))
+ `when`(mockTotpHint.factorId).thenReturn("totp")
+ `when`(mockTotpHint.uid).thenReturn("totp-factor-uid")
+ `when`(mockResolver.resolveSignIn(any())).thenReturn(Tasks.forResult(mockAuthResult))
+
+ `when`(mockAuthResult.user).thenReturn(mockUser)
+ `when`(mockUser.uid).thenReturn("mfa-user-uid")
+ }
+
+ @After
+ fun tearDown() {
+ FirebaseAuthUI.clearInstanceCache()
+
+ val context = ApplicationProvider.getApplicationContext()
+ FirebaseApp.getApps(context).forEach { app ->
+ app.delete()
+ }
+ }
+
+ @Test
+ fun `successful mfa challenge renders the authenticated destination`() {
+ `when`(mockUser.isEmailVerified).thenReturn(true)
+
+ resolveChallenge()
+
+ composeTestRule.onNodeWithTag(AUTHENTICATED_TAG).assertIsDisplayed()
+ }
+
+ @Test
+ fun `successful mfa challenge invokes onSignInSuccess with the resolved result`() {
+ `when`(mockUser.isEmailVerified).thenReturn(true)
+
+ val results = mutableListOf()
+ resolveChallenge(onSignInSuccess = { results.add(it) })
+
+ assertThat(results).containsExactly(mockAuthResult)
+ }
+
+ @Test
+ fun `mfa challenge for an unverified password user does not invoke onSignInSuccess`() {
+ `when`(mockUser.isEmailVerified).thenReturn(false)
+ `when`(mockUser.email).thenReturn("user@example.com")
+ `when`(mockPasswordProvider.providerId).thenReturn("password")
+ `when`(mockUser.providerData).thenReturn(listOf(mockPasswordProvider))
+
+ val results = mutableListOf()
+ resolveChallenge(onSignInSuccess = { results.add(it) })
+
+ assertThat(results).isEmpty()
+ }
+
+ /**
+ * Drives [FirebaseAuthScreen] into the MFA challenge route and completes the challenge by
+ * invoking the captured [MfaChallengeContentState.onVerifyClick], which resolves against the
+ * mocked [MultiFactorResolver].
+ */
+ private fun resolveChallenge(onSignInSuccess: (AuthResult) -> Unit = {}) {
+ val configuration = authUIConfiguration {
+ context = ApplicationProvider.getApplicationContext()
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ }
+ var challengeState: MfaChallengeContentState? = null
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ onSignInSuccess = onSignInSuccess,
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ mfaChallengeContent = { state -> challengeState = state },
+ authenticatedContent = { _, _ ->
+ Text(text = "authenticated", modifier = Modifier.testTag(AUTHENTICATED_TAG))
+ }
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(AuthState.RequiresMfa(mockResolver))
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.runOnIdle {
+ requireNotNull(challengeState) { "MFA challenge route was never composed" }
+ .onVerificationCodeChange(VERIFICATION_CODE)
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.runOnIdle {
+ requireNotNull(challengeState).onVerifyClick()
+ }
+ composeTestRule.waitForIdle()
+ }
+
+ private companion object {
+ const val AUTHENTICATED_TAG = "authenticated-destination"
+ const val VERIFICATION_CODE = "123456"
+ }
+}
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..15f1450f2
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt
@@ -0,0 +1,1774 @@
+/*
+ * 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.mfa.MfaChallengeContentState
+import com.firebase.ui.auth.ui.components.ERROR_DIALOG_ACTION_TEST_TAG
+import com.firebase.ui.auth.ui.screens.reauth.ReauthContentState
+import com.google.android.gms.tasks.Task
+import com.google.android.gms.tasks.Tasks
+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.MultiFactorAssertion
+import com.google.firebase.auth.MultiFactorResolver
+import com.google.firebase.auth.TotpMultiFactorGenerator
+import com.google.firebase.auth.TotpMultiFactorInfo
+import com.google.firebase.auth.UserInfo
+import kotlinx.coroutines.CompletableDeferred
+import java.util.concurrent.atomic.AtomicInteger
+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.any
+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.Reauthentication.Required(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.Reauthentication.Required(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.Reauthentication.Required(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.Reauthentication.Required(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.Reauthentication.Required(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.Reauthentication.Required(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.Reauthentication.Required(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] (e.g. a cancelled phone verification retracted on dispose). 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.Reauthentication.Required(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.Reauthentication.Required(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.Reauthentication.Required(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.Reauthentication.Required(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.Reauthentication.Required] 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.Reauthentication.Required(user, retryOperation = { ran.add("first") })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.Reauthentication.Required(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.Reauthentication.Required(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.Reauthentication.Required(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.Reauthentication.Required(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()
+ }
+
+ /**
+ * A TOTP resolver, which is the challenge shape that needs no phone verification round trip.
+ */
+ private fun totpResolver(resolveSignIn: Task): MultiFactorResolver {
+ val hint = mock(TotpMultiFactorInfo::class.java)
+ `when`(hint.factorId).thenReturn(TotpMultiFactorGenerator.FACTOR_ID)
+ `when`(hint.uid).thenReturn("enrollment-1")
+ val resolver = mock(MultiFactorResolver::class.java)
+ `when`(resolver.hints).thenReturn(listOf(hint))
+ `when`(resolver.resolveSignIn(any(MultiFactorAssertion::class.java)))
+ .thenReturn(resolveSignIn)
+ return resolver
+ }
+
+ /**
+ * Firebase requires the second factor to complete the reauthentication too, so the challenge
+ * has to be presented *inside* the reauth surface — on the outer NavHost it renders beneath
+ * the modal, unreachable, and the operation stays pending forever.
+ */
+ @Test
+ fun `an MFA challenge inside the reauth slot runs the pending operation exactly once`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val signedInAuthUI = signedInAuthUI(user)
+ val resolver = totpResolver(Tasks.forResult(mock(AuthResult::class.java)))
+ var retryCount = 0
+ var challenge: MfaChallengeContentState? = null
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = signedInAuthUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ mfaChallengeContent = { state ->
+ challenge = state
+ Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge"))
+ },
+ reauthContent = {
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ },
+ authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") },
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(
+ AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator"))
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist()
+
+ composeTestRule.runOnIdle {
+ requireNotNull(challenge).onVerificationCodeChange("123456")
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle { requireNotNull(challenge).onVerifyClick() }
+ composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount > 0 }
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ assertThat(retryCount).isEqualTo(1)
+ }
+
+ /**
+ * The default sheet needs the same sub-flow: its own NavHost, so the challenge replaces the
+ * provider screen inside the modal instead of rendering under it.
+ */
+ @Test
+ fun `an MFA challenge inside the default reauth sheet runs the pending operation exactly once`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val signedInAuthUI = signedInAuthUI(user)
+ val resolver = totpResolver(Tasks.forResult(mock(AuthResult::class.java)))
+ var retryCount = 0
+ var challenge: MfaChallengeContentState? = null
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = signedInAuthUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ mfaChallengeContent = { state ->
+ challenge = state
+ Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge"))
+ },
+ authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") },
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(
+ AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ })
+ )
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator"))
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed()
+
+ composeTestRule.runOnIdle {
+ requireNotNull(challenge).onVerificationCodeChange("123456")
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle { requireNotNull(challenge).onVerifyClick() }
+ composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount > 0 }
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ assertThat(retryCount).isEqualTo(1)
+ }
+
+ /**
+ * Backing out of the challenge is not abandoning reauthentication: the request stays armed, so
+ * the host must not be told the flow was cancelled and the operation must still be runnable.
+ */
+ @Test
+ fun `cancelling the MFA challenge returns to provider selection with the request still armed`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val signedInAuthUI = signedInAuthUI(user)
+ val resolver = totpResolver(Tasks.forResult(mock(AuthResult::class.java)))
+ var retryCount = 0
+ var cancelledCount = 0
+ var challenge: MfaChallengeContentState? = null
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = signedInAuthUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = { cancelledCount++ },
+ mfaChallengeContent = { state ->
+ challenge = state
+ Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge"))
+ },
+ reauthContent = {
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ },
+ authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") },
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(
+ AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator"))
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed()
+
+ composeTestRule.runOnIdle { requireNotNull(challenge).onCancelClick() }
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("mfa_challenge").assertDoesNotExist()
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+ assertThat(cancelledCount).isEqualTo(0)
+ assertThat(retryCount).isEqualTo(0)
+
+ // Still armed: a later genuine reauthentication of the same user still runs the operation.
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(
+ AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)
+ )
+ }
+ composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount > 0 }
+ assertThat(retryCount).isEqualTo(1)
+ assertThat(cancelledCount).isEqualTo(0)
+ }
+
+ /** A failed challenge is an ordinary failed attempt: it latches into the slot's error. */
+ @Test
+ fun `an MFA challenge failure surfaces as an attempt failure in the reauth slot`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val signedInAuthUI = signedInAuthUI(user)
+ val resolver = totpResolver(Tasks.forException(RuntimeException("wrong code")))
+ var retryCount = 0
+ var cancelledCount = 0
+ var captured: ReauthContentState? = null
+ var challenge: MfaChallengeContentState? = null
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = signedInAuthUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = { cancelledCount++ },
+ mfaChallengeContent = { state ->
+ challenge = state
+ Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge"))
+ },
+ reauthContent = { state ->
+ captured = state
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ },
+ authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") },
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(
+ AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator"))
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed()
+
+ composeTestRule.runOnIdle {
+ requireNotNull(challenge).onVerificationCodeChange("123456")
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle { requireNotNull(challenge).onVerifyClick() }
+ composeTestRule.waitUntil(timeoutMillis = 5_000) { captured?.error != null }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+ val state = requireNotNull(captured)
+ assertThat(state.error).isNotNull()
+ assertThat(state.exception).isInstanceOf(AuthException::class.java)
+ assertThat(retryCount).isEqualTo(0)
+ assertThat(cancelledCount).isEqualTo(0)
+ }
+
+ /**
+ * The stamp is what proves the reauthentication, and it needs a user to name. With no current
+ * user there is nothing to stamp, so the attempt must fail rather than publish a bare Success.
+ */
+ @Test
+ fun `an MFA challenge resolved with no current user does not run the pending operation`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val resolver = totpResolver(Tasks.forResult(mock(AuthResult::class.java)))
+ var retryCount = 0
+ var captured: ReauthContentState? = null
+ var challenge: MfaChallengeContentState? = null
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ mfaChallengeContent = { state ->
+ challenge = state
+ Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge"))
+ },
+ reauthContent = { state ->
+ captured = state
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ },
+ authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") },
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator"))
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed()
+
+ composeTestRule.runOnIdle {
+ requireNotNull(challenge).onVerificationCodeChange("123456")
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle { requireNotNull(challenge).onVerifyClick() }
+ composeTestRule.waitUntil(timeoutMillis = 5_000) { captured?.error != null }
+ composeTestRule.waitForIdle()
+
+ assertThat(retryCount).isEqualTo(0)
+ assertThat(requireNotNull(captured).exception)
+ .isInstanceOf(AuthException.UserNotFoundException::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.Reauthentication.Required(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 preserves the request-owned failure, including its typed exception. */
+ @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.Reauthentication.Required(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)
+ .isInstanceOf(AuthException.InvalidCredentialsException::class.java)
+ }
+
+ /**
+ * 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.Reauthentication.Required(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.Reauthentication.Required(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)
+ }
+
+ /** Activity recreation keeps the request and retry callback in the process-owned AuthState. */
+ @Test
+ fun `an attempt survives Activity recreation and completes the same request`() {
+ 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.Reauthentication.Required(user, retryOperation = { retryCount++ })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Loading()) }
+ composeTestRule.waitForIdle()
+
+ restorationTester.emulateSavedInstanceStateRestore()
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+ composeTestRule
+ .onNodeWithText(context.getString(R.string.fui_error_reauth_interrupted))
+ .assertDoesNotExist()
+
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(
+ AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)
+ )
+ }
+ composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount == 1 }
+
+ assertThat(retryCount).isEqualTo(1)
+ composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist()
+ }
+
+ /**
+ * Process death, unlike rotation, also takes the process-cached [FirebaseAuthUI] holding the
+ * arming: the restored screen's first state comes from the persisted session, so it is an
+ * [AuthState.Success] and no [AuthState.Reauthentication.Required] is ever available to
+ * re-derive from. The pending operation is gone and must still be reported, not dropped.
+ */
+ @Test
+ fun `an arming lost to process death is reported rather than dropped`() {
+ val user = passwordOnlyUser("linked@example.com")
+ var retryCount = 0
+ // Read on every composition, so the restore below observes the replacement instance.
+ var currentAuthUI = signedInAuthUI(user)
+ val restorationTester = StateRestorationTester(composeTestRule)
+
+ restorationTester.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = currentAuthUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = {
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ }
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ currentAuthUI.updateAuthState(
+ AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ })
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+
+ // The instance cache dies with the process; what comes back knows only the session.
+ composeTestRule.runOnIdle {
+ FirebaseAuthUI.clearInstanceCache()
+ currentAuthUI = signedInAuthUI(user)
+ }
+ restorationTester.emulateSavedInstanceStateRestore()
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ composeTestRule
+ .onAllNodesWithText(context.getString(R.string.fui_error_reauth_interrupted))
+ .fetchSemanticsNodes().isNotEmpty()
+ }
+
+ composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist()
+
+ composeTestRule.runOnIdle {
+ currentAuthUI.updateAuthState(
+ AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ assertThat(retryCount).isEqualTo(0)
+ }
+
+ /**
+ * The mirror image, and the regression the broadened guard risks: rotation keeps the cached
+ * [FirebaseAuthUI], so the arming re-derives and must not be reported as interrupted.
+ */
+ @Test
+ fun `recreation that can re-derive the arming reports no interruption`() {
+ 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.Reauthentication.Required(user, retryOperation = { retryCount++ })
+ )
+ }
+ composeTestRule.waitForIdle()
+
+ restorationTester.emulateSavedInstanceStateRestore()
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed()
+ composeTestRule
+ .onNodeWithText(context.getString(R.string.fui_error_reauth_interrupted))
+ .assertDoesNotExist()
+
+ 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)
+ }
+
+ /** A real restored Idle is distinguishable from collectAsState's null placeholder. */
+ @Test
+ fun `process death that restores a signed-out Idle reports interruption`() {
+ val user = passwordOnlyUser("linked@example.com")
+ var currentAuthUI = signedInAuthUI(user)
+ val restorationTester = StateRestorationTester(composeTestRule)
+
+ restorationTester.setContent {
+ FirebaseAuthScreen(
+ configuration = emailAndPhoneConfiguration(),
+ authUI = currentAuthUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = {
+ Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
+ }
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ currentAuthUI.updateAuthState(AuthState.Reauthentication.Required(user))
+ }
+ composeTestRule.waitForIdle()
+
+ // Signed out, so the replacement process emits a real Idle after the null UI placeholder.
+ composeTestRule.runOnIdle {
+ FirebaseAuthUI.clearInstanceCache()
+ currentAuthUI = signedOutAuthUI()
+ }
+ restorationTester.emulateSavedInstanceStateRestore()
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ composeTestRule
+ .onAllNodesWithText(context.getString(R.string.fui_error_reauth_interrupted))
+ .fetchSemanticsNodes().isNotEmpty()
+ }
+
+ composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist()
+ }
+
+ /**
+ * The sensitive operation must run at most once. Its retry is composition-scoped, so a
+ * recreation while it is suspended on the network kills it without any outcome being published
+ * — leaving [AuthState.Reauthentication.RetryingOperation] as the restored screen's first state.
+ * Firebase `Task`s are not cancellable, so the killed attempt may well have committed already:
+ * re-running it is the one outcome worse than losing it, which is reported instead.
+ */
+ @Test
+ fun `recreation during the retry never runs the operation twice`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val signedInAuthUI = signedInAuthUI(user)
+ val runs = AtomicInteger(0)
+ val hangForever = CompletableDeferred()
+ 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.Reauthentication.Required(
+ user,
+ retryOperation = {
+ runs.incrementAndGet()
+ hangForever.await()
+ },
+ )
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle {
+ signedInAuthUI.updateAuthState(
+ AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)
+ )
+ }
+ composeTestRule.waitUntil(timeoutMillis = 5_000) { runs.get() == 1 }
+
+ // Rotate while the operation is still in flight.
+ restorationTester.emulateSavedInstanceStateRestore()
+ composeTestRule.waitForIdle()
+ composeTestRule.waitForIdle()
+
+ assertThat(runs.get()).isEqualTo(1)
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ composeTestRule
+ .onAllNodesWithText(context.getString(R.string.fui_error_reauth_interrupted))
+ .fetchSemanticsNodes().isNotEmpty()
+ }
+ composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist()
+
+ hangForever.complete(Unit)
+ composeTestRule.waitForIdle()
+ assertThat(runs.get()).isEqualTo(1)
+ }
+
+ /**
+ * The latched failure is never the live [AuthState.Error], so the dialog needs its own dedupe
+ * key: leaving and re-entering a sub-flow re-adds the effect that shows it, and the dialog the
+ * user already dismissed would reappear over the freshly reopened sub-flow.
+ */
+ @Test
+ fun `a dismissed attempt-failure dialog does not reappear on reopening a sub-flow`() {
+ val user = passwordOnlyUser("linked@example.com")
+ val signedInAuthUI = signedInAuthUI(user)
+ val thrown = FirebaseAuthInvalidUserException("ERROR_USER_DISABLED", "RAW-BACKEND-CODE-17")
+ val expectedMessage = requireNotNull(AuthException.from(thrown, stringProvider).message)
+
+ 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.Reauthentication.Required(user))
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Error(thrown)) }
+ composeTestRule.waitForIdle()
+
+ // Opening the email sub-flow replaces the slot, so the failure is surfaced as a dialog.
+ composeTestRule.onNodeWithTag("pick_provider").performClick()
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText(expectedMessage).assertIsDisplayed()
+
+ composeTestRule.onNodeWithText(stringProvider.dismissAction).performClick()
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithContentDescription(stringProvider.backAction).performClick()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("pick_provider").performClick()
+ composeTestRule.waitForIdle()
+
+ assertThat(
+ composeTestRule.onAllNodesWithText(expectedMessage).fetchSemanticsNodes()
+ ).isEmpty()
+ }
+
+ /** A [FirebaseAuthUI] over a mocked, *signed-out* [FirebaseAuth]: `authStateFlow()` is Idle. */
+ private fun signedOutAuthUI(): FirebaseAuthUI {
+ val auth = mock(FirebaseAuth::class.java)
+ `when`(auth.currentUser).thenReturn(null)
+ `when`(auth.app).thenReturn(FirebaseApp.getInstance())
+ return FirebaseAuthUI.create(FirebaseApp.getInstance(), auth)
+ }
+}
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..3fe6426ba 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
@@ -15,31 +15,36 @@
package com.firebase.ui.auth.ui.screens
import androidx.compose.material3.Text
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
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.AuthState
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.auth_provider.AuthProvider
-import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+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.FirebaseAuth.AuthStateListener
import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.UserInfo
+import kotlinx.coroutines.yield
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
import org.mockito.Mock
+import org.mockito.Mockito.atLeastOnce
import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
import org.robolectric.RobolectricTestRunner
@@ -56,7 +61,6 @@ class FirebaseAuthScreenReauthIdleResetTest {
private lateinit var mockFirebaseAuth: FirebaseAuth
private lateinit var authUI: FirebaseAuthUI
- private lateinit var stringProvider: DefaultAuthUIStringProvider
@Before
fun setUp() {
@@ -79,7 +83,6 @@ class FirebaseAuthScreenReauthIdleResetTest {
`when`(mockFirebaseAuth.app).thenReturn(defaultApp)
authUI = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth)
- stringProvider = DefaultAuthUIStringProvider(context)
}
@After
@@ -95,6 +98,7 @@ class FirebaseAuthScreenReauthIdleResetTest {
val mockProviderInfo = mock(UserInfo::class.java)
`when`(mockProviderInfo.providerId).thenReturn("password")
val mockUser = mock(FirebaseUser::class.java)
+ `when`(mockUser.uid).thenReturn("uid-password")
`when`(mockUser.providerData).thenReturn(listOf(mockProviderInfo))
val configuration = authUIConfiguration {
@@ -109,6 +113,7 @@ class FirebaseAuthScreenReauthIdleResetTest {
}
}
+ var capturedError: String? = null
composeTestRule.setContent {
FirebaseAuthScreen(
configuration = configuration,
@@ -116,7 +121,8 @@ class FirebaseAuthScreenReauthIdleResetTest {
onSignInSuccess = {},
onSignInFailure = {},
onSignInCancelled = {},
- reauthContent = { _, _ ->
+ reauthContent = { state ->
+ capturedError = state.error
Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker"))
}
)
@@ -124,23 +130,114 @@ class FirebaseAuthScreenReauthIdleResetTest {
// Enter the reauth flow.
composeTestRule.runOnIdle {
- authUI.updateAuthState(AuthState.ReauthenticationRequired(mockUser))
+ authUI.updateAuthState(AuthState.Reauthentication.Required(mockUser))
}
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("reauth_marker").assertIsDisplayed()
- // Wrong password entered inside the reauth flow surfaces an Error on the same authUI.
+ // Wrong password entered inside the reauth flow becomes failure state on the same request.
composeTestRule.runOnIdle {
authUI.updateAuthState(AuthState.Error(Exception("wrong password")))
}
composeTestRule.waitForIdle()
- composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertIsDisplayed()
- // Dismiss the error dialog, which self-consumes the Error back to Idle.
- composeTestRule.onNodeWithText(stringProvider.dismissAction).performClick()
+ // Custom reauth content owns the error presentation and the request remains active.
+ assertThat(capturedError).isNotNull()
+ composeTestRule.onNodeWithTag("reauth_marker").assertIsDisplayed()
+ }
+
+ /**
+ * `FirebaseAuthUI.delete()` signs the user out as its *success* condition, so a successful
+ * retry fires the AuthStateListener with a null current user while the request is still in
+ * `RetryingOperation`. The listener's stale-state reset used to force `Idle` from every
+ * `Reauthentication` phase, which cancelled the coroutine running the operation and left the
+ * saved presentation to report `fui_error_reauth_interrupted` — over a deleted account.
+ *
+ * Screen-level tests mock [FirebaseAuth], so `addAuthStateListener` is inert; the listener is
+ * captured off the mock and invoked from inside the retry operation itself, which is how this
+ * test reaches that branch at all.
+ */
+ @Test
+ fun `an operation that signs the user out is reported as completed, not interrupted`() {
+ val mockProviderInfo = mock(UserInfo::class.java)
+ `when`(mockProviderInfo.providerId).thenReturn("password")
+ val mockUser = mock(FirebaseUser::class.java)
+ `when`(mockUser.uid).thenReturn("uid-password")
+ `when`(mockUser.providerData).thenReturn(listOf(mockProviderInfo))
+ `when`(mockUser.isEmailVerified).thenReturn(true)
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser)
+
+ val configuration = authUIConfiguration {
+ context = ApplicationProvider.getApplicationContext()
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ }
+
+ val observed = mutableListOf()
+ composeTestRule.setContent {
+ LaunchedEffect(Unit) { authUI.authStateFlow().collect { observed.add(it) } }
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = {
+ Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker"))
+ }
+ )
+ }
composeTestRule.waitForIdle()
- // The reauth sheet must survive the notification-consume Idle.
+ val listenerCaptor = ArgumentCaptor.forClass(AuthStateListener::class.java)
+ verify(mockFirebaseAuth, atLeastOnce()).addAuthStateListener(listenerCaptor.capture())
+ val listeners = listenerCaptor.allValues.toList()
+
+ var operationStarted = false
+ var operationCompleted = false
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.Reauthentication.Required(
+ user = mockUser,
+ retryOperation = {
+ operationStarted = true
+ // Exactly what a successful delete() does: FirebaseAuth drops the user and
+ // notifies its listeners while the operation is still in flight.
+ `when`(mockFirebaseAuth.currentUser).thenReturn(null)
+ listeners.forEach { it.onAuthStateChanged(mockFirebaseAuth) }
+ yield()
+ operationCompleted = true
+ },
+ )
+ )
+ }
+ composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("reauth_marker").assertIsDisplayed()
+
+ // Credentials accepted for the same user, which drives the request into its retry phase.
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(
+ AuthState.Success(
+ result = null,
+ user = mockUser,
+ reauthenticatedUid = "uid-password",
+ )
+ )
+ }
+ composeTestRule.waitForIdle()
+
+ val interruptedMessage = ApplicationProvider.getApplicationContext()
+ .getString(R.string.fui_error_reauth_interrupted)
+ assertThat(operationStarted).isTrue()
+ assertThat(operationCompleted).isTrue()
+ assertThat(observed.filterIsInstance()).isEmpty()
+ assertThat(observed.filterIsInstance().map { it.exception.message })
+ .doesNotContain(interruptedMessage)
}
}
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt
index 6273b32b4..01c86d73b 100644
--- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt
@@ -216,6 +216,7 @@ class FirebaseAuthScreenSlotsTest {
val mockProviderInfo = mock(UserInfo::class.java)
`when`(mockProviderInfo.providerId).thenReturn("password")
val mockUser = mock(FirebaseUser::class.java)
+ `when`(mockUser.uid).thenReturn("uid-custom-picker")
`when`(mockUser.providerData).thenReturn(listOf(mockProviderInfo))
val configuration = authUIConfiguration {
@@ -242,7 +243,7 @@ class FirebaseAuthScreenSlotsTest {
)
}
- authUI.updateAuthState(AuthState.ReauthenticationRequired(mockUser))
+ authUI.updateAuthState(AuthState.Reauthentication.Required(mockUser))
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("custom_reauth_picker").assertIsDisplayed()
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt
index ffe4ec882..7f0b44255 100644
--- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt
@@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.test.junit4.createComposeRule
import com.firebase.ui.auth.configuration.MfaFactor
import com.firebase.ui.auth.mfa.MfaChallengeContentState
+import com.firebase.ui.auth.ui.screens.mfa.MfaChallengeScreen
import com.google.firebase.FirebaseApp
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.MultiFactorResolver
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt
index 119ef0572..6d81fa994 100644
--- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt
@@ -22,6 +22,7 @@ import com.firebase.ui.auth.configuration.MfaConfiguration
import com.firebase.ui.auth.configuration.MfaFactor
import com.firebase.ui.auth.mfa.MfaEnrollmentContentState
import com.firebase.ui.auth.mfa.MfaEnrollmentStep
+import com.firebase.ui.auth.ui.screens.mfa.MfaEnrollmentScreen
import com.google.firebase.FirebaseApp
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
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..51dd6a7a1
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenReauthEmailLockTest.kt
@@ -0,0 +1,467 @@
+/*
+ * 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
+ * reaches ResetPassword itself, and a custom `emailContent` slot can reach the rest.
+ */
+ @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))
+ }
+
+ /**
+ * The two out-of-band email routes are not equivalent during reauthentication. A password reset
+ * email leaves the sheet up and the request armed, so it stays available — blocking it stranded
+ * a user who had forgotten their password with no route but dismissal. An email *link* reopens
+ * the app with nothing armed, so completing it reports an interruption instead of finishing the
+ * pending operation, and it stays hidden.
+ */
+ @Test
+ fun `password recovery is offered while reauthenticating but email-link sign-in is not`() {
+ 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).assertExists()
+ composeTestRule.onNodeWithText(stringProvider.signInWithEmailLink, ignoreCase = true)
+ .assertDoesNotExist()
+ }
+
+ /**
+ * The callback side of the same asymmetry, which a custom `emailContent` slot reaches directly:
+ * the ResetPassword switch has to work, the EmailLink switch has to stay inert.
+ */
+ @Test
+ fun `the reauth ResetPassword mode switch works while the EmailLink one is 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()
+
+ assertThat(observed.last()).isEqualTo(EmailAuthMode.ResetPassword)
+
+ composeTestRule.runOnIdle { requireNotNull(goToEmailLinkSignIn).invoke() }
+ composeTestRule.waitForIdle()
+
+ assertThat(observed.last()).isEqualTo(EmailAuthMode.ResetPassword)
+ assertThat(observed.toSet())
+ .containsExactly(EmailAuthMode.SignIn, EmailAuthMode.ResetPassword)
+ }
+
+ /**
+ * 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..e392d95d4 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,84 @@ 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 com.google.firebase.auth.actionCodeSettings
+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 +111,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 +245,314 @@ 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(emailProvider(isEmailLinkSignInEnabled = emailLinkEnabled)) }
+ isCredentialManagerEnabled = credentialManagerEnabled
+ }
+ return FirebaseAuthUI.create(app, auth).createReauthFlow(configuration).configuration
+ }
+
+ /** Email link needs action code settings to validate, so the two always travel together. */
+ private fun emailProvider(isEmailLinkSignInEnabled: Boolean) = AuthProvider.Email(
+ isEmailLinkSignInEnabled = isEmailLinkSignInEnabled,
+ emailLinkActionCodeSettings = if (isEmailLinkSignInEnabled) {
+ actionCodeSettings {
+ url = "https://example.com/verify"
+ handleCodeInApp = true
+ }
+ } else {
+ null
+ },
+ passwordValidationRules = emptyList()
+ )
+
+ /**
+ * 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
+
+ /** Set before [createReauthFlowConfiguration] to enable the email-link affordance. Same
+ * per-instance safety argument as [credentialManagerEnabled]. */
+ private var emailLinkEnabled = 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, so without this notice the
+ * screen is a near-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 asymmetry between the two out-of-band email routes during reauthentication. A password
+ * reset email leaves the reauth sheet up and the request armed, so blocking it only stranded a
+ * user who had forgotten their password with no route but dismissal. An email *link* reopens
+ * the app with nothing armed, so completing it reports an interruption instead of finishing the
+ * pending operation — useless, and it stays hidden.
+ */
+ @Test
+ fun `reauthentication offers password recovery but hides email link sign-in`() {
+ emailLinkEnabled = true
+
+ setStatefulSignInUIContent(
+ createReauthFlowConfiguration(),
+ initialEmail = "linked@example.com",
+ isEmailLocked = true,
+ )
+
+ composeTestRule.onNodeWithText(stringProvider.troubleSigningIn).assertExists()
+ composeTestRule
+ .onNode(hasText(stringProvider.signInWithEmailLink.uppercase()) and hasClickAction())
+ .assertDoesNotExist()
+ }
+
+ /** Control for the test above: outside reauthentication the email-link toggle is offered. */
+ @Test
+ fun `email link sign-in is offered outside reauthentication mode`() {
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(emailProvider(isEmailLinkSignInEnabled = true)) }
+ }
+
+ setStatefulSignInUIContent(configuration, initialEmail = "")
+
+ composeTestRule.onNodeWithText(stringProvider.troubleSigningIn).assertExists()
+ composeTestRule
+ .onNode(hasText(stringProvider.signInWithEmailLink.uppercase()) and hasClickAction())
+ .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/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt
index 1eb925f8f..41d6d37b3 100644
--- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt
@@ -43,6 +43,7 @@ import com.google.firebase.auth.PhoneAuthCredential
import com.google.firebase.auth.PhoneAuthOptions
import com.google.firebase.auth.PhoneAuthProvider
import com.google.firebase.auth.PhoneAuthProvider.OnVerificationStateChangedCallbacks
+import com.google.firebase.auth.UserInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -238,6 +239,17 @@ class PhoneAuthScreenVerificationLifecycleTest {
return result
}
+ /** A signed-in user linked to the phone provider, as a reauthentication requires. */
+ private fun phoneUser(): FirebaseUser {
+ val providerInfo = mock(UserInfo::class.java)
+ `when`(providerInfo.providerId).thenReturn("phone")
+ val user = mock(FirebaseUser::class.java)
+ `when`(user.providerData).thenReturn(listOf(providerInfo))
+ `when`(user.uid).thenReturn("uid-phone")
+ `when`(user.email).thenReturn(null)
+ return user
+ }
+
private fun multiFactorException(): FirebaseAuthMultiFactorException {
val resolver = mock(MultiFactorResolver::class.java)
`when`(resolver.hints).thenReturn(emptyList())
@@ -509,6 +521,20 @@ class PhoneAuthScreenVerificationLifecycleTest {
}
}
+ @Test
+ fun `change-number clears the loading state left behind by the cancelled attempt`() {
+ mockStatic(PhoneAuthProvider::class.java).use { statics ->
+ setScreenContent()
+ sendCode(statics)
+ assertThat(capturedState!!.isLoading).isTrue()
+
+ onUi { it.onChangeNumberClick() }
+ settle()
+
+ assertThat(capturedState!!.isLoading).isFalse()
+ }
+ }
+
@Test
fun `a failed sign-in reports exactly one error`() {
val credential = mock(PhoneAuthCredential::class.java)
@@ -530,6 +556,50 @@ class PhoneAuthScreenVerificationLifecycleTest {
}
}
+ /**
+ * The same teardown, but while a reauthentication request is armed. The failure is folded into
+ * [AuthState.Reauthentication.AttemptFailed], so a `when` that only tears down on
+ * [AuthState.Error] leaves the verification open and the late auto-retrieval below starts a
+ * second reauthentication the user never asked for. `resend cancels the superseded
+ * verification attempt` above establishes that a cancelled attempt's emissions are dropped.
+ */
+ @Test
+ fun `a failed reauthentication attempt cancels the in-flight verification`() {
+ configuration = phoneConfiguration(timeout = 0L).copy(isReauthenticationMode = true)
+ val user = phoneUser()
+ `when`(mockAuth.currentUser).thenReturn(user)
+ `when`(user.reauthenticate(any())).thenReturn(Tasks.forException(Exception("wrong code")))
+ val credential = mock(PhoneAuthCredential::class.java)
+
+ val observed = mutableListOf()
+ val collector = CoroutineScope(Dispatchers.Main.immediate).launch {
+ authUI.authStateFlow().collect { observed += it }
+ }
+ // What FirebaseAuthScreen does: register a drainer so ordinary states are folded into the
+ // armed request, then arm it.
+ authUI.addReauthenticationDrainer()
+ authUI.updateAuthState(AuthState.Reauthentication.Required(user))
+
+ mockStatic(PhoneAuthProvider::class.java).use { statics ->
+ stubGetCredential(statics, credential)
+ setScreenContent()
+ val callbacks = sendCode(statics)
+ codeSent(callbacks, "verification-id-1")
+
+ submitCode("123456")
+ settle()
+ verify(user, times(1)).reauthenticate(any())
+ // Precondition: the failure really did reach the screen as the reauthentication phase.
+ assertThat(observed.filterIsInstance())
+ .isNotEmpty()
+
+ autoVerified(callbacks, credential)
+ settle()
+ verify(user, times(1)).reauthenticate(any())
+ }
+ collector.cancel()
+ }
+
@Test
fun `a cooldown-rejected send still reports its cooldown error`() {
configuration = phoneConfiguration(timeout = 60L)
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/CredentialLinkingScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/CredentialLinkingScreenTest.kt
index 49346377f..59c92a4f8 100644
--- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/CredentialLinkingScreenTest.kt
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/CredentialLinkingScreenTest.kt
@@ -248,11 +248,16 @@ class CredentialLinkingScreenTest {
shadowOf(Looper.getMainLooper()).idle()
// Step 7: Wait for success
+ // Note: `currentAuthState` may already be `AuthState.Success` from the initial
+ // email/password sign-in, so checking `is AuthState.Success` alone can pass
+ // immediately before the phone link has actually completed. Wait for the
+ // linked provider to actually show up on the (mutated-in-place) user instead.
println("TEST: Waiting for auth state change after phone verification...")
composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
shadowOf(Looper.getMainLooper()).idle()
println("TEST: Auth state: $currentAuthState")
- currentAuthState is AuthState.Success
+ val state = currentAuthState
+ state is AuthState.Success && state.user.providerData.any { it.providerId == "phone" }
}
// Step 8: Verify the UID is preserved (linking happened, not a new account)
@@ -369,11 +374,16 @@ class CredentialLinkingScreenTest {
shadowOf(Looper.getMainLooper()).idle()
// Step 6: Wait for linking to complete
+ // Note: `currentAuthState` may already be `AuthState.Success` from the initial
+ // email/password sign-in, so checking `is AuthState.Success` alone can pass
+ // immediately before the Google link has actually completed. Wait for the
+ // linked provider to actually show up on the (mutated-in-place) user instead.
println("TEST: Waiting for Google linking to complete...")
composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
shadowOf(Looper.getMainLooper()).idle()
println("TEST: Auth state: $currentAuthState")
- currentAuthState is AuthState.Success
+ val state = currentAuthState
+ state is AuthState.Success && state.user.providerData.any { it.providerId == "google.com" }
}
// Step 7: Verify the UID is preserved and Google provider is added
diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt
index 62675284b..0d99c527c 100644
--- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt
@@ -36,6 +36,7 @@ import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringPro
import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
import com.firebase.ui.auth.mfa.MfaChallengeContentState
import com.firebase.ui.auth.testutil.ensureTestFirebaseApp
+import com.firebase.ui.auth.ui.screens.mfa.MfaChallengeScreen
import com.google.common.truth.Truth.assertThat
import com.google.firebase.auth.MultiFactorInfo
import com.google.firebase.auth.MultiFactorResolver
diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt
index f0b3eca4b..af6a8966e 100644
--- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt
@@ -38,6 +38,7 @@ import com.firebase.ui.auth.mfa.MfaEnrollmentStep
import com.firebase.ui.auth.mfa.getHelperText
import com.firebase.ui.auth.mfa.getTitle
import com.firebase.ui.auth.testutil.ensureTestFirebaseApp
+import com.firebase.ui.auth.ui.screens.mfa.MfaEnrollmentScreen
import com.google.common.truth.Truth.assertThat
import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.MultiFactor
@@ -433,18 +434,6 @@ class MfaEnrollmentScreenTest {
Text("BACK")
}
}
-
- MfaEnrollmentStep.ShowRecoveryCodes -> {
- state.recoveryCodes?.forEach { code ->
- Text(code)
- }
- Button(
- onClick = state.onCodesSavedClick,
- enabled = !state.isLoading
- ) {
- Text("DONE")
- }
- }
}
}
}
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..73526c0d5 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
@@ -28,6 +30,7 @@ import com.firebase.ui.auth.testutil.EmulatorAuthApi
import com.firebase.ui.auth.testutil.ensureFreshUser
import com.firebase.ui.auth.testutil.ensureTestFirebaseApp
import com.firebase.ui.auth.testutil.verifyEmailInEmulator
+import com.firebase.ui.auth.ui.screens.reauth.ReauthContentState
import com.google.common.truth.Truth.assertThat
import org.junit.After
import org.junit.Assume
@@ -77,7 +80,7 @@ class ReauthFlowTest {
}
/**
- * Full cycle: sign in via the main flow, then emit ReauthenticationRequired to simulate a
+ * Full cycle: sign in via the main flow, then emit Reauthentication.Required to simulate a
* sensitive operation. Verifies the default ModalBottomSheet reauth UI appears, completing
* reauthentication triggers the pending retry operation.
*
@@ -166,9 +169,9 @@ class ReauthFlowTest {
val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" }
- // Step 2: Emit ReauthenticationRequired to simulate a sensitive operation requiring reauth.
+ // Step 2: Emit Reauthentication.Required to simulate an operation requiring reauth.
authUI.updateAuthState(
- AuthState.ReauthenticationRequired(
+ AuthState.Reauthentication.Required(
user = signedInUser,
reason = "Please verify your identity to continue",
retryOperation = { retryOperationCalled = true },
@@ -184,10 +187,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 +209,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 +251,13 @@ class ReauthFlowTest {
passwordValidationRules = emptyList()
)
)
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null
+ )
+ )
}
isCredentialManagerEnabled = false
}
@@ -248,10 +272,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") }
}
},
) { _, _ ->
@@ -264,11 +289,12 @@ class ReauthFlowTest {
shadowOf(Looper.getMainLooper()).idle()
- // Emit ReauthenticationRequired to trigger the custom reauthContent slot.
+ // Emit Reauthentication.Required to trigger the custom reauthContent slot.
authUI.updateAuthState(
- AuthState.ReauthenticationRequired(
+ AuthState.Reauthentication.Required(
user = capturedUser,
reason = expectedReason,
+ retryOperation = { retryOperationCalled = true },
)
)
@@ -284,18 +310,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()
+ }
+
+ 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.Reauthentication.Required(
+ 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(currentAuthState).isInstanceOf(AuthState.Idle::class.java)
+ assertThat(retryOperationCalled).isTrue()
}
@Test
@@ -376,9 +519,9 @@ class ReauthFlowTest {
val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" }
- // Step 2: emit ReauthenticationRequired with a retryOperation.
+ // Step 2: emit Reauthentication.Required with a retryOperation.
authUI.updateAuthState(
- AuthState.ReauthenticationRequired(
+ AuthState.Reauthentication.Required(
user = signedInUser,
reason = "Please verify your identity to continue",
retryOperation = { retryOperationCalled = true },
@@ -393,10 +536,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/okf-bundle/modules/auth.md b/okf-bundle/modules/auth.md
index e96b686da..f2fc3428f 100644
--- a/okf-bundle/modules/auth.md
+++ b/okf-bundle/modules/auth.md
@@ -45,6 +45,9 @@ Tier commands and handoff sequence: [validation checklist](../testing/validation
- Configuration is Kotlin DSL (`AuthProvider.Email()`, etc.), not 9.x `IdpConfig` builders.
- Theming uses `AuthUITheme` / Material 3, not XML Auth themes as the primary path.
- State is reactive (`Flow`-oriented Auth state), not only `AuthStateListener` callbacks.
+- Reauthentication phases share one request-scoped `AuthState.Reauthentication` hierarchy;
+ Compose saves only a request marker/sub-route so Activity recreation resumes the same request
+ and process death reports the lost callback explicitly.
- Credential Manager integration lives under `credentialmanager/` — treat password-save/retrieve as Auth-critical surface.
- MFA (SMS/TOTP) has dedicated screens and e2e coverage (`MfaEnrollmentScreenTest`, `MfaChallengeScreenTest`, …).
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
+}