diff --git a/README.md b/README.md
index b34d88cc6..61a523aac 100644
--- a/README.md
+++ b/README.md
@@ -197,7 +197,7 @@ Then you can depend on snapshot versions:
implementation 'com.firebaseui:firebase-ui-auth:$X.Y.Z-SNAPSHOT'
```
-You can see which `SNAPSHOT` builds are avaiable here:
+You can see which `SNAPSHOT` builds are available here:
https://oss.jfrog.org/webapp/#/artifacts/browse/tree/General/oss-snapshot-local/com/firebaseui
Snapshot builds come with absolutely no guarantees and we will close any issues asking to troubleshoot
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7403aaf50..ca5cda2d8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -55,6 +55,37 @@
android:label="Custom Slots & Theming Demo"
android:exported="false"
android:theme="@style/Theme.FirebaseUIAndroid" />
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/firebaseui/android/demo/CredentialLinkingDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/CredentialLinkingDemoActivity.kt
new file mode 100644
index 000000000..c901084cc
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/CredentialLinkingDemoActivity.kt
@@ -0,0 +1,186 @@
+package com.firebaseui.android.demo
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+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.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.ui.screens.AuthRoute
+import com.firebase.ui.auth.ui.screens.AuthSuccessUiContext
+import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen
+
+class CredentialLinkingDemoActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val authUI = FirebaseAuthUI.getInstance()
+
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ isCredentialLinkingEnabled = true
+ providers {
+ provider(
+ AuthProvider.Email(
+ isNewAccountsAllowed = true,
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList(),
+ )
+ )
+ provider(
+ AuthProvider.Google(
+ scopes = listOf("email"),
+ serverClientId = "406099696497-a12gakvts4epfk5pkio7dphc1anjiggc.apps.googleusercontent.com",
+ )
+ )
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = emptyList(),
+ timeout = 120L,
+ )
+ )
+ }
+ }
+
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = { _: AuthException -> },
+ onSignInCancelled = {},
+ authenticatedContent = { state, uiContext ->
+ CredentialLinkingAuthenticatedContent(state, uiContext)
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CredentialLinkingAuthenticatedContent(
+ state: AuthState,
+ uiContext: AuthSuccessUiContext,
+) {
+ when (state) {
+ is AuthState.Success -> {
+ val user = state.user
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "Signed in",
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text("UID: ${user.uid}", style = MaterialTheme.typography.bodySmall)
+ Text("Email: ${user.email ?: "—"}")
+ Text("Phone: ${user.phoneNumber ?: "—"}")
+ Text(
+ "Providers: ${user.providerData.map { it.providerId }}",
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Start
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = { uiContext.onNavigate(AuthRoute.MethodPicker) }
+ ) {
+ Text("Add sign-in method")
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedButton(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = uiContext.onSignOut
+ ) {
+ Text("Sign out")
+ }
+ }
+ }
+
+ is AuthState.RequiresEmailVerification -> {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "Verify your email",
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "A verification link was sent to ${state.email}. Once verified, tap the button below.",
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = uiContext.onReloadUser
+ ) {
+ Text("I've verified my email")
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedButton(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = uiContext.onSignOut
+ ) {
+ Text("Sign out")
+ }
+ }
+ }
+
+ else -> {}
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt
new file mode 100644
index 000000000..54eadccc3
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt
@@ -0,0 +1,354 @@
+package com.firebaseui.android.demo
+
+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.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUIAsset
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults
+import com.firebase.ui.auth.ui.components.AuthProviderButton
+import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration
+import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen
+
+class CustomMethodPickerDemoActivity : 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.GenericOAuth(
+ providerName = "Discord",
+ providerId = "oidc.discord",
+ scopes = emptyList(),
+ customParameters = emptyMap(),
+ buttonLabel = "Sign in with Discord",
+ buttonIcon = AuthUIAsset.Resource(R.drawable.ic_discord_24dp),
+ buttonColor = Color(0xFF5865F2),
+ contentColor = Color.White
+ )
+ )
+ provider(
+ AuthProvider.GenericOAuth(
+ providerName = "LINE",
+ providerId = "oidc.line",
+ scopes = emptyList(),
+ customParameters = emptyMap(),
+ buttonLabel = "Sign in with LINE",
+ buttonIcon = AuthUIAsset.Resource(R.drawable.ic_line_logo_24dp),
+ buttonColor = Color(0xFF06C755),
+ contentColor = Color.White
+ )
+ )
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null
+ )
+ )
+ provider(AuthProvider.Anonymous)
+ }
+ }
+
+ setContent {
+ AuthUITheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ var termsAccepted by remember { mutableStateOf(false) }
+
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ onSignInSuccess = { result ->
+ Log.d("CustomMethodPickerDemo", "Auth success: ${result.user?.uid}")
+ },
+ onSignInFailure = { exception: AuthException ->
+ Log.e("CustomMethodPickerDemo", "Auth failed", exception)
+ },
+ onSignInCancelled = {
+ Log.d("CustomMethodPickerDemo", "Auth cancelled")
+ },
+ customMethodPickerLayout = { providers, onProviderSelected ->
+ SpotlightMethodPicker(
+ providers = providers,
+ onProviderSelected = onProviderSelected,
+ enabled = termsAccepted
+ )
+ },
+ customMethodPickerTermsConfiguration = MethodPickerTermsConfiguration(
+ content = {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = termsAccepted,
+ onCheckedChange = { termsAccepted = it }
+ )
+ Text(
+ text = "I have read and accept the Terms of Service and Privacy Policy",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ }
+ },
+ accepted = termsAccepted,
+ disableProvidersUntilAccepted = true,
+ ),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun SpotlightMethodPicker(
+ providers: List,
+ onProviderSelected: (AuthProvider) -> Unit,
+ enabled: Boolean = true,
+) {
+ val stringProvider = LocalAuthUIStringProvider.current
+
+ val groups = providers.groupBy {
+ when (it) {
+ is AuthProvider.Google, is AuthProvider.Apple -> "featured"
+ is AuthProvider.Email, is AuthProvider.Phone -> "credential"
+ is AuthProvider.Anonymous -> "anonymous"
+ else -> "social"
+ }
+ }
+ val featured = groups.getOrElse("featured") { emptyList() }
+ val social = groups.getOrElse("social") { emptyList() }
+ val credential = groups.getOrElse("credential") { emptyList() }
+ val anonymous = groups["anonymous"]?.firstOrNull()
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(vertical = 48.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ item {
+ Text(
+ text = "Sign in",
+ style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 32.dp)
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Choose how you'd like to continue",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 32.dp)
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+
+ items(featured) { provider ->
+ AuthProviderButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 32.dp),
+ provider = provider,
+ onClick = { onProviderSelected(provider) },
+ enabled = enabled,
+ stringProvider = stringProvider
+ )
+ }
+
+ if (social.isNotEmpty()) {
+ item {
+ Spacer(modifier = Modifier.height(4.dp))
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.outlineVariant,
+ modifier = Modifier.padding(horizontal = 32.dp)
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ item {
+ LazyRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ contentPadding = PaddingValues(horizontal = 16.dp)
+ ) {
+ items(social) { provider ->
+ val style = styleForProvider(provider)
+ ProviderIconButton(
+ style = style,
+ contentDescription = provider.providerId,
+ enabled = enabled,
+ onClick = { onProviderSelected(provider) }
+ )
+ }
+ }
+ }
+ }
+
+ if (credential.isNotEmpty()) {
+ item {
+ Spacer(modifier = Modifier.height(4.dp))
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.outlineVariant,
+ modifier = Modifier.padding(horizontal = 32.dp)
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ items(credential) { provider ->
+ AuthProviderButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 32.dp),
+ provider = provider,
+ onClick = { onProviderSelected(provider) },
+ enabled = enabled,
+ stringProvider = stringProvider
+ )
+ }
+ }
+
+ anonymous?.let {
+ item {
+ Spacer(modifier = Modifier.height(8.dp))
+ TextButton(onClick = { onProviderSelected(it) }, enabled = enabled) {
+ Text("Continue as guest")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ProviderIconButton(
+ style: AuthUITheme.ProviderStyle,
+ contentDescription: String,
+ onClick: () -> Unit,
+ enabled: Boolean = true,
+) {
+ Button(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = Modifier.size(52.dp),
+ shape = CircleShape,
+ colors = ButtonDefaults.buttonColors(containerColor = style.backgroundColor),
+ contentPadding = PaddingValues(0.dp),
+ elevation = ButtonDefaults.buttonElevation(defaultElevation = style.elevation)
+ ) {
+ style.icon?.let { asset ->
+ val painter = asset.asPainter()
+ val tint = style.iconTint
+ if (tint != null) {
+ Icon(
+ painter = painter,
+ contentDescription = contentDescription,
+ tint = tint,
+ modifier = Modifier.size(22.dp)
+ )
+ } else {
+ Image(
+ painter = painter,
+ contentDescription = contentDescription,
+ modifier = Modifier.size(22.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private 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) {
+ 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.GenericOAuth -> AuthUITheme.ProviderStyle(
+ icon = provider.buttonIcon,
+ backgroundColor = provider.buttonColor ?: Color(0xFF666666),
+ contentColor = provider.contentColor ?: Color.White
+ )
+ else -> AuthUITheme.ProviderStyle(
+ icon = null,
+ backgroundColor = Color(0xFF666666),
+ contentColor = Color.White
+ )
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt
index 00b0054f0..4b824eed5 100644
--- a/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt
@@ -1,1105 +1,134 @@
package com.firebaseui.android.demo
+import android.content.Intent
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.layout.*
+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.padding
+import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
-import androidx.compose.ui.Alignment
+import androidx.compose.material3.Card
+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.graphics.Color
-import androidx.compose.ui.text.input.PasswordVisualTransformation
-import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
-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.PasswordRule
-import com.firebase.ui.auth.configuration.authUIConfiguration
-import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
-import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
-import com.firebase.ui.auth.configuration.theme.AuthUITheme
-import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults
-import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
-import com.firebase.ui.auth.ui.components.AuthProviderButton
-import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState
-import com.firebase.ui.auth.ui.screens.email.EmailAuthMode
-import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen
-import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState
-import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen
-import com.firebase.ui.auth.ui.screens.phone.PhoneAuthStep
-import com.google.firebase.auth.AuthResult
-/**
- * Demo activity showcasing custom slots and theming capabilities:
- * - EmailAuthScreen with custom slot UI
- * - PhoneAuthScreen with custom slot UI
- * - Provider button shape customization with global and per-provider overrides
- * - AuthUITheme.fromMaterialTheme() with custom ProviderStyle overrides
- */
class CustomSlotsThemingDemoActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
- val authUI = FirebaseAuthUI.getInstance()
- val appContext = applicationContext
-
- // Configuration for email authentication
- val emailConfiguration = authUIConfiguration {
- context = appContext
- providers {
- provider(
- AuthProvider.Email(
- isDisplayNameRequired = true,
- isNewAccountsAllowed = true,
- isEmailLinkSignInEnabled = false,
- emailLinkActionCodeSettings = null,
- isEmailLinkForceSameDeviceEnabled = false,
- minimumPasswordLength = 8,
- passwordValidationRules = listOf(
- PasswordRule.MinimumLength(8),
- PasswordRule.RequireLowercase,
- PasswordRule.RequireUppercase,
- PasswordRule.RequireDigit
- )
- )
- )
- }
- tosUrl = "https://policies.google.com/terms"
- privacyPolicyUrl = "https://policies.google.com/privacy"
- }
-
- // Configuration for phone authentication
- val phoneConfiguration = authUIConfiguration {
- context = appContext
- providers {
- provider(
- AuthProvider.Phone(
- defaultNumber = null,
- defaultCountryCode = "US",
- allowedCountries = emptyList(),
- smsCodeLength = 6,
- timeout = 60L,
- isInstantVerificationEnabled = true
- )
- )
- }
- }
-
setContent {
- // Custom theme using fromMaterialTheme() with custom provider styles
- CustomAuthUITheme {
+ MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
- var selectedDemo by remember { mutableStateOf(DemoType.Email) }
-
- Column(
- modifier = Modifier
- .fillMaxSize()
- .systemBarsPadding()
- ) {
- // Demo selector tabs
- DemoSelector(
- selectedDemo = selectedDemo,
- onDemoSelected = { selectedDemo = it }
- )
-
- // Show selected demo
- when (selectedDemo) {
- DemoType.Email -> EmailAuthDemo(
- authUI = authUI,
- configuration = emailConfiguration,
- context = appContext
- )
- DemoType.Phone -> PhoneAuthDemo(
- authUI = authUI,
- configuration = phoneConfiguration,
- context = appContext
- )
- DemoType.ShapeCustomization -> ShapeCustomizationDemo()
+ CustomSlotsDemoChooser(
+ onEmailAuthSlotClick = {
+ startActivity(Intent(this, EmailAuthSlotDemoActivity::class.java))
+ },
+ onPhoneAuthSlotClick = {
+ startActivity(Intent(this, PhoneAuthSlotDemoActivity::class.java))
+ },
+ onShapeCustomizationClick = {
+ startActivity(Intent(this, ShapeCustomizationDemoActivity::class.java))
+ },
+ onCustomMethodPickerClick = {
+ startActivity(Intent(this, CustomMethodPickerDemoActivity::class.java))
}
- }
- }
- }
- }
- }
-}
-
-enum class DemoType {
- Email,
- Phone,
- ShapeCustomization
-}
-
-@Composable
-fun CustomAuthUITheme(content: @Composable () -> Unit) {
- // Use Material Theme colors
- MaterialTheme {
- // UPDATED: Now uses ProviderStyleDefaults and the new providerButtonShape API
- // Apply custom theme using fromMaterialTheme with global button shape
- val authTheme = AuthUITheme.fromMaterialTheme(
- providerButtonShape = RoundedCornerShape(12.dp) // Global shape for all buttons
- )
-
- AuthUITheme(theme = authTheme) {
- content()
- }
- }
-}
-
-@Composable
-fun DemoSelector(
- selectedDemo: DemoType,
- onDemoSelected: (DemoType) -> Unit
-) {
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer
- )
- ) {
- Column(
- modifier = Modifier.padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Text(
- text = "Custom Slots & Theming Demo",
- style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onPrimaryContainer
- )
- Text(
- text = "Select a demo to see custom UI implementations using slot APIs",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onPrimaryContainer
- )
-
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- FilterChip(
- selected = selectedDemo == DemoType.Email,
- onClick = { onDemoSelected(DemoType.Email) },
- label = { Text("Email Auth") },
- modifier = Modifier.weight(1f)
- )
- FilterChip(
- selected = selectedDemo == DemoType.Phone,
- onClick = { onDemoSelected(DemoType.Phone) },
- label = { Text("Phone Auth") },
- modifier = Modifier.weight(1f)
)
}
- FilterChip(
- selected = selectedDemo == DemoType.ShapeCustomization,
- onClick = { onDemoSelected(DemoType.ShapeCustomization) },
- label = { Text("Shape Customization") },
- modifier = Modifier.fillMaxWidth()
- )
}
}
}
}
@Composable
-fun EmailAuthDemo(
- authUI: FirebaseAuthUI,
- configuration: AuthUIConfiguration,
- context: android.content.Context
+fun CustomSlotsDemoChooser(
+ onEmailAuthSlotClick: () -> Unit,
+ onPhoneAuthSlotClick: () -> Unit,
+ onShapeCustomizationClick: () -> Unit,
+ onCustomMethodPickerClick: () -> Unit,
) {
- var currentUser by remember { mutableStateOf(authUI.getCurrentUser()) }
-
- // Monitor auth state changes
- LaunchedEffect(Unit) {
- authUI.authStateFlow().collect { _ ->
- currentUser = authUI.getCurrentUser()
- }
- }
-
- if (currentUser != null) {
- // Show success screen
- val successScrollState = rememberScrollState()
-
- Column(
- modifier = Modifier
- .fillMaxSize()
- .verticalScroll(successScrollState)
- .padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- Text(
- text = "✓",
- style = MaterialTheme.typography.displayLarge,
- color = MaterialTheme.colorScheme.primary
- )
- Spacer(modifier = Modifier.height(16.dp))
- Text(
- text = "Successfully Authenticated!",
- style = MaterialTheme.typography.headlineSmall,
- textAlign = TextAlign.Center
- )
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = currentUser?.email ?: "Signed in",
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- Spacer(modifier = Modifier.height(32.dp))
- Button(onClick = {
- authUI.auth.signOut()
- }) {
- Text("Sign Out")
- }
- }
- } else {
- // Show custom email auth UI using slot API
- // Provide the string provider required by EmailAuthScreen
- CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) {
- EmailAuthScreen(
- context = context,
- configuration = configuration,
- authUI = authUI,
- onSuccess = { result: AuthResult ->
- Log.d("CustomSlotsDemo", "Email auth success: ${result.user?.uid}")
- },
- onError = { exception: AuthException ->
- Log.e("CustomSlotsDemo", "Email auth error", exception)
- },
- onCancel = {
- Log.d("CustomSlotsDemo", "Email auth cancelled")
- }
- ) { state: EmailAuthContentState ->
- // Custom UI using the slot API
- CustomEmailAuthUI(state)
- }
- }
- }
-}
-
-@Composable
-fun CustomEmailAuthUI(state: EmailAuthContentState) {
- val scrollState = rememberScrollState()
-
Column(
modifier = Modifier
.fillMaxSize()
- .verticalScroll(scrollState)
+ .verticalScroll(rememberScrollState())
+ .systemBarsPadding()
.padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
- Spacer(modifier = Modifier.height(16.dp))
-
- // Title based on mode
- Text(
- text = when (state.mode) {
- EmailAuthMode.SignIn, EmailAuthMode.EmailLinkSignIn -> "📧 Welcome Back"
- EmailAuthMode.SignUp -> "📧 Create Account"
- EmailAuthMode.ResetPassword -> "📧 Reset Password"
- },
- style = MaterialTheme.typography.headlineMedium,
- color = MaterialTheme.colorScheme.onSurface
- )
-
- Spacer(modifier = Modifier.height(8.dp))
-
- // Error display
- state.error?.let { errorMessage ->
- Card(
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.errorContainer
- ),
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(
- text = errorMessage,
- modifier = Modifier.padding(12.dp),
- color = MaterialTheme.colorScheme.onErrorContainer,
- style = MaterialTheme.typography.bodySmall
- )
- }
- }
-
- // Render UI based on mode
- when (state.mode) {
- EmailAuthMode.SignIn, EmailAuthMode.EmailLinkSignIn -> SignInUI(state)
- EmailAuthMode.SignUp -> SignUpUI(state)
- EmailAuthMode.ResetPassword -> ResetPasswordUI(state)
- }
- }
-}
-
-@Composable
-fun SignInUI(state: EmailAuthContentState) {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- OutlinedTextField(
- value = state.email,
- onValueChange = state.onEmailChange,
- label = { Text("Email") },
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- enabled = !state.isLoading
- )
-
- OutlinedTextField(
- value = state.password,
- onValueChange = state.onPasswordChange,
- label = { Text("Password") },
- visualTransformation = PasswordVisualTransformation(),
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- enabled = !state.isLoading
- )
-
- if (state.emailSignInLinkSent) {
- Text(
- text = "✓ Sign-in link sent! Check your email.",
- color = MaterialTheme.colorScheme.primary,
- style = MaterialTheme.typography.bodySmall,
- modifier = Modifier.fillMaxWidth()
- )
- }
-
Spacer(modifier = Modifier.height(8.dp))
- Button(
- onClick = state.onSignInClick,
- modifier = Modifier.fillMaxWidth(),
- enabled = !state.isLoading
- ) {
- if (state.isLoading) {
- CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
- color = MaterialTheme.colorScheme.onPrimary
- )
- } else {
- Text("Sign In")
- }
- }
-
- TextButton(
- onClick = state.onGoToResetPassword,
- modifier = Modifier.align(Alignment.CenterHorizontally)
- ) {
- Text("Forgot Password?")
- }
-
- HorizontalDivider()
-
- TextButton(
- onClick = state.onGoToSignUp,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text("Don't have an account? Sign Up")
- }
- }
-}
-
-@Composable
-fun SignUpUI(state: EmailAuthContentState) {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- OutlinedTextField(
- value = state.displayName,
- onValueChange = state.onDisplayNameChange,
- label = { Text("Display Name") },
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- enabled = !state.isLoading
- )
-
- OutlinedTextField(
- value = state.email,
- onValueChange = state.onEmailChange,
- label = { Text("Email") },
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- enabled = !state.isLoading
- )
-
- OutlinedTextField(
- value = state.password,
- onValueChange = state.onPasswordChange,
- label = { Text("Password") },
- visualTransformation = PasswordVisualTransformation(),
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- enabled = !state.isLoading
- )
-
- OutlinedTextField(
- value = state.confirmPassword,
- onValueChange = state.onConfirmPasswordChange,
- label = { Text("Confirm Password") },
- visualTransformation = PasswordVisualTransformation(),
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- enabled = !state.isLoading
+ Text(
+ text = "Custom Slots & Theming",
+ style = MaterialTheme.typography.headlineMedium
)
-
- Spacer(modifier = Modifier.height(8.dp))
-
- Button(
- onClick = state.onSignUpClick,
- modifier = Modifier.fillMaxWidth(),
- enabled = !state.isLoading
- ) {
- if (state.isLoading) {
- CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
- color = MaterialTheme.colorScheme.onPrimary
- )
- } else {
- Text("Create Account")
- }
- }
-
- HorizontalDivider()
-
- TextButton(
- onClick = state.onGoToSignIn,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text("Already have an account? Sign In")
- }
- }
-}
-
-@Composable
-fun ResetPasswordUI(state: EmailAuthContentState) {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
Text(
- text = "Enter your email address and we'll send you a link to reset your password.",
+ text = "Select a demo to explore slot APIs and theme customization",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
- OutlinedTextField(
- value = state.email,
- onValueChange = state.onEmailChange,
- label = { Text("Email") },
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- enabled = !state.isLoading
+ DemoCard(
+ title = "Email Auth — Custom Slot",
+ description = "Replace the default email sign-in UI with a fully custom composable using the content slot.",
+ onClick = onEmailAuthSlotClick
)
- if (state.resetLinkSent) {
- Card(
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer
- ),
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(
- text = "✓ Password reset link sent! Check your email.",
- modifier = Modifier.padding(12.dp),
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- style = MaterialTheme.typography.bodyMedium
- )
- }
- }
-
- Spacer(modifier = Modifier.height(8.dp))
-
- Button(
- onClick = state.onSendResetLinkClick,
- modifier = Modifier.fillMaxWidth(),
- enabled = !state.isLoading && !state.resetLinkSent
- ) {
- if (state.isLoading) {
- CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
- color = MaterialTheme.colorScheme.onPrimary
- )
- } else {
- Text("Send Reset Link")
- }
- }
-
- HorizontalDivider()
-
- TextButton(
- onClick = state.onGoToSignIn,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text("Back to Sign In")
- }
- }
-}
-
-@Composable
-fun PhoneAuthDemo(
- authUI: FirebaseAuthUI,
- configuration: AuthUIConfiguration,
- context: android.content.Context
-) {
- var currentUser by remember { mutableStateOf(authUI.getCurrentUser()) }
-
- // Monitor auth state changes
- LaunchedEffect(Unit) {
- authUI.authStateFlow().collect { _ ->
- currentUser = authUI.getCurrentUser()
- }
- }
-
- if (currentUser != null) {
- // Show success screen
- val successScrollState = rememberScrollState()
-
- Column(
- modifier = Modifier
- .fillMaxSize()
- .verticalScroll(successScrollState)
- .padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- Text(
- text = "📱",
- style = MaterialTheme.typography.displayLarge,
- color = MaterialTheme.colorScheme.primary
- )
- Spacer(modifier = Modifier.height(16.dp))
- Text(
- text = "Phone Verified!",
- style = MaterialTheme.typography.headlineSmall,
- textAlign = TextAlign.Center
- )
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = currentUser?.phoneNumber ?: "Signed in",
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- Spacer(modifier = Modifier.height(32.dp))
- Button(onClick = {
- authUI.auth.signOut()
- }) {
- Text("Sign Out")
- }
- }
- } else {
- // Show custom phone auth UI using slot API
- // Provide the string provider required by PhoneAuthScreen
- CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) {
- PhoneAuthScreen(
- context = context,
- configuration = configuration,
- authUI = authUI,
- onSuccess = { result: AuthResult ->
- Log.d("CustomSlotsDemo", "Phone auth success: ${result.user?.uid}")
- },
- onError = { exception: AuthException ->
- Log.e("CustomSlotsDemo", "Phone auth error", exception)
- },
- onCancel = {
- Log.d("CustomSlotsDemo", "Phone auth cancelled")
- }
- ) { state: PhoneAuthContentState ->
- // Custom UI using the slot API
- CustomPhoneAuthUI(state)
- }
- }
- }
-}
-
-@Composable
-fun CustomPhoneAuthUI(state: PhoneAuthContentState) {
- val scrollState = rememberScrollState()
-
- Column(
- modifier = Modifier
- .fillMaxSize()
- .verticalScroll(scrollState)
- .padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Spacer(modifier = Modifier.height(16.dp))
-
- // Title based on step
- Text(
- text = when (state.step) {
- PhoneAuthStep.EnterPhoneNumber -> "📱 Phone Verification"
- PhoneAuthStep.EnterVerificationCode -> "📱 Enter Code"
- },
- style = MaterialTheme.typography.headlineMedium,
- color = MaterialTheme.colorScheme.onSurface
+ DemoCard(
+ title = "Phone Auth — Custom Slot",
+ description = "Replace the default phone auth UI with a fully custom composable using the content slot.",
+ onClick = onPhoneAuthSlotClick
)
- Spacer(modifier = Modifier.height(8.dp))
-
- // Error display
- state.error?.let { errorMessage ->
- Card(
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.errorContainer
- ),
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(
- text = errorMessage,
- modifier = Modifier.padding(12.dp),
- color = MaterialTheme.colorScheme.onErrorContainer,
- style = MaterialTheme.typography.bodySmall
- )
- }
- }
-
- // Render UI based on step
- when (state.step) {
- PhoneAuthStep.EnterPhoneNumber -> EnterPhoneNumberUI(state)
- PhoneAuthStep.EnterVerificationCode -> EnterVerificationCodeUI(state)
- }
- }
-}
-
-@Composable
-fun EnterPhoneNumberUI(state: PhoneAuthContentState) {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Text(
- text = "Enter your phone number to receive a verification code",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
+ DemoCard(
+ title = "Shape Customization",
+ description = "Preview provider button shapes using global and per-provider overrides via AuthUITheme.",
+ onClick = onShapeCustomizationClick
)
- Spacer(modifier = Modifier.height(8.dp))
-
- // Country selector (simplified for demo)
- OutlinedCard(
- onClick = { /* In real app, open country selector */ },
- modifier = Modifier.fillMaxWidth()
- ) {
- Row(
- modifier = Modifier.padding(16.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = "${state.selectedCountry.flagEmoji} ${state.selectedCountry.dialCode}",
- style = MaterialTheme.typography.bodyLarge
- )
- Spacer(modifier = Modifier.weight(1f))
- Text(
- text = state.selectedCountry.name,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
-
- OutlinedTextField(
- value = state.phoneNumber,
- onValueChange = state.onPhoneNumberChange,
- label = { Text("Phone Number") },
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- enabled = !state.isLoading
+ DemoCard(
+ title = "Custom Method Picker Layout & Terms",
+ 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
)
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Button(
- onClick = state.onSendCodeClick,
- modifier = Modifier.fillMaxWidth(),
- enabled = !state.isLoading && state.phoneNumber.isNotBlank()
- ) {
- if (state.isLoading) {
- CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
- color = MaterialTheme.colorScheme.onPrimary
- )
- } else {
- Text("Send Code")
- }
- }
}
}
@Composable
-fun EnterVerificationCodeUI(state: PhoneAuthContentState) {
- Column(
+private fun DemoCard(
+ title: String,
+ description: String,
+ onClick: () -> Unit,
+) {
+ Card(
modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(12.dp)
+ onClick = onClick
) {
- Text(
- text = "We sent a verification code to:",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
- )
-
- Text(
- text = state.fullPhoneNumber,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.primary,
- textAlign = TextAlign.Center,
- modifier = Modifier.fillMaxWidth()
- )
-
- Spacer(modifier = Modifier.height(8.dp))
-
- OutlinedTextField(
- value = state.verificationCode,
- onValueChange = state.onVerificationCodeChange,
- label = { Text("6-Digit Code") },
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- enabled = !state.isLoading
- )
-
- Spacer(modifier = Modifier.height(8.dp))
-
- Button(
- onClick = state.onVerifyCodeClick,
- modifier = Modifier.fillMaxWidth(),
- enabled = !state.isLoading && state.verificationCode.length == 6
- ) {
- if (state.isLoading) {
- CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
- color = MaterialTheme.colorScheme.onPrimary
- )
- } else {
- Text("Verify Code")
- }
- }
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- TextButton(onClick = state.onChangeNumberClick) {
- Text("Change Number")
- }
-
- TextButton(
- onClick = state.onResendCodeClick,
- enabled = state.resendTimer == 0
- ) {
- Text(
- if (state.resendTimer > 0)
- "Resend (${state.resendTimer}s)"
- else
- "Resend Code"
- )
- }
- }
- }
-}
-
-/**
- * Demo showcasing provider button shape customization capabilities.
- * Demonstrates:
- * - Global shape configuration for all buttons
- * - Per-provider shape overrides
- * - Using ProviderStyleDefaults with .copy()
- */
-@Composable
-fun ShapeCustomizationDemo() {
- val context = androidx.compose.ui.platform.LocalContext.current
- val stringProvider = DefaultAuthUIStringProvider(context)
- var selectedPreset by remember { mutableStateOf(ShapePreset.DEFAULT) }
-
- Column(
- modifier = Modifier
- .fillMaxSize()
- .verticalScroll(rememberScrollState())
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- // Title and description
- Text(
- text = "Provider Button Shape Customization",
- style = MaterialTheme.typography.headlineSmall,
- color = MaterialTheme.colorScheme.primary
- )
-
- Text(
- text = "This demo showcases the new shape customization API for provider buttons. " +
- "You can set a global shape for all buttons or customize individual providers.",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- HorizontalDivider()
-
- // Preset selector
- Text(
- text = "Select Shape Preset:",
- style = MaterialTheme.typography.titleMedium
- )
-
- ShapePreset.entries.forEach { preset ->
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- RadioButton(
- selected = selectedPreset == preset,
- onClick = { selectedPreset = preset }
- )
- Spacer(modifier = Modifier.width(8.dp))
- Column {
- Text(
- text = preset.displayName,
- style = MaterialTheme.typography.bodyLarge
- )
- Text(
- text = preset.description,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- }
-
- HorizontalDivider()
-
- // Preview section
- Text(
- text = "Preview:",
- style = MaterialTheme.typography.titleMedium
- )
-
- // Render buttons with the selected preset
- when (selectedPreset) {
- ShapePreset.DEFAULT -> DefaultShapeButtons(stringProvider)
- ShapePreset.DEFAULT_COPY -> DefaultCopyShapeButtons(stringProvider)
- ShapePreset.DARK_COPY -> DarkCopyShapeButtons(stringProvider)
- ShapePreset.FROM_MATERIAL -> FromMaterialThemeButtons(stringProvider)
- ShapePreset.PILL -> PillShapeButtons(stringProvider)
- ShapePreset.MIXED -> MixedShapeButtons(stringProvider)
- }
-
- // Code example
- HorizontalDivider()
-
- Text(
- text = "Code Example:",
- style = MaterialTheme.typography.titleMedium
- )
-
- Surface(
- modifier = Modifier.fillMaxWidth(),
- color = MaterialTheme.colorScheme.surfaceVariant,
- shape = RoundedCornerShape(8.dp)
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
+ Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(
- text = selectedPreset.codeExample,
- style = MaterialTheme.typography.bodySmall.copy(
- fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
- ),
- modifier = Modifier.padding(12.dp)
+ text = description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
-
-enum class ShapePreset(
- val displayName: String,
- val description: String,
- val codeExample: String
-) {
- DEFAULT(
- "Default Shapes",
- "Uses the standard 4dp rounded corners",
- """
-// No customization needed
-val theme = AuthUITheme.Default
- """.trimIndent()
- ),
- DEFAULT_COPY(
- "Default.copy()",
- "Customize default light theme with .copy()",
- """
-val theme = AuthUITheme.Default.copy(
- providerButtonShape = RoundedCornerShape(12.dp)
-)
- """.trimIndent()
- ),
- DARK_COPY(
- "DefaultDark.copy()",
- "Customize default dark theme with .copy()",
- """
-val theme = AuthUITheme.DefaultDark.copy(
- providerButtonShape = RoundedCornerShape(16.dp)
-)
- """.trimIndent()
- ),
- FROM_MATERIAL(
- "fromMaterialTheme()",
- "Inherit from Material Theme",
- """
-val theme = AuthUITheme.fromMaterialTheme(
- providerButtonShape = RoundedCornerShape(12.dp)
-)
- """.trimIndent()
- ),
- PILL(
- "Pill Shape",
- "Creates pill-shaped buttons (Default.copy)",
- """
-val theme = AuthUITheme.Default.copy(
- providerButtonShape = RoundedCornerShape(28.dp)
-)
- """.trimIndent()
- ),
- MIXED(
- "Mixed Shapes",
- "Different shapes per provider (Default.copy)",
- """
-val customStyles = mapOf(
- "google.com" to ProviderStyleDefaults.Google.copy(
- shape = RoundedCornerShape(24.dp)
- ),
- "facebook.com" to ProviderStyleDefaults.Facebook.copy(
- shape = RoundedCornerShape(8.dp)
- )
-)
-
-val theme = AuthUITheme.Default.copy(
- providerButtonShape = RoundedCornerShape(12.dp),
- providerStyles = customStyles
-)
- """.trimIndent()
- )
-}
-
-@Composable
-fun DefaultShapeButtons(stringProvider: DefaultAuthUIStringProvider) {
- // Default theme - no customization
- AuthUITheme {
- ButtonPreviewColumn(stringProvider)
- }
-}
-
-@Composable
-fun DefaultCopyShapeButtons(stringProvider: DefaultAuthUIStringProvider) {
- // Using AuthUITheme.Default.copy() to customize the light theme
- val theme = AuthUITheme.Default.copy(
- providerButtonShape = RoundedCornerShape(12.dp)
- )
- AuthUITheme(theme = theme) {
- ButtonPreviewColumn(stringProvider)
- }
-}
-
-@Composable
-fun DarkCopyShapeButtons(stringProvider: DefaultAuthUIStringProvider) {
- // Using AuthUITheme.DefaultDark.copy() to customize the dark theme
- val theme = AuthUITheme.DefaultDark.copy(
- providerButtonShape = RoundedCornerShape(16.dp)
- )
- AuthUITheme(theme = theme) {
- ButtonPreviewColumn(stringProvider)
- }
-}
-
-@Composable
-fun FromMaterialThemeButtons(stringProvider: DefaultAuthUIStringProvider) {
- // Using AuthUITheme.fromMaterialTheme() to inherit from Material Theme
- val theme = AuthUITheme.fromMaterialTheme(
- providerButtonShape = RoundedCornerShape(12.dp)
- )
- AuthUITheme(theme = theme) {
- ButtonPreviewColumn(stringProvider)
- }
-}
-
-@Composable
-fun PillShapeButtons(stringProvider: DefaultAuthUIStringProvider) {
- // Pill-shaped buttons using Default.copy()
- val theme = AuthUITheme.Default.copy(
- providerButtonShape = RoundedCornerShape(28.dp)
- )
- AuthUITheme(theme = theme) {
- ButtonPreviewColumn(stringProvider)
- }
-}
-
-@Composable
-fun MixedShapeButtons(stringProvider: DefaultAuthUIStringProvider) {
- // Mixed shapes per provider using Default.copy()
- val customStyles = mapOf(
- "google.com" to ProviderStyleDefaults.Google.copy(
- shape = RoundedCornerShape(24.dp) // Pill shape for Google
- ),
- "facebook.com" to ProviderStyleDefaults.Facebook.copy(
- shape = RoundedCornerShape(8.dp) // Medium rounded for Facebook
- )
- // Email uses global default (12dp)
- )
-
- val theme = AuthUITheme.Default.copy(
- providerButtonShape = RoundedCornerShape(12.dp),
- providerStyles = customStyles
- )
-
- AuthUITheme(theme = theme) {
- ButtonPreviewColumn(stringProvider)
- }
-}
-
-@Composable
-fun ButtonPreviewColumn(stringProvider: DefaultAuthUIStringProvider) {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- AuthProviderButton(
- provider = AuthProvider.Google(scopes = emptyList(), serverClientId = null),
- onClick = { },
- stringProvider = stringProvider,
- modifier = Modifier.fillMaxWidth()
- )
-
- AuthProviderButton(
- provider = AuthProvider.Facebook(),
- onClick = { },
- stringProvider = stringProvider,
- modifier = Modifier.fillMaxWidth()
- )
-
- AuthProviderButton(
- provider = AuthProvider.Email(
- emailLinkActionCodeSettings = null,
- passwordValidationRules = emptyList()
- ),
- onClick = { },
- stringProvider = stringProvider,
- modifier = Modifier.fillMaxWidth()
- )
- }
-}
diff --git a/app/src/main/java/com/firebaseui/android/demo/EmailAuthSlotDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/EmailAuthSlotDemoActivity.kt
new file mode 100644
index 000000000..cb9605621
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/EmailAuthSlotDemoActivity.kt
@@ -0,0 +1,437 @@
+package com.firebaseui.android.demo
+
+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.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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+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.PasswordRule
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState
+import com.firebase.ui.auth.ui.screens.email.EmailAuthMode
+import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen
+import com.google.firebase.auth.AuthResult
+
+class EmailAuthSlotDemoActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val authUI = FirebaseAuthUI.getInstance()
+ val appContext = applicationContext
+
+ val configuration = authUIConfiguration {
+ context = appContext
+ providers {
+ provider(
+ AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isNewAccountsAllowed = true,
+ isEmailLinkSignInEnabled = false,
+ emailLinkActionCodeSettings = null,
+ isEmailLinkForceSameDeviceEnabled = false,
+ minimumPasswordLength = 8,
+ passwordValidationRules = listOf(
+ PasswordRule.MinimumLength(8),
+ PasswordRule.RequireLowercase,
+ PasswordRule.RequireUppercase,
+ PasswordRule.RequireDigit
+ )
+ )
+ )
+ }
+ tosUrl = "https://policies.google.com/terms"
+ privacyPolicyUrl = "https://policies.google.com/privacy"
+ }
+
+ setContent {
+ CustomAuthUITheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .systemBarsPadding()
+ ) {
+ EmailAuthDemo(
+ authUI = authUI,
+ configuration = configuration,
+ context = appContext
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun CustomAuthUITheme(content: @Composable () -> Unit) {
+ MaterialTheme {
+ val authTheme = AuthUITheme.fromMaterialTheme(
+ providerButtonShape = RoundedCornerShape(12.dp)
+ )
+ AuthUITheme(theme = authTheme) {
+ content()
+ }
+ }
+}
+
+@Composable
+fun EmailAuthDemo(
+ authUI: FirebaseAuthUI,
+ configuration: AuthUIConfiguration,
+ context: android.content.Context
+) {
+ var currentUser by remember { mutableStateOf(authUI.getCurrentUser()) }
+
+ LaunchedEffect(Unit) {
+ authUI.authStateFlow().collect { _ ->
+ currentUser = authUI.getCurrentUser()
+ }
+ }
+
+ if (currentUser != null) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "Successfully Authenticated!",
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = currentUser?.email ?: "Signed in",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ Button(onClick = { authUI.auth.signOut() }) {
+ Text("Sign Out")
+ }
+ }
+ } else {
+ CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) {
+ EmailAuthScreen(
+ context = context,
+ configuration = configuration,
+ authUI = authUI,
+ onSuccess = { result: AuthResult ->
+ Log.d("EmailAuthSlotDemo", "Auth success: ${result.user?.uid}")
+ },
+ onError = { exception: AuthException ->
+ Log.e("EmailAuthSlotDemo", "Auth error", exception)
+ },
+ onCancel = {
+ Log.d("EmailAuthSlotDemo", "Auth cancelled")
+ }
+ ) { state: EmailAuthContentState ->
+ CustomEmailAuthUI(state)
+ }
+ }
+ }
+}
+
+@Composable
+fun CustomEmailAuthUI(state: EmailAuthContentState) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = when (state.mode) {
+ EmailAuthMode.SignIn, EmailAuthMode.EmailLinkSignIn -> "Welcome Back"
+ EmailAuthMode.SignUp -> "Create Account"
+ EmailAuthMode.ResetPassword -> "Reset Password"
+ },
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ state.error?.let { errorMessage ->
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = errorMessage,
+ modifier = Modifier.padding(12.dp),
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+
+ when (state.mode) {
+ EmailAuthMode.SignIn, EmailAuthMode.EmailLinkSignIn -> SignInUI(state)
+ EmailAuthMode.SignUp -> SignUpUI(state)
+ EmailAuthMode.ResetPassword -> ResetPasswordUI(state)
+ }
+ }
+}
+
+@Composable
+fun SignInUI(state: EmailAuthContentState) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedTextField(
+ value = state.email,
+ onValueChange = state.onEmailChange,
+ label = { Text("Email") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ OutlinedTextField(
+ value = state.password,
+ onValueChange = state.onPasswordChange,
+ label = { Text("Password") },
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ if (state.emailSignInLinkSent) {
+ Text(
+ text = "Sign-in link sent! Check your email.",
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Button(
+ onClick = state.onSignInClick,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !state.isLoading
+ ) {
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Sign In")
+ }
+ }
+
+ TextButton(
+ onClick = state.onGoToResetPassword,
+ modifier = Modifier.align(Alignment.CenterHorizontally)
+ ) {
+ Text("Forgot Password?")
+ }
+
+ HorizontalDivider()
+
+ TextButton(
+ onClick = state.onGoToSignUp,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Don't have an account? Sign Up")
+ }
+ }
+}
+
+@Composable
+fun SignUpUI(state: EmailAuthContentState) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedTextField(
+ value = state.displayName,
+ onValueChange = state.onDisplayNameChange,
+ label = { Text("Display Name") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ OutlinedTextField(
+ value = state.email,
+ onValueChange = state.onEmailChange,
+ label = { Text("Email") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ OutlinedTextField(
+ value = state.password,
+ onValueChange = state.onPasswordChange,
+ label = { Text("Password") },
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ OutlinedTextField(
+ value = state.confirmPassword,
+ onValueChange = state.onConfirmPasswordChange,
+ label = { Text("Confirm Password") },
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Button(
+ onClick = state.onSignUpClick,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !state.isLoading
+ ) {
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Create Account")
+ }
+ }
+
+ HorizontalDivider()
+
+ TextButton(
+ onClick = state.onGoToSignIn,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Already have an account? Sign In")
+ }
+ }
+}
+
+@Composable
+fun ResetPasswordUI(state: EmailAuthContentState) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "Enter your email address and we'll send you a link to reset your password.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = state.email,
+ onValueChange = state.onEmailChange,
+ label = { Text("Email") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ if (state.resetLinkSent) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = "Password reset link sent! Check your email.",
+ modifier = Modifier.padding(12.dp),
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Button(
+ onClick = state.onSendResetLinkClick,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !state.isLoading && !state.resetLinkSent
+ ) {
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Send Reset Link")
+ }
+ }
+
+ HorizontalDivider()
+
+ TextButton(
+ onClick = state.onGoToSignIn,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Back to Sign In")
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt
index fcae3ea9c..145be42eb 100644
--- a/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt
@@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -20,16 +22,27 @@ import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
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
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
import com.firebase.ui.auth.FirebaseAuthUI
@@ -42,6 +55,8 @@ 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.util.EmailLinkConstants
+import com.firebase.ui.auth.util.displayIdentifier
+import com.firebase.ui.auth.util.getDisplayEmail
import com.google.firebase.auth.actionCodeSettings
class HighLevelApiDemoActivity : ComponentActivity() {
@@ -190,6 +205,13 @@ class HighLevelApiDemoActivity : ComponentActivity() {
onSignInCancelled = {
Log.d("HighLevelApiDemoActivity", "Authentication cancelled")
},
+ reauthContent = { state, onDismiss ->
+ ReauthDialog(
+ authUI = authUI,
+ state = state,
+ onDismiss = onDismiss,
+ )
+ },
authenticatedContent = { state, uiContext ->
AppAuthenticatedContent(state, uiContext)
}
@@ -210,8 +232,24 @@ private fun AppAuthenticatedContent(
val configuration = uiContext.configuration
when (state) {
is AuthState.Success -> {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ var isDeletingAccount by remember { mutableStateOf(false) }
val user = uiContext.authUI.getCurrentUser()
- val identifier = user?.email ?: user?.phoneNumber ?: user?.uid.orEmpty()
+ val identifier = user.displayIdentifier()
+ var showChangePasswordDialog by remember { mutableStateOf(false) }
+
+ if (showChangePasswordDialog) {
+ ChangePasswordDialog(
+ authUI = uiContext.authUI,
+ configuration = uiContext.configuration,
+ stringProvider = uiContext.stringProvider,
+ context = context,
+ lifecycleOwner = lifecycleOwner,
+ onDismiss = { showChangePasswordDialog = false },
+ )
+ }
+
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -245,7 +283,7 @@ private fun AppAuthenticatedContent(
}
},
state = rememberTooltipState(
- initialIsVisible = !configuration.isMfaEnabled
+ initialIsVisible = false
)
) {
Button(
@@ -259,11 +297,37 @@ private fun AppAuthenticatedContent(
Button(onClick = uiContext.onSignOut) {
Text(stringProvider.signOutAction)
}
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = { showChangePasswordDialog = true }) {
+ Text("Change password (withReauth)")
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(
+ onClick = {
+ lifecycleOwner.lifecycleScope.launch {
+ isDeletingAccount = true
+ try {
+ uiContext.authUI.delete(context)
+ } catch (e: AuthException.InvalidCredentialsException) {
+ // ReauthenticationRequired state was emitted —
+ // FirebaseAuthScreen navigates to the reauth flow automatically.
+ Log.d("HighLevelApiDemoActivity", "Reauth required before delete")
+ } catch (e: AuthException) {
+ Log.e("HighLevelApiDemoActivity", "Delete failed", e)
+ } finally {
+ isDeletingAccount = false
+ }
+ }
+ },
+ enabled = !isDeletingAccount
+ ) {
+ if (isDeletingAccount) CircularProgressIndicator() else Text("Delete account")
+ }
}
}
is AuthState.RequiresEmailVerification -> {
- val email = uiContext.authUI.getCurrentUser()?.email ?: stringProvider.emailProvider
+ val email = uiContext.authUI.getCurrentUser().getDisplayEmail(stringProvider.emailProvider)
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -324,3 +388,198 @@ private fun AppAuthenticatedContent(
}
}
}
+
+@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()
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ title = {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text("Verify your identity")
+ state.reason?.let { reason ->
+ Text(
+ reason,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(
+ "Signing in as $email",
+ 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) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ )
+ } else {
+ Text("Verify")
+ }
+ }
+ },
+ )
+}
+
+@Composable
+private fun ChangePasswordDialog(
+ authUI: FirebaseAuthUI,
+ configuration: com.firebase.ui.auth.configuration.AuthUIConfiguration,
+ stringProvider: com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider,
+ context: android.content.Context,
+ lifecycleOwner: androidx.lifecycle.LifecycleOwner,
+ onDismiss: () -> Unit,
+) {
+ var newPassword by remember { mutableStateOf("") }
+ var confirmPassword by remember { mutableStateOf("") }
+ var isUpdating by remember { mutableStateOf(false) }
+ var updateError by remember { mutableStateOf(null) }
+
+ val emailProvider = remember(configuration) {
+ configuration.providers.filterIsInstance().firstOrNull()
+ }
+ val passwordValidator = remember(emailProvider, stringProvider) {
+ com.firebase.ui.auth.configuration.validators.PasswordValidator(
+ stringProvider = stringProvider,
+ rules = emailProvider?.passwordValidationRules ?: emptyList(),
+ )
+ }
+ val confirmValidator = remember(stringProvider) {
+ com.firebase.ui.auth.configuration.validators.PasswordValidator(
+ stringProvider = stringProvider,
+ rules = emptyList(),
+ )
+ }
+
+ val passwordsMatch = newPassword == confirmPassword
+ val isValid = !passwordValidator.hasError && newPassword.isNotBlank() &&
+ passwordsMatch && confirmPassword.isNotBlank()
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Change password") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ com.firebase.ui.auth.ui.components.AuthTextField(
+ value = newPassword,
+ onValueChange = {
+ newPassword = it
+ updateError = null
+ },
+ label = { Text("New password") },
+ isSecureTextField = true,
+ validator = passwordValidator,
+ )
+ com.firebase.ui.auth.ui.components.AuthTextField(
+ value = confirmPassword,
+ onValueChange = {
+ confirmPassword = it
+ updateError = null
+ },
+ label = { Text("Confirm password") },
+ isSecureTextField = true,
+ isError = confirmPassword.isNotEmpty() && !passwordsMatch,
+ errorMessage = if (confirmPassword.isNotEmpty() && !passwordsMatch) "Passwords do not match" else null,
+ validator = confirmValidator,
+ )
+ if (updateError != null) {
+ Text(
+ updateError!!,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text("Cancel") }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ lifecycleOwner.lifecycleScope.launch {
+ isUpdating = true
+ updateError = null
+ try {
+ authUI.withReauth(
+ context,
+ reason = "Verify your identity to change your password",
+ ) {
+ authUI.getCurrentUser()?.updatePassword(newPassword)?.await()
+ Log.d("HighLevelApiDemoActivity", "Password changed successfully")
+ onDismiss()
+ }
+ } catch (e: Exception) {
+ updateError = "Failed to update password. Please try again."
+ } finally {
+ isUpdating = false
+ }
+ }
+ },
+ enabled = isValid && !isUpdating,
+ ) {
+ if (isUpdating) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ )
+ } else {
+ Text("Update")
+ }
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt
index 300014174..b1fbd486e 100644
--- a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt
@@ -94,6 +94,9 @@ class MainActivity : ComponentActivity() {
onCustomSlotsClick = {
startActivity(Intent(this, CustomSlotsThemingDemoActivity::class.java))
},
+ onCredentialLinkingClick = {
+ startActivity(Intent(this, CredentialLinkingDemoActivity::class.java))
+ },
isEmulatorMode = USE_AUTH_EMULATOR
)
}
@@ -107,6 +110,7 @@ fun ChooserScreen(
onHighLevelApiClick: () -> Unit,
onLowLevelApiClick: () -> Unit,
onCustomSlotsClick: () -> Unit,
+ onCredentialLinkingClick: () -> Unit = {},
isEmulatorMode: Boolean = false
) {
val scrollState = rememberScrollState()
@@ -272,6 +276,32 @@ fun ChooserScreen(
}
}
+ // Credential Linking Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onCredentialLinkingClick
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "🔗 Credential Linking",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "isCredentialLinkingEnabled",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = "Sign in with one provider, then add another to the same account without losing your UID.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
Spacer(modifier = Modifier.height(16.dp))
// Info card
diff --git a/app/src/main/java/com/firebaseui/android/demo/PhoneAuthSlotDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/PhoneAuthSlotDemoActivity.kt
new file mode 100644
index 000000000..9639beefb
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/PhoneAuthSlotDemoActivity.kt
@@ -0,0 +1,338 @@
+package com.firebaseui.android.demo
+
+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.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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+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.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthStep
+import com.google.firebase.auth.AuthResult
+
+class PhoneAuthSlotDemoActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val authUI = FirebaseAuthUI.getInstance()
+ val appContext = applicationContext
+
+ val configuration = authUIConfiguration {
+ context = appContext
+ providers {
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = "US",
+ allowedCountries = emptyList(),
+ smsCodeLength = 6,
+ timeout = 60L,
+ isInstantVerificationEnabled = true
+ )
+ )
+ }
+ }
+
+ setContent {
+ CustomAuthUITheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .systemBarsPadding()
+ ) {
+ PhoneAuthDemo(
+ authUI = authUI,
+ configuration = configuration,
+ context = appContext
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun PhoneAuthDemo(
+ authUI: FirebaseAuthUI,
+ configuration: AuthUIConfiguration,
+ context: android.content.Context
+) {
+ var currentUser by remember { mutableStateOf(authUI.getCurrentUser()) }
+
+ LaunchedEffect(Unit) {
+ authUI.authStateFlow().collect { _ ->
+ currentUser = authUI.getCurrentUser()
+ }
+ }
+
+ if (currentUser != null) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "Phone Verified!",
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = currentUser?.phoneNumber ?: "Signed in",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ Button(onClick = { authUI.auth.signOut() }) {
+ Text("Sign Out")
+ }
+ }
+ } else {
+ CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) {
+ PhoneAuthScreen(
+ context = context,
+ configuration = configuration,
+ authUI = authUI,
+ onSuccess = { result: AuthResult ->
+ Log.d("PhoneAuthSlotDemo", "Auth success: ${result.user?.uid}")
+ },
+ onError = { exception: AuthException ->
+ Log.e("PhoneAuthSlotDemo", "Auth error", exception)
+ },
+ onCancel = {
+ Log.d("PhoneAuthSlotDemo", "Auth cancelled")
+ }
+ ) { state: PhoneAuthContentState ->
+ CustomPhoneAuthUI(state)
+ }
+ }
+ }
+}
+
+@Composable
+fun CustomPhoneAuthUI(state: PhoneAuthContentState) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = when (state.step) {
+ PhoneAuthStep.EnterPhoneNumber -> "Phone Verification"
+ PhoneAuthStep.EnterVerificationCode -> "Enter Code"
+ },
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ state.error?.let { errorMessage ->
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = errorMessage,
+ modifier = Modifier.padding(12.dp),
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+
+ when (state.step) {
+ PhoneAuthStep.EnterPhoneNumber -> EnterPhoneNumberUI(state)
+ PhoneAuthStep.EnterVerificationCode -> EnterVerificationCodeUI(state)
+ }
+ }
+}
+
+@Composable
+fun EnterPhoneNumberUI(state: PhoneAuthContentState) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "Enter your phone number to receive a verification code",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedCard(
+ onClick = { },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "${state.selectedCountry.flagEmoji} ${state.selectedCountry.dialCode}",
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ text = state.selectedCountry.name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ OutlinedTextField(
+ value = state.phoneNumber,
+ onValueChange = state.onPhoneNumberChange,
+ label = { Text("Phone Number") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Button(
+ onClick = state.onSendCodeClick,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !state.isLoading && state.phoneNumber.isNotBlank()
+ ) {
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Send Code")
+ }
+ }
+ }
+}
+
+@Composable
+fun EnterVerificationCodeUI(state: PhoneAuthContentState) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "We sent a verification code to:",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+
+ Text(
+ text = state.fullPhoneNumber,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = state.verificationCode,
+ onValueChange = state.onVerificationCodeChange,
+ label = { Text("6-Digit Code") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Button(
+ onClick = state.onVerifyCodeClick,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !state.isLoading && state.verificationCode.length == 6
+ ) {
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Verify Code")
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ TextButton(onClick = state.onChangeNumberClick) {
+ Text("Change Number")
+ }
+
+ TextButton(
+ onClick = state.onResendCodeClick,
+ enabled = state.resendTimer == 0
+ ) {
+ Text(
+ if (state.resendTimer > 0) "Resend (${state.resendTimer}s)"
+ else "Resend Code"
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/ShapeCustomizationDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/ShapeCustomizationDemoActivity.kt
new file mode 100644
index 000000000..5faba7336
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/ShapeCustomizationDemoActivity.kt
@@ -0,0 +1,263 @@
+package com.firebaseui.android.demo
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults
+import com.firebase.ui.auth.ui.components.AuthProviderButton
+
+class ShapeCustomizationDemoActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .systemBarsPadding()
+ ) {
+ ShapeCustomizationDemo()
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ShapeCustomizationDemo() {
+ val context = LocalContext.current
+ val stringProvider = DefaultAuthUIStringProvider(context)
+ var selectedPreset by remember { mutableStateOf(ShapePreset.DEFAULT) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = "Provider Button Shape Customization",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+
+ Text(
+ text = "Showcases the shape customization API for provider buttons. " +
+ "Set a global shape for all buttons or customize individual providers.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ HorizontalDivider()
+
+ Text(text = "Select Shape Preset:", style = MaterialTheme.typography.titleMedium)
+
+ ShapePreset.entries.forEach { preset ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = selectedPreset == preset,
+ onClick = { selectedPreset = preset }
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Column {
+ Text(text = preset.displayName, style = MaterialTheme.typography.bodyLarge)
+ Text(
+ text = preset.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ HorizontalDivider()
+
+ Text(text = "Preview:", style = MaterialTheme.typography.titleMedium)
+
+ when (selectedPreset) {
+ ShapePreset.DEFAULT -> DefaultShapeButtons(stringProvider)
+ ShapePreset.DEFAULT_COPY -> DefaultCopyShapeButtons(stringProvider)
+ ShapePreset.DARK_COPY -> DarkCopyShapeButtons(stringProvider)
+ ShapePreset.FROM_MATERIAL -> FromMaterialThemeButtons(stringProvider)
+ ShapePreset.PILL -> PillShapeButtons(stringProvider)
+ ShapePreset.MIXED -> MixedShapeButtons(stringProvider)
+ }
+
+ HorizontalDivider()
+
+ Text(text = "Code Example:", style = MaterialTheme.typography.titleMedium)
+
+ androidx.compose.material3.Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = RoundedCornerShape(8.dp)
+ ) {
+ Text(
+ text = selectedPreset.codeExample,
+ style = MaterialTheme.typography.bodySmall.copy(
+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
+ ),
+ modifier = Modifier.padding(12.dp)
+ )
+ }
+ }
+}
+
+enum class ShapePreset(
+ val displayName: String,
+ val description: String,
+ val codeExample: String
+) {
+ DEFAULT(
+ "Default Shapes",
+ "Uses the standard 4dp rounded corners",
+ "// No customization needed\nval theme = AuthUITheme.Default"
+ ),
+ DEFAULT_COPY(
+ "Default.copy()",
+ "Customize default light theme with .copy()",
+ "val theme = AuthUITheme.Default.copy(\n providerButtonShape = RoundedCornerShape(12.dp)\n)"
+ ),
+ DARK_COPY(
+ "DefaultDark.copy()",
+ "Customize default dark theme with .copy()",
+ "val theme = AuthUITheme.DefaultDark.copy(\n providerButtonShape = RoundedCornerShape(16.dp)\n)"
+ ),
+ FROM_MATERIAL(
+ "fromMaterialTheme()",
+ "Inherit from Material Theme",
+ "val theme = AuthUITheme.fromMaterialTheme(\n providerButtonShape = RoundedCornerShape(12.dp)\n)"
+ ),
+ PILL(
+ "Pill Shape",
+ "Creates pill-shaped buttons (Default.copy)",
+ "val theme = AuthUITheme.Default.copy(\n providerButtonShape = RoundedCornerShape(28.dp)\n)"
+ ),
+ MIXED(
+ "Mixed Shapes",
+ "Different shapes per provider (Default.copy)",
+ "val customStyles = mapOf(\n \"google.com\" to ProviderStyleDefaults.Google.copy(\n shape = RoundedCornerShape(24.dp)\n ),\n \"facebook.com\" to ProviderStyleDefaults.Facebook.copy(\n shape = RoundedCornerShape(8.dp)\n )\n)\n\nval theme = AuthUITheme.Default.copy(\n providerButtonShape = RoundedCornerShape(12.dp),\n providerStyles = customStyles\n)"
+ )
+}
+
+@Composable
+fun DefaultShapeButtons(stringProvider: DefaultAuthUIStringProvider) {
+ AuthUITheme { ButtonPreviewColumn(stringProvider) }
+}
+
+@Composable
+fun DefaultCopyShapeButtons(stringProvider: DefaultAuthUIStringProvider) {
+ AuthUITheme(theme = AuthUITheme.Default.copy(providerButtonShape = RoundedCornerShape(12.dp))) {
+ ButtonPreviewColumn(stringProvider)
+ }
+}
+
+@Composable
+fun DarkCopyShapeButtons(stringProvider: DefaultAuthUIStringProvider) {
+ AuthUITheme(theme = AuthUITheme.DefaultDark.copy(providerButtonShape = RoundedCornerShape(16.dp))) {
+ ButtonPreviewColumn(stringProvider)
+ }
+}
+
+@Composable
+fun FromMaterialThemeButtons(stringProvider: DefaultAuthUIStringProvider) {
+ AuthUITheme(theme = AuthUITheme.fromMaterialTheme(providerButtonShape = RoundedCornerShape(12.dp))) {
+ ButtonPreviewColumn(stringProvider)
+ }
+}
+
+@Composable
+fun PillShapeButtons(stringProvider: DefaultAuthUIStringProvider) {
+ AuthUITheme(theme = AuthUITheme.Default.copy(providerButtonShape = RoundedCornerShape(28.dp))) {
+ ButtonPreviewColumn(stringProvider)
+ }
+}
+
+@Composable
+fun MixedShapeButtons(stringProvider: DefaultAuthUIStringProvider) {
+ val customStyles = mapOf(
+ "google.com" to ProviderStyleDefaults.Google.copy(shape = RoundedCornerShape(24.dp)),
+ "facebook.com" to ProviderStyleDefaults.Facebook.copy(shape = RoundedCornerShape(8.dp))
+ )
+ AuthUITheme(
+ theme = AuthUITheme.Default.copy(
+ providerButtonShape = RoundedCornerShape(12.dp),
+ providerStyles = customStyles
+ )
+ ) {
+ ButtonPreviewColumn(stringProvider)
+ }
+}
+
+@Composable
+fun ButtonPreviewColumn(stringProvider: DefaultAuthUIStringProvider) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ AuthProviderButton(
+ provider = AuthProvider.Google(scopes = emptyList(), serverClientId = null),
+ onClick = { },
+ stringProvider = stringProvider,
+ modifier = Modifier.fillMaxWidth()
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Facebook(),
+ onClick = { },
+ stringProvider = stringProvider,
+ modifier = Modifier.fillMaxWidth()
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ ),
+ onClick = { },
+ stringProvider = stringProvider,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
diff --git a/auth/README.md b/auth/README.md
index 04c6a77b2..564691b2c 100644
--- a/auth/README.md
+++ b/auth/README.md
@@ -52,6 +52,7 @@ Equivalent FirebaseUI libraries are available for [iOS](https://github.com/fireb
- [High-Level API (Recommended)](#high-level-api-recommended)
- [Low-Level API (Advanced)](#low-level-api-advanced)
- [Custom UI with Slots](#custom-ui-with-slots)
+ - [Reauthentication](#reauthentication)
8. [Theming & Customization](#theming--customization)
- [Using Default Themes](#using-default-themes)
- [Using Adaptive Theme](#using-adaptive-theme-recommended)
@@ -794,131 +795,258 @@ class AuthActivity : ComponentActivity() {
### Custom UI with Slots
-For complete UI control while keeping authentication logic, use content slots:
+`FirebaseAuthScreen` accepts optional slot parameters that let you replace individual screens with your own UI while keeping all authentication logic intact. Each slot receives a state object with the data and callbacks needed to drive your UI.
```kotlin
-@Composable
-fun CustomEmailAuth() {
- val emailConfig = AuthProvider.Email(
- passwordValidationRules = listOf(
- PasswordRule.MinimumLength(8),
- PasswordRule.RequireDigit
- )
- )
+FirebaseAuthScreen(
+ configuration = configuration,
+ onSignInSuccess = { /* ... */ },
+ onSignInFailure = { /* ... */ },
+ onSignInCancelled = { /* ... */ },
+ customMethodPickerLayout = { providers, onProviderSelected -> /* ... */ },
+ customMethodPickerTermsConfiguration = MethodPickerTermsConfiguration(
+ content = { Text("By continuing you agree to our Terms") },
+ accepted = termsAccepted,
+ disableProvidersUntilAccepted = true,
+ ),
+ emailContent = { state -> /* ... */ },
+ phoneContent = { state -> /* ... */ },
+ mfaEnrollmentContent = { state -> /* ... */ },
+ mfaChallengeContent = { state -> /* ... */ },
+ reauthContent = { state, onDismiss -> /* ... */ },
+) { authState, uiContext ->
+ // authenticated content
+}
+```
- EmailAuthScreen(
- configuration = emailConfig,
- onSuccess = { /* ... */ },
- onError = { /* ... */ },
- onCancel = { /* ... */ }
- ) { state ->
- // Custom UI with full control
- when (state.mode) {
- EmailAuthMode.SignIn -> {
- CustomSignInUI(state)
- }
- EmailAuthMode.SignUp -> {
- CustomSignUpUI(state)
- }
- EmailAuthMode.ResetPassword -> {
- CustomResetPasswordUI(state)
+#### Method picker (`customMethodPickerLayout`)
+
+Replaces the default provider selection screen. Receives the configured providers and a callback to invoke when the user selects one.
+
+```kotlin
+customMethodPickerLayout = { providers, onProviderSelected ->
+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
+ providers.forEach { provider ->
+ OutlinedButton(
+ onClick = { onProviderSelected(provider) },
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ ) {
+ Text("Continue with ${provider.providerName}")
}
}
}
}
+```
-@Composable
-fun CustomSignInUI(state: EmailAuthContentState) {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = "Welcome Back!",
- style = MaterialTheme.typography.headlineLarge
- )
-
- Spacer(modifier = Modifier.height(32.dp))
+Use `customMethodPickerTermsConfiguration` alongside it to add a terms-of-service checkbox that can optionally gate provider selection until accepted.
- OutlinedTextField(
- value = state.email,
- onValueChange = state.onEmailChange,
- label = { Text("Email") },
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
- modifier = Modifier.fillMaxWidth()
- )
+```kotlin
+customMethodPickerTermsConfiguration = MethodPickerTermsConfiguration(
+ content = { Text("I agree to the Terms of Service") },
+ accepted = termsAccepted,
+ disableProvidersUntilAccepted = true,
+)
+```
- Spacer(modifier = Modifier.height(16.dp))
+#### Email (`emailContent`)
- OutlinedTextField(
- value = state.password,
- onValueChange = state.onPasswordChange,
- label = { Text("Password") },
- visualTransformation = PasswordVisualTransformation(),
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
- modifier = Modifier.fillMaxWidth()
- )
+Replaces the default email sign-in / sign-up / password reset screens. The `EmailAuthContentState` carries the current `mode` (`SignIn`, `SignUp`, `ResetPassword`, `EmailLinkSignIn`), field values, and callbacks for every action.
- if (state.error != null) {
- Text(
- text = state.error!!,
- color = MaterialTheme.colorScheme.error,
- modifier = Modifier.padding(top = 8.dp)
- )
+```kotlin
+emailContent = { state ->
+ when (state.mode) {
+ EmailAuthMode.SignIn -> {
+ Column {
+ OutlinedTextField(
+ value = state.email,
+ onValueChange = state.onEmailChange,
+ label = { Text("Email") },
+ )
+ OutlinedTextField(
+ value = state.password,
+ onValueChange = state.onPasswordChange,
+ label = { Text("Password") },
+ visualTransformation = PasswordVisualTransformation(),
+ )
+ state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
+ Button(onClick = state.onSignInClick, enabled = !state.isLoading) {
+ Text("Sign in")
+ }
+ TextButton(onClick = state.onGoToSignUp) { Text("Create account") }
+ TextButton(onClick = state.onGoToResetPassword) { Text("Forgot password?") }
+ }
}
+ EmailAuthMode.SignUp -> { /* ... */ }
+ EmailAuthMode.ResetPassword -> { /* ... */ }
+ EmailAuthMode.EmailLinkSignIn -> { /* ... */ }
+ }
+}
+```
- Spacer(modifier = Modifier.height(24.dp))
+#### Phone (`phoneContent`)
- Button(
- onClick = state.onSignInClick,
- enabled = !state.isLoading,
- modifier = Modifier.fillMaxWidth()
- ) {
- if (state.isLoading) {
- CircularProgressIndicator(modifier = Modifier.size(24.dp))
- } else {
- Text("Sign In")
+Replaces the default phone number entry and SMS code verification screens. The `PhoneAuthContentState` carries the current `step` (`EnterPhoneNumber`, `EnterVerificationCode`), field values, and callbacks.
+
+```kotlin
+phoneContent = { state ->
+ when (state.step) {
+ PhoneAuthStep.EnterPhoneNumber -> {
+ Column {
+ OutlinedTextField(
+ value = state.phoneNumber,
+ onValueChange = state.onPhoneNumberChange,
+ label = { Text("Phone number") },
+ )
+ state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
+ Button(onClick = state.onSendCodeClick, enabled = !state.isLoading) {
+ Text("Send code")
+ }
}
}
-
- TextButton(onClick = state.onGoToResetPassword) {
- Text("Forgot Password?")
+ PhoneAuthStep.EnterVerificationCode -> {
+ Column {
+ OutlinedTextField(
+ value = state.verificationCode,
+ onValueChange = state.onVerificationCodeChange,
+ label = { Text("Verification code") },
+ )
+ Button(onClick = state.onVerifyCodeClick, enabled = !state.isLoading) {
+ Text("Verify")
+ }
+ if (state.resendTimer == 0) {
+ TextButton(onClick = state.onResendCodeClick) { Text("Resend code") }
+ }
+ }
}
+ }
+}
+```
+
+#### MFA enrollment (`mfaEnrollmentContent`)
+
+Replaces the default MFA enrollment screens. The `MfaEnrollmentContentState` carries the current `step`, `availableFactors`, `enrolledFactors`, and callbacks for factor selection, unenrollment, and navigation.
- TextButton(onClick = state.onGoToSignUp) {
- Text("Create Account")
+```kotlin
+mfaEnrollmentContent = { state ->
+ when (state.step) {
+ MfaEnrollmentStep.SelectFactor -> {
+ Column {
+ state.availableFactors.forEach { factor ->
+ Button(onClick = { state.onFactorSelected(factor) }) {
+ Text("Enroll ${factor.name}")
+ }
+ }
+ state.onSkipClick?.let { skip ->
+ TextButton(onClick = skip) { Text("Skip") }
+ }
+ }
}
+ // Handle other steps...
+ else -> { /* ... */ }
}
}
```
-Similarly, create custom phone authentication UI:
+#### MFA challenge (`mfaChallengeContent`)
+
+Replaces the default MFA verification screen shown during sign-in. The `MfaChallengeContentState` carries `factorType`, `verificationCode`, `resendTimer`, and callbacks to verify or resend.
```kotlin
-@Composable
-fun CustomPhoneAuth() {
- val phoneConfig = AuthProvider.Phone(defaultCountryCode = "US")
-
- PhoneAuthScreen(
- configuration = phoneConfig,
- onSuccess = { /* ... */ },
- onError = { /* ... */ },
- onCancel = { /* ... */ }
- ) { state ->
- when (state.step) {
- PhoneAuthStep.EnterPhoneNumber -> {
- CustomPhoneNumberInput(state)
- }
- PhoneAuthStep.EnterVerificationCode -> {
- CustomVerificationCodeInput(state)
+mfaChallengeContent = { state ->
+ Column {
+ state.maskedPhoneNumber?.let { Text("Code sent to $it") }
+ OutlinedTextField(
+ value = state.verificationCode,
+ onValueChange = state.onVerificationCodeChange,
+ label = { Text("Verification code") },
+ )
+ state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
+ Button(onClick = state.onVerifyClick, enabled = !state.isLoading) {
+ Text("Verify")
+ }
+ if (state.resendTimer == 0) {
+ state.onResendCodeClick?.let { resend ->
+ TextButton(onClick = resend) { Text("Resend code") }
}
+ } else {
+ Text("Resend available in ${state.resendTimer}s")
}
+ TextButton(onClick = state.onCancelClick) { Text("Cancel") }
+ }
+}
+```
+
+#### 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`.
+
+```kotlin
+reauthContent = { state, onDismiss ->
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Verify your identity") },
+ text = {
+ Column {
+ state.reason?.let { Text(it) }
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("Password") },
+ visualTransformation = PasswordVisualTransformation(),
+ )
+ }
+ },
+ confirmButton = {
+ Button(onClick = {
+ // Re-authenticate then update auth state on success
+ }) { Text("Confirm") }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text("Cancel") }
+ },
+ )
+}
+```
+
+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.
+
+```kotlin
+lifecycleScope.launch {
+ authUI.withReauth(
+ context = context,
+ reason = "Verify your identity to delete your account",
+ ) {
+ auth.currentUser?.delete()?.await()
}
}
```
+`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.
+4. On successful reauthentication, retries the operation automatically and emits `AuthState.Success` or `AuthState.Error`.
+
+**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
+ },
+)
+val intent = reauth.createIntent(context)
+launcher.launch(intent)
+```
+
## Multi-Factor Authentication
### MFA Configuration
diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthException.kt b/auth/src/main/java/com/firebase/ui/auth/AuthException.kt
index 46d22f068..779b93a8d 100644
--- a/auth/src/main/java/com/firebase/ui/auth/AuthException.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/AuthException.kt
@@ -14,7 +14,10 @@
package com.firebase.ui.auth
+import android.content.Context
import com.firebase.ui.auth.AuthException.Companion.from
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
import com.google.firebase.FirebaseException
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.FirebaseAuthException
@@ -120,6 +123,25 @@ abstract class AuthException(
val reason: String? = null
) : AuthException(message, cause)
+ /**
+ * The password violates one or more Google Identity Platform password policy requirements.
+ *
+ * This exception is thrown when GIdP password policy enforcement is enabled and the supplied
+ * password fails one or more configured constraints (e.g. minimum length, missing uppercase).
+ *
+ * [message] is a newline-separated, human-readable description of each failing constraint
+ * as returned by the server, suitable for direct display in the UI.
+ *
+ * @property message Human-readable description of the failing constraints
+ * @property failingRequirements The individual constraint strings from the server
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class PasswordPolicyViolationException(
+ message: String,
+ val failingRequirements: List,
+ cause: Throwable? = null
+ ) : AuthException(message, cause)
+
/**
* An account with the given email already exists.
*
@@ -341,15 +363,47 @@ abstract class AuthException(
* @return An appropriate [AuthException] subtype
*/
@JvmStatic
- fun from(firebaseException: Exception): AuthException {
+ fun from(firebaseException: Exception, context: Context): AuthException =
+ from(firebaseException, DefaultAuthUIStringProvider(context))
+
+ @JvmStatic
+ @JvmOverloads
+ fun from(firebaseException: Exception, stringProvider: AuthUIStringProvider? = null): AuthException {
return when (firebaseException) {
// If already an AuthException, return it directly
is AuthException -> firebaseException
-
- // Handle specific Firebase Auth exceptions first (before general FirebaseException)
+
+ // Handle specific Firebase Auth exceptions first (before general FirebaseException).
+ // FirebaseAuthWeakPasswordException extends FirebaseAuthInvalidCredentialsException,
+ // so it must be checked before the parent type.
+ is FirebaseAuthWeakPasswordException -> {
+ val sourceText = firebaseException.reason ?: firebaseException.message ?: ""
+ if (sourceText.contains("PASSWORD_DOES_NOT_MEET_REQUIREMENTS", ignoreCase = true)) {
+ val requirements = parsePasswordPolicyRequirements(sourceText)
+ PasswordPolicyViolationException(
+ message = requirements.joinToString("\n").ifEmpty {
+ stringProvider?.errorWeakPasswordGeneric.nonEmpty()
+ ?: "Password does not meet policy requirements"
+ },
+ failingRequirements = requirements,
+ cause = firebaseException
+ )
+ } else {
+ WeakPasswordException(
+ message = stringProvider?.errorWeakPasswordGeneric.nonEmpty()
+ ?: firebaseException.message
+ ?: "Password is too weak",
+ cause = firebaseException,
+ reason = firebaseException.reason
+ )
+ }
+ }
+
is FirebaseAuthInvalidCredentialsException -> {
InvalidCredentialsException(
- message = firebaseException.message ?: "Invalid credentials provided",
+ message = stringProvider?.errorInvalidCredentials.nonEmpty()
+ ?: firebaseException.message
+ ?: "Invalid credentials provided",
cause = firebaseException
)
}
@@ -357,53 +411,56 @@ abstract class AuthException(
is FirebaseAuthInvalidUserException -> {
when (firebaseException.errorCode) {
"ERROR_USER_NOT_FOUND" -> UserNotFoundException(
- message = firebaseException.message ?: "User not found",
+ message = stringProvider?.errorUserNotFound.nonEmpty()
+ ?: firebaseException.message
+ ?: "User not found",
cause = firebaseException
)
"ERROR_USER_DISABLED" -> InvalidCredentialsException(
- message = firebaseException.message ?: "User account has been disabled",
+ message = stringProvider?.errorUserDisabled.nonEmpty()
+ ?: firebaseException.message
+ ?: "User account has been disabled",
cause = firebaseException
)
else -> UserNotFoundException(
- message = firebaseException.message ?: "User account error",
+ message = stringProvider?.errorUserAccountGeneric.nonEmpty()
+ ?: firebaseException.message
+ ?: "User account error",
cause = firebaseException
)
}
}
- is FirebaseAuthWeakPasswordException -> {
- WeakPasswordException(
- message = firebaseException.message ?: "Password is too weak",
- cause = firebaseException,
- reason = firebaseException.reason
- )
- }
-
is FirebaseAuthUserCollisionException -> {
when (firebaseException.errorCode) {
"ERROR_EMAIL_ALREADY_IN_USE" -> EmailAlreadyInUseException(
- message = firebaseException.message
+ message = stringProvider?.errorEmailAlreadyInUse.nonEmpty()
+ ?: firebaseException.message
?: "Email address is already in use",
cause = firebaseException,
email = firebaseException.email
)
"ERROR_ACCOUNT_EXISTS_WITH_DIFFERENT_CREDENTIAL" -> AccountLinkingRequiredException(
- message = firebaseException.message
+ message = stringProvider?.errorAccountExistsDifferentCredential.nonEmpty()
+ ?: firebaseException.message
?: "Account already exists with different credentials",
cause = firebaseException
)
"ERROR_CREDENTIAL_ALREADY_IN_USE" -> AccountLinkingRequiredException(
- message = firebaseException.message
+ message = stringProvider?.errorCredentialAlreadyInUse.nonEmpty()
+ ?: firebaseException.message
?: "Credential is already associated with a different user account",
cause = firebaseException
)
else -> AccountLinkingRequiredException(
- message = firebaseException.message ?: "Account collision error",
+ message = stringProvider?.errorAccountCollisionGeneric.nonEmpty()
+ ?: firebaseException.message
+ ?: "Account collision error",
cause = firebaseException
)
}
@@ -411,7 +468,8 @@ abstract class AuthException(
is FirebaseAuthMultiFactorException -> {
MfaRequiredException(
- message = firebaseException.message
+ message = stringProvider?.errorMfaRequiredFallback.nonEmpty()
+ ?: firebaseException.message
?: "Multi-factor authentication required",
cause = firebaseException
)
@@ -419,23 +477,25 @@ abstract class AuthException(
is FirebaseAuthRecentLoginRequiredException -> {
InvalidCredentialsException(
- message = firebaseException.message
+ message = stringProvider?.errorRecentLoginRequired.nonEmpty()
+ ?: firebaseException.message
?: "Recent login required for this operation",
cause = firebaseException
)
}
is FirebaseAuthException -> {
- // Handle FirebaseAuthException and check for specific error codes
when (firebaseException.errorCode) {
"ERROR_TOO_MANY_REQUESTS" -> TooManyRequestsException(
- message = firebaseException.message
+ message = stringProvider?.errorTooManyRequests.nonEmpty()
+ ?: firebaseException.message
?: "Too many requests. Please try again later",
cause = firebaseException
)
else -> UnknownException(
- message = firebaseException.message
+ message = stringProvider?.errorUnknownAuth.nonEmpty()
+ ?: firebaseException.message
?: "An unknown authentication error occurred",
cause = firebaseException
)
@@ -443,33 +503,64 @@ abstract class AuthException(
}
is FirebaseException -> {
- // Handle general Firebase exceptions, which include network errors
- NetworkException(
- message = firebaseException.message ?: "Network error occurred",
- cause = firebaseException
- )
+ val msg = firebaseException.message ?: ""
+ if (msg.contains("PASSWORD_DOES_NOT_MEET_REQUIREMENTS", ignoreCase = true)) {
+ val requirements = parsePasswordPolicyRequirements(msg)
+ PasswordPolicyViolationException(
+ message = requirements.joinToString("\n").ifEmpty {
+ stringProvider?.errorWeakPasswordGeneric.nonEmpty()
+ ?: "Password does not meet policy requirements"
+ },
+ failingRequirements = requirements,
+ cause = firebaseException
+ )
+ } else {
+ NetworkException(
+ message = stringProvider?.errorNetworkGeneric.nonEmpty()
+ ?: msg.ifEmpty { "Network error occurred" },
+ cause = firebaseException
+ )
+ }
}
else -> {
- // Check for common cancellation patterns
- if (firebaseException.message?.contains(
- "cancelled",
- ignoreCase = true
- ) == true ||
+ if (firebaseException.message?.contains("cancelled", ignoreCase = true) == true ||
firebaseException.message?.contains("canceled", ignoreCase = true) == true
) {
AuthCancelledException(
- message = firebaseException.message ?: "Authentication was cancelled",
+ message = stringProvider?.errorAuthCancelled.nonEmpty()
+ ?: firebaseException.message
+ ?: "Authentication was cancelled",
cause = firebaseException
)
} else {
UnknownException(
- message = firebaseException.message ?: "An unknown error occurred",
+ message = stringProvider?.errorUnknownAuth.nonEmpty()
+ ?: firebaseException.message
+ ?: "An unknown error occurred",
cause = firebaseException
)
}
}
}
}
+
+ private fun String?.nonEmpty(): String? = this?.ifEmpty { null }
+
+ // Finds the [...] content that immediately follows PASSWORD_DOES_NOT_MEET_REQUIREMENTS
+ // in both FirebaseException and FirebaseAuthWeakPasswordException messages.
+ // GIdP returns human-readable requirement strings inside those brackets, e.g.
+ // "...PASSWORD_DOES_NOT_MEET_REQUIREMENTS:Missing password requirements: [Password must contain at least 10 characters]"
+ private fun parsePasswordPolicyRequirements(message: String): List {
+ val policyIndex = message.indexOf("PASSWORD_DOES_NOT_MEET_REQUIREMENTS", ignoreCase = true)
+ if (policyIndex == -1) return emptyList()
+ val start = message.indexOf('[', policyIndex)
+ val end = message.indexOf(']', policyIndex)
+ if (start == -1 || end == -1 || end <= start) return emptyList()
+ return message.substring(start + 1, end)
+ .split(',')
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ }
}
}
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 44cdf45aa..93974a174 100644
--- a/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt
@@ -103,7 +103,7 @@ import java.util.concurrent.atomic.AtomicBoolean
*/
class AuthFlowController internal constructor(
private val authUI: FirebaseAuthUI,
- private val configuration: AuthUIConfiguration
+ internal val configuration: AuthUIConfiguration
) {
private val coroutineScope = CoroutineScope(Dispatchers.Main + Job())
@@ -158,7 +158,11 @@ class AuthFlowController internal constructor(
*/
fun createIntent(context: Context): Intent {
checkNotDisposed()
- return FirebaseAuthActivity.createIntent(context, configuration)
+ return FirebaseAuthActivity.createIntent(
+ context = context,
+ configuration = configuration,
+ authUI = authUI
+ )
}
/**
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 061b33a45..697480213 100644
--- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt
@@ -208,6 +208,35 @@ abstract class AuthState private constructor() {
"AuthState.RequiresProfileCompletion(user=$user, missingFields=$missingFields)"
}
+ /**
+ * Reauthentication is required before a sensitive operation (e.g. delete account, change email)
+ * can proceed. Use [FirebaseAuthUI.createReauthFlow] to launch the reauthentication flow.
+ *
+ * @property user The [FirebaseUser] that needs to reauthenticate
+ * @property reason Optional human-readable reason to show the user
+ */
+ class ReauthenticationRequired(
+ val user: FirebaseUser,
+ val reason: String? = null,
+ // Not included in equals/hashCode — lambdas have no meaningful equality.
+ val retryOperation: (suspend (android.content.Context) -> Unit)? = null,
+ ) : AuthState() {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is ReauthenticationRequired) return false
+ return user == other.user && reason == other.reason
+ }
+
+ override fun hashCode(): Int {
+ var result = user.hashCode()
+ result = 31 * result + (reason?.hashCode() ?: 0)
+ return result
+ }
+
+ override fun toString(): String =
+ "AuthState.ReauthenticationRequired(user=$user, reason=$reason)"
+ }
+
/**
* Password reset link has been sent to the user's email.
*/
diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt
index 168670da1..32e9eacd9 100644
--- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt
@@ -18,6 +18,7 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
+import androidx.annotation.RestrictTo
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -72,15 +73,16 @@ class FirebaseAuthActivity : ComponentActivity() {
private lateinit var authUI: FirebaseAuthUI
private lateinit var configuration: AuthUIConfiguration
+ private var launchKey: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
- // Extract configuration from cache using UUID key
- val configKey = intent.getStringExtra(EXTRA_CONFIGURATION_KEY)
- configuration = if (configKey != null) {
- configurationCache.remove(configKey)
+ // Extract configuration and auth instance from cache using UUID key
+ launchKey = intent.getStringExtra(EXTRA_CONFIGURATION_KEY)
+ configuration = if (launchKey != null) {
+ configurationCache[launchKey]
} else {
null
} ?: run {
@@ -90,7 +92,12 @@ class FirebaseAuthActivity : ComponentActivity() {
return
}
- authUI = FirebaseAuthUI.getInstance()
+ authUI = launchKey?.let { authUICache[it] } ?: run {
+ // Missing auth instance, finish with error
+ setResult(RESULT_CANCELED)
+ finish()
+ return
+ }
// Extract email link if present
val emailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK)
@@ -150,11 +157,17 @@ class FirebaseAuthActivity : ComponentActivity() {
}
override fun onDestroy() {
- super.onDestroy()
- // Reset auth state when activity is destroyed
- if (!isFinishing) {
+ if (isFinishing) {
+ launchKey?.let { key ->
+ configurationCache.remove(key)
+ authUICache.remove(key)
+ }
+ } else {
+ // Preserve cached launch state so the recreated activity can recover it.
authUI.updateAuthState(AuthState.Idle)
}
+
+ super.onDestroy()
}
companion object {
@@ -191,14 +204,31 @@ class FirebaseAuthActivity : ComponentActivity() {
*/
internal fun createIntent(
context: Context,
- configuration: AuthUIConfiguration
+ configuration: AuthUIConfiguration,
+ authUI: FirebaseAuthUI = FirebaseAuthUI.getInstance()
): Intent {
val configKey = UUID.randomUUID().toString()
configurationCache[configKey] = configuration
+ authUICache[configKey] = authUI
return Intent(context, FirebaseAuthActivity::class.java).apply {
putExtra(EXTRA_CONFIGURATION_KEY, configKey)
}
}
+
+ /**
+ * Clears cached launch state. This method is intended for testing purposes only.
+ *
+ * @suppress This is an internal API and should not be used in production code.
+ * @RestrictTo RestrictTo.Scope.TESTS
+ */
+ @JvmStatic
+ @RestrictTo(RestrictTo.Scope.TESTS)
+ fun clearLaunchStateCache() {
+ configurationCache.clear()
+ authUICache.clear()
+ }
+
+ private val authUICache = ConcurrentHashMap()
}
}
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 9f829a37f..45300cf23 100644
--- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
@@ -19,12 +19,16 @@ import android.content.Intent
import androidx.annotation.RestrictTo
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.auth_provider.filterToLinkedProviders
+import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException
import com.firebase.ui.auth.configuration.auth_provider.signOutFromFacebook
import com.firebase.ui.auth.configuration.auth_provider.signOutFromGoogle
import com.google.firebase.Firebase
import com.google.firebase.FirebaseApp
+import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseAuth.AuthStateListener
+import com.google.firebase.auth.FirebaseAuth.IdTokenListener
import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.auth
import kotlinx.coroutines.CancellationException
@@ -209,6 +213,45 @@ class FirebaseAuthUI private constructor(
return AuthFlowController(this, configuration)
}
+ /**
+ * Creates a reauthentication flow scoped to the current user's linked providers.
+ *
+ * This method builds a sign-in flow where:
+ * - Only providers already linked to the current [FirebaseUser] are offered
+ * - Account creation is disabled
+ * - The credential path calls [FirebaseUser.reauthenticateWithCredential] instead of
+ * [FirebaseAuth.signInWithCredential]
+ *
+ * Use this before sensitive operations (delete account, change email, etc.) that require
+ * a recent sign-in.
+ *
+ * @param configuration Base [AuthUIConfiguration] whose provider list is filtered to
+ * the user's linked providers. All other settings are preserved.
+ * @param reason Optional human-readable string shown to the user explaining why
+ * reauthentication is needed (e.g. "To delete your account we need to verify it's you").
+ * @return An [AuthFlowController] configured for reauthentication
+ * @throws AuthException.UserNotFoundException if no user is currently signed in
+ * @throws IllegalStateException if none of the configured providers are linked to the
+ * current user
+ * @since 10.0.0
+ */
+ fun createReauthFlow(configuration: AuthUIConfiguration): AuthFlowController {
+ val currentUser = auth.currentUser
+ ?: throw AuthException.UserNotFoundException(
+ message = "No user is currently signed in"
+ )
+ val linked = configuration.providers.filterToLinkedProviders(currentUser)
+ check(linked.isNotEmpty()) {
+ "No configured providers are linked to the current user"
+ }
+ val reauthConfig = configuration.copy(
+ providers = linked,
+ isNewEmailAccountsAllowed = false,
+ isReauthenticationMode = true,
+ )
+ return AuthFlowController(this, reauthConfig)
+ }
+
/**
* Returns a [Flow] that emits [AuthState] changes.
*
@@ -255,56 +298,48 @@ class FirebaseAuthUI private constructor(
fun authStateFlow(): Flow {
// Create a flow from FirebaseAuth state listener
val firebaseAuthFlow = callbackFlow {
- // Set initial state based on current auth state
- val initialState = auth.currentUser?.let { user ->
- // Check if email verification is required
- if (!user.isEmailVerified &&
- user.email != null &&
- user.providerData.any { it.providerId == "password" }
- ) {
- AuthState.RequiresEmailVerification(
- user = user,
- email = user.email!!
- )
+ fun buildState(currentUser: FirebaseUser?): AuthState {
+ return if (currentUser != null) {
+ handleAuthUserState(currentUser, result = null, isNewUser = false)
} else {
- AuthState.Success(result = null, user = user, isNewUser = false)
+ AuthState.Idle
}
- } ?: AuthState.Idle
+ }
+
+ // Set initial state based on current auth state
+ val initialState = buildState(auth.currentUser)
trySend(initialState)
// Create auth state listener
val authStateListener = AuthStateListener { firebaseAuth ->
- val currentUser = firebaseAuth.currentUser
- val state = if (currentUser != null) {
- // Check if email verification is required
- if (!currentUser.isEmailVerified &&
- currentUser.email != null &&
- currentUser.providerData.any { it.providerId == "password" }
+ // When user signs out, clear stale user-presence internal states so the combine
+ // 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
) {
- AuthState.RequiresEmailVerification(
- user = currentUser,
- email = currentUser.email!!
- )
- } else {
- AuthState.Success(
- result = null,
- user = currentUser,
- isNewUser = false
- )
+ _authStateFlow.value = AuthState.Idle
}
- } else {
- AuthState.Idle
}
- trySend(state)
+ trySend(buildState(firebaseAuth.currentUser))
+ }
+
+ // AuthStateListener does not reliably fire for account linking, but IdTokenListener does.
+ val idTokenListener = IdTokenListener { firebaseAuth ->
+ trySend(buildState(firebaseAuth.currentUser))
}
// Add listener
auth.addAuthStateListener(authStateListener)
+ auth.addIdTokenListener(idTokenListener)
// Remove listener when flow collection is cancelled
awaitClose {
auth.removeAuthStateListener(authStateListener)
+ auth.removeIdTokenListener(idTokenListener)
}
}
@@ -329,6 +364,32 @@ class FirebaseAuthUI private constructor(
_authStateFlow.value = state
}
+ internal fun updateAuthStateWithResult(result: AuthResult?, defaultIsNewUser: Boolean = false) {
+ val user = result?.user
+ if (user != null) {
+ updateAuthState(
+ handleAuthUserState(
+ user = user,
+ result = result,
+ isNewUser = result.additionalUserInfo?.isNewUser ?: defaultIsNewUser
+ )
+ )
+ } else {
+ updateAuthState(AuthState.Idle)
+ }
+ }
+
+ private fun handleAuthUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState {
+ return if (!user.isEmailVerified &&
+ user.email != null &&
+ user.providerData.any { it.providerId == "password" }
+ ) {
+ AuthState.RequiresEmailVerification(user = user, email = user.email!!)
+ } else {
+ AuthState.Success(result = result, user = user, isNewUser = isNewUser)
+ }
+ }
+
/**
* Signs out the current user and clears authentication state.
*
@@ -391,7 +452,7 @@ class FirebaseAuthUI private constructor(
throw e
} catch (e: Exception) {
// Map to appropriate AuthException
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
throw authException
}
@@ -427,6 +488,65 @@ class FirebaseAuthUI private constructor(
* @throws AuthException.UnknownException for other errors
* @since 10.0.0
*/
+ /**
+ * Executes a sensitive operation, automatically handling reauthentication if required.
+ *
+ * If the [operation] throws [FirebaseAuthRecentLoginRequiredException], this method emits
+ * [AuthState.ReauthenticationRequired] with the operation attached as [AuthState.ReauthenticationRequired.retryOperation].
+ * [FirebaseAuthScreen] observes this state and presents a reauthentication sheet; on success
+ * the operation is retried automatically without any further action from the caller.
+ *
+ * All other exceptions propagate normally.
+ *
+ * **Example:**
+ * ```kotlin
+ * lifecycleScope.launch {
+ * authUI.withReauth(context, reason = "Verify your identity to change email") {
+ * user.updateEmail(newEmail).await()
+ * }
+ * }
+ * ```
+ *
+ * @param context Android [Context]
+ * @param reason Optional message shown to the user explaining why reauthentication is needed
+ * @param operation The sensitive operation to attempt
+ * @since 10.0.0
+ */
+ suspend fun withReauth(
+ context: Context,
+ reason: String? = null,
+ operation: suspend () -> Unit,
+ ) {
+ try {
+ operation()
+ } catch (e: FirebaseAuthRecentLoginRequiredException) {
+ val user = auth.currentUser
+ ?: throw AuthException.UserNotFoundException(message = "No user is currently signed in")
+ updateAuthState(
+ AuthState.ReauthenticationRequired(
+ user = user,
+ reason = reason,
+ retryOperation = {
+ try {
+ operation()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ updateAuthState(AuthState.Error(e))
+ return@ReauthenticationRequired
+ }
+ val currentUser = auth.currentUser
+ if (currentUser != null) {
+ updateAuthState(AuthState.Success(result = null, user = currentUser))
+ } else {
+ updateAuthState(AuthState.Idle)
+ }
+ },
+ )
+ )
+ }
+ }
+
suspend fun delete(context: Context) {
try {
val currentUser = auth.currentUser
@@ -443,6 +563,19 @@ class FirebaseAuthUI private constructor(
// Update state to idle (user deleted and signed out)
updateAuthState(AuthState.Idle)
+ } catch (e: FirebaseAuthRecentLoginRequiredException) {
+ auth.currentUser?.let {
+ updateAuthState(
+ AuthState.ReauthenticationRequired(
+ user = it,
+ retryOperation = { ctx -> delete(ctx) },
+ )
+ )
+ }
+ throw AuthException.InvalidCredentialsException(
+ message = e.message ?: "Recent login required for this operation",
+ cause = e
+ )
} catch (e: CancellationException) {
// Handle coroutine cancellation
val cancelledException = AuthException.AuthCancelledException(
@@ -457,7 +590,7 @@ class FirebaseAuthUI private constructor(
throw e
} catch (e: Exception) {
// Map to appropriate AuthException
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
throw authException
}
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 3fa7f394b..adc8878c5 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
@@ -42,6 +42,7 @@ class AuthUIConfigurationBuilder {
var isCredentialManagerEnabled: Boolean = true
var isMfaEnabled: Boolean = true
var isAnonymousUpgradeEnabled: Boolean = false
+ var isCredentialLinkingEnabled: Boolean = false
var tosUrl: String? = null
var privacyPolicyUrl: String? = null
var logo: AuthUIAsset? = null
@@ -50,6 +51,7 @@ class AuthUIConfigurationBuilder {
var isDisplayNameRequired: Boolean = true
var isProviderChoiceAlwaysShown: Boolean = false
var transitions: AuthUITransitions? = null
+ internal var isReauthenticationMode: Boolean = false
fun providers(block: AuthProvidersBuilder.() -> Unit) =
providers.addAll(AuthProvidersBuilder().apply(block).build())
@@ -63,10 +65,12 @@ class AuthUIConfigurationBuilder {
"At least one provider must be configured"
}
- // No unsupported providers (allow predefined providers and custom OIDC providers starting with "oidc.")
+ // No unsupported providers (allow predefined providers and custom OIDC/SAML providers)
val supportedProviderIds = Provider.entries.map { it.id }.toSet()
+ val customPrefixes = listOf("oidc.", "saml.")
val unknownProviders = providers.filter { provider ->
- provider.providerId !in supportedProviderIds && !provider.providerId.startsWith("oidc.")
+ provider.providerId !in supportedProviderIds &&
+ customPrefixes.none { provider.providerId.startsWith(it) }
}
require(unknownProviders.isEmpty()) {
"Unknown providers: ${unknownProviders.joinToString { it.providerId }}"
@@ -107,6 +111,7 @@ class AuthUIConfigurationBuilder {
isCredentialManagerEnabled = isCredentialManagerEnabled,
isMfaEnabled = isMfaEnabled,
isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled,
+ isCredentialLinkingEnabled = isCredentialLinkingEnabled,
tosUrl = tosUrl,
privacyPolicyUrl = privacyPolicyUrl,
logo = logo,
@@ -114,7 +119,8 @@ class AuthUIConfigurationBuilder {
isNewEmailAccountsAllowed = isNewEmailAccountsAllowed,
isDisplayNameRequired = isDisplayNameRequired,
isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown,
- transitions = transitions
+ transitions = transitions,
+ isReauthenticationMode = isReauthenticationMode,
)
}
}
@@ -164,6 +170,13 @@ class AuthUIConfiguration(
*/
val isAnonymousUpgradeEnabled: Boolean = false,
+ /**
+ * Allows linking a new credential to an already authenticated (non-anonymous) user.
+ * When enabled, signing in via FirebaseUI while a user is already signed in will link
+ * the new credential to the existing account instead of creating a new one.
+ */
+ val isCredentialLinkingEnabled: Boolean = false,
+
/**
* The URL for the terms of service.
*/
@@ -204,4 +217,34 @@ class AuthUIConfiguration(
* If null, uses default fade in/out transitions.
*/
val transitions: AuthUITransitions? = null,
-)
+
+ /**
+ * When true, the flow operates as a reauthentication flow: account creation is disabled and
+ * only providers already linked to the current user are shown. Set by [FirebaseAuthUI.createReauthFlow].
+ */
+ internal val isReauthenticationMode: Boolean = false,
+) {
+ internal fun copy(
+ providers: List = this.providers,
+ isNewEmailAccountsAllowed: Boolean = this.isNewEmailAccountsAllowed,
+ isReauthenticationMode: Boolean = this.isReauthenticationMode,
+ ): AuthUIConfiguration = AuthUIConfiguration(
+ context = this.context,
+ providers = providers,
+ theme = this.theme,
+ locale = this.locale,
+ stringProvider = this.stringProvider,
+ isCredentialManagerEnabled = this.isCredentialManagerEnabled,
+ isMfaEnabled = this.isMfaEnabled,
+ isAnonymousUpgradeEnabled = this.isAnonymousUpgradeEnabled,
+ tosUrl = this.tosUrl,
+ privacyPolicyUrl = this.privacyPolicyUrl,
+ logo = this.logo,
+ passwordResetActionCodeSettings = this.passwordResetActionCodeSettings,
+ isNewEmailAccountsAllowed = isNewEmailAccountsAllowed,
+ isDisplayNameRequired = this.isDisplayNameRequired,
+ isProviderChoiceAlwaysShown = this.isProviderChoiceAlwaysShown,
+ transitions = this.transitions,
+ isReauthenticationMode = isReauthenticationMode,
+ )
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/PasswordRule.kt
index d10f1c811..fef83a4ec 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/PasswordRule.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/PasswordRule.kt
@@ -111,7 +111,7 @@ abstract class PasswordRule {
* @param password The password to validate
* @return true if the password meets this rule's requirements, false otherwise
*/
- internal abstract fun isValid(password: String): Boolean
+ abstract fun isValid(password: String): Boolean
/**
* Returns the appropriate error message for this rule when validation fails.
@@ -119,5 +119,5 @@ abstract class PasswordRule {
* @param stringProvider The string provider for localized error messages
* @return The localized error message for this rule
*/
- internal abstract fun getErrorMessage(stringProvider: AuthUIStringProvider): String
+ abstract fun getErrorMessage(stringProvider: AuthUIStringProvider): String
}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt
index 009765727..baf9cef82 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt
@@ -20,6 +20,7 @@ import kotlinx.coroutines.tasks.await
*/
@Composable
internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(): () -> Unit {
+ val context = androidx.compose.ui.platform.LocalContext.current
val coroutineScope = rememberCoroutineScope()
return remember(this) {
{
@@ -30,7 +31,7 @@ internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(): () -> Unit {
// Already an AuthException, don't re-wrap it
updateAuthState(AuthState.Error(e))
} catch (e: Exception) {
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
}
}
@@ -109,8 +110,8 @@ internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(): () -> Unit {
internal suspend fun FirebaseAuthUI.signInAnonymously() {
try {
updateAuthState(AuthState.Loading("Signing in anonymously..."))
- auth.signInAnonymously().await()
- updateAuthState(AuthState.Idle)
+ val result = auth.signInAnonymously().await()
+ updateAuthStateWithResult(result, defaultIsNewUser = true)
} catch (e: CancellationException) {
val cancelledException = AuthException.AuthCancelledException(
message = "Sign in anonymously was cancelled",
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 5cf392a8c..59bff5d73 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
@@ -211,6 +211,7 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
return actionCodeSettings {
url = continueUrl
handleCodeInApp = emailLinkActionCodeSettings.canHandleCodeInApp()
+ linkDomain = emailLinkActionCodeSettings.linkDomain
iosBundleId = emailLinkActionCodeSettings.iosBundle
setAndroidPackageName(
emailLinkActionCodeSettings.androidPackageName ?: "",
@@ -990,6 +991,13 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
&& currentUser.isAnonymous
}
+ internal fun canLinkCredential(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean {
+ val currentUser = auth.currentUser
+ return config.isCredentialLinkingEnabled
+ && currentUser != null
+ && !currentUser.isAnonymous
+ }
+
/**
* Merges profile information (display name and photo URL) with the current user's profile.
*
@@ -1044,3 +1052,17 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
}
}
}
+
+/**
+ * Filters this provider list to only those whose [AuthProvider.providerId] matches a provider
+ * already linked to [user], as reported by [com.google.firebase.auth.FirebaseUser.providerData].
+ *
+ * Used by [com.firebase.ui.auth.FirebaseAuthUI.createReauthFlow] to restrict the reauthentication
+ * UI to methods the user has actually registered.
+ */
+internal fun List.filterToLinkedProviders(
+ user: com.google.firebase.auth.FirebaseUser,
+): List {
+ val linkedIds = user.providerData.map { it.providerId }.toSet()
+ return filter { it.providerId in linkedIds }
+}
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 8d4bae6d1..c888e8d39 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
@@ -22,6 +22,7 @@ import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
import com.firebase.ui.auth.FirebaseAuthUI
import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canLinkCredential
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.mergeProfile
import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException
@@ -45,6 +46,25 @@ import kotlinx.coroutines.tasks.await
private const val TAG = "EmailAuthProvider"
+/**
+ * Signs in or reauthenticates with [credential] depending on [AuthUIConfiguration.isReauthenticationMode].
+ *
+ * - Normal mode: [com.google.firebase.auth.FirebaseAuth.signInWithCredential], returns [AuthResult].
+ * - Reauth mode: [com.google.firebase.auth.FirebaseUser.reauthenticate] (Task), returns null.
+ * Callers must reconstruct auth state from [com.google.firebase.auth.FirebaseAuth.currentUser].
+ */
+internal suspend fun FirebaseAuthUI.signInOrReauth(
+ credential: AuthCredential,
+ config: AuthUIConfiguration,
+): AuthResult? = if (config.isReauthenticationMode) {
+ val currentUser = auth.currentUser
+ ?: throw AuthException.UserNotFoundException(message = "No user is currently signed in for reauthentication")
+ currentUser.reauthenticate(credential).await()
+ null
+} else {
+ auth.signInWithCredential(credential).await()
+}
+
/**
* Creates an email/password account or links the credential to an anonymous user.
*
@@ -126,12 +146,14 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
credentialProvider: AuthProvider.Email.CredentialProvider = AuthProvider.Email.DefaultCredentialProvider(),
): AuthResult? {
val canUpgrade = canUpgradeAnonymous(config, auth)
+ val canLink = canLinkCredential(config, auth)
+ val shouldLinkCredential = canUpgrade || canLink
val pendingCredential =
- if (canUpgrade) credentialProvider.getCredential(email, password) else null
+ if (shouldLinkCredential) credentialProvider.getCredential(email, password) else null
try {
- // Check if new accounts are allowed (only for non-upgrade flows)
- if (!canUpgrade && !provider.isNewAccountsAllowed) {
+ // Check if new accounts are allowed (only for non-upgrade/non-linking flows)
+ if (!shouldLinkCredential && !provider.isNewAccountsAllowed) {
throw AuthException.UserNotFoundException(
message = context.getString(R.string.fui_error_email_does_not_exist)
)
@@ -156,7 +178,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
}
updateAuthState(AuthState.Loading("Creating user..."))
- val result = if (canUpgrade) {
+ val result = if (shouldLinkCredential) {
auth.currentUser?.linkWithCredential(requireNotNull(pendingCredential))?.await()
} else {
auth.createUserWithEmailAndPassword(email, password).await()
@@ -197,7 +219,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
}
}
- updateAuthState(AuthState.Idle)
+ updateAuthStateWithResult(result, defaultIsNewUser = true)
return result
} catch (e: FirebaseAuthUserCollisionException) {
// Account collision: email already exists
@@ -205,10 +227,10 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
message = "An account already exists with this email. " +
"Please sign in with your existing account.",
email = e.email ?: email,
- credential = if (canUpgrade) {
- e.updatedCredential ?: pendingCredential
- } else {
- null
+ credential = when {
+ canUpgrade -> e.updatedCredential ?: pendingCredential
+ canLink -> pendingCredential
+ else -> null
},
cause = e
)
@@ -225,7 +247,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
updateAuthState(AuthState.Error(e))
throw e
} catch (e: Exception) {
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
throw authException
}
@@ -322,6 +344,14 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
): AuthResult? {
try {
updateAuthState(AuthState.Loading("Signing in..."))
+ // In reauth mode build a credential and go through signInAndLinkWithCredential so
+ // signInOrReauth routes to FirebaseUser.reauthenticate() instead of signInWithCredential().
+ if (config.isReauthenticationMode) {
+ return signInAndLinkWithCredential(
+ config = config,
+ credential = EmailAuthProvider.getCredential(email, password),
+ )
+ }
return if (canUpgradeAnonymous(config, auth)) {
// Anonymous upgrade flow: validate credential in scratch auth
val credentialToValidate = EmailAuthProvider.getCredential(email, password)
@@ -431,7 +461,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
}
}
- updateAuthState(AuthState.Idle)
+ updateAuthStateWithResult(result)
}
} catch (e: FirebaseAuthMultiFactorException) {
// MFA required - extract resolver and update state
@@ -450,7 +480,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
updateAuthState(AuthState.Error(e))
throw e
} catch (e: Exception) {
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
throw authException
}
@@ -548,17 +578,22 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential(
): AuthResult? {
try {
updateAuthState(AuthState.Loading("Signing in user..."))
- return if (canUpgradeAnonymous(config, auth)) {
+ val result = if (canUpgradeAnonymous(config, auth) || canLinkCredential(config, auth)) {
auth.currentUser?.linkWithCredential(credential)?.await()
} else {
- auth.signInWithCredential(credential).await()
- }.also { result ->
- // Merge profile information from the provider
- result?.user?.let {
- mergeProfile(auth, displayName, photoUrl)
+ signInOrReauth(credential, config)
+ }
+ // 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))
}
- updateAuthState(AuthState.Idle)
+ return null
}
+ result?.user?.let { mergeProfile(auth, displayName, photoUrl) }
+ updateAuthStateWithResult(result)
+ return result
} catch (e: FirebaseAuthMultiFactorException) {
// MFA required - extract resolver and update state
val resolver = e.resolver
@@ -766,7 +801,7 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail(
updateAuthState(AuthState.Error(e))
throw e
} catch (e: Exception) {
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
throw authException
}
@@ -974,7 +1009,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink(
}
// Clear DataStore after success
persistenceManager.clear(context)
- updateAuthState(AuthState.Idle)
+ updateAuthStateWithResult(result)
return result
} catch (e: CancellationException) {
val cancelledException = AuthException.AuthCancelledException(
@@ -987,7 +1022,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink(
updateAuthState(AuthState.Error(e))
throw e
} catch (e: Exception) {
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
throw authException
}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt
index 28ef45636..674f02d33 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt
@@ -46,6 +46,7 @@ import kotlinx.coroutines.launch
* @param context Android context for DataStore access when saving credentials for linking
* @param config The [AuthUIConfiguration] containing authentication settings
* @param provider The [AuthProvider.Facebook] configuration with scopes and credential provider
+ * @param loginManagerProvider Provides logout operations to clear stale Facebook sessions
*
* @return A launcher function that starts the Facebook sign-in flow when invoked
*
@@ -56,6 +57,7 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
context: Context,
config: AuthUIConfiguration,
provider: AuthProvider.Facebook,
+ loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(),
): () -> Unit {
val coroutineScope = rememberCoroutineScope()
val callbackManager = remember { CallbackManager.Factory.create() }
@@ -69,7 +71,7 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
onResult = {},
)
- DisposableEffect(Unit) {
+ DisposableEffect(config) {
loginManager.registerCallback(
callbackManager,
object : FacebookCallback {
@@ -86,7 +88,7 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
// Already an AuthException, don't re-wrap it
updateAuthState(AuthState.Error(e))
} catch (e: Exception) {
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
}
}
@@ -98,7 +100,7 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
override fun onError(error: FacebookException) {
Log.e("FacebookAuthProvider", "Error during Facebook sign in", error)
- val authException = AuthException.from(error)
+ val authException = AuthException.from(error, context)
updateAuthState(
AuthState.Error(
authException
@@ -114,6 +116,11 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
updateAuthState(
AuthState.Loading("Signing in with facebook...")
)
+ try {
+ (testLoginManagerProvider ?: loginManagerProvider).logOut()
+ } catch (e: Exception) {
+ Log.w("FacebookAuthProvider", "Failed to clear Facebook session before sign in", e)
+ }
launcher.launch(provider.scopes)
}
}
@@ -190,7 +197,7 @@ internal suspend fun FirebaseAuthUI.signInWithFacebook(
updateAuthState(AuthState.Error(e))
throw e
} catch (e: FacebookException) {
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
throw authException
} catch (e: CancellationException) {
@@ -204,7 +211,7 @@ internal suspend fun FirebaseAuthUI.signInWithFacebook(
updateAuthState(AuthState.Error(e))
throw e
} catch (e: Exception) {
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
throw authException
}
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 4d18cb0a9..496e1cd44 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
@@ -59,7 +59,7 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler(
provider: AuthProvider.Google,
): () -> Unit {
val coroutineScope = rememberCoroutineScope()
- return remember(this) {
+ return remember(this, config) {
{
coroutineScope.launch {
try {
@@ -67,7 +67,7 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler(
} catch (e: AuthException) {
updateAuthState(AuthState.Error(e))
} catch (e: Exception) {
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
}
}
@@ -128,7 +128,7 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle(
authorizationProvider.authorize(context, requestedScopes)
} catch (e: Exception) {
// Continue with sign-in even if scope authorization fails
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
}
}
@@ -227,7 +227,7 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle(
throw e
} catch (e: Exception) {
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
throw authException
}
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 485065746..ef974785f 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
@@ -61,7 +61,7 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler(
"Ensure FirebaseAuthScreen is used within an Activity."
)
- return remember(this, provider.providerId) {
+ return remember(this, provider.providerId, config) {
{
coroutineScope.launch {
try {
@@ -74,7 +74,7 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler(
} catch (e: AuthException) {
updateAuthState(AuthState.Error(e))
} catch (e: Exception) {
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
}
}
@@ -162,15 +162,20 @@ internal suspend fun FirebaseAuthUI.signInWithProvider(
photoUrl = authResult.user?.photoUrl,
)
}
- updateAuthState(AuthState.Idle)
return
}
- // Determine if we should upgrade anonymous user or do normal sign-in
- val authResult = if (canUpgradeAnonymous(config, auth)) {
- auth.currentUser?.startActivityForLinkWithProvider(activity, oauthProvider)?.await()
- } else {
- auth.startActivityForSignInWithProvider(activity, oauthProvider).await()
+ // Determine if we should upgrade anonymous user, reauthenticate, or do normal sign-in
+ val authResult = when {
+ canUpgradeAnonymous(config, auth) ->
+ auth.currentUser?.startActivityForLinkWithProvider(activity, oauthProvider)?.await()
+ config.isReauthenticationMode -> {
+ val currentUser = auth.currentUser
+ ?: throw AuthException.UserNotFoundException(message = "No user is currently signed in for reauthentication")
+ currentUser.startActivityForReauthenticateWithProvider(activity, oauthProvider).await()
+ }
+ else ->
+ auth.startActivityForSignInWithProvider(activity, oauthProvider).await()
}
// Extract OAuth credential and complete sign-in
@@ -195,8 +200,7 @@ internal suspend fun FirebaseAuthUI.signInWithProvider(
android.util.Log.w("OAuthProvider", "Failed to save sign-in preference", e)
}
- // Just update state to Idle
- updateAuthState(AuthState.Idle)
+ updateAuthStateWithResult(authResult)
} else {
throw AuthException.UnknownException(
message = "OAuth sign-in did not return a valid credential"
@@ -231,7 +235,7 @@ internal suspend fun FirebaseAuthUI.signInWithProvider(
throw e
} catch (e: Exception) {
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
throw authException
}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt
index 0be8ee8fa..dd8662064 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
@@ -224,7 +224,7 @@ internal suspend fun FirebaseAuthUI.submitVerificationCode(
updateAuthState(AuthState.Error(e))
throw e
} catch (e: Exception) {
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
throw authException
}
@@ -334,7 +334,7 @@ internal suspend fun FirebaseAuthUI.signInWithPhoneAuthCredential(
updateAuthState(AuthState.Error(e))
throw e
} catch (e: Exception) {
- val authException = AuthException.from(e)
+ val authException = AuthException.from(e, context)
updateAuthState(AuthState.Error(authException))
throw authException
}
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 a062debdd..bc7a8acdb 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
@@ -542,4 +542,53 @@ interface AuthUIStringProvider {
/** Tooltip message shown when MFA is disabled */
val mfaDisabledTooltip: String
+
+ // =============================================================================================
+ // AuthException error messages
+ // =============================================================================================
+
+ /** Error when a user account has been disabled by an administrator. */
+ val errorUserDisabled: String
+
+ /** Error when provided credentials are invalid. Return empty to use the Firebase SDK message. */
+ val errorInvalidCredentials: String
+
+ /** Error when the user account does not exist. Return empty to use the Firebase SDK message. */
+ val errorUserNotFound: String
+
+ /** Generic error for unexpected user account issues. Return empty to use the Firebase SDK message. */
+ val errorUserAccountGeneric: String
+
+ /** Error when the password is too weak. Return empty to use the Firebase SDK message. */
+ val errorWeakPasswordGeneric: String
+
+ /** Error when the email address is already registered. Return empty to use the Firebase SDK message. */
+ val errorEmailAlreadyInUse: String
+
+ /** Error when an account already exists with a different sign-in method. Return empty to use the Firebase SDK message. */
+ val errorAccountExistsDifferentCredential: String
+
+ /** Error when a credential is already linked to another account. Return empty to use the Firebase SDK message. */
+ val errorCredentialAlreadyInUse: String
+
+ /** Generic error for account collision issues. Return empty to use the Firebase SDK message. */
+ val errorAccountCollisionGeneric: String
+
+ /** Error when multi-factor authentication is required. Return empty to use the Firebase SDK message. */
+ val errorMfaRequiredFallback: String
+
+ /** Error when the operation requires a recent sign-in. Return empty to use the Firebase SDK message. */
+ val errorRecentLoginRequired: String
+
+ /** Error when sign-in is blocked due to too many attempts. Return empty to use the Firebase SDK message. */
+ val errorTooManyRequests: String
+
+ /** Generic unknown authentication error. Return empty to use the Firebase SDK message. */
+ val errorUnknownAuth: String
+
+ /** Error for network failures during authentication. Return empty to use the Firebase SDK message. */
+ val errorNetworkGeneric: String
+
+ /** Error when authentication is cancelled. Return empty to use the Firebase SDK message. */
+ val errorAuthCancelled: 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 429d6d286..3d2b9772d 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
@@ -494,4 +494,49 @@ class DefaultAuthUIStringProvider(
override val mfaDisabledTooltip: String
get() = localizedContext.getString(R.string.fui_mfa_disabled_tooltip)
+
+ override val errorUserDisabled: String
+ get() = localizedContext.getString(R.string.fui_error_user_disabled)
+
+ override val errorInvalidCredentials: String
+ get() = localizedContext.getString(R.string.fui_error_invalid_credentials)
+
+ override val errorUserNotFound: String
+ get() = localizedContext.getString(R.string.fui_error_user_not_found)
+
+ override val errorUserAccountGeneric: String
+ get() = localizedContext.getString(R.string.fui_error_user_account_generic)
+
+ override val errorWeakPasswordGeneric: String
+ get() = localizedContext.getString(R.string.fui_error_weak_password_generic)
+
+ override val errorEmailAlreadyInUse: String
+ get() = localizedContext.getString(R.string.fui_error_email_already_in_use)
+
+ override val errorAccountExistsDifferentCredential: String
+ get() = localizedContext.getString(R.string.fui_error_account_exists_different_credential)
+
+ override val errorCredentialAlreadyInUse: String
+ get() = localizedContext.getString(R.string.fui_error_credential_already_in_use)
+
+ override val errorAccountCollisionGeneric: String
+ get() = localizedContext.getString(R.string.fui_error_account_collision_generic)
+
+ override val errorMfaRequiredFallback: String
+ get() = localizedContext.getString(R.string.fui_error_mfa_required_fallback)
+
+ override val errorRecentLoginRequired: String
+ get() = localizedContext.getString(R.string.fui_error_recent_login_required)
+
+ override val errorTooManyRequests: String
+ get() = localizedContext.getString(R.string.fui_error_too_many_requests)
+
+ override val errorUnknownAuth: String
+ get() = localizedContext.getString(R.string.fui_error_unknown_auth)
+
+ override val errorNetworkGeneric: String
+ get() = localizedContext.getString(R.string.fui_error_network_generic)
+
+ override val errorAuthCancelled: String
+ get() = localizedContext.getString(R.string.fui_error_auth_cancelled)
}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PasswordValidator.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PasswordValidator.kt
index b7a8a70eb..a7d7e698e 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PasswordValidator.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PasswordValidator.kt
@@ -17,7 +17,7 @@ package com.firebase.ui.auth.configuration.validators
import com.firebase.ui.auth.configuration.PasswordRule
import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
-internal class PasswordValidator(
+class PasswordValidator(
override val stringProvider: AuthUIStringProvider,
private val rules: List
) : FieldValidator {
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 dff4daa60..230bb835a 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
@@ -140,6 +140,11 @@ private fun getRecoveryMessage(
} ?: baseMessage
}
+ is AuthException.PasswordPolicyViolationException -> {
+ error.message?.takeIf { it.isNotBlank() }
+ ?: stringProvider.weakPasswordRecoveryMessage
+ }
+
is AuthException.EmailAlreadyInUseException -> {
// Include email if available
val baseMessage = stringProvider.emailAlreadyInUseRecoveryMessage
@@ -201,6 +206,7 @@ private fun getRecoveryActionText(
is AuthException.NetworkException,
is AuthException.InvalidCredentialsException,
is AuthException.WeakPasswordException,
+ is AuthException.PasswordPolicyViolationException,
is AuthException.TooManyRequestsException,
is AuthException.PhoneVerificationCooldownException -> stringProvider.retryAction
is AuthException.UnknownException -> stringProvider.retryAction
@@ -221,6 +227,7 @@ private fun isRecoverable(error: AuthException): Boolean {
is AuthException.InvalidCredentialsException -> true
is AuthException.UserNotFoundException -> true
is AuthException.WeakPasswordException -> true
+ is AuthException.PasswordPolicyViolationException -> true
is AuthException.EmailAlreadyInUseException -> true
is AuthException.TooManyRequestsException -> false // User must wait
is AuthException.PhoneVerificationCooldownException -> false // User must wait for cooldown
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt
index bf4c3b6a5..feb04fb7c 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt
@@ -46,6 +46,24 @@ import com.firebase.ui.auth.configuration.theme.AuthUIAsset
import com.firebase.ui.auth.ui.components.AuthProviderButton
import com.firebase.ui.auth.util.SignInPreferenceManager
+/**
+ * Configuration for a custom Terms of Service/Privacy Policy footer in [AuthMethodPicker].
+ *
+ * @param content A composable that replaces the default "By continuing..." footer. Use this to
+ * supply a checkbox or any custom consent UI.
+ * @param accepted The current acceptance state. Only used when [disableProvidersUntilAccepted]
+ * is true.
+ * @param disableProvidersUntilAccepted When true, provider buttons are disabled until [accepted]
+ * is true. Defaults to false — buttons remain enabled unless explicitly opted in.
+ *
+ * @since 10.0.0
+ */
+class MethodPickerTermsConfiguration(
+ val content: @Composable () -> Unit,
+ val accepted: Boolean = true,
+ val disableProvidersUntilAccepted: Boolean = false,
+)
+
/**
* Renders the provider selection screen.
*
@@ -68,6 +86,8 @@ import com.firebase.ui.auth.util.SignInPreferenceManager
* @param termsOfServiceUrl The URL for the Terms of Service.
* @param privacyPolicyUrl The URL for the Privacy Policy.
* @param lastSignInPreference The last sign-in preference to show a "Continue as..." button.
+ * @param termsConfiguration Optional configuration for a custom ToS/Privacy Policy footer.
+ * When provided, replaces the default "By continuing..." text. See [MethodPickerTermsConfiguration].
*
* @since 10.0.0
*/
@@ -77,14 +97,18 @@ fun AuthMethodPicker(
providers: List,
logo: AuthUIAsset? = null,
onProviderSelected: (AuthProvider) -> Unit,
- customLayout: @Composable ((List, (AuthProvider) -> Unit) -> Unit)? = null,
termsOfServiceUrl: String? = null,
privacyPolicyUrl: String? = null,
lastSignInPreference: SignInPreferenceManager.SignInPreference? = null,
+ customLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null,
+ termsConfiguration: MethodPickerTermsConfiguration? = null,
) {
val context = LocalContext.current
val inPreview = LocalInspectionMode.current
val stringProvider = LocalAuthUIStringProvider.current
+ val providerButtonsEnabled = termsConfiguration == null ||
+ !termsConfiguration.disableProvidersUntilAccepted ||
+ termsConfiguration.accepted
Column(
modifier = modifier
@@ -100,7 +124,9 @@ fun AuthMethodPicker(
)
}
if (customLayout != null) {
- customLayout(providers, onProviderSelected)
+ Box(modifier = Modifier.weight(1f)) {
+ customLayout(providers, onProviderSelected)
+ }
} else {
BoxWithConstraints(
modifier = Modifier
@@ -121,6 +147,7 @@ fun AuthMethodPicker(
ContinueAsButton(
provider = lastProvider,
identifier = preference.identifier,
+ enabled = providerButtonsEnabled,
onClick = { onProviderSelected(lastProvider) }
)
Spacer(modifier = Modifier.height(24.dp))
@@ -155,6 +182,7 @@ fun AuthMethodPicker(
onClick = {
onProviderSelected(provider)
},
+ enabled = providerButtonsEnabled,
provider = provider,
stringProvider = LocalAuthUIStringProvider.current
)
@@ -163,20 +191,24 @@ fun AuthMethodPicker(
}
}
}
- AnnotatedStringResource(
- modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp),
- context = context,
- inPreview = inPreview,
- previewText = "By continuing, you accept our Terms of Service and Privacy Policy.",
- text = stringProvider.tosAndPrivacyPolicy(
- termsOfServiceLabel = stringProvider.termsOfService,
- privacyPolicyLabel = stringProvider.privacyPolicy
- ),
- links = arrayOf(
- stringProvider.termsOfService to (termsOfServiceUrl ?: ""),
- stringProvider.privacyPolicy to (privacyPolicyUrl ?: "")
+ if (termsConfiguration != null) {
+ termsConfiguration.content()
+ } else {
+ AnnotatedStringResource(
+ modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp),
+ context = context,
+ inPreview = inPreview,
+ previewText = "By continuing, you accept our Terms of Service and Privacy Policy.",
+ text = stringProvider.tosAndPrivacyPolicy(
+ termsOfServiceLabel = stringProvider.termsOfService,
+ privacyPolicyLabel = stringProvider.privacyPolicy
+ ),
+ links = arrayOf(
+ stringProvider.termsOfService to (termsOfServiceUrl ?: ""),
+ stringProvider.privacyPolicy to (privacyPolicyUrl ?: "")
+ )
)
- )
+ }
}
}
@@ -191,6 +223,7 @@ fun AuthMethodPicker(
private fun ContinueAsButton(
provider: AuthProvider,
identifier: String?,
+ enabled: Boolean = true,
onClick: () -> Unit
) {
val stringProvider = LocalAuthUIStringProvider.current
@@ -200,6 +233,7 @@ private fun ContinueAsButton(
.fillMaxWidth()
.testTag("ContinueAsButton"),
onClick = onClick,
+ enabled = enabled,
provider = provider,
stringProvider = stringProvider,
subtitle = identifier,
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 5a065400c..9691d101d 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
@@ -30,6 +30,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
@@ -37,6 +38,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -52,6 +54,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@@ -62,6 +65,7 @@ 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.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.auth_provider.filterToLinkedProviders
import com.firebase.ui.auth.configuration.auth_provider.rememberAnonymousSignInHandler
import com.firebase.ui.auth.configuration.auth_provider.rememberGoogleSignInHandler
import com.firebase.ui.auth.configuration.auth_provider.rememberOAuthSignInHandler
@@ -73,15 +77,23 @@ import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvi
import com.firebase.ui.auth.configuration.theme.LocalAuthUITheme
import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController
import com.firebase.ui.auth.ui.components.rememberTopLevelDialogController
+import com.firebase.ui.auth.mfa.MfaChallengeContentState
+import com.firebase.ui.auth.mfa.MfaEnrollmentContentState
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.phone.PhoneAuthContentState
import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen
import com.firebase.ui.auth.util.EmailLinkPersistenceManager
import com.firebase.ui.auth.util.SignInPreferenceManager
+import com.firebase.ui.auth.util.displayIdentifier
+import com.firebase.ui.auth.util.getDisplayEmail
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.MultiFactorResolver
import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
/**
* High-level authentication screen that wires together provider selection, individual provider
@@ -94,6 +106,7 @@ import kotlinx.coroutines.launch
*
* @since 10.0.0
*/
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FirebaseAuthScreen(
configuration: AuthUIConfiguration,
@@ -104,6 +117,13 @@ fun FirebaseAuthScreen(
authUI: FirebaseAuthUI = FirebaseAuthUI.getInstance(),
emailLink: String? = null,
mfaConfiguration: MfaConfiguration = MfaConfiguration(),
+ customMethodPickerLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null,
+ customMethodPickerTermsConfiguration: MethodPickerTermsConfiguration? = null,
+ emailContent: (@Composable (EmailAuthContentState) -> Unit)? = null,
+ phoneContent: (@Composable (PhoneAuthContentState) -> Unit)? = null,
+ mfaEnrollmentContent: (@Composable (MfaEnrollmentContentState) -> Unit)? = null,
+ mfaChallengeContent: (@Composable (MfaChallengeContentState) -> Unit)? = null,
+ reauthContent: (@Composable (state: AuthState.ReauthenticationRequired, onDismiss: () -> Unit) -> Unit)? = null,
authenticatedContent: (@Composable (state: AuthState, uiContext: AuthSuccessUiContext) -> Unit)? = null,
) {
// Set FirebaseUI version
@@ -122,108 +142,40 @@ fun FirebaseAuthScreen(
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) }
val emailLinkFromDifferentDevice = remember { mutableStateOf(null) }
val lastSignInPreference =
remember { mutableStateOf(null) }
+ val startRoute = remember(configuration.providers, configuration.isProviderChoiceAlwaysShown) {
+ getStartRoute(configuration)
+ }
+ val skipsMethodPicker = startRoute != AuthRoute.MethodPicker
// Load last sign-in preference on launch
LaunchedEffect(authState) {
lastSignInPreference.value = SignInPreferenceManager.getLastSignIn(context)
}
- val anonymousProvider =
- configuration.providers.filterIsInstance().firstOrNull()
- val googleProvider =
- configuration.providers.filterIsInstance().firstOrNull()
val emailProvider = configuration.providers.filterIsInstance().firstOrNull()
- val facebookProvider =
- configuration.providers.filterIsInstance().firstOrNull()
- val appleProvider = configuration.providers.filterIsInstance().firstOrNull()
- val githubProvider =
- configuration.providers.filterIsInstance().firstOrNull()
- val microsoftProvider =
- configuration.providers.filterIsInstance().firstOrNull()
- val yahooProvider = configuration.providers.filterIsInstance().firstOrNull()
- val twitterProvider =
- configuration.providers.filterIsInstance().firstOrNull()
- val genericOAuthProviders =
- configuration.providers.filterIsInstance()
-
val logoAsset = configuration.logo
-
- val onSignInAnonymously = anonymousProvider?.let {
- authUI.rememberAnonymousSignInHandler()
- }
-
- val onSignInWithGoogle = googleProvider?.let {
- authUI.rememberGoogleSignInHandler(
- context = context,
- config = configuration,
- provider = it
- )
- }
-
- val onSignInWithFacebook = facebookProvider?.let {
- authUI.rememberSignInWithFacebookLauncher(
- context = context,
- config = configuration,
- provider = it
- )
- }
-
- val onSignInWithApple = appleProvider?.let {
- authUI.rememberOAuthSignInHandler(
- context = context,
- activity = activity,
- config = configuration,
- provider = it
- )
- }
-
- val onSignInWithGithub = githubProvider?.let {
- authUI.rememberOAuthSignInHandler(
- context = context,
- activity = activity,
- config = configuration,
- provider = it
- )
- }
-
- val onSignInWithMicrosoft = microsoftProvider?.let {
- authUI.rememberOAuthSignInHandler(
- context = context,
- activity = activity,
- config = configuration,
- provider = it
- )
- }
-
- val onSignInWithYahoo = yahooProvider?.let {
- authUI.rememberOAuthSignInHandler(
- context = context,
- activity = activity,
- config = configuration,
- provider = it
- )
- }
-
- val onSignInWithTwitter = twitterProvider?.let {
- authUI.rememberOAuthSignInHandler(
- context = context,
- activity = activity,
- config = configuration,
- provider = it
- )
- }
-
- val genericOAuthHandlers = genericOAuthProviders.associateWith {
- authUI.rememberOAuthSignInHandler(
- context = context,
- activity = activity,
- config = configuration,
- provider = it
- )
- }
+ val onProviderSelected = authUI.rememberOnProviderSelected(
+ context = context,
+ activity = activity,
+ config = configuration,
+ onNavigate = { route -> navController.navigate(route.route) },
+ onUnknownProvider = { provider ->
+ onSignInFailure(
+ AuthException.UnknownException(
+ message = "Provider ${provider.providerId} is not supported in FirebaseAuthScreen",
+ cause = IllegalArgumentException(
+ "Provider ${provider.providerId} is not supported in FirebaseAuthScreen"
+ )
+ )
+ )
+ },
+ )
CompositionLocalProvider(
LocalAuthUIStringProvider provides configuration.stringProvider,
@@ -236,7 +188,7 @@ fun FirebaseAuthScreen(
) {
NavHost(
navController = navController,
- startDestination = AuthRoute.MethodPicker.route,
+ startDestination = startRoute.route,
enterTransition = configuration.transitions?.enterTransition ?: {
fadeIn(animationSpec = tween(700))
},
@@ -260,46 +212,9 @@ fun FirebaseAuthScreen(
termsOfServiceUrl = configuration.tosUrl,
privacyPolicyUrl = configuration.privacyPolicyUrl,
lastSignInPreference = lastSignInPreference.value,
- onProviderSelected = { provider ->
- when (provider) {
- is AuthProvider.Anonymous -> onSignInAnonymously?.invoke()
-
- is AuthProvider.Email -> {
- navController.navigate(AuthRoute.Email.route)
- }
-
- is AuthProvider.Phone -> {
- navController.navigate(AuthRoute.Phone.route)
- }
-
- is AuthProvider.Google -> onSignInWithGoogle?.invoke()
-
- is AuthProvider.Facebook -> onSignInWithFacebook?.invoke()
-
- is AuthProvider.Apple -> onSignInWithApple?.invoke()
-
- is AuthProvider.Github -> onSignInWithGithub?.invoke()
-
- is AuthProvider.Microsoft -> onSignInWithMicrosoft?.invoke()
-
- is AuthProvider.Yahoo -> onSignInWithYahoo?.invoke()
-
- is AuthProvider.Twitter -> onSignInWithTwitter?.invoke()
-
- is AuthProvider.GenericOAuth -> genericOAuthHandlers[provider]?.invoke()
-
- else -> {
- onSignInFailure(
- AuthException.UnknownException(
- message = "Provider ${provider.providerId} is not supported in FirebaseAuthScreen",
- cause = IllegalArgumentException(
- "Provider ${provider.providerId} is not supported in FirebaseAuthScreen"
- )
- )
- )
- }
- }
- }
+ customLayout = customMethodPickerLayout,
+ termsConfiguration = customMethodPickerTermsConfiguration,
+ onProviderSelected = onProviderSelected,
)
}
}
@@ -311,6 +226,7 @@ fun FirebaseAuthScreen(
authUI = authUI,
credentialForLinking = pendingLinkingCredential.value,
emailLinkFromDifferentDevice = emailLinkFromDifferentDevice.value,
+ content = emailContent,
onSuccess = {
pendingLinkingCredential.value = null
},
@@ -319,7 +235,9 @@ fun FirebaseAuthScreen(
},
onCancel = {
pendingLinkingCredential.value = null
- if (!navController.popBackStack()) {
+ if (skipsMethodPicker) {
+ onSignInCancelled()
+ } else if (!navController.popBackStack()) {
navController.navigate(AuthRoute.MethodPicker.route) {
popUpTo(AuthRoute.MethodPicker.route) { inclusive = true }
launchSingleTop = true
@@ -334,12 +252,15 @@ fun FirebaseAuthScreen(
context = context,
configuration = configuration,
authUI = authUI,
+ content = phoneContent,
onSuccess = {},
onError = { exception ->
onSignInFailure(exception)
},
onCancel = {
- if (!navController.popBackStack()) {
+ if (skipsMethodPicker) {
+ onSignInCancelled()
+ } else if (!navController.popBackStack()) {
navController.navigate(AuthRoute.MethodPicker.route) {
popUpTo(AuthRoute.MethodPicker.route) { inclusive = true }
launchSingleTop = true
@@ -361,7 +282,7 @@ fun FirebaseAuthScreen(
authUI.signOut(context)
// Keep sign-in preference for "Continue as..." on next launch
} catch (e: Exception) {
- onSignInFailure(AuthException.from(e))
+ onSignInFailure(AuthException.from(e, stringProvider))
} finally {
pendingLinkingCredential.value = null
pendingResolver.value = null
@@ -383,27 +304,22 @@ fun FirebaseAuthScreen(
coroutineScope.launch {
try {
// Reload user to get fresh data from server
- authUI.getCurrentUser()?.reload()
- authUI.getCurrentUser()?.getIdToken(true)
-
- // Check the user's email verification status after reload
- val user = authUI.getCurrentUser()
- if (user != null) {
- // If email is now verified, transition to Success state
- if (user.isEmailVerified) {
+ authUI.getCurrentUser()?.let {
+ it.reload().await()
+ it.getIdToken(true).await()
+ if (it.isEmailVerified) {
authUI.updateAuthState(
AuthState.Success(
result = null,
- user = user,
+ user = it,
isNewUser = false
)
)
} else {
- // Email still not verified, keep showing verification screen
authUI.updateAuthState(
AuthState.RequiresEmailVerification(
- user = user,
- email = user.email ?: ""
+ user = it,
+ email = it.email ?: ""
)
)
}
@@ -439,10 +355,11 @@ fun FirebaseAuthScreen(
auth = authUI.auth,
configuration = mfaConfiguration,
authConfiguration = configuration,
+ content = mfaEnrollmentContent,
onComplete = { navController.popBackStack() },
onSkip = { navController.popBackStack() },
onError = { exception ->
- onSignInFailure(AuthException.from(exception))
+ onSignInFailure(AuthException.from(exception, stringProvider))
}
)
} else {
@@ -456,6 +373,7 @@ fun FirebaseAuthScreen(
MfaChallengeScreen(
resolver = resolver,
auth = authUI.auth,
+ content = mfaChallengeContent,
onSuccess = {
pendingResolver.value = null
// Reset auth state to Idle so the firebaseAuthFlow Success state takes over
@@ -467,7 +385,7 @@ fun FirebaseAuthScreen(
navController.popBackStack()
},
onError = { exception ->
- onSignInFailure(AuthException.from(exception))
+ onSignInFailure(AuthException.from(exception, stringProvider))
}
)
} else {
@@ -520,6 +438,26 @@ fun FirebaseAuthScreen(
pendingResolver.value = null
pendingLinkingCredential.value = null
+ // If reauth just completed, execute the pending retry and skip normal success handling
+ 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)
@@ -535,12 +473,36 @@ fun FirebaseAuthScreen(
if (currentRoute != AuthRoute.Success.route) {
navController.navigate(AuthRoute.Success.route) {
- popUpTo(AuthRoute.MethodPicker.route) { inclusive = true }
+ popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
launchSingleTop = true
}
}
}
+ is AuthState.ReauthenticationRequired -> {
+ pendingReauthOperation.value = state.retryOperation
+ val linked = configuration.providers.filterToLinkedProviders(state.user)
+ if (linked.isEmpty()) {
+ authUI.updateAuthState(
+ AuthState.Error(
+ AuthException.UnknownException(
+ "No configured providers are linked to the current user"
+ )
+ )
+ )
+ return@LaunchedEffect
+ }
+ if (reauthContent != null) {
+ pendingReauthState.value = state
+ } else {
+ pendingReauthConfig.value = configuration.copy(
+ providers = linked,
+ isNewEmailAccountsAllowed = false,
+ isReauthenticationMode = true,
+ )
+ }
+ }
+
is AuthState.RequiresEmailVerification,
is AuthState.RequiresProfileCompletion,
-> {
@@ -548,7 +510,7 @@ fun FirebaseAuthScreen(
pendingLinkingCredential.value = null
if (currentRoute != AuthRoute.Success.route) {
navController.navigate(AuthRoute.Success.route) {
- popUpTo(AuthRoute.MethodPicker.route) { inclusive = true }
+ popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
launchSingleTop = true
}
}
@@ -564,12 +526,15 @@ fun FirebaseAuthScreen(
}
is AuthState.Cancelled -> {
+ pendingReauthOperation.value = null
+ pendingReauthConfig.value = null
+ pendingReauthState.value = null
pendingResolver.value = null
pendingLinkingCredential.value = null
lastSuccessfulUserId.value = null
- if (currentRoute != AuthRoute.MethodPicker.route) {
- navController.navigate(AuthRoute.MethodPicker.route) {
- popUpTo(AuthRoute.MethodPicker.route) { inclusive = true }
+ if (currentRoute != startRoute.route) {
+ navController.navigate(startRoute.route) {
+ popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
launchSingleTop = true
}
}
@@ -577,12 +542,15 @@ fun FirebaseAuthScreen(
}
is AuthState.Idle -> {
+ pendingReauthOperation.value = null
+ pendingReauthConfig.value = null
+ pendingReauthState.value = null
pendingResolver.value = null
pendingLinkingCredential.value = null
lastSuccessfulUserId.value = null
- if (currentRoute != AuthRoute.MethodPicker.route) {
- navController.navigate(AuthRoute.MethodPicker.route) {
- popUpTo(AuthRoute.MethodPicker.route) { inclusive = true }
+ if (currentRoute != startRoute.route) {
+ navController.navigate(startRoute.route) {
+ popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
launchSingleTop = true
}
}
@@ -598,7 +566,7 @@ fun FirebaseAuthScreen(
LaunchedEffect(errorState) {
val exception = when (val throwable = errorState.exception) {
is AuthException -> throwable
- else -> AuthException.from(throwable)
+ else -> AuthException.from(throwable, stringProvider)
}
dialogController.showErrorDialog(
@@ -654,6 +622,44 @@ fun FirebaseAuthScreen(
if (loadingState != null) {
LoadingDialog(loadingState.message ?: stringProvider.progressDialogLoading)
}
+
+ // Custom reauth UI — rendered when the caller provides reauthContent.
+ val pendingReauth = pendingReauthState.value
+ if (pendingReauth != null && reauthContent != null) {
+ reauthContent(pendingReauth) {
+ pendingReauthOperation.value = null
+ pendingReauthState.value = null
+ authUI.updateAuthState(AuthState.Idle)
+ }
+ }
+
+ // 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(
+ authUI = authUI,
+ reauthConfig = reauthConfig,
+ activity = activity,
+ context = context,
+ emailContent = emailContent,
+ phoneContent = phoneContent,
+ customMethodPickerLayout = customMethodPickerLayout,
+ onDismiss = {
+ pendingReauthOperation.value = null
+ pendingReauthConfig.value = null
+ authUI.updateAuthState(AuthState.Idle)
+ },
+ )
+ }
+ }
}
}
}
@@ -667,6 +673,18 @@ sealed class AuthRoute(val route: String) {
object MfaChallenge : AuthRoute("auth_mfa_challenge")
}
+internal fun getStartRoute(configuration: AuthUIConfiguration): AuthRoute {
+ if (configuration.isProviderChoiceAlwaysShown || configuration.providers.size != 1) {
+ return AuthRoute.MethodPicker
+ }
+
+ return when (configuration.providers.single()) {
+ is AuthProvider.Email -> AuthRoute.Email
+ is AuthProvider.Phone -> AuthRoute.Phone
+ else -> AuthRoute.MethodPicker
+ }
+}
+
data class AuthSuccessUiContext(
val authUI: FirebaseAuthUI,
val stringProvider: AuthUIStringProvider,
@@ -733,7 +751,7 @@ private fun AuthSuccessContent(
onManageMfa: () -> Unit,
) {
val user = authUI.getCurrentUser()
- val userIdentifier = user?.email ?: user?.phoneNumber ?: user?.uid.orEmpty()
+ val userIdentifier = user.displayIdentifier()
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
@@ -757,7 +775,7 @@ private fun AuthSuccessContent(
}
},
state = rememberTooltipState(
- initialIsVisible = !configuration.isMfaEnabled
+ initialIsVisible = false
)
) {
Button(
@@ -783,7 +801,7 @@ private fun EmailVerificationContent(
onSignOut: () -> Unit,
) {
val user = authUI.getCurrentUser()
- val emailLabel = user?.email ?: stringProvider.emailProvider
+ val emailLabel = user.getDisplayEmail(stringProvider.emailProvider)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
@@ -859,3 +877,121 @@ 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) {
+ Scaffold { innerPadding ->
+ AuthMethodPicker(
+ modifier = Modifier.padding(innerPadding),
+ providers = reauthConfig.providers,
+ customLayout = customMethodPickerLayout,
+ 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(
+ context: android.content.Context,
+ activity: android.app.Activity?,
+ config: AuthUIConfiguration,
+ onNavigate: (AuthRoute) -> Unit,
+ onUnknownProvider: ((AuthProvider) -> Unit)? = null,
+): (AuthProvider) -> Unit {
+ val anonymousProvider = config.providers.filterIsInstance().firstOrNull()
+ val googleProvider = config.providers.filterIsInstance().firstOrNull()
+ val facebookProvider = config.providers.filterIsInstance().firstOrNull()
+ val appleProvider = config.providers.filterIsInstance().firstOrNull()
+ val githubProvider = config.providers.filterIsInstance().firstOrNull()
+ val microsoftProvider = config.providers.filterIsInstance().firstOrNull()
+ val yahooProvider = config.providers.filterIsInstance().firstOrNull()
+ val twitterProvider = config.providers.filterIsInstance().firstOrNull()
+ val genericOAuthProviders = config.providers.filterIsInstance()
+
+ val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler() }
+ val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, config, it) }
+ val onSignInWithFacebook = facebookProvider?.let { rememberSignInWithFacebookLauncher(context, config, it) }
+ val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) }
+ val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) }
+ val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) }
+ val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) }
+ val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) }
+ val genericOAuthHandlers = genericOAuthProviders.associateWith {
+ rememberOAuthSignInHandler(context, activity, config, it)
+ }
+
+ return { provider ->
+ when (provider) {
+ is AuthProvider.Anonymous -> onSignInAnonymously?.invoke()
+ is AuthProvider.Email -> onNavigate(AuthRoute.Email)
+ is AuthProvider.Phone -> onNavigate(AuthRoute.Phone)
+ is AuthProvider.Google -> onSignInWithGoogle?.invoke()
+ is AuthProvider.Facebook -> onSignInWithFacebook?.invoke()
+ is AuthProvider.Apple -> onSignInWithApple?.invoke()
+ is AuthProvider.Github -> onSignInWithGithub?.invoke()
+ is AuthProvider.Microsoft -> onSignInWithMicrosoft?.invoke()
+ is AuthProvider.Yahoo -> onSignInWithYahoo?.invoke()
+ is AuthProvider.Twitter -> onSignInWithTwitter?.invoke()
+ is AuthProvider.GenericOAuth -> genericOAuthHandlers[provider]?.invoke()
+ else -> onUnknownProvider?.invoke(provider)
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt
index 2ebc2542f..1f566f5cf 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
@@ -181,7 +181,7 @@ fun EmailAuthScreen(
}
is AuthState.Error -> {
- val exception = AuthException.from(state.exception)
+ val exception = AuthException.from(state.exception, stringProvider)
onError(exception)
dialogController?.showErrorDialog(
exception = exception,
@@ -265,7 +265,7 @@ fun EmailAuthScreen(
skipCredentialSave = isUsingRetrievedCredential
)
} catch (e: Exception) {
- onError(AuthException.from(e))
+ onError(AuthException.from(e, stringProvider))
}
}
},
@@ -290,7 +290,7 @@ fun EmailAuthScreen(
)
}
} catch (e: Exception) {
- onError(AuthException.from(e))
+ onError(AuthException.from(e, stringProvider))
}
}
},
@@ -306,7 +306,7 @@ fun EmailAuthScreen(
password = passwordTextValue.value,
)
} catch (e: Exception) {
-
+ onError(AuthException.from(e, stringProvider))
}
}
},
@@ -318,7 +318,7 @@ fun EmailAuthScreen(
actionCodeSettings = configuration.passwordResetActionCodeSettings,
)
} catch (e: Exception) {
-
+ onError(AuthException.from(e, stringProvider))
}
}
},
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 fa6278976..26161da78 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
@@ -210,7 +210,7 @@ fun PhoneAuthScreen(
}
is AuthState.Error -> {
- val exception = AuthException.from(state.exception)
+ val exception = AuthException.from(state.exception, stringProvider)
onError(exception)
// Show dialog for phone-specific errors using top-level controller
diff --git a/auth/src/main/java/com/firebase/ui/auth/util/UserUtils.kt b/auth/src/main/java/com/firebase/ui/auth/util/UserUtils.kt
new file mode 100644
index 000000000..8e25766e0
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/util/UserUtils.kt
@@ -0,0 +1,38 @@
+/*
+ * 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.util
+
+import com.google.firebase.auth.FirebaseUser
+
+/**
+ * Returns the best available display identifier for the user, trying each field in order:
+ * email → phoneNumber → displayName → uid.
+ *
+ * Each field is checked for blank (not just null) so that an empty string returned by the
+ * Firebase SDK falls through to the next candidate rather than being displayed as-is.
+ * Returns an empty string if the user is null.
+ */
+fun FirebaseUser?.displayIdentifier(): String =
+ this?.email?.takeIf { it.isNotBlank() }
+ ?: this?.phoneNumber?.takeIf { it.isNotBlank() }
+ ?: this?.displayName?.takeIf { it.isNotBlank() }
+ ?: this?.uid
+ ?: ""
+
+/**
+ * Returns the user's email if it is non-blank, otherwise returns the provided [fallback].
+ */
+fun FirebaseUser?.getDisplayEmail(fallback: String): String =
+ this?.email?.takeIf { it.isNotBlank() } ?: fallback
diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml
index bb4b4e813..cc5cfa6b3 100644
--- a/auth/src/main/res/values/strings.xml
+++ b/auth/src/main/res/values/strings.xml
@@ -222,6 +222,23 @@
Additional verification required. Please complete multi-factor authentication.
Account needs to be linked. Please try a different sign-in method.
Authentication was cancelled. Please try again when ready.
+
+ User account has been disabled
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Choose Authentication Method
diff --git a/auth/src/test/java/com/firebase/ui/auth/AuthExceptionTest.kt b/auth/src/test/java/com/firebase/ui/auth/AuthExceptionTest.kt
index 0b7b5bbbf..ea8ec7ecd 100644
--- a/auth/src/test/java/com/firebase/ui/auth/AuthExceptionTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/AuthExceptionTest.kt
@@ -14,11 +14,16 @@
package com.firebase.ui.auth
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
import com.google.common.truth.Truth.assertThat
import com.google.firebase.FirebaseException
import com.google.firebase.auth.FirebaseAuthException
+import com.google.firebase.auth.FirebaseAuthInvalidUserException
+import com.google.firebase.auth.FirebaseAuthWeakPasswordException
import org.junit.Test
import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@@ -109,6 +114,7 @@ class AuthExceptionTest {
assertThat(AuthException.InvalidCredentialsException("Test")).isInstanceOf(AuthException::class.java)
assertThat(AuthException.UserNotFoundException("Test")).isInstanceOf(AuthException::class.java)
assertThat(AuthException.WeakPasswordException("Test")).isInstanceOf(AuthException::class.java)
+ assertThat(AuthException.PasswordPolicyViolationException("Test", emptyList())).isInstanceOf(AuthException::class.java)
assertThat(AuthException.EmailAlreadyInUseException("Test")).isInstanceOf(AuthException::class.java)
assertThat(AuthException.TooManyRequestsException("Test")).isInstanceOf(AuthException::class.java)
assertThat(AuthException.MfaRequiredException("Test")).isInstanceOf(AuthException::class.java)
@@ -136,4 +142,151 @@ class AuthExceptionTest {
// Assert
assertThat(exception.email).isEqualTo(email)
}
+
+ // =============================================================================================
+ // AuthUIStringProvider message customisation
+ // =============================================================================================
+
+ @Test
+ fun `from() uses string provider message when non-empty`() {
+ val firebaseException = mock(FirebaseAuthInvalidUserException::class.java)
+ whenever(firebaseException.errorCode).thenReturn("ERROR_USER_DISABLED")
+ whenever(firebaseException.message).thenReturn("Firebase: user disabled")
+
+ val stringProvider = mock(AuthUIStringProvider::class.java)
+ whenever(stringProvider.errorUserDisabled).thenReturn("Custom: account disabled")
+
+ val result = AuthException.from(firebaseException, stringProvider)
+
+ assertThat(result.message).isEqualTo("Custom: account disabled")
+ }
+
+ @Test
+ fun `from() falls back to Firebase message when string provider returns empty`() {
+ val firebaseException = mock(FirebaseAuthInvalidUserException::class.java)
+ whenever(firebaseException.errorCode).thenReturn("ERROR_USER_DISABLED")
+ whenever(firebaseException.message).thenReturn("Firebase: user disabled")
+
+ val stringProvider = mock(AuthUIStringProvider::class.java)
+ whenever(stringProvider.errorUserDisabled).thenReturn("")
+
+ val result = AuthException.from(firebaseException, stringProvider)
+
+ assertThat(result.message).isEqualTo("Firebase: user disabled")
+ }
+
+ @Test
+ fun `from() falls back to Firebase message when no string provider given`() {
+ val firebaseException = mock(FirebaseAuthInvalidUserException::class.java)
+ whenever(firebaseException.errorCode).thenReturn("ERROR_USER_DISABLED")
+ whenever(firebaseException.message).thenReturn("Firebase: user disabled")
+
+ val result = AuthException.from(firebaseException)
+
+ assertThat(result.message).isEqualTo("Firebase: user disabled")
+ }
+
+ // =============================================================================================
+ // GIdP password policy
+ // =============================================================================================
+
+ @Test
+ fun `from() maps GIdP policy violation FirebaseException to PasswordPolicyViolationException`() {
+ val msg = "An internal error has occurred. [ PASSWORD_DOES_NOT_MEET_REQUIREMENTS:" +
+ "Missing password requirements: [Password must contain at least 10 characters] ]"
+ val firebaseException = object : com.google.firebase.FirebaseException(msg) {}
+
+ val result = AuthException.from(firebaseException)
+
+ assertThat(result).isInstanceOf(AuthException.PasswordPolicyViolationException::class.java)
+ val policyEx = result as AuthException.PasswordPolicyViolationException
+ assertThat(policyEx.failingRequirements).containsExactly(
+ "Password must contain at least 10 characters"
+ )
+ assertThat(policyEx.message).isEqualTo("Password must contain at least 10 characters")
+ assertThat(policyEx.cause).isEqualTo(firebaseException)
+ }
+
+ @Test
+ fun `from() maps GIdP policy violation with multiple requirements`() {
+ val msg = "An internal error has occurred. [ PASSWORD_DOES_NOT_MEET_REQUIREMENTS:" +
+ "Missing password requirements: [Password must contain at least 10 characters, " +
+ "Password must contain at least one uppercase letter] ]"
+ val firebaseException = object : com.google.firebase.FirebaseException(msg) {}
+
+ val result = AuthException.from(firebaseException)
+
+ assertThat(result).isInstanceOf(AuthException.PasswordPolicyViolationException::class.java)
+ val policyEx = result as AuthException.PasswordPolicyViolationException
+ assertThat(policyEx.failingRequirements).containsExactly(
+ "Password must contain at least 10 characters",
+ "Password must contain at least one uppercase letter"
+ ).inOrder()
+ assertThat(policyEx.message).isEqualTo(
+ "Password must contain at least 10 characters\nPassword must contain at least one uppercase letter"
+ )
+ }
+
+ @Test
+ fun `from() maps GIdP policy violation in FirebaseAuthWeakPasswordException reason`() {
+ val firebaseException = FirebaseAuthWeakPasswordException(
+ "ERROR_WEAK_PASSWORD",
+ "weak",
+ "PASSWORD_DOES_NOT_MEET_REQUIREMENTS : [Password must contain uppercase, Password must contain a number]"
+ )
+
+ val result = AuthException.from(firebaseException)
+
+ assertThat(result).isInstanceOf(AuthException.PasswordPolicyViolationException::class.java)
+ val policyEx = result as AuthException.PasswordPolicyViolationException
+ assertThat(policyEx.failingRequirements).containsExactly(
+ "Password must contain uppercase",
+ "Password must contain a number"
+ ).inOrder()
+ assertThat(policyEx.message).isEqualTo("Password must contain uppercase\nPassword must contain a number")
+ }
+
+ @Test
+ fun `from() passes through unknown requirement strings as-is`() {
+ val msg = "An internal error has occurred. [ PASSWORD_DOES_NOT_MEET_REQUIREMENTS:" +
+ "Missing password requirements: [Some future requirement] ]"
+ val firebaseException = object : com.google.firebase.FirebaseException(msg) {}
+
+ val result = AuthException.from(firebaseException)
+
+ assertThat(result).isInstanceOf(AuthException.PasswordPolicyViolationException::class.java)
+ val policyEx = result as AuthException.PasswordPolicyViolationException
+ assertThat(policyEx.failingRequirements).containsExactly("Some future requirement")
+ assertThat(policyEx.message).isEqualTo("Some future requirement")
+ }
+
+ @Test
+ fun `from() maps plain weak password (no policy) to WeakPasswordException`() {
+ val firebaseException = FirebaseAuthWeakPasswordException(
+ "ERROR_WEAK_PASSWORD",
+ "The given password is invalid.",
+ "Password should be at least 6 characters"
+ )
+
+ val result = AuthException.from(firebaseException)
+
+ assertThat(result).isInstanceOf(AuthException.WeakPasswordException::class.java)
+ }
+
+ @Test
+ fun `from() maps plain FirebaseException without policy to NetworkException`() {
+ val firebaseException = object : com.google.firebase.FirebaseException("Network timeout") {}
+
+ val result = AuthException.from(firebaseException)
+
+ assertThat(result).isInstanceOf(AuthException.NetworkException::class.java)
+ }
+
+ @Test
+ fun `PasswordPolicyViolationException stores failingRequirements correctly`() {
+ val requirements = listOf("MISSING_UPPERCASE_CHARACTER", "MISSING_NUMERIC_CHARACTER")
+ val exception = AuthException.PasswordPolicyViolationException("msg", requirements)
+
+ assertThat(exception.failingRequirements).isEqualTo(requirements)
+ }
}
\ No newline at end of file
diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt
index 06e8c972a..a94999439 100644
--- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt
@@ -50,6 +50,7 @@ class FirebaseAuthActivityTest {
private lateinit var applicationContext: Context
private lateinit var authUI: FirebaseAuthUI
+ private lateinit var secondaryAuthUI: FirebaseAuthUI
private lateinit var configuration: AuthUIConfiguration
@Mock
@@ -79,8 +80,20 @@ class FirebaseAuthActivityTest {
.build()
)
+ val secondaryApp = FirebaseApp.initializeApp(
+ applicationContext,
+ FirebaseOptions.Builder()
+ .setApiKey("fake-api-key-2")
+ .setApplicationId("fake-app-id-2")
+ .setProjectId("fake-project-id-2")
+ .build(),
+ "secondary"
+ )
+
authUI = FirebaseAuthUI.getInstance()
authUI.auth.useEmulator("127.0.0.1", 9099)
+ secondaryAuthUI = FirebaseAuthUI.getInstance(secondaryApp)
+ secondaryAuthUI.auth.useEmulator("127.0.0.1", 9099)
configuration = AuthUIConfiguration(
context = applicationContext,
@@ -98,6 +111,7 @@ class FirebaseAuthActivityTest {
@After
fun tearDown() {
+ FirebaseAuthActivity.clearLaunchStateCache()
FirebaseAuthUI.clearInstanceCache()
FirebaseApp.getApps(applicationContext).forEach { app ->
try {
@@ -180,6 +194,46 @@ class FirebaseAuthActivityTest {
assertThat(activity.isFinishing).isFalse()
}
+ @Test
+ fun `activity launched from secondary auth flow observes supplied authUI instead of default app`() =
+ runTest {
+ val controller = secondaryAuthUI.createAuthFlow(configuration)
+ val intent = controller.createIntent(applicationContext)
+ val activity = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+ .create()
+ .start()
+ .resume()
+ .get()
+
+ `when`(mockFirebaseUser.uid).thenReturn("secondary-user-id")
+
+ authUI.updateAuthState(
+ AuthState.Success(
+ result = null,
+ user = mockFirebaseUser,
+ isNewUser = false
+ )
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertThat(activity.isFinishing).isFalse()
+
+ secondaryAuthUI.updateAuthState(
+ AuthState.Success(
+ result = null,
+ user = mockFirebaseUser,
+ isNewUser = false
+ )
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertThat(activity.isFinishing).isTrue()
+ val shadowActivity = shadowOf(activity)
+ assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_OK)
+ assertThat(shadowActivity.resultIntent.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID))
+ .isEqualTo("secondary-user-id")
+ }
+
// =============================================================================================
// Auth State Success Tests
// =============================================================================================
@@ -394,22 +448,28 @@ class FirebaseAuthActivityTest {
// =============================================================================================
@Test
- fun `configuration is removed from cache after onCreate`() {
- val intent1 = FirebaseAuthActivity.createIntent(applicationContext, configuration)
- val configKey1 = intent1.getStringExtra("com.firebase.ui.auth.CONFIGURATION_KEY")
+ fun `launch state survives recreation and is cleared when activity finishes`() {
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration)
- assertThat(configKey1).isNotNull()
+ val firstController = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+ val firstActivity = firstController.create().start().resume().get()
+ assertThat(firstActivity.isFinishing).isFalse()
- // Create activity - this should consume the configuration from cache
- val controller1 = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent1)
- controller1.create().get()
+ // Simulate recreation: the first activity is destroyed without finishing.
+ firstController.pause().stop().destroy()
+
+ val recreatedController = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+ val recreatedActivity = recreatedController.create().start().resume().get()
+ assertThat(recreatedActivity.isFinishing).isFalse()
- // Create another intent
- val intent2 = FirebaseAuthActivity.createIntent(applicationContext, configuration)
- val configKey2 = intent2.getStringExtra("com.firebase.ui.auth.CONFIGURATION_KEY")
+ // Once the recreated activity actually finishes, the cached launch state should be released.
+ recreatedActivity.finish()
+ recreatedController.pause().stop().destroy()
- // Should be a different key
- assertThat(configKey2).isNotEqualTo(configKey1)
+ val postFinishController = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+ val postFinishActivity = postFinishController.create().get()
+ assertThat(postFinishActivity.isFinishing).isTrue()
+ assertThat(shadowOf(postFinishActivity).resultCode).isEqualTo(Activity.RESULT_CANCELED)
}
@Test
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 d2558a8a8..8a4715c97 100644
--- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt
@@ -18,12 +18,16 @@ import androidx.test.core.app.ApplicationProvider
import com.google.common.truth.Truth.assertThat
import com.google.firebase.FirebaseApp
import com.google.firebase.FirebaseOptions
+import android.content.Context
+import com.google.android.gms.tasks.TaskCompletionSource
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseAuth.AuthStateListener
+import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException
import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.MultiFactorResolver
import com.google.firebase.auth.UserInfo
+import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
@@ -37,6 +41,7 @@ import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.Mock
import org.mockito.Mockito.mock
+import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
@@ -381,4 +386,194 @@ class FirebaseAuthUIAuthStateTest {
assertThat(state.user).isEqualTo(mockFirebaseUser)
assertThat(state.missingFields).containsExactly("displayName", "photoUrl")
}
+
+ // =============================================================================================
+ // delete() ReauthenticationRequired state Tests
+ // =============================================================================================
+
+ @Test
+ fun `delete() emits ReauthenticationRequired state when recent login required`() = runTest {
+ val mockUser = mock(FirebaseUser::class.java)
+ val tcs = TaskCompletionSource()
+ tcs.setException(
+ FirebaseAuthRecentLoginRequiredException(
+ "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required"
+ )
+ )
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser)
+ `when`(mockUser.delete()).thenReturn(tcs.task)
+
+ val context = ApplicationProvider.getApplicationContext()
+
+ try {
+ authUI.delete(context)
+ } catch (_: AuthException.InvalidCredentialsException) {
+ // expected — existing contract preserved
+ }
+
+ assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.ReauthenticationRequired::class.java)
+ val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired
+ assertThat(state.user).isEqualTo(mockUser)
+ }
+
+ @Test
+ fun `delete() attaches retryOperation to ReauthenticationRequired state`() = runTest {
+ val mockUser = mock(FirebaseUser::class.java)
+ val tcs = TaskCompletionSource()
+ tcs.setException(
+ FirebaseAuthRecentLoginRequiredException(
+ "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required"
+ )
+ )
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser)
+ `when`(mockUser.delete()).thenReturn(tcs.task)
+
+ val context = ApplicationProvider.getApplicationContext()
+ try { authUI.delete(context) } catch (_: AuthException.InvalidCredentialsException) {}
+
+ val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired
+ // Fails until delete() passes retryOperation into the state
+ assertThat(state.retryOperation).isNotNull()
+ }
+
+ // =============================================================================================
+ // withReauth() Tests
+ // =============================================================================================
+
+ @Test
+ fun `withReauth() executes operation normally when no reauth needed`() = runTest {
+ val context = ApplicationProvider.getApplicationContext()
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser)
+ var callCount = 0
+
+ authUI.withReauth(context) { callCount++ }
+
+ assertThat(callCount).isEqualTo(1)
+ }
+
+ @Test
+ fun `withReauth() emits ReauthenticationRequired when FirebaseAuthRecentLoginRequiredException thrown`() = runTest {
+ val context = ApplicationProvider.getApplicationContext()
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser)
+
+ authUI.withReauth(context) {
+ 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(state.user).isEqualTo(mockFirebaseUser)
+ }
+
+ @Test
+ fun `withReauth() forwards reason to ReauthenticationRequired state`() = runTest {
+ val context = ApplicationProvider.getApplicationContext()
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser)
+
+ authUI.withReauth(context, reason = "Verify identity to change email") {
+ throw FirebaseAuthRecentLoginRequiredException("ERROR_REQUIRES_RECENT_LOGIN", "Recent login required")
+ }
+
+ val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired
+ assertThat(state.reason).isEqualTo("Verify identity to change email")
+ }
+
+ @Test
+ fun `withReauth() attaches retryOperation that re-invokes the original operation`() = runTest {
+ val context = ApplicationProvider.getApplicationContext()
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser)
+ var callCount = 0
+
+ authUI.withReauth(context) {
+ callCount++
+ if (callCount == 1) throw FirebaseAuthRecentLoginRequiredException(
+ "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required"
+ )
+ }
+
+ val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired
+ assertThat(state.retryOperation).isNotNull()
+ state.retryOperation!!(context)
+ assertThat(callCount).isEqualTo(2)
+ }
+
+ @Test
+ fun `withReauth() retryOperation restores auth state after successful retry`() = runTest {
+ val context = ApplicationProvider.getApplicationContext()
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser)
+ var callCount = 0
+
+ authUI.withReauth(context) {
+ callCount++
+ if (callCount == 1) throw FirebaseAuthRecentLoginRequiredException(
+ "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required"
+ )
+ }
+
+ val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired
+
+ // Simulate FirebaseAuthScreen: set Loading, then invoke the retry
+ authUI.updateAuthState(AuthState.Loading())
+ state.retryOperation!!(context)
+
+ // Auth state must not be stuck on Loading — withReauth owns the state lifecycle
+ val authState = authUI.authStateFlow().first()
+ assertThat(authState).isNotInstanceOf(AuthState.Loading::class.java)
+ assertThat(authState).isInstanceOf(AuthState.Success::class.java)
+ }
+
+ @Test
+ fun `withReauth() does not throw when reauth is needed`() = runTest {
+ val context = ApplicationProvider.getApplicationContext()
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser)
+
+ // Should complete without throwing
+ authUI.withReauth(context) {
+ throw FirebaseAuthRecentLoginRequiredException("ERROR_REQUIRES_RECENT_LOGIN", "Recent login required")
+ }
+ }
+
+ @Test
+ fun `withReauth() propagates non-reauth exceptions`() = runTest {
+ val context = ApplicationProvider.getApplicationContext()
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser)
+ val cause = RuntimeException("Network error")
+ var thrown: Exception? = null
+
+ try {
+ authUI.withReauth(context) { throw cause }
+ } catch (e: Exception) {
+ thrown = e
+ }
+
+ assertThat(thrown).isEqualTo(cause)
+ }
+
+ @Test
+ fun `delete() retryOperation re-invokes delete on execution`() = runTest {
+ val mockUser = mock(FirebaseUser::class.java)
+
+ val failTcs = TaskCompletionSource()
+ failTcs.setException(
+ FirebaseAuthRecentLoginRequiredException(
+ "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required"
+ )
+ )
+ val successTcs = TaskCompletionSource()
+ successTcs.setResult(null)
+
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser)
+ `when`(mockUser.delete())
+ .thenReturn(failTcs.task)
+ .thenReturn(successTcs.task)
+
+ val context = ApplicationProvider.getApplicationContext()
+ try { authUI.delete(context) } catch (_: AuthException.InvalidCredentialsException) {}
+
+ val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired
+ // 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 05f61c538..7d5a75e3d 100644
--- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt
@@ -19,6 +19,7 @@ import android.content.Intent
import android.net.Uri
import androidx.test.core.app.ApplicationProvider
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.authUIConfiguration
import com.google.android.gms.tasks.TaskCompletionSource
import com.google.common.truth.Truth.assertThat
import com.google.firebase.FirebaseApp
@@ -679,6 +680,96 @@ class FirebaseAuthUITest {
}
}
+ // =============================================================================================
+ // createReauthFlow Tests
+ // =============================================================================================
+
+ private fun baseConfig(vararg providers: AuthProvider): com.firebase.ui.auth.configuration.AuthUIConfiguration {
+ val context = ApplicationProvider.getApplicationContext()
+ return authUIConfiguration {
+ this.context = context
+ providers.forEach { p -> this.providers { provider(p) } }
+ }
+ }
+
+ @Test
+ fun `createReauthFlow throws UserNotFoundException when no user is signed in`() {
+ val mockAuth = mock(FirebaseAuth::class.java)
+ `when`(mockAuth.currentUser).thenReturn(null)
+ val authUI = FirebaseAuthUI.create(defaultApp, mockAuth)
+
+ try {
+ authUI.createReauthFlow(baseConfig(AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList())))
+ assertThat(false).isTrue()
+ } catch (e: AuthException.UserNotFoundException) {
+ assertThat(e.message).contains("No user is currently signed in")
+ }
+ }
+
+ @Test
+ fun `createReauthFlow throws when no configured provider is linked to the current user`() {
+ 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)
+
+ // Config only has Google; user only has email linked
+ val config = baseConfig(AuthProvider.Google(scopes = emptyList(), serverClientId = "id"))
+
+ try {
+ authUI.createReauthFlow(config)
+ assertThat(false).isTrue()
+ } catch (e: IllegalStateException) {
+ assertThat(e.message).contains("No configured providers are linked")
+ }
+ }
+
+ @Test
+ fun `createReauthFlow returns a controller whose config has only linked providers`() {
+ val mockUser = mock(FirebaseUser::class.java)
+ val emailInfo = mock(UserInfo::class.java)
+ val googleInfo = mock(UserInfo::class.java)
+ `when`(emailInfo.providerId).thenReturn("password")
+ `when`(googleInfo.providerId).thenReturn("google.com")
+ `when`(mockUser.providerData).thenReturn(listOf(emailInfo, googleInfo))
+ val mockAuth = mock(FirebaseAuth::class.java)
+ `when`(mockAuth.currentUser).thenReturn(mockUser)
+ val authUI = FirebaseAuthUI.create(defaultApp, mockAuth)
+
+ // Three providers configured; only Email and Google are linked — Phone should be stripped
+ val config = baseConfig(
+ AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()),
+ AuthProvider.Google(scopes = emptyList(), serverClientId = "id"),
+ AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null),
+ )
+
+ val controller = authUI.createReauthFlow(config)
+
+ assertThat(controller.configuration.providers.map { it.providerId })
+ .containsExactly("password", "google.com")
+ }
+
+ @Test
+ fun `createReauthFlow resulting config disables new account creation and enables reauth mode`() {
+ 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 = baseConfig(AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()))
+ val controller = authUI.createReauthFlow(config)
+
+ assertThat(controller.configuration.isNewEmailAccountsAllowed).isFalse()
+ assertThat(controller.configuration.isReauthenticationMode).isTrue()
+ }
+
+
@Test
fun `canHandleIntent returns true when auth validates email link`() {
val emailLink = "https://example.com/__/auth/action?mode=signIn"
diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt
index 4afcfa84b..d017005d4 100644
--- a/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt
@@ -325,7 +325,7 @@ class AuthUIConfigurationTest {
}
@Test
- fun `validation accepts custom OIDC providers`() {
+ fun `validation accepts custom OIDC and SAML providers`() {
val linkedInProvider = AuthProvider.GenericOAuth(
providerName = "LinkedIn",
providerId = "oidc.linkedin",
@@ -348,17 +348,30 @@ class AuthUIConfigurationTest {
contentColor = null,
)
+ val samlProvider = AuthProvider.GenericOAuth(
+ providerName = "Corp SSO",
+ providerId = "saml.corp-sso",
+ scopes = listOf(),
+ customParameters = mapOf(),
+ buttonLabel = "Sign in with Corp SSO",
+ buttonIcon = null,
+ buttonColor = null,
+ contentColor = null,
+ )
+
val config = authUIConfiguration {
context = applicationContext
providers {
provider(linkedInProvider)
provider(oktaProvider)
+ provider(samlProvider)
}
}
- assertThat(config.providers).hasSize(2)
+ assertThat(config.providers).hasSize(3)
assertThat(config.providers[0].providerId).isEqualTo("oidc.linkedin")
assertThat(config.providers[1].providerId).isEqualTo("oidc.okta")
+ assertThat(config.providers[2].providerId).isEqualTo("saml.corp-sso")
}
@Test
@@ -458,6 +471,7 @@ class AuthUIConfigurationTest {
"isCredentialManagerEnabled",
"isMfaEnabled",
"isAnonymousUpgradeEnabled",
+ "isCredentialLinkingEnabled",
"tosUrl",
"privacyPolicyUrl",
"logo",
@@ -465,7 +479,8 @@ class AuthUIConfigurationTest {
"isNewEmailAccountsAllowed",
"isDisplayNameRequired",
"isProviderChoiceAlwaysShown",
- "transitions"
+ "transitions",
+ "isReauthenticationMode"
)
val actualProperties = allProperties.map { it.name }.toSet()
diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt
index 53f465b9a..df4f6bcde 100644
--- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt
@@ -41,6 +41,7 @@ import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers
import org.mockito.Mock
import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
@@ -110,8 +111,8 @@ class AnonymousAuthProviderFirebaseAuthUITest {
verify(mockFirebaseAuth).signInAnonymously()
- val finalState = instance.authStateFlow().first { it is AuthState.Idle }
- assertThat(finalState).isInstanceOf(AuthState.Idle::class.java)
+ val finalState = instance.authStateFlow().first { it is AuthState.Success }
+ assertThat(finalState).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = true))
}
@Test
@@ -313,4 +314,53 @@ class AnonymousAuthProviderFirebaseAuthUITest {
assertThat(result).isNotNull()
verify(mockAnonymousUser).linkWithCredential(credential)
}
+
+ // =============================================================================================
+ // Credential Linking for Authenticated (Non-Anonymous) Users Tests
+ // =============================================================================================
+
+ @Test
+ fun `createOrLinkUserWithEmailAndPassword - links email credential to authenticated non-anonymous user when isCredentialLinkingEnabled`() = runTest {
+ val authenticatedUser = mock(FirebaseUser::class.java)
+ `when`(authenticatedUser.isAnonymous).thenReturn(false)
+ `when`(mockFirebaseAuth.currentUser).thenReturn(authenticatedUser)
+
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setResult(mock(AuthResult::class.java))
+ `when`(authenticatedUser.linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java)))
+ .thenReturn(taskCompletionSource.task)
+ // Stub createUserWithEmailAndPassword so the test fails at verify, not with NPE
+ `when`(mockFirebaseAuth.createUserWithEmailAndPassword(
+ ArgumentMatchers.anyString(),
+ ArgumentMatchers.anyString()
+ )).thenReturn(taskCompletionSource.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val emailProvider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(emailProvider)
+ }
+ isCredentialLinkingEnabled = true
+ }
+
+ instance.createOrLinkUserWithEmailAndPassword(
+ context = applicationContext,
+ config = config,
+ provider = emailProvider,
+ name = null,
+ email = "test@example.com",
+ password = "Pass@123"
+ )
+
+ verify(authenticatedUser).linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java))
+ verify(mockFirebaseAuth, never()).createUserWithEmailAndPassword(
+ ArgumentMatchers.anyString(),
+ ArgumentMatchers.anyString()
+ )
+ }
}
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 718d38ad3..747e03ff2 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
@@ -4,10 +4,14 @@ import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.firebase.ui.auth.R
import com.google.common.truth.Truth.assertThat
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.UserInfo
import com.google.firebase.auth.actionCodeSettings
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.`when`
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@@ -32,6 +36,29 @@ class AuthProviderTest {
// Email Provider Tests
// =============================================================================================
+ @Test
+ fun `addSessionInfoToActionCodeSettings preserves linkDomain`() {
+ val actionCodeSettings = actionCodeSettings {
+ url = "https://example.com"
+ handleCodeInApp = true
+ linkDomain = "myapp.page.link"
+ setAndroidPackageName("com.example", true, null)
+ }
+
+ val provider = AuthProvider.Email(
+ isEmailLinkSignInEnabled = true,
+ emailLinkActionCodeSettings = actionCodeSettings,
+ passwordValidationRules = emptyList()
+ )
+
+ val result = provider.addSessionInfoToActionCodeSettings(
+ sessionId = "abc123",
+ anonymousUserId = ""
+ )
+
+ assertThat(result.linkDomain).isEqualTo("myapp.page.link")
+ }
+
@Test
fun `email provider with valid configuration should succeed`() {
val provider = AuthProvider.Email(
@@ -382,6 +409,77 @@ class AuthProviderTest {
}
}
+ // =============================================================================================
+ // filterToLinkedProviders Tests
+ // =============================================================================================
+
+ private fun mockUser(vararg providerIds: String): FirebaseUser {
+ val user = mock(FirebaseUser::class.java)
+ val infos = providerIds.map { id ->
+ mock(UserInfo::class.java).also { `when`(it.providerId).thenReturn(id) }
+ }
+ `when`(user.providerData).thenReturn(infos)
+ return user
+ }
+
+ @Test
+ fun `filterToLinkedProviders keeps only providers matching user providerData`() {
+ val user = mockUser("password", "google.com")
+ val providers = listOf(
+ AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()),
+ AuthProvider.Google(scopes = emptyList(), serverClientId = "id"),
+ AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null),
+ )
+
+ val result = providers.filterToLinkedProviders(user)
+
+ assertThat(result.map { it.providerId }).containsExactly("password", "google.com")
+ }
+
+ @Test
+ fun `filterToLinkedProviders returns empty list when no providers match`() {
+ val user = mockUser("password")
+ val providers = listOf(
+ AuthProvider.Google(scopes = emptyList(), serverClientId = "id"),
+ )
+
+ val result = providers.filterToLinkedProviders(user)
+
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `filterToLinkedProviders returns all providers when all are linked`() {
+ val user = mockUser("password", "phone")
+ val email = AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList())
+ val phone = AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null)
+
+ val result = listOf(email, phone).filterToLinkedProviders(user)
+
+ assertThat(result).containsExactly(email, phone)
+ }
+
+ @Test
+ fun `filterToLinkedProviders on empty list returns empty list`() {
+ val user = mockUser("password")
+
+ val result = emptyList().filterToLinkedProviders(user)
+
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `filterToLinkedProviders ignores providers linked to user but absent from list`() {
+ val user = mockUser("password", "google.com", "facebook.com")
+ val providers = listOf(
+ AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()),
+ )
+
+ val result = providers.filterToLinkedProviders(user)
+
+ assertThat(result.map { it.providerId }).containsExactly("password")
+ }
+
@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 dc027e3dc..b592f23e7 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
@@ -608,11 +608,47 @@ class EmailAuthProviderFirebaseAuthUITest {
assertThat(e.credential).isEqualTo(updatedCredential)
assertThat(e.cause).isEqualTo(collisionException)
}
+ }
+
+ @Test
+ fun `signInAndLinkWithCredential - links credential to authenticated non-anonymous user when isCredentialLinkingEnabled`() = runTest {
+ val authenticatedUser = mock(FirebaseUser::class.java)
+ `when`(authenticatedUser.isAnonymous).thenReturn(false)
+ `when`(mockFirebaseAuth.currentUser).thenReturn(authenticatedUser)
+
+ val credential = GoogleAuthProvider.getCredential("google-id-token", null)
+ val mockAuthResult = mock(AuthResult::class.java)
+ `when`(mockAuthResult.user).thenReturn(authenticatedUser)
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setResult(mockAuthResult)
+ `when`(authenticatedUser.linkWithCredential(credential))
+ .thenReturn(taskCompletionSource.task)
+ // Also stub signInWithCredential so the test fails at the verify assertion,
+ // not with a NPE from an unmocked call
+ `when`(mockFirebaseAuth.signInWithCredential(credential))
+ .thenReturn(taskCompletionSource.task)
- val currentState = instance.authStateFlow().first { it is AuthState.Error }
- assertThat(currentState).isInstanceOf(AuthState.Error::class.java)
- val errorState = currentState as AuthState.Error
- assertThat(errorState.exception).isInstanceOf(AuthException.AccountLinkingRequiredException::class.java)
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val emailProvider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(emailProvider)
+ }
+ isCredentialLinkingEnabled = true
+ }
+
+ val result = instance.signInAndLinkWithCredential(
+ config = config,
+ credential = credential
+ )
+
+ assertThat(result).isNotNull()
+ verify(authenticatedUser).linkWithCredential(credential)
+ verify(mockFirebaseAuth, never()).signInWithCredential(credential)
}
// =============================================================================================
@@ -1457,4 +1493,151 @@ class EmailAuthProviderFirebaseAuthUITest {
assertThat(e).isNotNull()
}
}
+
+ @Test
+ fun `signInWithEmailAndPassword - emits AuthState Success with non-null result`() = runTest {
+ val mockUser = mock(FirebaseUser::class.java)
+ val mockAuthResult = mock(AuthResult::class.java)
+ `when`(mockAuthResult.user).thenReturn(mockUser)
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setResult(mockAuthResult)
+ `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123"))
+ .thenReturn(taskCompletionSource.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val emailProvider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers { provider(emailProvider) }
+ }
+
+ instance.signInWithEmailAndPassword(
+ context = applicationContext,
+ config = config,
+ email = "test@example.com",
+ password = "Pass@123"
+ )
+
+ val state = instance.authStateFlow().first { it !is AuthState.Loading }
+ assertThat(state).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false))
+ }
+
+ @Test
+ fun `signInAndLinkWithCredential - emits AuthState Success with non-null result`() = runTest {
+ val credential = GoogleAuthProvider.getCredential("google-id-token", null)
+ val mockUser = mock(FirebaseUser::class.java)
+ val mockAuthResult = mock(AuthResult::class.java)
+ `when`(mockAuthResult.user).thenReturn(mockUser)
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setResult(mockAuthResult)
+ `when`(mockFirebaseAuth.signInWithCredential(credential))
+ .thenReturn(taskCompletionSource.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val emailProvider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers { provider(emailProvider) }
+ }
+
+ instance.signInAndLinkWithCredential(config = config, credential = credential)
+
+ val state = instance.authStateFlow().first { it !is AuthState.Loading }
+ assertThat(state).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false))
+ }
+
+ @Test
+ fun `createOrLinkUserWithEmailAndPassword - emits AuthState Success with non-null result`() = runTest {
+ val mockUser = mock(FirebaseUser::class.java)
+ val mockAuthResult = mock(AuthResult::class.java)
+ `when`(mockAuthResult.user).thenReturn(mockUser)
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setResult(mockAuthResult)
+ `when`(mockFirebaseAuth.createUserWithEmailAndPassword("new@example.com", "Pass@123"))
+ .thenReturn(taskCompletionSource.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val emailProvider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers { provider(emailProvider) }
+ }
+
+ instance.createOrLinkUserWithEmailAndPassword(
+ context = applicationContext,
+ config = config,
+ provider = emailProvider,
+ name = null,
+ email = "new@example.com",
+ password = "Pass@123"
+ )
+
+ val state = instance.authStateFlow().first { it !is AuthState.Loading }
+ assertThat(state).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = true))
+ }
+
+ @Test
+ fun `signInWithEmailLink - emits AuthState Success with non-null result`() = runTest {
+ val mockUser = mock(FirebaseUser::class.java)
+ `when`(mockUser.email).thenReturn("test@example.com")
+ `when`(mockUser.isAnonymous).thenReturn(false)
+ val mockAuthResult = mock(AuthResult::class.java)
+ `when`(mockAuthResult.user).thenReturn(mockUser)
+
+ `when`(mockFirebaseAuth.currentUser).thenReturn(null)
+ `when`(mockFirebaseAuth.isSignInWithEmailLink(anyString())).thenReturn(true)
+
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setResult(mockAuthResult)
+ `when`(mockFirebaseAuth.signInWithCredential(any())).thenReturn(taskCompletionSource.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) }
+ }
+
+ 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"
+
+ instance.signInWithEmailLink(
+ context = applicationContext,
+ config = config,
+ provider = provider,
+ email = "test@example.com",
+ emailLink = emailLink,
+ persistenceManager = mockPersistence
+ )
+
+ val state = instance.authStateFlow().first { it !is AuthState.Loading }
+ assertThat(state).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false))
+ }
}
diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt
index 1e48bae90..fe10118e6 100644
--- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt
@@ -16,9 +16,11 @@ package com.firebase.ui.auth.configuration.auth_provider
import android.content.Context
import android.net.Uri
+import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.core.app.ApplicationProvider
import com.facebook.AccessToken
import com.facebook.FacebookException
+import com.facebook.FacebookSdk
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
import com.firebase.ui.auth.FirebaseAuthUI
@@ -40,6 +42,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
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
@@ -61,6 +64,9 @@ import org.robolectric.annotation.Config
@Config(manifest = Config.NONE)
class FacebookAuthProviderFirebaseAuthUITest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
@Mock
private lateinit var mockFirebaseAuth: FirebaseAuth
@@ -78,6 +84,11 @@ class FacebookAuthProviderFirebaseAuthUITest {
applicationContext = ApplicationProvider.getApplicationContext()
+ FacebookSdk.setApplicationId("fake-app-id")
+ FacebookSdk.setClientToken("fake-client-token")
+ @Suppress("DEPRECATION")
+ FacebookSdk.sdkInitialize(applicationContext)
+
FirebaseApp.getApps(applicationContext).forEach { app ->
app.delete()
}
@@ -102,6 +113,84 @@ class FacebookAuthProviderFirebaseAuthUITest {
}
}
+ @Test
+ @Config(manifest = Config.NONE, qualifiers = "night")
+ fun `rememberSignInWithFacebookLauncher - calls logOut before launching to clear stale token`() {
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val provider = AuthProvider.Facebook()
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(provider)
+ }
+ }
+
+ var launcher: (() -> Unit)? = null
+
+ composeTestRule.setContent {
+ launcher = instance.rememberSignInWithFacebookLauncher(
+ context = applicationContext,
+ config = config,
+ provider = provider,
+ loginManagerProvider = mockFBAuthCredentialProvider,
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ try {
+ launcher?.invoke()
+ } catch (_: Exception) {
+ // launcher.launch() may throw in test environment — that's expected
+ }
+ }
+
+ verify(mockFBAuthCredentialProvider).logOut()
+ }
+
+ @Test
+ @Config(manifest = Config.NONE, qualifiers = "night")
+ fun `rememberSignInWithFacebookLauncher - does not propagate stale token logout failure`() {
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val provider = AuthProvider.Facebook()
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(provider)
+ }
+ }
+ val logoutException = RuntimeException("logout failed")
+ doAnswer {
+ throw logoutException
+ }.whenever(mockFBAuthCredentialProvider).logOut()
+
+ var launcher: (() -> Unit)? = null
+ var thrownException: Exception? = null
+
+ composeTestRule.setContent {
+ launcher = instance.rememberSignInWithFacebookLauncher(
+ context = applicationContext,
+ config = config,
+ provider = provider,
+ loginManagerProvider = mockFBAuthCredentialProvider,
+ )
+ }
+
+ composeTestRule.runOnIdle {
+ try {
+ launcher?.invoke()
+ } catch (e: Exception) {
+ thrownException = e
+ }
+ }
+
+ var exceptionInChain: Throwable? = thrownException
+ while (exceptionInChain != null) {
+ assertThat(exceptionInChain).isNotEqualTo(logoutException)
+ exceptionInChain = exceptionInChain.cause
+ }
+ verify(mockFBAuthCredentialProvider).logOut()
+ }
+
@Test
@Config(manifest = Config.NONE, qualifiers = "night")
fun `signInWithFacebook - successful sign in signs user in and emits Success authState`() = runTest {
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 2fd855c37..81f94b161 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
@@ -180,9 +180,9 @@ class GoogleAuthProviderFirebaseAuthUITest {
// Verify Firebase sign-in was called
verify(mockFirebaseAuth).signInWithCredential(mockCredential)
- // Verify state is Idle after success
- val finalState = instance.authStateFlow().first()
- assertThat(finalState).isEqualTo(AuthState.Idle)
+ // Verify state is Success (with the real AuthResult) after sign-in
+ val finalState = instance.authStateFlow().first { it !is AuthState.Loading }
+ assertThat(finalState).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false))
}
@Test
@@ -602,6 +602,69 @@ class GoogleAuthProviderFirebaseAuthUITest {
verify(mockFirebaseAuth, never()).signInWithCredential(any())
}
+ // =============================================================================================
+ // signInWithGoogle - Credential Linking for Authenticated (Non-Anonymous) Users
+ // =============================================================================================
+
+ @Test
+ fun `Sign in with Google with authenticated non-anonymous user and isCredentialLinkingEnabled should link credentials`() = runTest {
+ val mockCredential = mock(AuthCredential::class.java)
+ val mockAuthenticatedUser = mock(FirebaseUser::class.java)
+ `when`(mockAuthenticatedUser.isAnonymous).thenReturn(false)
+ `when`(mockAuthenticatedUser.uid).thenReturn("authenticated-uid")
+
+ val mockAuthResult = mock(AuthResult::class.java)
+ `when`(mockAuthResult.user).thenReturn(mockAuthenticatedUser)
+
+ val googleSignInResult = AuthProvider.Google.GoogleSignInResult(
+ credential = mockCredential,
+ idToken = "test-id-token",
+ displayName = "Test User",
+ photoUrl = null
+ )
+
+ `when`(
+ mockCredentialManagerProvider.getGoogleCredential(
+ context = eq(applicationContext),
+ credentialManager = any(),
+ serverClientId = eq("test-client-id"),
+ filterByAuthorizedAccounts = eq(true),
+ autoSelectEnabled = eq(false)
+ )
+ ).thenReturn(googleSignInResult)
+
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setResult(mockAuthResult)
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockAuthenticatedUser)
+ `when`(mockAuthenticatedUser.linkWithCredential(mockCredential))
+ .thenReturn(taskCompletionSource.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val googleProvider = AuthProvider.Google(
+ serverClientId = "test-client-id",
+ scopes = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ isCredentialLinkingEnabled = true
+ providers {
+ provider(googleProvider)
+ }
+ }
+
+ instance.signInWithGoogle(
+ context = applicationContext,
+ config = config,
+ provider = googleProvider,
+ authorizationProvider = mockAuthorizationProvider,
+ credentialManagerProvider = mockCredentialManagerProvider
+ )
+
+ // Verify link was called instead of sign-in
+ verify(mockAuthenticatedUser).linkWithCredential(mockCredential)
+ verify(mockFirebaseAuth, never()).signInWithCredential(any())
+ }
+
// =============================================================================================
// signInWithGoogle - Configuration Properties
// =============================================================================================
@@ -853,8 +916,8 @@ class GoogleAuthProviderFirebaseAuthUITest {
credentialManagerProvider = mockCredentialManagerProvider
)
- // Verify final state
- val finalState = instance.authStateFlow().first()
- assertThat(finalState).isEqualTo(AuthState.Idle)
+ // Verify final state is Success (with the real AuthResult)
+ val finalState = instance.authStateFlow().first { it !is AuthState.Loading }
+ assertThat(finalState).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false))
}
}
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 1d027d9ea..672e0c11d 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
@@ -143,9 +143,9 @@ class OAuthProviderFirebaseAuthUITest {
any()
)
- // Verify state is Idle after success
- val finalState = instance.authStateFlow().first()
- assertThat(finalState).isEqualTo(AuthState.Idle)
+ // Verify state is Success after sign-in
+ val finalState = instance.authStateFlow().first { it !is AuthState.Loading }
+ assertThat(finalState).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false))
}
// =============================================================================================
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt
index b40917d5b..c4029b198 100644
--- a/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt
@@ -7,7 +7,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
@@ -18,6 +20,7 @@ import androidx.compose.ui.test.performScrollToNode
import androidx.test.core.app.ApplicationProvider
import com.firebase.ui.auth.R
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration
import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
import com.firebase.ui.auth.configuration.theme.AuthUIAsset
@@ -280,6 +283,146 @@ class AuthMethodPickerTest {
Truth.assertThat(selectedProvider).isEqualTo(googleProvider)
}
+ @Test
+ fun `AuthMethodPicker still renders default ToS text when customLayout is provided`() {
+ val links = arrayOf("Terms of Service" to "", "Privacy Policy" to "")
+ val labels = links.map { it.first }.toTypedArray()
+ val providers = listOf(
+ AuthProvider.Google(scopes = emptyList(), serverClientId = null)
+ )
+
+ setContentWithStringProvider {
+ AuthMethodPicker(
+ providers = providers,
+ onProviderSelected = { selectedProvider = it },
+ customLayout = { _, _ -> Text("Custom Layout") }
+ )
+ }
+
+ composeTestRule
+ .onNodeWithText(context.getString(R.string.fui_tos_and_pp, *labels))
+ .assertIsDisplayed()
+ }
+
+ // =============================================================================================
+ // Custom Terms Content Tests
+ // =============================================================================================
+
+ @Test
+ fun `AuthMethodPicker renders termsConfiguration content instead of default ToS when provided`() {
+ val links = arrayOf("Terms of Service" to "", "Privacy Policy" to "")
+ val labels = links.map { it.first }.toTypedArray()
+ val providers = listOf(
+ AuthProvider.Google(scopes = emptyList(), serverClientId = null)
+ )
+
+ setContentWithStringProvider {
+ AuthMethodPicker(
+ providers = providers,
+ onProviderSelected = { selectedProvider = it },
+ termsConfiguration = MethodPickerTermsConfiguration(
+ content = { Text("Custom ToS checkbox") }
+ )
+ )
+ }
+
+ composeTestRule
+ .onNodeWithText("Custom ToS checkbox")
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(context.getString(R.string.fui_tos_and_pp, *labels))
+ .assertDoesNotExist()
+ }
+
+ @Test
+ fun `AuthMethodPicker still renders providers when termsConfiguration is provided`() {
+ val providers = listOf(
+ AuthProvider.Google(scopes = emptyList(), serverClientId = null)
+ )
+
+ setContentWithStringProvider {
+ AuthMethodPicker(
+ providers = providers,
+ onProviderSelected = { selectedProvider = it },
+ termsConfiguration = MethodPickerTermsConfiguration(
+ content = { Text("Custom ToS checkbox") }
+ )
+ )
+ }
+
+ composeTestRule
+ .onNodeWithText(context.getString(R.string.fui_sign_in_with_google))
+ .assertIsDisplayed()
+ }
+
+ // =============================================================================================
+ // Terms Accepted / Gating Tests
+ // =============================================================================================
+
+ @Test
+ fun `AuthMethodPicker disables provider buttons when disableProvidersUntilAccepted is true and accepted is false`() {
+ val googleProvider = AuthProvider.Google(scopes = emptyList(), serverClientId = null)
+
+ setContentWithStringProvider {
+ AuthMethodPicker(
+ providers = listOf(googleProvider),
+ onProviderSelected = { selectedProvider = it },
+ termsConfiguration = MethodPickerTermsConfiguration(
+ content = { Text("Checkbox") },
+ accepted = false,
+ disableProvidersUntilAccepted = true
+ )
+ )
+ }
+
+ composeTestRule
+ .onNodeWithText(context.getString(R.string.fui_sign_in_with_google))
+ .assertIsNotEnabled()
+ }
+
+ @Test
+ fun `AuthMethodPicker enables provider buttons when disableProvidersUntilAccepted is true and accepted is true`() {
+ val googleProvider = AuthProvider.Google(scopes = emptyList(), serverClientId = null)
+
+ setContentWithStringProvider {
+ AuthMethodPicker(
+ providers = listOf(googleProvider),
+ onProviderSelected = { selectedProvider = it },
+ termsConfiguration = MethodPickerTermsConfiguration(
+ content = { Text("Checkbox") },
+ accepted = true,
+ disableProvidersUntilAccepted = true
+ )
+ )
+ }
+
+ composeTestRule
+ .onNodeWithText(context.getString(R.string.fui_sign_in_with_google))
+ .assertIsEnabled()
+ }
+
+ @Test
+ fun `AuthMethodPicker ignores accepted when disableProvidersUntilAccepted is false`() {
+ val googleProvider = AuthProvider.Google(scopes = emptyList(), serverClientId = null)
+
+ setContentWithStringProvider {
+ AuthMethodPicker(
+ providers = listOf(googleProvider),
+ onProviderSelected = { selectedProvider = it },
+ termsConfiguration = MethodPickerTermsConfiguration(
+ content = { Text("Checkbox") },
+ accepted = false,
+ disableProvidersUntilAccepted = false
+ )
+ )
+ }
+
+ composeTestRule
+ .onNodeWithText(context.getString(R.string.fui_sign_in_with_google))
+ .assertIsEnabled()
+ }
+
// =============================================================================================
// Scrolling Tests
// =============================================================================================
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenRouteTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenRouteTest.kt
new file mode 100644
index 000000000..4108004dd
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenRouteTest.kt
@@ -0,0 +1,118 @@
+package com.firebase.ui.auth.ui.screens
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE)
+class FirebaseAuthScreenRouteTest {
+
+ private lateinit var applicationContext: Context
+
+ @Before
+ fun setUp() {
+ applicationContext = ApplicationProvider.getApplicationContext()
+ }
+
+ @Test
+ fun `single email provider starts at email route`() {
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ }
+
+ assertThat(getStartRoute(configuration)).isEqualTo(AuthRoute.Email)
+ }
+
+ @Test
+ fun `single phone provider starts at phone route`() {
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null
+ )
+ )
+ }
+ }
+
+ assertThat(getStartRoute(configuration)).isEqualTo(AuthRoute.Phone)
+ }
+
+ @Test
+ fun `single google provider starts at method picker`() {
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Google(
+ scopes = emptyList(),
+ serverClientId = "test-client-id"
+ )
+ )
+ }
+ }
+
+ assertThat(getStartRoute(configuration)).isEqualTo(AuthRoute.MethodPicker)
+ }
+
+ @Test
+ fun `single email provider shows picker when always shown is enabled`() {
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ isProviderChoiceAlwaysShown = true
+ }
+
+ assertThat(getStartRoute(configuration)).isEqualTo(AuthRoute.MethodPicker)
+ }
+
+ @Test
+ fun `multiple providers start at method picker`() {
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null
+ )
+ )
+ }
+ }
+
+ assertThat(getStartRoute(configuration)).isEqualTo(AuthRoute.MethodPicker)
+ }
+}
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
new file mode 100644
index 000000000..0c3836ace
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt
@@ -0,0 +1,199 @@
+/*
+ * 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.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.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.google.firebase.FirebaseApp
+import com.google.firebase.FirebaseOptions
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Tests that [FirebaseAuthScreen] correctly forwards each customization slot to the
+ * appropriate sub-screen.
+ *
+ * These tests cover the fix for the API gap where slots such as [customMethodPickerLayout],
+ * [emailContent], and [phoneContent] were accepted by sub-screens but never reachable through
+ * the high-level [FirebaseAuthScreen] composable.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE, sdk = [34])
+class FirebaseAuthScreenSlotsTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private lateinit var context: Context
+ private lateinit var authUI: FirebaseAuthUI
+
+ @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()
+ }
+
+ @After
+ fun tearDown() {
+ FirebaseAuthUI.clearInstanceCache()
+ FirebaseApp.getApps(context).forEach {
+ try { it.delete() } catch (_: Exception) {}
+ }
+ }
+
+ // =============================================================================================
+ // customMethodPickerLayout slot tests
+ // =============================================================================================
+
+ @Test
+ fun `customMethodPickerLayout is rendered when provided`() {
+ val configuration = authUIConfiguration {
+ context = this@FirebaseAuthScreenSlotsTest.context
+ providers {
+ provider(AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()))
+ provider(AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null))
+ }
+ }
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ customMethodPickerLayout = { _, _ ->
+ Text(
+ text = "Custom Picker",
+ modifier = Modifier.testTag("custom_method_picker")
+ )
+ }
+ )
+ }
+
+ composeTestRule.onNodeWithTag("custom_method_picker").assertIsDisplayed()
+ }
+
+ @Test
+ fun `default method picker renders when customMethodPickerLayout is null`() {
+ val configuration = authUIConfiguration {
+ context = this@FirebaseAuthScreenSlotsTest.context
+ providers {
+ provider(AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()))
+ provider(AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null))
+ }
+ }
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {}
+ )
+ }
+
+ composeTestRule.onNodeWithTag("AuthMethodPicker LazyColumn").assertIsDisplayed()
+ }
+
+ // =============================================================================================
+ // emailContent slot tests
+ // =============================================================================================
+
+ @Test
+ fun `emailContent slot is rendered when provided`() {
+ val configuration = authUIConfiguration {
+ context = this@FirebaseAuthScreenSlotsTest.context
+ providers {
+ provider(AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()))
+ }
+ }
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ emailContent = { _ ->
+ Text(
+ text = "Custom Email UI",
+ modifier = Modifier.testTag("custom_email_slot")
+ )
+ }
+ )
+ }
+
+ composeTestRule.onNodeWithTag("custom_email_slot").assertIsDisplayed()
+ }
+
+ // =============================================================================================
+ // phoneContent slot tests
+ // =============================================================================================
+
+ @Test
+ fun `phoneContent slot is rendered when provided`() {
+ val configuration = authUIConfiguration {
+ context = this@FirebaseAuthScreenSlotsTest.context
+ providers {
+ provider(AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null))
+ }
+ }
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ phoneContent = { _ ->
+ Text(
+ text = "Custom Phone UI",
+ modifier = Modifier.testTag("custom_phone_slot")
+ )
+ }
+ )
+ }
+
+ composeTestRule.onNodeWithTag("custom_phone_slot").assertIsDisplayed()
+ }
+}
diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/testutil/TestHelpers.kt b/e2eTest/src/test/java/com/firebase/ui/auth/testutil/TestHelpers.kt
index 958df6f53..98b570008 100644
--- a/e2eTest/src/test/java/com/firebase/ui/auth/testutil/TestHelpers.kt
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/testutil/TestHelpers.kt
@@ -1,6 +1,7 @@
package com.firebase.ui.auth.testutil
import android.os.Looper
+import android.util.Base64
import com.firebase.ui.auth.FirebaseAuthUI
import com.google.firebase.auth.FirebaseUser
import org.robolectric.Shadows.shadowOf
@@ -99,3 +100,27 @@ fun verifyEmailInEmulator(authUI: FirebaseAuthUI, emulatorApi: EmulatorAuthApi,
println("TEST: Email verified successfully for user ${user.uid}")
println("TEST: User isEmailVerified: ${authUI.auth.currentUser?.isEmailVerified}")
}
+
+fun generateMockGoogleIdToken(
+ email: String,
+ sub: String = "test-user-id",
+ name: String? = null,
+ photoUrl: String? = null,
+): String {
+ val header = """{"alg":"RS256","kid":"test"}"""
+ val payload = buildString {
+ append("{")
+ append("\"iss\":\"https://accounts.google.com\",")
+ append("\"aud\":\"test-client-id\",")
+ append("\"sub\":\"$sub\",")
+ append("\"email\":\"$email\",")
+ append("\"email_verified\":true")
+ name?.let { append(",\"name\":\"$it\"") }
+ photoUrl?.let { append(",\"picture\":\"$it\"") }
+ append(",\"iat\":1689600000,\"exp\":1689603600")
+ append("}")
+ }
+ val encodedHeader = Base64.encodeToString(header.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
+ val encodedPayload = Base64.encodeToString(payload.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
+ return "$encodedHeader.$encodedPayload.mock-signature"
+}
diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/AnonymousAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/AnonymousAuthScreenTest.kt
index 59c5d829a..743a26db4 100644
--- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/AnonymousAuthScreenTest.kt
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/AnonymousAuthScreenTest.kt
@@ -167,7 +167,7 @@ class AnonymousAuthScreenTest {
@Test
fun `anonymous upgrade enabled links new user sign-up and emits RequiresEmailVerification auth state`() {
val name = "Anonymous Upgrade User"
- val email = "anonymousupgrade@example.com"
+ val email = "anonymous-upgrade-${System.currentTimeMillis()}@example.com"
val password = "Test@123"
val configuration = authUIConfiguration {
context = applicationContext
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
new file mode 100644
index 000000000..a862a4e57
--- /dev/null
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/CredentialLinkingScreenTest.kt
@@ -0,0 +1,436 @@
+package com.firebase.ui.auth.ui.screens
+
+import android.content.Context
+import android.net.Uri
+import android.os.Bundle
+import android.os.Looper
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.hasSetTextAction
+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.performClick
+import androidx.compose.ui.test.performScrollTo
+import androidx.compose.ui.test.performScrollToNode
+import androidx.compose.ui.test.performTextInput
+import androidx.credentials.CredentialManager
+import androidx.credentials.GetCredentialRequest
+import androidx.credentials.GetCredentialResponse
+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.testutil.AUTH_STATE_WAIT_TIMEOUT_MS
+import com.firebase.ui.auth.testutil.EmulatorAuthApi
+import com.firebase.ui.auth.testutil.awaitWithLooper
+import com.firebase.ui.auth.testutil.ensureFreshUser
+import com.firebase.ui.auth.testutil.generateMockGoogleIdToken
+import com.firebase.ui.auth.testutil.verifyEmailInEmulator
+import com.firebase.ui.auth.util.CountryUtils
+import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
+import com.google.common.truth.Truth.assertThat
+import com.google.firebase.FirebaseApp
+import com.google.firebase.FirebaseOptions
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assume
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+
+@Config(sdk = [34])
+@RunWith(RobolectricTestRunner::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+class CredentialLinkingScreenTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Mock
+ private lateinit var mockCredentialManager: CredentialManager
+
+ private lateinit var applicationContext: Context
+ private lateinit var stringProvider: AuthUIStringProvider
+ private lateinit var authUI: FirebaseAuthUI
+ private lateinit var emulatorApi: EmulatorAuthApi
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.openMocks(this)
+
+ applicationContext = ApplicationProvider.getApplicationContext()
+ stringProvider = DefaultAuthUIStringProvider(applicationContext)
+
+ FirebaseApp.getApps(applicationContext).forEach { app ->
+ app.delete()
+ }
+
+ val firebaseApp = FirebaseApp.initializeApp(
+ applicationContext,
+ FirebaseOptions.Builder()
+ .setApiKey("fake-api-key")
+ .setApplicationId("fake-app-id")
+ .setProjectId("fake-project-id")
+ .build()
+ )
+
+ authUI = FirebaseAuthUI.getInstance()
+ authUI.auth.useEmulator("127.0.0.1", 9099)
+
+ authUI.testCredentialManagerProvider = object : AuthProvider.Google.CredentialManagerProvider {
+ override suspend fun getGoogleCredential(
+ context: Context,
+ credentialManager: CredentialManager,
+ serverClientId: String,
+ filterByAuthorizedAccounts: Boolean,
+ autoSelectEnabled: Boolean,
+ ): AuthProvider.Google.GoogleSignInResult {
+ return AuthProvider.Google.DefaultCredentialManagerProvider().getGoogleCredential(
+ context = context,
+ credentialManager = mockCredentialManager,
+ serverClientId = serverClientId,
+ filterByAuthorizedAccounts = filterByAuthorizedAccounts,
+ autoSelectEnabled = autoSelectEnabled,
+ )
+ }
+
+ override suspend fun clearCredentialState(context: Context, credentialManager: CredentialManager) {}
+ }
+
+ emulatorApi = EmulatorAuthApi(
+ projectId = firebaseApp.options.projectId
+ ?: throw IllegalStateException("Project ID is required for emulator interactions"),
+ emulatorHost = "127.0.0.1",
+ emulatorPort = 9099
+ )
+
+ emulatorApi.clearEmulatorData()
+ }
+
+ @After
+ fun tearDown() {
+ FirebaseAuthUI.clearInstanceCache()
+ emulatorApi.clearEmulatorData()
+ }
+
+ @Test
+ fun `isCredentialLinkingEnabled links phone to existing email user preserving UID`() {
+ val email = "credentiallink@example.com"
+ val password = "Test@123"
+ val phone = "2025550123"
+ val country = CountryUtils.findByCountryCode("US")!!
+
+ // Step 1: Create an email/password user, verify their email, and sign in
+ println("TEST: Creating email/password user...")
+ val createdUser = ensureFreshUser(authUI, email, password)
+ requireNotNull(createdUser) { "Failed to create user" }
+
+ println("TEST: Verifying email in emulator...")
+ verifyEmailInEmulator(authUI, emulatorApi, createdUser)
+
+ val signInResult = authUI.auth.signInWithEmailAndPassword(email, password).awaitWithLooper()
+ val originalUID = signInResult.user!!.uid
+ println("TEST: Signed in as $email, UID: $originalUID")
+
+ assertThat(authUI.auth.currentUser).isNotNull()
+ assertThat(authUI.auth.currentUser!!.isAnonymous).isFalse()
+ assertThat(authUI.auth.currentUser!!.isEmailVerified).isTrue()
+
+ // Step 2: Set up auth screen with isCredentialLinkingEnabled + phone provider
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = country.countryCode,
+ allowedCountries = null,
+ timeout = 60L,
+ )
+ )
+ }
+ isCredentialLinkingEnabled = true
+ isCredentialManagerEnabled = false
+ }
+
+ var currentAuthState: AuthState = AuthState.Idle
+
+ composeTestRule.setContent {
+ TestAuthScreen(configuration = configuration)
+ val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
+ currentAuthState = authState
+ }
+
+ composeTestRule.waitForIdle()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Wait for the authenticated content to render
+ composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
+ shadowOf(Looper.getMainLooper()).idle()
+ currentAuthState is AuthState.Success
+ }
+
+ // Step 3: Navigate to phone auth from the authenticated content slot
+ composeTestRule.onNodeWithText("Link Phone")
+ .assertIsDisplayed()
+ .performClick()
+
+ composeTestRule.waitForIdle()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Step 4: Enter phone number and request verification code
+ println("TEST: Entering phone number...")
+ composeTestRule.onNodeWithText(stringProvider.phoneNumberHint)
+ .assertIsDisplayed()
+ .performTextInput(phone)
+
+ composeTestRule.onNodeWithText(stringProvider.sendVerificationCode.uppercase())
+ .performScrollTo()
+ .assertIsEnabled()
+ .performClick()
+
+ composeTestRule.waitForIdle()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Step 5: Fetch verification code from emulator
+ println("TEST: Fetching phone verification code...")
+ var phoneCode: String? = null
+ var retries = 0
+ val maxRetries = 5
+ while (phoneCode == null && retries < maxRetries) {
+ Thread.sleep(if (retries == 0) 200L else 500L * retries)
+ shadowOf(Looper.getMainLooper()).idle()
+ try {
+ phoneCode = emulatorApi.fetchVerifyPhoneCode(phone)
+ println("TEST: Found phone code after ${retries + 1} attempts")
+ } catch (e: Exception) {
+ retries++
+ if (retries >= maxRetries) {
+ Assume.assumeTrue(
+ "Skipping test: Firebase Auth Emulator not available. Error: ${e.message}",
+ false
+ )
+ }
+ println("TEST: Phone code not found yet, retrying... (attempt $retries/$maxRetries)")
+ }
+ }
+ requireNotNull(phoneCode) { "Phone code should not be null at this point" }
+
+ // Step 6: Enter verification code
+ println("TEST: Entering verification code: $phoneCode")
+ val textFields = composeTestRule.onAllNodes(hasSetTextAction())
+ phoneCode.forEachIndexed { index, digit ->
+ composeTestRule.waitForIdle()
+ textFields[index].performTextInput(digit.toString())
+ }
+
+ composeTestRule.onNodeWithText(stringProvider.verifyPhoneNumber.uppercase())
+ .performScrollTo()
+ .assertIsEnabled()
+ .performClick()
+
+ composeTestRule.waitForIdle()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Step 7: Wait for success
+ 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
+ }
+
+ // Step 8: Verify the UID is preserved (linking happened, not a new account)
+ val linkedUser = authUI.auth.currentUser!!
+ println("TEST: Original UID: $originalUID, Linked UID: ${linkedUser.uid}")
+ assertThat(linkedUser.uid).isEqualTo(originalUID)
+ assertThat(linkedUser.email).isEqualTo(email)
+ assertThat(linkedUser.phoneNumber).isEqualTo(
+ CountryUtils.formatPhoneNumber(country.dialCode, phone)
+ )
+ val providerIds = linkedUser.providerData.map { it.providerId }
+ assertThat(providerIds).contains("password")
+ assertThat(providerIds).contains("phone")
+ }
+
+ @Test
+ fun `isCredentialLinkingEnabled links Google to existing email user preserving UID`() = runTest {
+ val email = "googlelinktest@example.com"
+ val password = "Test@123"
+ val googleEmail = "googlelinktest@gmail.com"
+ val googleName = "Google Link Test User"
+ val googlePhotoUrl = "https://example.com/avatar.jpg"
+
+ // Step 1: Create an email/password user, verify their email, and sign in
+ println("TEST: Creating email/password user...")
+ val createdUser = ensureFreshUser(authUI, email, password)
+ requireNotNull(createdUser) { "Failed to create user" }
+
+ println("TEST: Verifying email in emulator...")
+ verifyEmailInEmulator(authUI, emulatorApi, createdUser)
+
+ val signInResult = authUI.auth.signInWithEmailAndPassword(email, password).awaitWithLooper()
+ val originalUID = signInResult.user!!.uid
+ println("TEST: Signed in as $email, UID: $originalUID")
+
+ assertThat(authUI.auth.currentUser).isNotNull()
+ assertThat(authUI.auth.currentUser!!.isAnonymous).isFalse()
+ assertThat(authUI.auth.currentUser!!.isEmailVerified).isTrue()
+
+ // Step 2: Configure mock Google credential
+ val mockIdToken = generateMockGoogleIdToken(
+ email = googleEmail,
+ name = googleName,
+ photoUrl = googlePhotoUrl,
+ )
+ val mockCredential = mock {
+ on { type } doReturn GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
+ on { data } doReturn Bundle().apply {
+ putString("com.google.android.libraries.identity.googleid.BUNDLE_KEY_ID_TOKEN", mockIdToken)
+ putString("com.google.android.libraries.identity.googleid.BUNDLE_KEY_ID", googleEmail)
+ putString("com.google.android.libraries.identity.googleid.BUNDLE_KEY_DISPLAY_NAME", googleName)
+ putParcelable("com.google.android.libraries.identity.googleid.BUNDLE_KEY_PROFILE_PICTURE_URI", Uri.parse(googlePhotoUrl))
+ }
+ on { displayName } doReturn googleName
+ on { profilePictureUri } doReturn Uri.parse(googlePhotoUrl)
+ }
+ val mockResult = mock {
+ on { credential } doReturn mockCredential
+ }
+ whenever(mockCredentialManager.getCredential(any(), any()))
+ .thenReturn(mockResult)
+
+ // Step 3: Set up auth screen with isCredentialLinkingEnabled + Google provider
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Google(
+ scopes = listOf("email"),
+ serverClientId = "test-server-client-id",
+ )
+ )
+ }
+ isCredentialLinkingEnabled = true
+ isCredentialManagerEnabled = false
+ }
+
+ var currentAuthState: AuthState = AuthState.Idle
+
+ composeTestRule.setContent {
+ TestAuthScreen(configuration = configuration)
+ val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
+ currentAuthState = authState
+ }
+
+ composeTestRule.waitForIdle()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Wait for authenticated content to render
+ composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
+ shadowOf(Looper.getMainLooper()).idle()
+ currentAuthState is AuthState.Success
+ }
+
+ // Step 4: Click "Link Google" from authenticated content
+ composeTestRule.onNodeWithText("Link Google")
+ .assertIsDisplayed()
+ .performClick()
+
+ composeTestRule.waitForIdle()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Step 5: Click the Google sign-in button on the method picker
+ println("TEST: Clicking Google sign-in button...")
+ composeTestRule
+ .onNodeWithTag("AuthMethodPicker LazyColumn")
+ .performScrollToNode(hasText(stringProvider.signInWithGoogle))
+ composeTestRule
+ .onNode(hasText(stringProvider.signInWithGoogle))
+ .assertIsDisplayed()
+ .performClick()
+
+ composeTestRule.waitForIdle()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Step 6: Wait for linking to complete
+ 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
+ }
+
+ // Step 7: Verify the UID is preserved and Google provider is added
+ val linkedUser = authUI.auth.currentUser!!
+ println("TEST: Original UID: $originalUID, Linked UID: ${linkedUser.uid}")
+ assertThat(linkedUser.uid).isEqualTo(originalUID)
+ assertThat(linkedUser.email).isEqualTo(email)
+ val providerIds = linkedUser.providerData.map { it.providerId }
+ assertThat(providerIds).contains("password")
+ assertThat(providerIds).contains("google.com")
+ }
+
+ @Composable
+ private fun TestAuthScreen(configuration: AuthUIConfiguration) {
+ composeTestRule.waitForIdle()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = { _: AuthException -> },
+ onSignInCancelled = {},
+ authenticatedContent = { state, uiContext ->
+ when (state) {
+ is AuthState.Success -> {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text("UID - ${state.user.uid}")
+ Text("Email - ${state.user.email}")
+ Text("Phone - ${state.user.phoneNumber}")
+ Button(onClick = { uiContext.onNavigate(AuthRoute.Phone) }) {
+ Text("Link Phone")
+ }
+ Button(onClick = { uiContext.onNavigate(AuthRoute.MethodPicker) }) {
+ Text("Link Google")
+ }
+ }
+ }
+ }
+ }
+ )
+ }
+}
diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt
index 423aa8d62..d438eb45b 100644
--- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt
@@ -149,7 +149,7 @@ class EmailAuthScreenTest {
}
@Test
- fun `initial EmailAuthMode is SignIn`() {
+ fun `single email provider starts on email screen when provider choice always shown is false`() {
val configuration = authUIConfiguration {
context = applicationContext
providers {
@@ -167,15 +167,30 @@ class EmailAuthScreenTest {
TestFirebaseAuthScreen(configuration = configuration, authUI = authUI)
}
- // Click on email provider in AuthMethodPicker
- composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail)
- .assertIsDisplayed()
- .performClick()
+ assertDirectEmailStart()
+ }
- composeAndroidTestRule.waitForIdle()
+ @Test
+ fun `single email provider shows method picker when provider choice always shown is true`() {
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ isCredentialManagerEnabled = false
+ isProviderChoiceAlwaysShown = true
+ }
- composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault)
- .assertIsDisplayed()
+ composeAndroidTestRule.setContent {
+ TestFirebaseAuthScreen(configuration = configuration, authUI = authUI)
+ }
+
+ openEmailProviderFromMethodPicker()
}
@Test
@@ -212,12 +227,7 @@ class EmailAuthScreenTest {
currentAuthState = authState
}
- // Click on email provider in AuthMethodPicker
- composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail)
- .assertIsDisplayed()
- .performClick()
-
- composeAndroidTestRule.waitForIdle()
+ assertDirectEmailStart()
composeAndroidTestRule.onNodeWithText(stringProvider.emailHint)
.performScrollTo()
@@ -306,12 +316,7 @@ class EmailAuthScreenTest {
currentAuthState = authState
}
- // Click on email provider in AuthMethodPicker
- composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail)
- .assertIsDisplayed()
- .performClick()
-
- composeAndroidTestRule.waitForIdle()
+ assertDirectEmailStart()
composeAndroidTestRule.onNodeWithText(stringProvider.emailHint)
.performScrollTo()
@@ -381,12 +386,7 @@ class EmailAuthScreenTest {
currentAuthState = authState
}
- // Click on email provider in AuthMethodPicker
- composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail)
- .assertIsDisplayed()
- .performClick()
-
- composeAndroidTestRule.waitForIdle()
+ assertDirectEmailStart()
composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault)
.assertIsDisplayed()
@@ -471,12 +471,7 @@ class EmailAuthScreenTest {
currentAuthState = authState
}
- // Click on email provider in AuthMethodPicker
- composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail)
- .assertIsDisplayed()
- .performClick()
-
- composeAndroidTestRule.waitForIdle()
+ assertDirectEmailStart()
composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault)
.assertIsDisplayed()
@@ -569,15 +564,7 @@ class EmailAuthScreenTest {
currentAuthState = authState
}
- // Click on email provider in AuthMethodPicker
- composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail)
- .assertIsDisplayed()
- .performClick()
-
- composeAndroidTestRule.waitForIdle()
-
- composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault)
- .assertIsDisplayed()
+ assertDirectEmailStart()
// Click "Sign in with email link" button to switch to email link mode
composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmailLink.uppercase())
@@ -744,6 +731,7 @@ class EmailAuthScreenTest {
)
)
}
+ isProviderChoiceAlwaysShown = true
}
// Track auth state changes
@@ -758,12 +746,7 @@ class EmailAuthScreenTest {
// STEP 1: Sign up and verify credential saved
println("TEST: Starting sign-up flow...")
- // Click on email provider
- composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail)
- .assertIsDisplayed()
- .performClick()
-
- composeAndroidTestRule.waitForIdle()
+ openEmailProviderFromMethodPicker()
// Click sign-up
composeAndroidTestRule.onNodeWithText(stringProvider.signupPageTitle.uppercase())
@@ -816,13 +799,9 @@ class EmailAuthScreenTest {
// STEP 3: Navigate to SignInUI screen to trigger credential retrieval
println("TEST: Navigating to sign-in screen to trigger credential retrieval...")
- // Click on email provider to show SignInUI, which will trigger auto-retrieval
- composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail)
- .assertIsDisplayed()
- .performClick()
-
composeAndroidTestRule.waitForIdle()
shadowOf(Looper.getMainLooper()).idle()
+ clickEmailProviderFromMethodPicker()
// SignInUI's LaunchedEffect should now trigger credential retrieval and auto-sign-in
println("TEST: Waiting for automatic credential retrieval and auto-sign-in...")
@@ -877,6 +856,7 @@ class EmailAuthScreenTest {
)
)
}
+ isProviderChoiceAlwaysShown = true
}
var currentAuthState: AuthState = AuthState.Idle
@@ -890,11 +870,7 @@ class EmailAuthScreenTest {
// STEP 1: Sign up and save credential
println("TEST: Starting sign-up flow...")
- composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail)
- .assertIsDisplayed()
- .performClick()
-
- composeAndroidTestRule.waitForIdle()
+ openEmailProviderFromMethodPicker()
composeAndroidTestRule.onNodeWithText(stringProvider.signupPageTitle.uppercase())
.assertIsDisplayed()
@@ -940,12 +916,9 @@ class EmailAuthScreenTest {
// STEP 3: Navigate to SignInUI to trigger credential retrieval
println("TEST: Navigating to sign-in screen...")
- composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail)
- .assertIsDisplayed()
- .performClick()
-
composeAndroidTestRule.waitForIdle()
shadowOf(Looper.getMainLooper()).idle()
+ clickEmailProviderFromMethodPicker()
println("TEST: Waiting for automatic credential retrieval and auto-sign-in...")
@@ -997,11 +970,7 @@ class EmailAuthScreenTest {
}
// Sign up
- composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail)
- .assertIsDisplayed()
- .performClick()
-
- composeAndroidTestRule.waitForIdle()
+ assertDirectEmailStart()
composeAndroidTestRule.onNodeWithText(stringProvider.signupPageTitle.uppercase())
.assertIsDisplayed()
@@ -1078,4 +1047,21 @@ class EmailAuthScreenTest {
}
}
}
+
+ private fun assertDirectEmailStart() {
+ composeAndroidTestRule.waitForIdle()
+ composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault)
+ .assertIsDisplayed()
+ }
+
+ private fun openEmailProviderFromMethodPicker() {
+ clickEmailProviderFromMethodPicker()
+ assertDirectEmailStart()
+ }
+
+ private fun clickEmailProviderFromMethodPicker() {
+ composeAndroidTestRule.onNodeWithText(stringProvider.signInWithEmail)
+ .assertIsDisplayed()
+ .performClick()
+ }
}
diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt
index 64103ec32..942d70678 100644
--- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt
@@ -4,7 +4,6 @@ import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.os.Looper
-import android.util.Base64
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -40,6 +39,7 @@ import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
import com.firebase.ui.auth.testutil.AUTH_STATE_WAIT_TIMEOUT_MS
import com.firebase.ui.auth.testutil.EmulatorAuthApi
+import com.firebase.ui.auth.testutil.generateMockGoogleIdToken
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.common.truth.Truth.assertThat
import com.google.firebase.FirebaseApp
@@ -149,13 +149,15 @@ class GoogleAuthScreenTest {
@Test
fun `anonymous upgrade with google links anonymous user and emits Success auth state`() = runTest {
- val email = "anonymousupgrade@example.com"
+ val email = "anonymous-google-upgrade-${System.currentTimeMillis()}@example.com"
+ val sub = "anonymous-google-upgrade-${System.nanoTime()}"
val name = "Anonymous Upgrade User"
val photoUrl = "https://example.com/avatar.jpg"
// Generate a JWT token for the Google account
val mockIdToken = generateMockGoogleIdToken(
email = email,
+ sub = sub,
name = name,
photoUrl = photoUrl
)
@@ -447,45 +449,4 @@ class GoogleAuthScreenTest {
)
}
- /**
- * Generates a mock Google ID token (JWT) with the specified email.
- * This is useful for testing so that the token payload matches the test data.
- */
- private fun generateMockGoogleIdToken(
- email: String,
- sub: String = "test-user-id",
- name: String? = null,
- photoUrl: String? = null
- ): String {
- // JWT Header
- val header = """{"alg":"RS256","kid":"test"}"""
-
- // JWT Payload with dynamic email
- val payload = buildString {
- append("{")
- append("\"iss\":\"https://accounts.google.com\",")
- append("\"aud\":\"test-client-id\",")
- append("\"sub\":\"$sub\",")
- append("\"email\":\"$email\",")
- append("\"email_verified\":true")
- name?.let { append(",\"name\":\"$it\"") }
- photoUrl?.let { append(",\"picture\":\"$it\"") }
- append(",\"iat\":1689600000,\"exp\":1689603600")
- append("}")
- }
-
- // Base64 encode header and payload (URL-safe, no padding, no wrap)
- val encodedHeader = Base64.encodeToString(
- header.toByteArray(),
- Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
- )
- val encodedPayload = Base64.encodeToString(
- payload.toByteArray(),
- Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
- )
-
- // Return JWT format: header.payload.signature
- // Signature doesn't need to be valid for testing
- return "$encodedHeader.$encodedPayload.mock-signature"
- }
}
\ No newline at end of file
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
new file mode 100644
index 000000000..2939527ac
--- /dev/null
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt
@@ -0,0 +1,311 @@
+package com.firebase.ui.auth.ui.screens
+
+import android.content.Context
+import android.os.Looper
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Button
+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.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
+import androidx.compose.ui.test.performTextInput
+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.configuration.string_provider.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.testutil.AUTH_STATE_WAIT_TIMEOUT_MS
+import com.firebase.ui.auth.testutil.EmulatorAuthApi
+import com.firebase.ui.auth.testutil.ensureFreshUser
+import com.firebase.ui.auth.testutil.verifyEmailInEmulator
+import com.google.common.truth.Truth.assertThat
+import com.google.firebase.FirebaseApp
+import com.google.firebase.FirebaseOptions
+import org.junit.After
+import org.junit.Assume
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+@Config(sdk = [34])
+@RunWith(RobolectricTestRunner::class)
+class ReauthFlowTest {
+
+ @get:Rule
+ val composeAndroidTestRule = createAndroidComposeRule()
+
+ private lateinit var applicationContext: Context
+ private lateinit var stringProvider: DefaultAuthUIStringProvider
+ private lateinit var authUI: FirebaseAuthUI
+ private lateinit var emulatorApi: EmulatorAuthApi
+
+ @Before
+ fun setUp() {
+ applicationContext = ApplicationProvider.getApplicationContext()
+ stringProvider = DefaultAuthUIStringProvider(applicationContext)
+
+ FirebaseApp.getApps(applicationContext).forEach { it.delete() }
+
+ val firebaseApp = FirebaseApp.initializeApp(
+ applicationContext,
+ FirebaseOptions.Builder()
+ .setApiKey("fake-api-key")
+ .setApplicationId("fake-app-id")
+ .setProjectId("fake-project-id")
+ .build()
+ )
+
+ authUI = FirebaseAuthUI.getInstance()
+ authUI.auth.useEmulator("127.0.0.1", 9099)
+
+ emulatorApi = EmulatorAuthApi(
+ projectId = firebaseApp.options.projectId
+ ?: throw IllegalStateException("Project ID is required"),
+ emulatorHost = "127.0.0.1",
+ emulatorPort = 9099,
+ )
+
+ emulatorApi.clearEmulatorData()
+ }
+
+ @After
+ fun tearDown() {
+ FirebaseAuthUI.clearInstanceCache()
+ emulatorApi.clearEmulatorData()
+ }
+
+ /**
+ * Full cycle: sign in via the main flow, then emit ReauthenticationRequired to simulate a
+ * sensitive operation. Verifies the default ModalBottomSheet reauth UI appears, completing
+ * reauthentication triggers the pending retry operation.
+ *
+ * The initial sign-in must complete first so the main screen shows the authenticated view —
+ * this avoids having two simultaneous email input forms (one in the main sign-in screen and
+ * one in the reauth bottom sheet).
+ */
+ @Test
+ fun `reauth bottom sheet appears and triggers retry operation on successful reauthentication`() {
+ val email = "reauth-test-${System.currentTimeMillis()}@example.com"
+ val password = "test123"
+
+ val user = ensureFreshUser(authUI, email, password)
+ requireNotNull(user) { "Failed to create user" }
+
+ // Email must be verified so sign-in (both initial and reauth) resolves to Success.
+ try {
+ verifyEmailInEmulator(authUI, emulatorApi, user)
+ } catch (e: Exception) {
+ Assume.assumeTrue(
+ "Skipping: Firebase Auth Emulator OOB codes not available. Error: ${e.message}",
+ false
+ )
+ }
+
+ // Sign out so the screen starts on the sign-in form.
+ authUI.auth.signOut()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ var currentAuthState: AuthState = AuthState.Idle
+ 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 = {},
+ ) { state, _ ->
+ if (state is AuthState.Success) Text("AUTHENTICATED") else Text("NOT AUTHENTICATED")
+ }
+ val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
+ currentAuthState = authState
+ }
+ }
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Step 1: Complete initial sign-in via the main screen form.
+ composeAndroidTestRule.onNodeWithText(stringProvider.emailHint)
+ .performScrollTo()
+ .performTextInput(email)
+ composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint)
+ .performScrollTo()
+ .performTextInput(password)
+ composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault.uppercase())
+ .performScrollTo()
+ .performClick()
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
+ shadowOf(Looper.getMainLooper()).idle()
+ currentAuthState is AuthState.Success
+ }
+
+ // Main screen now shows authenticated content — no email form visible.
+ composeAndroidTestRule.onNodeWithText("AUTHENTICATED").assertIsDisplayed()
+
+ val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" }
+
+ // Step 2: Emit ReauthenticationRequired to simulate a sensitive operation requiring reauth.
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(
+ user = signedInUser,
+ reason = "Please verify your identity to continue",
+ retryOperation = { retryOperationCalled = true },
+ )
+ )
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Wait for the reauth bottom sheet email form to appear (now the only email form visible).
+ composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
+ shadowOf(Looper.getMainLooper()).idle()
+ composeAndroidTestRule.onAllNodesWithText(stringProvider.emailHint)
+ .fetchSemanticsNodes().isNotEmpty()
+ }
+
+ // Step 3: Enter credentials in the reauth bottom sheet.
+ composeAndroidTestRule.onNodeWithText(stringProvider.emailHint)
+ .performScrollTo()
+ .performTextInput(email)
+ composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint)
+ .performScrollTo()
+ .performTextInput(password)
+ composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault.uppercase())
+ .performScrollTo()
+ .performClick()
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Verify the retry operation fires after successful reauthentication.
+ composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
+ shadowOf(Looper.getMainLooper()).idle()
+ retryOperationCalled
+ }
+
+ assertThat(retryOperationCalled).isTrue()
+ }
+
+ /**
+ * Verifies that when reauthContent is provided, it receives the ReauthenticationRequired state
+ * and calling onDismiss resets the auth state to Idle.
+ */
+ @Test
+ fun `custom reauthContent receives ReauthenticationRequired state and dismisses to Idle`() {
+ val email = "reauth-custom-${System.currentTimeMillis()}@example.com"
+ val password = "test123"
+
+ val user = ensureFreshUser(authUI, email, password)
+ requireNotNull(user) { "Failed to create user" }
+
+ 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
+ val expectedReason = "Sensitive operation requires sign-in"
+
+ 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, onDismiss ->
+ Column {
+ Text("REAUTH REQUIRED - ${reauthState.reason}")
+ Button(onClick = onDismiss) { Text("DISMISS REAUTH") }
+ }
+ },
+ ) { _, _ ->
+ Text("CONTENT")
+ }
+ val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
+ currentAuthState = authState
+ }
+ }
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Emit ReauthenticationRequired to trigger the custom reauthContent slot.
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(
+ user = capturedUser,
+ reason = expectedReason,
+ )
+ )
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Verify the custom reauth content is displayed with the correct reason.
+ composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
+ shadowOf(Looper.getMainLooper()).idle()
+ composeAndroidTestRule.onAllNodesWithText("REAUTH REQUIRED - $expectedReason")
+ .fetchSemanticsNodes().isNotEmpty()
+ }
+
+ composeAndroidTestRule.onNodeWithText("REAUTH REQUIRED - $expectedReason")
+ .assertIsDisplayed()
+
+ // Dismiss the custom reauth UI via the onDismiss callback.
+ 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
+ }
+
+ assertThat(currentAuthState).isInstanceOf(AuthState.Idle::class.java)
+ }
+}