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..72f1b8025 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -55,6 +55,30 @@
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/CustomMethodPickerDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt
new file mode 100644
index 000000000..cd73ce212
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt
@@ -0,0 +1,318 @@
+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.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.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.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.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
+ ) {
+ 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
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun SpotlightMethodPicker(
+ providers: List,
+ onProviderSelected: (AuthProvider) -> Unit,
+) {
+ 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) },
+ 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,
+ 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) },
+ stringProvider = stringProvider
+ )
+ }
+ }
+
+ anonymous?.let {
+ item {
+ Spacer(modifier = Modifier.height(8.dp))
+ TextButton(onClick = { onProviderSelected(it) }) {
+ Text("Continue as guest")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ProviderIconButton(
+ style: AuthUITheme.ProviderStyle,
+ contentDescription: String,
+ onClick: () -> Unit,
+) {
+ Button(
+ onClick = onClick,
+ 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..4aa50b05f 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",
+ description = "Replace the default vertical provider list with a 2-column grid using customMethodPickerLayout 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..a4c708a6b 100644
--- a/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt
@@ -42,6 +42,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() {
@@ -211,7 +213,7 @@ private fun AppAuthenticatedContent(
when (state) {
is AuthState.Success -> {
val user = uiContext.authUI.getCurrentUser()
- val identifier = user?.email ?: user?.phoneNumber ?: user?.uid.orEmpty()
+ val identifier = user.displayIdentifier()
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -245,7 +247,7 @@ private fun AppAuthenticatedContent(
}
},
state = rememberTooltipState(
- initialIsVisible = !configuration.isMfaEnabled
+ initialIsVisible = false
)
) {
Button(
@@ -263,7 +265,7 @@ private fun AppAuthenticatedContent(
}
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,
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/src/main/java/com/firebase/ui/auth/AuthException.kt b/auth/src/main/java/com/firebase/ui/auth/AuthException.kt
index 46d22f068..ae9d96e53 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
@@ -341,15 +344,22 @@ 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)
is FirebaseAuthInvalidCredentialsException -> {
InvalidCredentialsException(
- message = firebaseException.message ?: "Invalid credentials provided",
+ message = stringProvider?.errorInvalidCredentials.nonEmpty()
+ ?: firebaseException.message
+ ?: "Invalid credentials provided",
cause = firebaseException
)
}
@@ -357,17 +367,23 @@ 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
)
}
@@ -375,7 +391,9 @@ abstract class AuthException(
is FirebaseAuthWeakPasswordException -> {
WeakPasswordException(
- message = firebaseException.message ?: "Password is too weak",
+ message = stringProvider?.errorWeakPasswordGeneric.nonEmpty()
+ ?: firebaseException.message
+ ?: "Password is too weak",
cause = firebaseException,
reason = firebaseException.reason
)
@@ -384,26 +402,31 @@ abstract class AuthException(
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 +434,8 @@ abstract class AuthException(
is FirebaseAuthMultiFactorException -> {
MfaRequiredException(
- message = firebaseException.message
+ message = stringProvider?.errorMfaRequiredFallback.nonEmpty()
+ ?: firebaseException.message
?: "Multi-factor authentication required",
cause = firebaseException
)
@@ -419,23 +443,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 +469,36 @@ abstract class AuthException(
}
is FirebaseException -> {
- // Handle general Firebase exceptions, which include network errors
NetworkException(
- message = firebaseException.message ?: "Network error occurred",
+ message = stringProvider?.errorNetworkGeneric.nonEmpty()
+ ?: firebaseException.message
+ ?: "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 }
}
}
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..917584219 100644
--- a/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt
@@ -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/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..827c37abd 100644
--- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
@@ -23,8 +23,10 @@ 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
@@ -255,56 +257,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 +323,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 +411,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
}
@@ -457,7 +477,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/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..ffbe5242a 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 ?: "",
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..670b451bc 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
@@ -197,7 +197,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
}
}
- updateAuthState(AuthState.Idle)
+ updateAuthStateWithResult(result, defaultIsNewUser = true)
return result
} catch (e: FirebaseAuthUserCollisionException) {
// Account collision: email already exists
@@ -225,7 +225,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
}
@@ -431,7 +431,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
}
}
- updateAuthState(AuthState.Idle)
+ updateAuthStateWithResult(result)
}
} catch (e: FirebaseAuthMultiFactorException) {
// MFA required - extract resolver and update state
@@ -450,7 +450,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
}
@@ -557,7 +557,7 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential(
result?.user?.let {
mergeProfile(auth, displayName, photoUrl)
}
- updateAuthState(AuthState.Idle)
+ updateAuthStateWithResult(result)
}
} catch (e: FirebaseAuthMultiFactorException) {
// MFA required - extract resolver and update state
@@ -766,7 +766,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 +974,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 +987,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..10be33cb9 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() }
@@ -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..f8cbbdddf 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
@@ -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..e2d141400 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
@@ -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,7 +162,6 @@ internal suspend fun FirebaseAuthUI.signInWithProvider(
photoUrl = authResult.user?.photoUrl,
)
}
- updateAuthState(AuthState.Idle)
return
}
@@ -195,8 +194,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 +229,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/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt
index bf4c3b6a5..083148016 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
@@ -77,10 +77,10 @@ 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,
) {
val context = LocalContext.current
val inPreview = LocalInspectionMode.current
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..a919223ad 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
@@ -52,6 +52,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
@@ -73,11 +74,17 @@ 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.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
@@ -104,6 +111,11 @@ fun FirebaseAuthScreen(
authUI: FirebaseAuthUI = FirebaseAuthUI.getInstance(),
emailLink: String? = null,
mfaConfiguration: MfaConfiguration = MfaConfiguration(),
+ customMethodPickerLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null,
+ emailContent: (@Composable (EmailAuthContentState) -> Unit)? = null,
+ phoneContent: (@Composable (PhoneAuthContentState) -> Unit)? = null,
+ mfaEnrollmentContent: (@Composable (MfaEnrollmentContentState) -> Unit)? = null,
+ mfaChallengeContent: (@Composable (MfaChallengeContentState) -> Unit)? = null,
authenticatedContent: (@Composable (state: AuthState, uiContext: AuthSuccessUiContext) -> Unit)? = null,
) {
// Set FirebaseUI version
@@ -125,6 +137,10 @@ fun FirebaseAuthScreen(
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) {
@@ -236,7 +252,7 @@ fun FirebaseAuthScreen(
) {
NavHost(
navController = navController,
- startDestination = AuthRoute.MethodPicker.route,
+ startDestination = startRoute.route,
enterTransition = configuration.transitions?.enterTransition ?: {
fadeIn(animationSpec = tween(700))
},
@@ -260,6 +276,7 @@ fun FirebaseAuthScreen(
termsOfServiceUrl = configuration.tosUrl,
privacyPolicyUrl = configuration.privacyPolicyUrl,
lastSignInPreference = lastSignInPreference.value,
+ customLayout = customMethodPickerLayout,
onProviderSelected = { provider ->
when (provider) {
is AuthProvider.Anonymous -> onSignInAnonymously?.invoke()
@@ -311,6 +328,7 @@ fun FirebaseAuthScreen(
authUI = authUI,
credentialForLinking = pendingLinkingCredential.value,
emailLinkFromDifferentDevice = emailLinkFromDifferentDevice.value,
+ content = emailContent,
onSuccess = {
pendingLinkingCredential.value = null
},
@@ -319,7 +337,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 +354,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 +384,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
@@ -439,10 +462,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 +480,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 +492,7 @@ fun FirebaseAuthScreen(
navController.popBackStack()
},
onError = { exception ->
- onSignInFailure(AuthException.from(exception))
+ onSignInFailure(AuthException.from(exception, stringProvider))
}
)
} else {
@@ -535,7 +560,7 @@ 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
}
}
@@ -548,7 +573,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
}
}
@@ -567,9 +592,9 @@ fun FirebaseAuthScreen(
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
}
}
@@ -580,9 +605,9 @@ fun FirebaseAuthScreen(
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 +623,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(
@@ -667,6 +692,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 +770,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 +794,7 @@ private fun AuthSuccessContent(
}
},
state = rememberTooltipState(
- initialIsVisible = !configuration.isMfaEnabled
+ initialIsVisible = false
)
) {
Button(
@@ -783,7 +820,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,
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..62972d18c 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))
}
}
},
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..caa382bb1 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,15 @@
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 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
@@ -136,4 +140,47 @@ 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")
+ }
}
\ 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/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt
index 53f465b9a..7ddfb3ac8 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
@@ -110,8 +110,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
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..126600e5d 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
@@ -32,6 +32,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(
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..d42bbab5f 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
@@ -1457,4 +1457,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..185483b9c 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
@@ -853,8 +853,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/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/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/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..0bbdc1372 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
@@ -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
)