diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 8458193f9a..bb74b0a759 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -5,6 +5,7 @@ plugins {
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.gms.google-services")
+ id("kotlin-kapt")
}
android {
@@ -53,6 +54,11 @@ kotlin {
dependencies {
implementation(project(":auth"))
+ implementation(project(":database"))
+ implementation(project(":firestore"))
+ implementation(project(":storage"))
+ implementation(libs.androidx.paging)
+ kapt(libs.glide.compiler)
implementation(libs.kotlin.stdlib)
implementation(libs.androidx.lifecycle.runtime)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ca5cda2d84..77abc57765 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -19,14 +19,9 @@
android:theme="@style/Theme.FirebaseUIAndroid">
-
-
-
-
-
@@ -38,54 +33,81 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt
index b1fbd486ef..131d11cb6b 100644
--- a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt
@@ -17,44 +17,68 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.firebase.ui.auth.FirebaseAuthUI
import com.firebase.ui.auth.util.EmailLinkConstants
+import com.firebaseui.android.demo.auth.AuthChooserActivity
+import com.firebaseui.android.demo.auth.HighLevelApiDemoActivity
+import com.firebaseui.android.demo.database.DatabaseDemoActivity
+import com.firebaseui.android.demo.firestore.FirestoreDemoActivity
+import com.firebaseui.android.demo.storage.StorageDemoActivity
import com.google.firebase.FirebaseApp
+import com.google.firebase.database.FirebaseDatabase
+import com.google.firebase.firestore.FirebaseFirestore
-/**
- * Main launcher activity that allows users to choose between different
- * authentication API demonstrations.
- */
class MainActivity : ComponentActivity() {
companion object {
- private const val USE_AUTH_EMULATOR = false
+ internal const val USE_AUTH_EMULATOR = true
private const val AUTH_EMULATOR_HOST = "10.0.2.2"
private const val AUTH_EMULATOR_PORT = 9099
+
+ // 10.0.2.2 is the Android emulator's alias for the host machine's localhost.
+ private const val USE_FIRESTORE_EMULATOR = true
+ private const val FIRESTORE_EMULATOR_HOST = "10.0.2.2"
+ private const val FIRESTORE_EMULATOR_PORT = 8080
+
+ private const val USE_DATABASE_EMULATOR = true
+ private const val DATABASE_EMULATOR_HOST = "10.0.2.2"
+ private const val DATABASE_EMULATOR_PORT = 8199
+
+ // useEmulator() throws once the Firestore/Database client has been used elsewhere in
+ // the process, so this must only run once per process, not on every onCreate().
+ private var emulatorsConfigured = false
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
- // Initialize Firebase and configure emulator if needed
FirebaseApp.initializeApp(applicationContext)
val authUI = FirebaseAuthUI.getInstance()
- if (USE_AUTH_EMULATOR) {
- authUI.auth.useEmulator(AUTH_EMULATOR_HOST, AUTH_EMULATOR_PORT)
+ if (!emulatorsConfigured) {
+ if (USE_AUTH_EMULATOR) {
+ authUI.auth.useEmulator(AUTH_EMULATOR_HOST, AUTH_EMULATOR_PORT)
+ }
+
+ if (USE_FIRESTORE_EMULATOR) {
+ FirebaseFirestore.getInstance()
+ .useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT)
+ }
+
+ if (USE_DATABASE_EMULATOR) {
+ FirebaseDatabase.getInstance()
+ .useEmulator(DATABASE_EMULATOR_HOST, DATABASE_EMULATOR_PORT)
+ }
+ emulatorsConfigured = true
}
var pendingEmailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK)
-
if (pendingEmailLink.isNullOrEmpty() && authUI.canHandleIntent(intent)) {
pendingEmailLink = intent.data?.toString()
}
@@ -62,10 +86,7 @@ class MainActivity : ComponentActivity() {
Log.d("MainActivity", "Pending email link: $pendingEmailLink")
fun launchHighLevelDemo() {
- val demoIntent = Intent(
- this,
- HighLevelApiDemoActivity::class.java
- ).apply {
+ val demoIntent = Intent(this, HighLevelApiDemoActivity::class.java).apply {
pendingEmailLink?.let { link ->
putExtra(EmailLinkConstants.EXTRA_EMAIL_LINK, link)
pendingEmailLink = null
@@ -87,17 +108,18 @@ class MainActivity : ComponentActivity() {
color = MaterialTheme.colorScheme.background
) {
ChooserScreen(
- onHighLevelApiClick = ::launchHighLevelDemo,
- onLowLevelApiClick = {
- startActivity(Intent(this, AuthFlowControllerDemoActivity::class.java))
+ onAuthClick = {
+ startActivity(Intent(this, AuthChooserActivity::class.java))
},
- onCustomSlotsClick = {
- startActivity(Intent(this, CustomSlotsThemingDemoActivity::class.java))
+ onDatabaseClick = {
+ startActivity(Intent(this, DatabaseDemoActivity::class.java))
},
- onCredentialLinkingClick = {
- startActivity(Intent(this, CredentialLinkingDemoActivity::class.java))
+ onFirestoreClick = {
+ startActivity(Intent(this, FirestoreDemoActivity::class.java))
},
- isEmulatorMode = USE_AUTH_EMULATOR
+ onStorageClick = {
+ startActivity(Intent(this, StorageDemoActivity::class.java))
+ }
)
}
}
@@ -107,226 +129,100 @@ class MainActivity : ComponentActivity() {
@Composable
fun ChooserScreen(
- onHighLevelApiClick: () -> Unit,
- onLowLevelApiClick: () -> Unit,
- onCustomSlotsClick: () -> Unit,
- onCredentialLinkingClick: () -> Unit = {},
- isEmulatorMode: Boolean = false
+ onAuthClick: () -> Unit,
+ onDatabaseClick: () -> Unit,
+ onFirestoreClick: () -> Unit,
+ onStorageClick: () -> Unit,
) {
- val scrollState = rememberScrollState()
-
Column(
modifier = Modifier
.fillMaxSize()
- .verticalScroll(scrollState)
+ .verticalScroll(rememberScrollState())
.systemBarsPadding()
.padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
- Spacer(modifier = Modifier.height(16.dp))
- // Header
- Text(
- text = "Firebase Auth UI Compose",
- style = MaterialTheme.typography.headlineLarge,
- textAlign = TextAlign.Center
- )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text("FirebaseUI Android", style = MaterialTheme.typography.headlineLarge)
Text(
- text = "Choose a demo to explore different authentication APIs",
+ "Choose a module to explore its demos",
style = MaterialTheme.typography.bodyLarge,
- textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
- // Emulator Mode Warning
- if (isEmulatorMode) {
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.errorContainer
- )
- ) {
- Column(
- modifier = Modifier.padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Text(
- text = "â ď¸ Emulator Mode",
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onErrorContainer
- )
- Text(
- text = "Running with Firebase Auth Emulator. Some features like third-party" +
- " OAuth providers (Facebook, Twitter, LINE etc.) may not work correctly." +
- " Disable Firebase Auth Emulator using" +
- " MainActivity.USE_AUTH_EMULATOR = false",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onErrorContainer
- )
- }
- }
- }
-
- // High-Level API Card
- Card(
- modifier = Modifier.fillMaxWidth(),
- onClick = onHighLevelApiClick
- ) {
+ Card(modifier = Modifier.fillMaxWidth(), onClick = onAuthClick) {
Column(
modifier = Modifier.padding(20.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp)
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
- text = "đ¨ High-Level API",
+ text = "Auth",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Text(
- text = "FirebaseAuthScreen Composable",
- style = MaterialTheme.typography.titleMedium
- )
- Text(
- text = "Best for: Pure Compose applications that want a complete, ready-to-use authentication UI with minimal setup.",
+ text = "High-Level API, Low-Level API, Custom Slots & Theming, Credential Linking",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = "Features:",
- style = MaterialTheme.typography.labelLarge
- )
- Text(
- text = "⢠Drop-in Composable\n⢠Automatic navigation\n⢠State management included\n⢠Customizable content",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
}
}
- // Low-Level API Card
- Card(
- modifier = Modifier.fillMaxWidth(),
- onClick = onLowLevelApiClick
- ) {
+ Card(modifier = Modifier.fillMaxWidth(), onClick = onDatabaseClick) {
Column(
modifier = Modifier.padding(20.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp)
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
- text = "âď¸ Low-Level API",
+ text = "Database",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Text(
- text = "AuthFlowController",
- style = MaterialTheme.typography.titleMedium
- )
- Text(
- text = "Best for: Applications that need fine-grained control over the authentication flow with ActivityResultLauncher integration.",
+ text = "Paginated list with FirebaseRecyclerPagingAdapter and orderByChild",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = "Features:",
- style = MaterialTheme.typography.labelLarge
- )
- Text(
- text = "⢠Lifecycle-safe controller\n⢠ActivityResultLauncher\n⢠Observable state with Flow\n⢠Manual flow control",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
}
}
- // Custom Slots & Theming Card
- Card(
- modifier = Modifier.fillMaxWidth(),
- onClick = onCustomSlotsClick
- ) {
+ Card(modifier = Modifier.fillMaxWidth(), onClick = onFirestoreClick) {
Column(
modifier = Modifier.padding(20.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp)
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
- text = "đ¨ Custom Slots & Theming",
+ text = "Firestore",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Text(
- text = "Slot APIs & Theme Customization",
- style = MaterialTheme.typography.titleMedium
- )
- Text(
- text = "Best for: Applications that need fully custom UI while leveraging the authentication logic and state management.",
+ text = "Paginated list with FirestorePagingAdapter and orderBy",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = "Features:",
- style = MaterialTheme.typography.labelLarge
- )
- Text(
- text = "⢠Custom email auth UI via slots\n⢠Custom phone auth UI via slots\n⢠AuthUITheme.fromMaterialTheme()\n⢠Custom ProviderStyle examples",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
}
}
- // Credential Linking Card
- Card(
- modifier = Modifier.fillMaxWidth(),
- onClick = onCredentialLinkingClick
- ) {
+ Card(modifier = Modifier.fillMaxWidth(), onClick = onStorageClick) {
Column(
modifier = Modifier.padding(20.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp)
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
- text = "đ Credential Linking",
+ text = "Storage",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Text(
- text = "isCredentialLinkingEnabled",
- style = MaterialTheme.typography.titleMedium
- )
- Text(
- text = "Sign in with one provider, then add another to the same account without losing your UID.",
+ text = "Loading images from Firebase Storage with Glide",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
- Spacer(modifier = Modifier.height(16.dp))
-
- // Info card
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.secondaryContainer
- )
- ) {
- Column(
- modifier = Modifier.padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Text(
- text = "đĄ Tip",
- style = MaterialTheme.typography.labelLarge
- )
- Text(
- text = "Both APIs provide the same authentication capabilities. Choose based on your app's architecture and control requirements.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSecondaryContainer
- )
- }
- }
-
- Spacer(modifier = Modifier.height(16.dp))
+ Spacer(modifier = Modifier.height(8.dp))
}
}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt
new file mode 100644
index 0000000000..a418427daa
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt
@@ -0,0 +1,285 @@
+package com.firebaseui.android.demo.auth
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebaseui.android.demo.MainActivity
+
+class AuthChooserActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ AuthChooserScreen(
+ onHighLevelApiClick = {
+ startActivity(Intent(this, HighLevelApiDemoActivity::class.java))
+ },
+ onLowLevelApiClick = {
+ startActivity(Intent(this, AuthFlowControllerDemoActivity::class.java))
+ },
+ onCustomSlotsClick = {
+ startActivity(Intent(this, CustomSlotsThemingDemoActivity::class.java))
+ },
+ onCredentialLinkingClick = {
+ startActivity(Intent(this, CredentialLinkingDemoActivity::class.java))
+ },
+ isEmulatorMode = MainActivity.USE_AUTH_EMULATOR
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun AuthChooserScreen(
+ onHighLevelApiClick: () -> Unit,
+ onLowLevelApiClick: () -> Unit,
+ onCustomSlotsClick: () -> Unit,
+ onCredentialLinkingClick: () -> Unit = {},
+ isEmulatorMode: Boolean = false
+) {
+ val scrollState = rememberScrollState()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ .systemBarsPadding()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ Spacer(modifier = Modifier.height(16.dp))
+ // Header
+ Text(
+ text = "Firebase Auth UI Compose",
+ style = MaterialTheme.typography.headlineLarge,
+ textAlign = TextAlign.Center
+ )
+
+ Text(
+ text = "Choose a demo to explore different authentication APIs",
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ // Emulator Mode Warning
+ if (isEmulatorMode) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "â ď¸ Emulator Mode",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ Text(
+ text = "Running with Firebase Auth Emulator. Some features like third-party" +
+ " OAuth providers (Facebook, Twitter, LINE etc.) may not work correctly." +
+ " Disable Firebase Auth Emulator using" +
+ " MainActivity.USE_AUTH_EMULATOR = false",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ }
+
+ // High-Level API Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onHighLevelApiClick
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "đ¨ High-Level API",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "FirebaseAuthScreen Composable",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = "Best for: Pure Compose applications that want a complete, ready-to-use authentication UI with minimal setup.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Features:",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = "⢠Drop-in Composable\n⢠Automatic navigation\n⢠State management included\n⢠Customizable content",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ // Low-Level API Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onLowLevelApiClick
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "âď¸ Low-Level API",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "AuthFlowController",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = "Best for: Applications that need fine-grained control over the authentication flow with ActivityResultLauncher integration.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Features:",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = "⢠Lifecycle-safe controller\n⢠ActivityResultLauncher\n⢠Observable state with Flow\n⢠Manual flow control",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ // Custom Slots & Theming Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onCustomSlotsClick
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "đ¨ Custom Slots & Theming",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "Slot APIs & Theme Customization",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = "Best for: Applications that need fully custom UI while leveraging the authentication logic and state management.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Features:",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = "⢠Custom email auth UI via slots\n⢠Custom phone auth UI via slots\n⢠AuthUITheme.fromMaterialTheme()\n⢠Custom ProviderStyle examples",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ // Credential Linking Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onCredentialLinkingClick
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "đ Credential Linking",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "isCredentialLinkingEnabled",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = "Sign in with one provider, then add another to the same account without losing your UID.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Info card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "đĄ Tip",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = "Both APIs provide the same authentication capabilities. Choose based on your app's architecture and control requirements.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/AuthFlowControllerDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/AuthFlowControllerDemoActivity.kt
similarity index 99%
rename from app/src/main/java/com/firebaseui/android/demo/AuthFlowControllerDemoActivity.kt
rename to app/src/main/java/com/firebaseui/android/demo/auth/AuthFlowControllerDemoActivity.kt
index ed6de15a6d..7d82c1a186 100644
--- a/app/src/main/java/com/firebaseui/android/demo/AuthFlowControllerDemoActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/AuthFlowControllerDemoActivity.kt
@@ -1,4 +1,4 @@
-package com.firebaseui.android.demo
+package com.firebaseui.android.demo.auth
import android.app.Activity
import android.content.Context
diff --git a/app/src/main/java/com/firebaseui/android/demo/CredentialLinkingDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/CredentialLinkingDemoActivity.kt
similarity index 99%
rename from app/src/main/java/com/firebaseui/android/demo/CredentialLinkingDemoActivity.kt
rename to app/src/main/java/com/firebaseui/android/demo/auth/CredentialLinkingDemoActivity.kt
index c901084cc1..afced8665b 100644
--- a/app/src/main/java/com/firebaseui/android/demo/CredentialLinkingDemoActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/CredentialLinkingDemoActivity.kt
@@ -1,4 +1,4 @@
-package com.firebaseui.android.demo
+package com.firebaseui.android.demo.auth
import android.os.Bundle
import androidx.activity.ComponentActivity
diff --git a/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/CustomMethodPickerDemoActivity.kt
similarity index 90%
rename from app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt
rename to app/src/main/java/com/firebaseui/android/demo/auth/CustomMethodPickerDemoActivity.kt
index 54eadccc36..5228927d9f 100644
--- a/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/CustomMethodPickerDemoActivity.kt
@@ -1,4 +1,4 @@
-package com.firebaseui.android.demo
+package com.firebaseui.android.demo.auth
import android.os.Bundle
import android.util.Log
@@ -14,6 +14,7 @@ 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.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
@@ -51,8 +52,8 @@ import com.firebase.ui.auth.configuration.theme.AuthUIAsset
import com.firebase.ui.auth.configuration.theme.AuthUITheme
import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults
import com.firebase.ui.auth.ui.components.AuthProviderButton
-import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration
import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen
+import com.firebaseui.android.demo.R
class CustomMethodPickerDemoActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -144,29 +145,11 @@ class CustomMethodPickerDemoActivity : ComponentActivity() {
SpotlightMethodPicker(
providers = providers,
onProviderSelected = onProviderSelected,
- enabled = termsAccepted
+ enabled = termsAccepted,
+ termsAccepted = termsAccepted,
+ onTermsAcceptedChange = { termsAccepted = it }
)
},
- customMethodPickerTermsConfiguration = MethodPickerTermsConfiguration(
- content = {
- Row(
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Checkbox(
- checked = termsAccepted,
- onCheckedChange = { termsAccepted = it }
- )
- Text(
- text = "I have read and accept the Terms of Service and Privacy Policy",
- style = MaterialTheme.typography.bodySmall,
- modifier = Modifier.padding(start = 8.dp)
- )
- }
- },
- accepted = termsAccepted,
- disableProvidersUntilAccepted = true,
- ),
)
}
}
@@ -179,6 +162,8 @@ fun SpotlightMethodPicker(
providers: List,
onProviderSelected: (AuthProvider) -> Unit,
enabled: Boolean = true,
+ termsAccepted: Boolean = true,
+ onTermsAcceptedChange: (Boolean) -> Unit = {},
) {
val stringProvider = LocalAuthUIStringProvider.current
@@ -196,7 +181,9 @@ fun SpotlightMethodPicker(
val anonymous = groups["anonymous"]?.firstOrNull()
LazyColumn(
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding(),
contentPadding = PaddingValues(vertical = 48.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -289,6 +276,24 @@ fun SpotlightMethodPicker(
}
}
}
+
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = termsAccepted,
+ onCheckedChange = onTermsAcceptedChange
+ )
+ Text(
+ text = "I have read and accept the Terms of Service and Privacy Policy",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ }
+ }
}
}
@@ -346,6 +351,7 @@ private fun styleForProvider(provider: AuthProvider): AuthUITheme.ProviderStyle
backgroundColor = provider.buttonColor ?: Color(0xFF666666),
contentColor = provider.contentColor ?: Color.White
)
+
else -> AuthUITheme.ProviderStyle(
icon = null,
backgroundColor = Color(0xFF666666),
diff --git a/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt
similarity index 99%
rename from app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt
rename to app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt
index 4b824eed59..df069091f9 100644
--- a/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt
@@ -1,4 +1,4 @@
-package com.firebaseui.android.demo
+package com.firebaseui.android.demo.auth
import android.content.Intent
import android.os.Bundle
diff --git a/app/src/main/java/com/firebaseui/android/demo/EmailAuthSlotDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/EmailAuthSlotDemoActivity.kt
similarity index 99%
rename from app/src/main/java/com/firebaseui/android/demo/EmailAuthSlotDemoActivity.kt
rename to app/src/main/java/com/firebaseui/android/demo/auth/EmailAuthSlotDemoActivity.kt
index cb9605621c..5c5e6e13c0 100644
--- a/app/src/main/java/com/firebaseui/android/demo/EmailAuthSlotDemoActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/EmailAuthSlotDemoActivity.kt
@@ -1,4 +1,4 @@
-package com.firebaseui.android.demo
+package com.firebaseui.android.demo.auth
import android.os.Bundle
import android.util.Log
diff --git a/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt
similarity index 78%
rename from app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt
rename to app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt
index 8aabec3b0a..cfa10b93b2 100644
--- a/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt
@@ -1,4 +1,4 @@
-package com.firebaseui.android.demo
+package com.firebaseui.android.demo.auth
import android.os.Bundle
import android.util.Log
@@ -26,6 +26,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -60,6 +61,7 @@ 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.firebaseui.android.demo.R
import com.google.firebase.auth.actionCodeSettings
class HighLevelApiDemoActivity : ComponentActivity() {
@@ -70,10 +72,6 @@ class HighLevelApiDemoActivity : ComponentActivity() {
val authUI = FirebaseAuthUI.getInstance()
val emailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK)
- val customTheme = AuthUITheme.Default.copy(
- providerButtonShape = ShapeDefaults.ExtraLarge
- )
-
class CustomAuthUIStringProvider(
private val defaultProvider: AuthUIStringProvider
) : AuthUIStringProvider by defaultProvider {
@@ -85,124 +83,132 @@ class HighLevelApiDemoActivity : ComponentActivity() {
val customStringProvider =
CustomAuthUIStringProvider(DefaultAuthUIStringProvider(applicationContext))
- val configuration = authUIConfiguration {
- context = applicationContext
- theme = customTheme
- logo = AuthUIAsset.Resource(R.drawable.firebase_auth)
- tosUrl = "https://policies.google.com/terms"
- privacyPolicyUrl = "https://policies.google.com/privacy"
- isAnonymousUpgradeEnabled = false
- isMfaEnabled = false
- stringProvider = customStringProvider
- transitions = AuthUITransitions(
- enterTransition = { slideInHorizontally { it } },
- exitTransition = { slideOutHorizontally { -it } },
- popEnterTransition = { slideInHorizontally { -it } },
- popExitTransition = { slideOutHorizontally { it } }
+ setContent {
+ val customTheme = AuthUITheme.Adaptive.copy(
+ providerButtonShape = ShapeDefaults.ExtraLarge,
+ topAppBarColors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color(0xFFFFA000),
+ scrolledContainerColor = Color(0xFFFFA000),
+ )
)
- providers {
- provider(AuthProvider.Anonymous)
- provider(
- AuthProvider.Google(
- scopes = listOf("email"),
- serverClientId = "406099696497-a12gakvts4epfk5pkio7dphc1anjiggc.apps.googleusercontent.com",
- )
+
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ theme = customTheme
+ logo = AuthUIAsset.Resource(R.drawable.firebase_auth)
+ tosUrl = "https://policies.google.com/terms"
+ privacyPolicyUrl = "https://policies.google.com/privacy"
+ isAnonymousUpgradeEnabled = false
+ isMfaEnabled = false
+ stringProvider = customStringProvider
+ transitions = AuthUITransitions(
+ enterTransition = { slideInHorizontally { it } },
+ exitTransition = { slideOutHorizontally { -it } },
+ popEnterTransition = { slideInHorizontally { -it } },
+ popExitTransition = { slideOutHorizontally { it } }
)
- provider(
- AuthProvider.Email(
- isDisplayNameRequired = true,
- isEmailLinkForceSameDeviceEnabled = false,
- isEmailLinkSignInEnabled = true,
- emailLinkActionCodeSettings = actionCodeSettings {
- url = "https://flutterfire-e2e-tests.firebaseapp.com"
- handleCodeInApp = true
- setAndroidPackageName(
- "com.firebaseui.android.demo",
- true,
- null
- )
- },
- isNewAccountsAllowed = true,
- minimumPasswordLength = 8,
- passwordValidationRules = listOf(
- PasswordRule.MinimumLength(8),
- PasswordRule.RequireLowercase,
- PasswordRule.RequireUppercase,
- ),
+ providers {
+ provider(AuthProvider.Anonymous)
+ provider(
+ AuthProvider.Google(
+ scopes = listOf("email"),
+ serverClientId = "406099696497-a12gakvts4epfk5pkio7dphc1anjiggc.apps.googleusercontent.com",
+ )
)
- )
- provider(
- AuthProvider.Phone(
- defaultNumber = null,
- defaultCountryCode = null,
- allowedCountries = emptyList(),
- smsCodeLength = 6,
- timeout = 120L,
- isInstantVerificationEnabled = true
+ provider(
+ AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isEmailLinkForceSameDeviceEnabled = false,
+ isEmailLinkSignInEnabled = true,
+ emailLinkActionCodeSettings = actionCodeSettings {
+ url = "https://flutterfire-e2e-tests.firebaseapp.com"
+ handleCodeInApp = true
+ setAndroidPackageName(
+ "com.firebaseui.android.demo",
+ true,
+ null
+ )
+ },
+ isNewAccountsAllowed = true,
+ minimumPasswordLength = 8,
+ passwordValidationRules = listOf(
+ PasswordRule.MinimumLength(8),
+ PasswordRule.RequireLowercase,
+ PasswordRule.RequireUppercase,
+ ),
+ )
)
- )
- provider(
- AuthProvider.Facebook()
- )
- provider(
- AuthProvider.Twitter(
- customParameters = emptyMap()
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = emptyList(),
+ smsCodeLength = 6,
+ timeout = 120L,
+ isInstantVerificationEnabled = true
+ )
)
- )
- provider(
- AuthProvider.Apple(
- customParameters = emptyMap(),
- locale = null
+ provider(
+ AuthProvider.Facebook()
)
- )
- provider(
- AuthProvider.Microsoft(
- scopes = emptyList(),
- tenant = "",
- customParameters = emptyMap(),
+ provider(
+ AuthProvider.Twitter(
+ customParameters = emptyMap()
+ )
)
- )
- provider(
- AuthProvider.Github(
- scopes = emptyList(),
- customParameters = emptyMap(),
+ provider(
+ AuthProvider.Apple(
+ customParameters = emptyMap(),
+ locale = null
+ )
)
- )
- provider(
- AuthProvider.Yahoo(
- scopes = emptyList(),
- customParameters = emptyMap(),
+ provider(
+ AuthProvider.Microsoft(
+ scopes = emptyList(),
+ tenant = "",
+ customParameters = emptyMap(),
+ )
)
- )
- 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.Github(
+ scopes = emptyList(),
+ 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.Yahoo(
+ scopes = emptyList(),
+ customParameters = emptyMap(),
+ )
)
- )
+ 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.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
+ )
+ )
+ }
}
- }
- setContent {
- AuthUITheme {
+ AuthUITheme(theme = customTheme) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
diff --git a/app/src/main/java/com/firebaseui/android/demo/PhoneAuthSlotDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/PhoneAuthSlotDemoActivity.kt
similarity index 99%
rename from app/src/main/java/com/firebaseui/android/demo/PhoneAuthSlotDemoActivity.kt
rename to app/src/main/java/com/firebaseui/android/demo/auth/PhoneAuthSlotDemoActivity.kt
index 9639beefbb..a36c567b5d 100644
--- a/app/src/main/java/com/firebaseui/android/demo/PhoneAuthSlotDemoActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/PhoneAuthSlotDemoActivity.kt
@@ -1,4 +1,4 @@
-package com.firebaseui.android.demo
+package com.firebaseui.android.demo.auth
import android.os.Bundle
import android.util.Log
diff --git a/app/src/main/java/com/firebaseui/android/demo/ShapeCustomizationDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/ShapeCustomizationDemoActivity.kt
similarity index 99%
rename from app/src/main/java/com/firebaseui/android/demo/ShapeCustomizationDemoActivity.kt
rename to app/src/main/java/com/firebaseui/android/demo/auth/ShapeCustomizationDemoActivity.kt
index 5faba7336d..77419b2104 100644
--- a/app/src/main/java/com/firebaseui/android/demo/ShapeCustomizationDemoActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/ShapeCustomizationDemoActivity.kt
@@ -1,4 +1,4 @@
-package com.firebaseui.android.demo
+package com.firebaseui.android.demo.auth
import android.os.Bundle
import androidx.activity.ComponentActivity
diff --git a/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt
new file mode 100644
index 0000000000..4e99cbfd33
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt
@@ -0,0 +1,182 @@
+package com.firebaseui.android.demo.database
+
+import android.os.Bundle
+import android.view.ViewGroup
+import android.widget.TextView
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.paging.LoadState
+import androidx.paging.PagingConfig
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.firebase.ui.database.paging.DatabasePagingOptions
+import com.firebase.ui.database.paging.FirebaseRecyclerPagingAdapter
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.database.DatabaseReference
+import com.google.firebase.database.FirebaseDatabase
+
+class DatabaseDemoActivity : ComponentActivity() {
+
+ private lateinit var adapter: ScoreAdapter
+ private var currentPage by mutableIntStateOf(1)
+ private var prevAppendWasLoading = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val ref = FirebaseDatabase.getInstance().reference.child("database_demo")
+
+ val options = DatabasePagingOptions.Builder()
+ .setLifecycleOwner(this)
+ .setQuery(ref.orderByChild("score"), PagingConfig(pageSize = 10), ScoreItem::class.java)
+ .build()
+
+ adapter = ScoreAdapter(options)
+
+ adapter.addLoadStateListener { states ->
+ if (states.refresh is LoadState.Loading) {
+ currentPage = 1
+ prevAppendWasLoading = false
+ return@addLoadStateListener
+ }
+ val appendLoading = states.append is LoadState.Loading
+ if (prevAppendWasLoading && !appendLoading) {
+ currentPage++
+ }
+ prevAppendWasLoading = appendLoading
+ }
+
+ setContent {
+ MaterialTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ DatabaseDemoScreen(
+ adapter = adapter,
+ currentPage = currentPage,
+ onSeedData = { signInThenSeed(ref) },
+ onRefresh = { adapter.refresh() }
+ )
+ }
+ }
+ }
+ }
+
+ private fun signInThenSeed(ref: DatabaseReference) {
+ val auth = FirebaseAuth.getInstance()
+ val signIn = if (auth.currentUser != null) {
+ com.google.android.gms.tasks.Tasks.forResult(null)
+ } else {
+ auth.signInAnonymously()
+ }
+ signIn.addOnSuccessListener { seedData(ref) }
+ }
+
+ private fun seedData(ref: DatabaseReference) {
+ repeat(50) { i ->
+ ref.push().setValue(ScoreItem("Item ${i + 1}", (1..100).random()))
+ }
+ }
+}
+
+data class ScoreItem(var name: String = "", var score: Int = 0)
+
+class ScoreViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(
+ TextView(parent.context).apply {
+ layoutParams = RecyclerView.LayoutParams(
+ RecyclerView.LayoutParams.MATCH_PARENT,
+ RecyclerView.LayoutParams.WRAP_CONTENT
+ )
+ val density = context.resources.displayMetrics.density
+ val hPadding = (16 * density).toInt()
+ val vPadding = (8 * density).toInt()
+ setPadding(hPadding, vPadding, hPadding, vPadding)
+ textSize = 16f
+ }
+)
+
+class ScoreAdapter(options: DatabasePagingOptions) :
+ FirebaseRecyclerPagingAdapter(options) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ScoreViewHolder(parent)
+
+ override fun onBindViewHolder(holder: ScoreViewHolder, position: Int, model: ScoreItem) {
+ (holder.itemView as TextView).text = "${model.name} â score: ${model.score}"
+ }
+}
+
+@Composable
+fun DatabaseDemoScreen(
+ adapter: ScoreAdapter,
+ currentPage: Int,
+ onSeedData: () -> Unit,
+ onRefresh: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .systemBarsPadding()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text("Firebase Database Paging", style = MaterialTheme.typography.headlineSmall)
+ Text(
+ "Paginated list using FirebaseRecyclerPagingAdapter with orderByChild(\"score\").",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = onSeedData) { Text("Authenticate & Seed Data") }
+ OutlinedButton(onClick = onRefresh) { Text("Refresh") }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ "Page: $currentPage",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ AndroidView(
+ factory = { context ->
+ RecyclerView(context).apply {
+ layoutManager = LinearLayoutManager(context)
+ addItemDecoration(
+ DividerItemDecoration(context, DividerItemDecoration.VERTICAL)
+ )
+ setAdapter(adapter)
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ )
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt
new file mode 100644
index 0000000000..91d07abb91
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt
@@ -0,0 +1,189 @@
+package com.firebaseui.android.demo.firestore
+
+import android.os.Bundle
+import android.view.ViewGroup
+import android.widget.TextView
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.paging.LoadState
+import androidx.paging.PagingConfig
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.firebase.ui.firestore.paging.FirestorePagingAdapter
+import com.firebase.ui.firestore.paging.FirestorePagingOptions
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.firestore.CollectionReference
+import com.google.firebase.firestore.FirebaseFirestore
+import com.google.firebase.firestore.Query
+
+class FirestoreDemoActivity : ComponentActivity() {
+
+ private lateinit var adapter: ScoreAdapter
+ private var currentPage by mutableIntStateOf(1)
+ private var prevAppendWasLoading = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val collection = FirebaseFirestore.getInstance().collection("firestore_demo")
+
+ // Query must only contain where()/orderBy() â the paging library adds limit().
+ val query: Query = collection.orderBy("score")
+
+ val options = FirestorePagingOptions.Builder()
+ .setLifecycleOwner(this)
+ .setQuery(query, PagingConfig(pageSize = 10), ScoreItem::class.java)
+ .build()
+
+ adapter = ScoreAdapter(options)
+
+ adapter.addLoadStateListener { states ->
+ if (states.refresh is LoadState.Loading) {
+ currentPage = 1
+ prevAppendWasLoading = false
+ return@addLoadStateListener
+ }
+ val appendLoading = states.append is LoadState.Loading
+ if (prevAppendWasLoading && !appendLoading) {
+ currentPage++
+ }
+ prevAppendWasLoading = appendLoading
+ }
+
+ setContent {
+ MaterialTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ FirestoreDemoScreen(
+ adapter = adapter,
+ currentPage = currentPage,
+ onSeedData = { signInThenSeed(collection) },
+ onRefresh = { adapter.refresh() }
+ )
+ }
+ }
+ }
+ }
+
+ private fun signInThenSeed(collection: CollectionReference) {
+ val auth = FirebaseAuth.getInstance()
+ val signIn = if (auth.currentUser != null) {
+ com.google.android.gms.tasks.Tasks.forResult(null)
+ } else {
+ auth.signInAnonymously()
+ }
+ signIn.addOnSuccessListener { seedData(collection) }
+ }
+
+ private fun seedData(collection: CollectionReference) {
+ val batch = collection.firestore.batch()
+ for (i in 1..50) {
+ val docRef = collection.document()
+ batch.set(docRef, ScoreItem("Item $i", (1..100).random()))
+ }
+ batch.commit()
+ }
+}
+
+data class ScoreItem(var name: String = "", var score: Int = 0)
+
+class ScoreViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(
+ TextView(parent.context).apply {
+ layoutParams = RecyclerView.LayoutParams(
+ RecyclerView.LayoutParams.MATCH_PARENT,
+ RecyclerView.LayoutParams.WRAP_CONTENT
+ )
+ val density = context.resources.displayMetrics.density
+ val hPadding = (16 * density).toInt()
+ val vPadding = (8 * density).toInt()
+ setPadding(hPadding, vPadding, hPadding, vPadding)
+ textSize = 16f
+ }
+)
+
+class ScoreAdapter(options: FirestorePagingOptions) :
+ FirestorePagingAdapter(options) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ScoreViewHolder(parent)
+
+ override fun onBindViewHolder(holder: ScoreViewHolder, position: Int, model: ScoreItem) {
+ (holder.itemView as TextView).text = "${model.name} â score: ${model.score}"
+ }
+}
+
+@Composable
+fun FirestoreDemoScreen(
+ adapter: ScoreAdapter,
+ currentPage: Int,
+ onSeedData: () -> Unit,
+ onRefresh: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .systemBarsPadding()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text("Firebase Firestore Paging", style = MaterialTheme.typography.headlineSmall)
+ Text(
+ "Paginated list using FirestorePagingAdapter with orderBy(\"score\").",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = onSeedData) { Text("Authenticate & Seed Data") }
+ OutlinedButton(onClick = onRefresh) { Text("Refresh") }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ "Page: $currentPage",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ AndroidView(
+ factory = { context ->
+ RecyclerView(context).apply {
+ layoutManager = LinearLayoutManager(context)
+ addItemDecoration(
+ DividerItemDecoration(context, DividerItemDecoration.VERTICAL)
+ )
+ setAdapter(adapter)
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ )
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt
new file mode 100644
index 0000000000..5959a5aa45
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt
@@ -0,0 +1,204 @@
+package com.firebaseui.android.demo.storage
+
+import android.graphics.drawable.Drawable
+import android.os.Bundle
+import android.widget.ImageView
+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.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.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+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.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import com.bumptech.glide.load.DataSource
+import com.bumptech.glide.load.engine.GlideException
+import com.bumptech.glide.request.RequestListener
+import com.bumptech.glide.request.target.Target
+import com.google.firebase.storage.FirebaseStorage
+
+class StorageDemoActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ StorageDemoScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun StorageDemoScreen() {
+ var gsUrl by remember { mutableStateOf("") }
+ var stringStatus by remember { mutableStateOf("Not loaded") }
+ var stringUrlToLoad by remember { mutableStateOf("") }
+ var refStatus by remember { mutableStateOf("Not loaded") }
+ var refUrlToLoad by remember { mutableStateOf("") }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .systemBarsPadding()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text("Firebase Storage + Glide", style = MaterialTheme.typography.headlineSmall)
+ Text(
+ "Enter a gs:// URL and load it using either approach below.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ OutlinedTextField(
+ value = gsUrl,
+ onValueChange = { gsUrl = it },
+ label = { Text("gs:// URL") },
+ placeholder = { Text("gs://your-project.appspot.com/path/to/image.png") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+
+ // Approach 1: gs:// string via StringLoader
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text("Via gs:// String", style = MaterialTheme.typography.titleMedium)
+ Text(
+ "Uses FirebaseImageLoader.StringLoader, registered in StorageGlideModule. " +
+ "Pass the gs:// URL string directly to Glide.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Button(onClick = {
+ if (gsUrl.isNotEmpty()) {
+ stringStatus = "Loading..."
+ stringUrlToLoad = gsUrl
+ }
+ }) { Text("Load") }
+ StatusText(stringStatus)
+ AndroidView(
+ factory = { ImageView(it) },
+ update = { view ->
+ if (stringUrlToLoad.isNotEmpty()) {
+ GlideApp.with(view)
+ .load(stringUrlToLoad)
+ .listener(glideListener { success, error ->
+ stringStatus = if (success) "Loaded" else "Error: $error"
+ })
+ .into(view)
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ )
+ }
+ }
+
+ // Approach 2: StorageReference
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text("Via StorageReference", style = MaterialTheme.typography.titleMedium)
+ Text(
+ "Converts the gs:// URL to a StorageReference first, then passes it to Glide. " +
+ "Handled by FirebaseImageLoader.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Button(onClick = {
+ if (gsUrl.isNotEmpty()) {
+ refStatus = "Loading..."
+ refUrlToLoad = gsUrl
+ }
+ }) { Text("Load") }
+ StatusText(refStatus)
+ AndroidView(
+ factory = { ImageView(it) },
+ update = { view ->
+ if (refUrlToLoad.isNotEmpty()) {
+ runCatching {
+ FirebaseStorage.getInstance().getReferenceFromUrl(refUrlToLoad)
+ }.onSuccess { ref ->
+ GlideApp.with(view)
+ .load(ref)
+ .listener(glideListener { success, error ->
+ refStatus = if (success) "Loaded" else "Error: $error"
+ })
+ .into(view)
+ }.onFailure { e ->
+ refStatus = "Invalid URL: ${e.message}"
+ }
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun StatusText(status: String) {
+ Text(
+ text = "Status: $status",
+ style = MaterialTheme.typography.bodySmall,
+ color = if (status.startsWith("Error") || status.startsWith("Invalid"))
+ MaterialTheme.colorScheme.error
+ else
+ MaterialTheme.colorScheme.onSurface
+ )
+}
+
+private fun glideListener(onResult: (success: Boolean, error: String?) -> Unit) =
+ object : RequestListener {
+ override fun onLoadFailed(
+ e: GlideException?,
+ model: Any?,
+ target: Target,
+ isFirstResource: Boolean
+ ): Boolean {
+ onResult(false, e?.message ?: "Unknown error")
+ return false
+ }
+
+ override fun onResourceReady(
+ resource: Drawable,
+ model: Any,
+ target: Target,
+ dataSource: DataSource,
+ isFirstResource: Boolean
+ ): Boolean {
+ onResult(true, null)
+ return false
+ }
+ }
diff --git a/app/src/main/java/com/firebaseui/android/demo/storage/StorageGlideModule.kt b/app/src/main/java/com/firebaseui/android/demo/storage/StorageGlideModule.kt
new file mode 100644
index 0000000000..499dfe253a
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/storage/StorageGlideModule.kt
@@ -0,0 +1,26 @@
+package com.firebaseui.android.demo.storage
+
+import android.content.Context
+import com.bumptech.glide.Glide
+import com.bumptech.glide.Registry
+import com.bumptech.glide.annotation.GlideModule
+import com.bumptech.glide.module.AppGlideModule
+import com.firebase.ui.storage.images.FirebaseImageLoader
+import com.google.firebase.storage.StorageReference
+import java.io.InputStream
+
+@GlideModule
+class StorageGlideModule : AppGlideModule() {
+ override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
+ registry.append(
+ StorageReference::class.java,
+ InputStream::class.java,
+ FirebaseImageLoader.Factory()
+ )
+ registry.append(
+ String::class.java,
+ InputStream::class.java,
+ FirebaseImageLoader.StringLoader.Factory()
+ )
+ }
+}
diff --git a/auth/README.md b/auth/README.md
index e4311d8f35..1cf4c8e00c 100644
--- a/auth/README.md
+++ b/auth/README.md
@@ -1546,6 +1546,26 @@ val configuration = authUIConfiguration {
}
```
+### Customizing the Top App Bar
+
+Override the colors used by the top app bar shown on auth screens:
+
+```kotlin
+val customTheme = AuthUITheme.Default.copy(
+ topAppBarColors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color(0xFF2E7D32),
+ scrolledContainerColor = Color(0xFF2E7D32),
+ )
+)
+
+val configuration = authUIConfiguration {
+ providers { provider(AuthProvider.Email()) }
+ theme = customTheme
+}
+```
+
+If left unset (`null`), the top app bar falls back to colors derived from `colorScheme`'s `primary`/`onPrimary`.
+
### Screen Transitions
Customize the animations when navigating between screens using the `AuthUITransitions` object:
diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt
index 6974802131..ccfa4db213 100644
--- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt
@@ -34,10 +34,19 @@ import com.google.firebase.auth.PhoneAuthProvider
*/
abstract class AuthState private constructor() {
+ /**
+ * Whether this is a one-off notification: something a screen shows once (a dialog, a "link
+ * sent" message) and must reset back to [Idle] immediately after consuming, so it doesn't
+ * leak to a screen/Activity created later. `abstract` so every new state must explicitly
+ * decide this rather than silently defaulting one way.
+ */
+ abstract val isNotification: Boolean
+
/**
* Initial state before any authentication operation has been started.
*/
class Idle internal constructor() : AuthState() {
+ override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean = other is Idle
override fun hashCode(): Int = javaClass.hashCode()
override fun toString(): String = "AuthState.Idle"
@@ -49,6 +58,7 @@ abstract class AuthState private constructor() {
* @property message Optional message describing what is being loaded
*/
class Loading(val message: String? = null) : AuthState() {
+ override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Loading) return false
@@ -72,6 +82,7 @@ abstract class AuthState private constructor() {
val user: FirebaseUser,
val isNewUser: Boolean = false
) : AuthState() {
+ override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Success) return false
@@ -101,6 +112,7 @@ abstract class AuthState private constructor() {
val exception: Exception,
val isRecoverable: Boolean = true
) : AuthState() {
+ override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Error) return false
@@ -122,6 +134,7 @@ abstract class AuthState private constructor() {
* Authentication was cancelled by the user.
*/
class Cancelled internal constructor() : AuthState() {
+ override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean = other is Cancelled
override fun hashCode(): Int = javaClass.hashCode()
override fun toString(): String = "AuthState.Cancelled"
@@ -137,6 +150,7 @@ abstract class AuthState private constructor() {
val resolver: MultiFactorResolver,
val hint: String? = null
) : AuthState() {
+ override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RequiresMfa) return false
@@ -164,6 +178,7 @@ abstract class AuthState private constructor() {
val user: FirebaseUser,
val email: String
) : AuthState() {
+ override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RequiresEmailVerification) return false
@@ -191,6 +206,7 @@ abstract class AuthState private constructor() {
val user: FirebaseUser,
val missingFields: List = emptyList()
) : AuthState() {
+ override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RequiresProfileCompletion) return false
@@ -221,6 +237,7 @@ abstract class AuthState private constructor() {
// Not included in equals/hashCode â lambdas have no meaningful equality.
val retryOperation: (suspend (android.content.Context) -> Unit)? = null,
) : AuthState() {
+ override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ReauthenticationRequired) return false
@@ -241,6 +258,7 @@ abstract class AuthState private constructor() {
* Password reset link has been sent to the user's email.
*/
class PasswordResetLinkSent : AuthState() {
+ override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean = other is PasswordResetLinkSent
override fun hashCode(): Int = javaClass.hashCode()
override fun toString(): String = "AuthState.PasswordResetLinkSent"
@@ -250,6 +268,7 @@ abstract class AuthState private constructor() {
* Email sign in link has been sent to the user's email.
*/
class EmailSignInLinkSent : AuthState() {
+ override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean = other is EmailSignInLinkSent
override fun hashCode(): Int = javaClass.hashCode()
override fun toString(): String = "AuthState.EmailSignInLinkSent"
@@ -267,6 +286,7 @@ abstract class AuthState private constructor() {
* @see PhoneNumberVerificationRequired for the manual verification flow
*/
class SMSAutoVerified(val credential: PhoneAuthCredential) : AuthState() {
+ override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SMSAutoVerified) return false
@@ -305,6 +325,7 @@ abstract class AuthState private constructor() {
val verificationId: String,
val forceResendingToken: PhoneAuthProvider.ForceResendingToken,
) : AuthState() {
+ override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is PhoneNumberVerificationRequired) return false
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 32e9eacd94..b936d1cb2a 100644
--- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt
@@ -77,6 +77,7 @@ class FirebaseAuthActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ window.decorView.filterTouchesWhenObscured = true
enableEdgeToEdge()
// Extract configuration and auth instance from cache using UUID key
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 bc2ca3b43f..9af38935bf 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
@@ -582,9 +582,6 @@ interface AuthUIStringProvider {
/** ToS and Privacy Policy combined message with placeholders for links */
fun tosAndPrivacyPolicy(termsOfServiceLabel: String, privacyPolicyLabel: String): String
- /** Tooltip message shown when new account sign-up is disabled */
- val newAccountsDisabledTooltip: String
-
/** Tooltip message shown when MFA is disabled */
val mfaDisabledTooltip: 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 cda581acb8..aec4a83ccf 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
@@ -523,9 +523,6 @@ class DefaultAuthUIStringProvider(
override fun tosAndPrivacyPolicy(termsOfServiceLabel: String, privacyPolicyLabel: String): String =
localizedContext.getString(R.string.fui_tos_and_pp, termsOfServiceLabel, privacyPolicyLabel)
- override val newAccountsDisabledTooltip: String
- get() = localizedContext.getString(R.string.fui_new_accounts_disabled_tooltip)
-
override val mfaDisabledTooltip: String
get() = localizedContext.getString(R.string.fui_mfa_disabled_tooltip)
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUITheme.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUITheme.kt
index 79e78f80f2..79a7db901b 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUITheme.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUITheme.kt
@@ -16,9 +16,9 @@ package com.firebase.ui.auth.configuration.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
+import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
@@ -81,6 +81,12 @@ class AuthUITheme(
* ```
*/
val providerButtonShape: Shape? = null,
+
+ /**
+ * Custom colors for the top app bar shown on auth screens. If null, falls back to
+ * colors derived from [colorScheme] (see [AuthUITheme.topAppBarColors]).
+ */
+ val topAppBarColors: TopAppBarColors? = null,
) {
/**
@@ -91,6 +97,7 @@ class AuthUITheme(
* @param shapes The shapes to use. Defaults to this theme's shapes.
* @param providerStyles Custom styling for individual providers. Defaults to this theme's provider styles.
* @param providerButtonShape Default shape for provider buttons. Defaults to this theme's provider button shape.
+ * @param topAppBarColors Custom top app bar colors. Defaults to this theme's top app bar colors.
* @return A new AuthUITheme instance with the specified properties.
*/
fun copy(
@@ -99,13 +106,15 @@ class AuthUITheme(
shapes: Shapes = this.shapes,
providerStyles: Map = this.providerStyles,
providerButtonShape: Shape? = this.providerButtonShape,
+ topAppBarColors: TopAppBarColors? = this.topAppBarColors,
): AuthUITheme {
return AuthUITheme(
colorScheme = colorScheme,
typography = typography,
shapes = shapes,
providerStyles = providerStyles,
- providerButtonShape = providerButtonShape
+ providerButtonShape = providerButtonShape,
+ topAppBarColors = topAppBarColors
)
}
@@ -118,6 +127,7 @@ class AuthUITheme(
if (shapes != other.shapes) return false
if (providerStyles != other.providerStyles) return false
if (providerButtonShape != other.providerButtonShape) return false
+ if (topAppBarColors != other.topAppBarColors) return false
return true
}
@@ -128,12 +138,14 @@ class AuthUITheme(
result = 31 * result + shapes.hashCode()
result = 31 * result + providerStyles.hashCode()
result = 31 * result + (providerButtonShape?.hashCode() ?: 0)
+ result = 31 * result + (topAppBarColors?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return "AuthUITheme(colorScheme=$colorScheme, typography=$typography, shapes=$shapes, " +
- "providerStyles=$providerStyles, providerButtonShape=$providerButtonShape)"
+ "providerStyles=$providerStyles, providerButtonShape=$providerButtonShape, " +
+ "topAppBarColors=$topAppBarColors)"
}
/**
@@ -228,7 +240,6 @@ class AuthUITheme(
)
}
- @OptIn(ExperimentalMaterial3Api::class)
@get:Composable
val topAppBarColors
get() = TopAppBarDefaults.topAppBarColors(
@@ -236,6 +247,14 @@ class AuthUITheme(
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
)
+
+ /**
+ * Resolves the top app bar colors to use for the current [LocalAuthUITheme], falling back
+ * to [topAppBarColors] when the current theme doesn't specify its own.
+ */
+ @get:Composable
+ val resolvedTopAppBarColors: TopAppBarColors
+ get() = LocalAuthUITheme.current.topAppBarColors ?: topAppBarColors
}
}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt
index ffb7baa8dc..d0d707bda8 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt
@@ -19,7 +19,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.window.DialogProperties
import com.firebase.ui.auth.AuthException
@@ -80,6 +82,8 @@ fun ErrorRecoveryDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = {
+ val view = LocalView.current
+ SideEffect { view.rootView.filterTouchesWhenObscured = true }
Text(
text = stringProvider.errorDialogTitle,
style = MaterialTheme.typography.headlineSmall
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt
index 08c9ed89cc..4622de9578 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt
@@ -29,6 +29,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -39,6 +40,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -75,6 +77,8 @@ fun ReauthenticationDialog(
AlertDialog(
onDismissRequest = { if (!isLoading) onDismiss() },
title = {
+ val view = LocalView.current
+ SideEffect { view.rootView.filterTouchesWhenObscured = true }
Text(
text = stringProvider.reauthDialogTitle,
style = MaterialTheme.typography.headlineSmall
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt
index 4cd0aadd8e..e5d22c1a82 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt
@@ -40,18 +40,18 @@ val LocalTopLevelDialogController = compositionLocalOf
* dialogController?.showErrorDialog(
@@ -68,7 +68,7 @@ val LocalTopLevelDialogController = compositionLocalOf AuthState
) {
private var dialogState by mutableStateOf(null)
private val shownErrorStates = mutableSetOf()
@@ -78,18 +78,22 @@ class TopLevelDialogController(
* Automatically prevents duplicate dialogs for the same AuthState.Error instance.
*
* @param exception The auth exception to display
+ * @param errorState The specific [AuthState.Error] instance this call is reacting to, used
+ * for de-duplication. Pass this explicitly when the caller might not be the only observer of
+ * the same error: by the time this runs, another observer may have already reset the live
+ * auth state to `Idle`, so falling back to [currentAuthState] alone would miss the dedup.
* @param onRetry Callback when user clicks retry button
* @param onRecover Callback when user clicks recover button (e.g., navigate to different screen)
* @param onDismiss Callback when dialog is dismissed
*/
fun showErrorDialog(
exception: AuthException,
+ errorState: AuthState.Error? = null,
onRetry: (AuthException) -> Unit = {},
onRecover: ((AuthException) -> Unit)? = null,
onDismiss: () -> Unit = {}
) {
- // Get current error state
- val currentErrorState = authState as? AuthState.Error
+ val currentErrorState = errorState ?: (currentAuthState() as? AuthState.Error)
// If this exact error state has already been shown, skip
if (currentErrorState != null && currentErrorState in shownErrorStates) {
@@ -162,13 +166,21 @@ class TopLevelDialogController(
/**
* Creates and remembers a [TopLevelDialogController].
+ *
+ * [authState] is a lambda rather than a snapshot value so the controller can read the
+ * live auth state on every [TopLevelDialogController.showErrorDialog] call without being
+ * recreated (and losing its de-duplication history) whenever the auth state changes.
+ *
+ * Keyed on [stringProvider] rather than left unkeyed: callers must pass a `remember`ed
+ * [stringProvider] (stable across recompositions), otherwise the controller â and its
+ * de-duplication history â would be recreated on every recomposition.
*/
@Composable
fun rememberTopLevelDialogController(
stringProvider: AuthUIStringProvider,
- authState: AuthState
+ authState: () -> AuthState
): TopLevelDialogController {
- return remember(stringProvider, authState) {
+ return remember(stringProvider) {
TopLevelDialogController(stringProvider, authState)
}
}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt
index ab79e8954c..58137835ca 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt
@@ -292,7 +292,7 @@ private fun SingleDigitField(
lineHeight = 24.sp,
),
keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.NumberPassword
+ keyboardType = KeyboardType.Number
),
decorationBox = { innerTextField ->
Box(
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 feb04fb7cb..e51c71c528 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
@@ -16,7 +16,6 @@ package com.firebase.ui.auth.ui.method_picker
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -24,6 +23,7 @@ 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.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
@@ -128,14 +128,16 @@ fun AuthMethodPicker(
customLayout(providers, onProviderSelected)
}
} else {
- BoxWithConstraints(
+ Box(
modifier = Modifier
+ .fillMaxWidth()
.weight(1f),
+ contentAlignment = Alignment.TopCenter,
) {
- val paddingWidth = maxWidth.value * 0.23
LazyColumn(
modifier = Modifier
- .padding(horizontal = paddingWidth.dp)
+ .widthIn(max = 400.dp)
+ .padding(horizontal = 24.dp)
.testTag("AuthMethodPicker LazyColumn"),
horizontalAlignment = Alignment.CenterHorizontally,
) {
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 fffcf59055..848f0693af 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
@@ -20,6 +20,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -104,6 +105,14 @@ import kotlinx.coroutines.tasks.await
* @param authenticatedContent Optional slot that allows callers to render the authenticated
* state themselves. When provided, it receives the current [AuthState] alongside an
* [AuthSuccessUiContext] containing common callbacks (sign out, manage MFA, reload user).
+ * @param customMethodPickerLayout Optional slot that fully replaces the method-picker screen.
+ * When provided, it renders as the *entire* screen content â edge-to-edge, with no logo, no
+ * Terms of Service/Privacy Policy footer, and no automatic system-inset handling. The caller is
+ * responsible for its own insets (e.g. `Modifier.safeDrawingPadding()`) and for displaying any
+ * required legal disclosures. [customMethodPickerTermsConfiguration] is ignored when this is set.
+ * @param customMethodPickerTermsConfiguration Optional custom Terms of Service/Privacy Policy
+ * footer for the *default* method-picker layout. Ignored when [customMethodPickerLayout] is
+ * provided, since that slot takes over the whole screen.
*
* @since 10.0.0
*/
@@ -135,11 +144,11 @@ fun FirebaseAuthScreen(
val activity = LocalActivity.current
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
- val stringProvider = DefaultAuthUIStringProvider(context)
+ val stringProvider = remember(context) { DefaultAuthUIStringProvider(context) }
val navController = rememberNavController()
val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle)
- val dialogController = rememberTopLevelDialogController(stringProvider, authState)
+ val dialogController = rememberTopLevelDialogController(stringProvider) { authState }
val lastSuccessfulUserId = remember { mutableStateOf(null) }
val pendingLinkingCredential = remember { mutableStateOf(null) }
val pendingResolver = remember { mutableStateOf(null) }
@@ -149,6 +158,9 @@ fun FirebaseAuthScreen(
val emailLinkFromDifferentDevice = remember { mutableStateOf(null) }
val lastSignInPreference =
remember { mutableStateOf(null) }
+ // Last-processed AuthState, so the Idle branch below can tell a genuine reset apart from
+ // Idle-as-a-side-effect of consuming a notification (see AuthState.isNotification).
+ val previousAuthState = remember { mutableStateOf(AuthState.Idle) }
val startRoute = remember(configuration.providers, configuration.isProviderChoiceAlwaysShown) {
getStartRoute(configuration)
}
@@ -207,19 +219,24 @@ fun FirebaseAuthScreen(
}
) {
composable(AuthRoute.MethodPicker.route) {
- Scaffold { innerPadding ->
- AuthMethodPicker(
- modifier = modifier
- .padding(innerPadding),
- providers = configuration.providers,
- logo = logoAsset,
- termsOfServiceUrl = configuration.tosUrl,
- privacyPolicyUrl = configuration.privacyPolicyUrl,
- lastSignInPreference = lastSignInPreference.value,
- customLayout = customMethodPickerLayout,
- termsConfiguration = customMethodPickerTermsConfiguration,
- onProviderSelected = onProviderSelected,
- )
+ if (customMethodPickerLayout != null) {
+ Box(modifier = modifier.fillMaxSize()) {
+ customMethodPickerLayout(configuration.providers, onProviderSelected)
+ }
+ } else {
+ Scaffold { innerPadding ->
+ AuthMethodPicker(
+ modifier = modifier
+ .padding(innerPadding),
+ providers = configuration.providers,
+ logo = logoAsset,
+ termsOfServiceUrl = configuration.tosUrl,
+ privacyPolicyUrl = configuration.privacyPolicyUrl,
+ lastSignInPreference = lastSignInPreference.value,
+ termsConfiguration = customMethodPickerTermsConfiguration,
+ onProviderSelected = onProviderSelected,
+ )
+ }
}
}
@@ -433,30 +450,36 @@ fun FirebaseAuthScreen(
// Synchronise auth state changes with navigation stack.
LaunchedEffect(authState) {
val state = authState
+ val previous = previousAuthState.value
+ previousAuthState.value = state
val currentRoute = navController.currentBackStackEntry?.destination?.route
when (state) {
is AuthState.Success -> {
pendingResolver.value = null
pendingLinkingCredential.value = null
- // If reauth just completed, execute the pending retry and skip normal success handling
- pendingReauthOperation.value?.let { retry ->
- pendingReauthOperation.value = null
- pendingReauthConfig.value = null
- pendingReauthState.value = null
- // Lock the state to Loading before launching the retry so no
- // intermediate Success emission can navigate to AuthRoute.Success.
- authUI.updateAuthState(AuthState.Loading())
- coroutineScope.launch {
- try {
- retry(context)
- } catch (e: kotlinx.coroutines.CancellationException) {
- throw e
- } catch (e: Exception) {
- authUI.updateAuthState(AuthState.Error(e))
+ // If reauth just completed, execute the pending retry and skip normal success handling.
+ // Guarded on !previous.isNotification: a wrong-password Error masks back into
+ // Success while signed in, and that must not be mistaken for a completed reauth.
+ if (!previous.isNotification) {
+ pendingReauthOperation.value?.let { retry ->
+ pendingReauthOperation.value = null
+ pendingReauthConfig.value = null
+ pendingReauthState.value = null
+ // Lock the state to Loading before launching the retry so no
+ // intermediate Success emission can navigate to AuthRoute.Success.
+ authUI.updateAuthState(AuthState.Loading())
+ coroutineScope.launch {
+ try {
+ retry(context)
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ authUI.updateAuthState(AuthState.Error(e))
+ }
}
+ return@LaunchedEffect
}
- return@LaunchedEffect
}
state.result?.let { result ->
@@ -542,19 +565,25 @@ fun FirebaseAuthScreen(
// Keep external cancellation reporting centralized here so child screens
// can handle local navigation without triggering duplicate callbacks.
onSignInCancelled()
+ // Consumed so this doesn't leak to a freshly created screen.
+ authUI.updateAuthState(AuthState.Idle)
}
is AuthState.Idle -> {
- pendingReauthOperation.value = null
- pendingReauthConfig.value = null
- pendingReauthState.value = null
- pendingResolver.value = null
- pendingLinkingCredential.value = null
- lastSuccessfulUserId.value = null
- if (currentRoute != startRoute.route) {
- navController.navigate(startRoute.route) {
- popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
- launchSingleTop = true
+ // A notification resets to Idle purely to avoid leaking to a freshly
+ // created screen â that's not a request to leave the current one.
+ if (!previous.isNotification) {
+ pendingReauthOperation.value = null
+ pendingReauthConfig.value = null
+ pendingReauthState.value = null
+ pendingResolver.value = null
+ pendingLinkingCredential.value = null
+ lastSuccessfulUserId.value = null
+ if (currentRoute != startRoute.route) {
+ navController.navigate(startRoute.route) {
+ popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
+ launchSingleTop = true
+ }
}
}
}
@@ -574,6 +603,7 @@ fun FirebaseAuthScreen(
dialogController.showErrorDialog(
exception = exception,
+ errorState = errorState,
onRetry = { _ ->
// Child screens handle their own retry logic
},
@@ -632,6 +662,8 @@ fun FirebaseAuthScreen(
// Dialog dismissed
}
)
+ // Consumed immediately so this doesn't leak to a freshly created screen.
+ authUI.updateAuthState(AuthState.Idle)
}
}
@@ -928,13 +960,18 @@ private fun ReauthSheetContent(
popExitTransition = { fadeOut(animationSpec = tween(700)) },
) {
composable(AuthRoute.MethodPicker.route) {
- Scaffold { innerPadding ->
- AuthMethodPicker(
- modifier = Modifier.padding(innerPadding),
- providers = reauthConfig.providers,
- customLayout = customMethodPickerLayout,
- onProviderSelected = onProviderSelected,
- )
+ if (customMethodPickerLayout != null) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ customMethodPickerLayout(reauthConfig.providers, onProviderSelected)
+ }
+ } else {
+ Scaffold { innerPadding ->
+ AuthMethodPicker(
+ modifier = Modifier.padding(innerPadding),
+ providers = reauthConfig.providers,
+ onProviderSelected = onProviderSelected,
+ )
+ }
}
}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt
index bca4bc4c5e..0780348ee3 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt
@@ -27,17 +27,22 @@ import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.firebase.ui.auth.configuration.MfaFactor
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
import com.firebase.ui.auth.configuration.validators.VerificationCodeValidator
import com.firebase.ui.auth.mfa.MfaChallengeContentState
import com.firebase.ui.auth.ui.components.VerificationCodeInputField
@@ -50,108 +55,136 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) {
VerificationCodeValidator(stringProvider)
}
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .verticalScroll(rememberScrollState())
- .padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Text(
- text = if (isSms) {
- val phoneLabel = state.maskedPhoneNumber ?: ""
- stringProvider.enterVerificationCodeTitle(phoneLabel)
- } else {
- stringProvider.mfaStepVerifyFactorTitle
- },
- style = MaterialTheme.typography.headlineSmall,
- textAlign = TextAlign.Center
- )
-
- if (isSms && state.maskedPhoneNumber != null) {
- Text(
- text = stringProvider.mfaStepVerifyFactorSmsHelper,
- style = MaterialTheme.typography.bodyMedium,
- textAlign = TextAlign.Center,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
-
- if (state.error != null) {
+ Scaffold { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(innerPadding)
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
Text(
- text = state.error,
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.bodySmall,
+ text = if (isSms) {
+ val phoneLabel = state.maskedPhoneNumber ?: ""
+ stringProvider.enterVerificationCodeTitle(phoneLabel)
+ } else {
+ stringProvider.mfaStepVerifyFactorTitle
+ },
+ style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
- }
- Spacer(modifier = Modifier.height(8.dp))
+ if (isSms && state.maskedPhoneNumber != null) {
+ Text(
+ text = stringProvider.mfaStepVerifyFactorSmsHelper,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
- VerificationCodeInputField(
- modifier = Modifier.align(Alignment.CenterHorizontally),
- codeLength = 6,
- validator = verificationCodeValidator,
- isError = state.error != null,
- errorMessage = state.error,
- onCodeChange = state.onVerificationCodeChange
- )
+ if (state.error != null) {
+ Text(
+ text = state.error,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center
+ )
+ }
- Spacer(modifier = Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(8.dp))
- if (isSms) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- TextButton(
- onClick = { state.onResendCodeClick?.invoke() },
- enabled = state.onResendCodeClick != null && !state.isLoading && state.resendTimer == 0
+ VerificationCodeInputField(
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ codeLength = 6,
+ validator = verificationCodeValidator,
+ isError = state.error != null,
+ errorMessage = state.error,
+ onCodeChange = state.onVerificationCodeChange
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ if (isSms) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
) {
- Text(
- text = if (state.resendTimer > 0) {
- val minutes = state.resendTimer / 60
- val seconds = state.resendTimer % 60
- val formatted = "$minutes:${String.format(java.util.Locale.ROOT, "%02d", seconds)}"
- stringProvider.resendCodeTimer(formatted)
- } else {
- stringProvider.resendCode
- }
- )
- }
+ TextButton(
+ onClick = { state.onResendCodeClick?.invoke() },
+ enabled = state.onResendCodeClick != null && !state.isLoading && state.resendTimer == 0
+ ) {
+ Text(
+ text = if (state.resendTimer > 0) {
+ val minutes = state.resendTimer / 60
+ val seconds = state.resendTimer % 60
+ val formatted = "$minutes:${String.format(java.util.Locale.ROOT, "%02d", seconds)}"
+ stringProvider.resendCodeTimer(formatted)
+ } else {
+ stringProvider.resendCode
+ }
+ )
+ }
- TextButton(
+ TextButton(
+ onClick = state.onCancelClick,
+ enabled = !state.isLoading
+ ) {
+ Text(stringProvider.useDifferentMethodAction)
+ }
+ }
+ } else {
+ OutlinedButton(
onClick = state.onCancelClick,
- enabled = !state.isLoading
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth()
) {
- Text(stringProvider.useDifferentMethodAction)
+ Text(stringProvider.dismissAction)
}
}
- } else {
- OutlinedButton(
- onClick = state.onCancelClick,
- enabled = !state.isLoading,
+
+ Button(
+ onClick = state.onVerifyClick,
+ enabled = state.isValid && !state.isLoading,
modifier = Modifier.fillMaxWidth()
) {
- Text(stringProvider.dismissAction)
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.padding(end = 8.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ Text(stringProvider.verifyAction)
}
}
+ }
+}
- Button(
- onClick = state.onVerifyClick,
- enabled = state.isValid && !state.isLoading,
- modifier = Modifier.fillMaxWidth()
+/**
+ * Renders with a simulated status/nav bar (see CP-240) so correct edge-to-edge inset handling
+ * can be verified in the IDE preview. A plain `@Preview` draws no system chrome at all, so
+ * inset issues would be invisible there.
+ */
+@Preview(showSystemUi = true)
+@Composable
+private fun PreviewDefaultMfaChallengeContentEdgeToEdge() {
+ val applicationContext = LocalContext.current
+ val stringProvider = DefaultAuthUIStringProvider(applicationContext)
+
+ AuthUITheme {
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides stringProvider
) {
- if (state.isLoading) {
- CircularProgressIndicator(
- modifier = Modifier.padding(end = 8.dp),
- strokeWidth = 2.dp,
- color = MaterialTheme.colorScheme.onPrimary
+ DefaultMfaChallengeContent(
+ state = MfaChallengeContentState(
+ factorType = MfaFactor.Sms,
+ maskedPhoneNumber = "+1â˘â˘â˘â˘â˘â˘890"
)
- }
- Text(stringProvider.verifyAction)
+ )
}
}
}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt
index 61f1151a60..1cbb459495 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt
@@ -53,6 +53,7 @@ import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.MfaFactor
import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
import com.firebase.ui.auth.mfa.MfaEnrollmentContentState
import com.firebase.ui.auth.mfa.MfaEnrollmentStep
import com.firebase.ui.auth.mfa.toMfaErrorMessage
@@ -253,7 +254,8 @@ private fun SelectFactorUI(
Scaffold(
topBar = {
TopAppBar(
- title = { Text(stringProvider.mfaManageFactorsTitle) }
+ title = { Text(stringProvider.mfaManageFactorsTitle) },
+ colors = AuthUITheme.resolvedTopAppBarColors
)
}
) { innerPadding ->
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 667fc364b3..c5dae7822c 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
@@ -24,6 +24,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
import com.firebase.ui.auth.FirebaseAuthUI
@@ -167,8 +168,11 @@ fun EmailAuthScreen(
val authCredentialForLinking = remember { credentialForLinking }
val errorMessage =
if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null
- val resetLinkSent = authState is AuthState.PasswordResetLinkSent
- val emailSignInLinkSent = authState is AuthState.EmailSignInLinkSent
+
+ // Latched locally since these get consumed (reset to Idle) below â deriving directly from
+ // authState would close ResetPasswordUI/SignInEmailLinkUI's dialogs as soon as it resets.
+ var resetLinkSentLocal by rememberSaveable { mutableStateOf(false) }
+ var emailSignInLinkSentLocal by rememberSaveable { mutableStateOf(false) }
// Track if credentials were retrieved from Credential Manager
val retrievedCredential = remember { mutableStateOf?>(null) }
@@ -187,6 +191,7 @@ fun EmailAuthScreen(
onError(exception)
dialogController?.showErrorDialog(
exception = exception,
+ errorState = state,
onRetry = { ex ->
when (ex) {
is AuthException.UserNotFoundException -> {
@@ -229,10 +234,24 @@ fun EmailAuthScreen(
// Dialog dismissed
}
)
+ // Consumed immediately so this doesn't leak to a freshly created screen.
+ authUI.updateAuthState(AuthState.Idle)
}
is AuthState.Cancelled -> {
onCancel()
+ // Consumed so this doesn't leak to a freshly created screen.
+ authUI.updateAuthState(AuthState.Idle)
+ }
+
+ is AuthState.PasswordResetLinkSent -> {
+ resetLinkSentLocal = true
+ authUI.updateAuthState(AuthState.Idle)
+ }
+
+ is AuthState.EmailSignInLinkSent -> {
+ emailSignInLinkSentLocal = true
+ authUI.updateAuthState(AuthState.Idle)
}
else -> Unit
@@ -247,8 +266,8 @@ fun EmailAuthScreen(
confirmPassword = confirmPasswordTextValue.value,
isLoading = isLoading,
error = errorMessage,
- resetLinkSent = resetLinkSent,
- emailSignInLinkSent = emailSignInLinkSent,
+ resetLinkSent = resetLinkSentLocal,
+ emailSignInLinkSent = emailSignInLinkSentLocal,
onEmailChange = { email ->
emailTextValue.value = email
},
@@ -286,6 +305,7 @@ fun EmailAuthScreen(
}
},
onSignInEmailLinkClick = {
+ emailSignInLinkSentLocal = false
coroutineScope.launch {
try {
if (emailLinkFromDifferentDevice != null) {
@@ -327,6 +347,7 @@ fun EmailAuthScreen(
}
},
onSendResetLinkClick = {
+ resetLinkSentLocal = false
coroutineScope.launch {
try {
authUI.sendPasswordResetEmail(
@@ -346,14 +367,17 @@ fun EmailAuthScreen(
onGoToSignIn = {
textValues.forEach { it.value = "" }
mode.value = EmailAuthMode.SignIn
+ emailSignInLinkSentLocal = false
},
onGoToResetPassword = {
textValues.forEach { it.value = "" }
mode.value = EmailAuthMode.ResetPassword
+ resetLinkSentLocal = false
},
onGoToEmailLinkSignIn = {
textValues.forEach { it.value = "" }
mode.value = EmailAuthMode.EmailLinkSignIn
+ emailSignInLinkSentLocal = false
},
)
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt
index 8b73f2c2d0..7d1de8a233 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt
@@ -21,7 +21,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
@@ -130,15 +129,14 @@ fun ResetPasswordUI(
}
}
},
- colors = AuthUITheme.topAppBarColors
+ colors = AuthUITheme.resolvedTopAppBarColors
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
- .safeDrawingPadding()
- .padding(horizontal = 16.dp)
+ .padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
AuthTextField(
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt
index 14e0458279..f2ec55fa36 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt
@@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -141,15 +140,14 @@ fun SignInEmailLinkUI(
}
}
},
- colors = AuthUITheme.topAppBarColors
+ colors = AuthUITheme.resolvedTopAppBarColors
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
- .safeDrawingPadding()
- .padding(horizontal = 16.dp)
+ .padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
AuthTextField(
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt
index eabde87a16..eb8b501597 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt
@@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
@@ -36,15 +35,10 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
-import androidx.compose.material3.TooltipAnchorPosition
-import androidx.compose.material3.TooltipBox
-import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -170,15 +164,14 @@ fun SignInUI(
}
}
},
- colors = AuthUITheme.topAppBarColors
+ colors = AuthUITheme.resolvedTopAppBarColors
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
- .safeDrawingPadding()
- .padding(horizontal = 16.dp)
+ .padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
AuthTextField(
@@ -228,29 +221,17 @@ fun SignInUI(
modifier = Modifier
.align(Alignment.End),
) {
- TooltipBox(
- positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
- TooltipAnchorPosition.Above
- ),
- tooltip = {
- PlainTooltip {
- Text(stringProvider.newAccountsDisabledTooltip)
- }
- },
- state = rememberTooltipState(
- initialIsVisible = !provider.isNewAccountsAllowed
- )
- ) {
+ if (provider.isNewAccountsAllowed) {
Button(
onClick = {
onGoToSignUp()
},
- enabled = provider.isNewAccountsAllowed && !isLoading,
+ enabled = !isLoading,
) {
Text(stringProvider.signupPageTitle.uppercase())
}
+ Spacer(modifier = Modifier.width(16.dp))
}
- Spacer(modifier = Modifier.width(16.dp))
Button(
onClick = {
onSignInClick()
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt
index dfc413bc6a..7b6ba03c5e 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
@@ -95,7 +94,7 @@ fun SignUpUI(
val isFormValid = remember(displayName, email, password, confirmPassword) {
derivedStateOf {
listOf(
- displayNameValidator.validate(displayName),
+ !provider.isDisplayNameRequired || displayNameValidator.validate(displayName),
emailValidator.validate(email),
passwordValidator.validate(password),
confirmPasswordValidator.validate(confirmPassword)
@@ -120,15 +119,14 @@ fun SignUpUI(
}
}
},
- colors = AuthUITheme.topAppBarColors
+ colors = AuthUITheme.resolvedTopAppBarColors
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
- .safeDrawingPadding()
- .padding(horizontal = 16.dp)
+ .padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
if (provider.isDisplayNameRequired) {
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt
index ef8bfe5454..2b9ffc13d1 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
@@ -99,15 +98,14 @@ fun EnterPhoneNumberUI(
}
}
},
- colors = AuthUITheme.topAppBarColors
+ colors = AuthUITheme.resolvedTopAppBarColors
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
- .safeDrawingPadding()
- .padding(horizontal = 16.dp)
+ .padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
Text(stringProvider.enterPhoneNumberTitle)
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt
index 122e73a87a..be90bbf0b2 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt
@@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -103,15 +102,14 @@ fun EnterVerificationCodeUI(
}
}
},
- colors = AuthUITheme.topAppBarColors
+ colors = AuthUITheme.resolvedTopAppBarColors
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
- .safeDrawingPadding()
- .padding(horizontal = 16.dp)
+ .padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
Text(
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 e317c639d1..fb3411c819 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
@@ -196,6 +196,9 @@ fun PhoneAuthScreen(
pendingVerificationPhoneNumber.value = null
verificationStartTime.value = null
+ // Consumed before the async sign-in call so it can't be clobbered by that call's own state.
+ authUI.updateAuthState(AuthState.Idle)
+
coroutineScope.launch {
try {
authUI.signInWithPhoneAuthCredential(
@@ -216,6 +219,7 @@ fun PhoneAuthScreen(
// Show dialog for phone-specific errors using top-level controller
dialogController?.showErrorDialog(
exception = exception,
+ errorState = state,
onRetry = { ex ->
when (ex) {
is AuthException.InvalidCredentialsException -> {
@@ -228,10 +232,14 @@ fun PhoneAuthScreen(
// Dialog dismissed
}
)
+ // Consumed immediately so this doesn't leak to a freshly created screen.
+ authUI.updateAuthState(AuthState.Idle)
}
is AuthState.Cancelled -> {
onCancel()
+ // Consumed so this doesn't leak to a freshly created screen.
+ authUI.updateAuthState(AuthState.Idle)
}
else -> Unit
diff --git a/auth/src/main/res/values-ar/strings.xml b/auth/src/main/res/values-ar/strings.xml
index e6d6f9b96a..f888d1d647 100755
--- a/auth/src/main/res/values-ar/strings.xml
+++ b/auth/src/main/res/values-ar/strings.xml
@@ -178,6 +178,5 @@
أعسŮŮا بعŮŘŻŮا ŮŮŘŞŘŮŮ ŘĽŮŮ %1$s
- This button is currently disabled because new accounts are not allowed
اŮŮ
ؾادŮŘŠ Ů
تؚدد؊ اŮŘšŮاŮ
Ů Ů
ؚءŮŘŠ ŘاŮŮŮا
diff --git a/auth/src/main/res/values-b+es+419/strings.xml b/auth/src/main/res/values-b+es+419/strings.xml
index 0513671be4..2f05307516 100755
--- a/auth/src/main/res/values-b+es+419/strings.xml
+++ b/auth/src/main/res/values-b+es+419/strings.xml
@@ -196,6 +196,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
Multi-factor authentication is currently disabled
diff --git a/auth/src/main/res/values-bg/strings.xml b/auth/src/main/res/values-bg/strings.xml
index 08432527be..344e45c44e 100755
--- a/auth/src/main/res/values-bg/strings.xml
+++ b/auth/src/main/res/values-bg/strings.xml
@@ -178,6 +178,5 @@
ĐСпŃаŃиŃ
По иПоКН Са пОŃвŃŃМдонио Đ´Đž %1$s
- This button is currently disabled because new accounts are not allowed
ĐнОгОŃакŃĐžŃнаŃа авŃонŃиŃикаŃĐ¸Ń Đ˛ ПОПонŃа Đľ доакŃивиŃана
diff --git a/auth/src/main/res/values-bn/strings.xml b/auth/src/main/res/values-bn/strings.xml
index 18010598a3..e0fd8e00bb 100755
--- a/auth/src/main/res/values-bn/strings.xml
+++ b/auth/src/main/res/values-bn/strings.xml
@@ -179,6 +179,5 @@
ŕŚŕŚŽŕŚ°ŕŚž %1$s-ঠŕŚŕŚŕŚŕŚż যাŕŚŕŚžŕŚŕŚŕŚ°ŕŚŁ ŕŚŕŚŽŕ§ŕڞ পাঠিয়ŕ§ŕŚŕŚż
- This button is currently disabled because new accounts are not allowed
মালŕ§ŕŚŕŚż-ফŕ§ŕŚŻŕŚžŕŚŕ§ŕŚŕڰ পŕ§ŕŚ°ŕŚŽŕŚžŕŚŁŕ§ŕŚŕŚ°ŕŚŁ বরŕ§ŕŚ¤ŕŚŽŕŚžŕŚ¨ŕ§ ŕŚ¨ŕŚżŕŚˇŕ§ŕŚŕ§ŕŚ°ŕŚżŕŚŻŕŚź
diff --git a/auth/src/main/res/values-ca/strings.xml b/auth/src/main/res/values-ca/strings.xml
index a4cb13b809..2fb311bfb4 100755
--- a/auth/src/main/res/values-ca/strings.xml
+++ b/auth/src/main/res/values-ca/strings.xml
@@ -179,6 +179,5 @@
Hem enviat un correu de verificaciĂł a %1$s
- This button is currently disabled because new accounts are not allowed
L\'autenticaciĂł multifactor estĂ desactivada actualment
diff --git a/auth/src/main/res/values-cs/strings.xml b/auth/src/main/res/values-cs/strings.xml
index 527ddce0fc..1d1dab36e0 100755
--- a/auth/src/main/res/values-cs/strings.xml
+++ b/auth/src/main/res/values-cs/strings.xml
@@ -178,6 +178,5 @@
Odeslali jsme ovÄĹovacĂ e-mail na %1$s
- This button is currently disabled because new accounts are not allowed
VĂcefaktorovĂŠ ovÄĹovĂĄnĂ je aktuĂĄlnÄ zakĂĄzĂĄno
diff --git a/auth/src/main/res/values-da/strings.xml b/auth/src/main/res/values-da/strings.xml
index 0fb15c8cf5..8096a7d84f 100755
--- a/auth/src/main/res/values-da/strings.xml
+++ b/auth/src/main/res/values-da/strings.xml
@@ -178,6 +178,5 @@
Vi har sendt en bekrĂŚftelsesemail til %1$s
- This button is currently disabled because new accounts are not allowed
Multifaktorgodkendelse er i øjeblikket deaktiveret
diff --git a/auth/src/main/res/values-de-rAT/strings.xml b/auth/src/main/res/values-de-rAT/strings.xml
index 10ebb575be..21dbe9cb62 100755
--- a/auth/src/main/res/values-de-rAT/strings.xml
+++ b/auth/src/main/res/values-de-rAT/strings.xml
@@ -196,6 +196,5 @@
Erneut authentifizieren
- This button is currently disabled because new accounts are not allowed
Die Multi-Faktor-Authentifizierung ist derzeit deaktiviert
diff --git a/auth/src/main/res/values-de-rCH/strings.xml b/auth/src/main/res/values-de-rCH/strings.xml
index f458950741..f18d40f5b4 100755
--- a/auth/src/main/res/values-de-rCH/strings.xml
+++ b/auth/src/main/res/values-de-rCH/strings.xml
@@ -197,6 +197,5 @@
Erneut authentifizieren
- This button is currently disabled because new accounts are not allowed
Die Multi-Faktor-Authentifizierung ist derzeit deaktiviert
diff --git a/auth/src/main/res/values-de/strings.xml b/auth/src/main/res/values-de/strings.xml
index 01232ea04f..da405144b9 100755
--- a/auth/src/main/res/values-de/strings.xml
+++ b/auth/src/main/res/values-de/strings.xml
@@ -196,6 +196,5 @@
Erneut authentifizieren
- This button is currently disabled because new accounts are not allowed
Die Multi-Faktor-Authentifizierung ist derzeit deaktiviert
diff --git a/auth/src/main/res/values-el/strings.xml b/auth/src/main/res/values-el/strings.xml
index 434a11268f..19f5daef94 100755
--- a/auth/src/main/res/values-el/strings.xml
+++ b/auth/src/main/res/values-el/strings.xml
@@ -179,6 +179,5 @@
ÎŁĎξίΝιΟξ email ÎľĎιΝΎθξĎ
ĎÎˇĎ ĎĎÎż %1$s
- This button is currently disabled because new accounts are not allowed
Î ÎΝξγĎÎżĎ ĎÎąĎ
ĎĎĎΡĎÎąĎ ĎοΝΝιĎÎťĎν ĎÎąĎιγĎνĎĎν ξίνιΚ ÎąĎξνξĎγοĎοΚΡΟÎÎ˝ÎżĎ ĎĎÎżĎ ĎÎż ĎÎąĎĎν
diff --git a/auth/src/main/res/values-en-rAU/strings.xml b/auth/src/main/res/values-en-rAU/strings.xml
index 4f5b7581ee..fe9f1a0f4b 100755
--- a/auth/src/main/res/values-en-rAU/strings.xml
+++ b/auth/src/main/res/values-en-rAU/strings.xml
@@ -178,6 +178,5 @@
We sent a verification email to %1$s
- This button is currently disabled because new accounts are not allowed
Multi-factor authentication is currently disabled
diff --git a/auth/src/main/res/values-en-rCA/strings.xml b/auth/src/main/res/values-en-rCA/strings.xml
index 214a3d91f3..2c53e3eb55 100755
--- a/auth/src/main/res/values-en-rCA/strings.xml
+++ b/auth/src/main/res/values-en-rCA/strings.xml
@@ -178,6 +178,5 @@
We sent a verification email to %1$s
- This button is currently disabled because new accounts are not allowed
Multi-factor authentication is currently disabled
diff --git a/auth/src/main/res/values-en-rGB/strings.xml b/auth/src/main/res/values-en-rGB/strings.xml
index c30f71c1d1..cb667e4c5d 100755
--- a/auth/src/main/res/values-en-rGB/strings.xml
+++ b/auth/src/main/res/values-en-rGB/strings.xml
@@ -178,6 +178,5 @@
We sent a verification email to %1$s
- This button is currently disabled because new accounts are not allowed
Multi-factor authentication is currently disabled
diff --git a/auth/src/main/res/values-en-rIE/strings.xml b/auth/src/main/res/values-en-rIE/strings.xml
index 1a3b4860e1..f73f72711e 100755
--- a/auth/src/main/res/values-en-rIE/strings.xml
+++ b/auth/src/main/res/values-en-rIE/strings.xml
@@ -171,6 +171,5 @@
We sent a verification email to %1$s
- This button is currently disabled because new accounts are not allowed
Multi-factor authentication is currently disabled
diff --git a/auth/src/main/res/values-en-rIN/strings.xml b/auth/src/main/res/values-en-rIN/strings.xml
index 1a3b4860e1..f73f72711e 100755
--- a/auth/src/main/res/values-en-rIN/strings.xml
+++ b/auth/src/main/res/values-en-rIN/strings.xml
@@ -171,6 +171,5 @@
We sent a verification email to %1$s
- This button is currently disabled because new accounts are not allowed
Multi-factor authentication is currently disabled
diff --git a/auth/src/main/res/values-en-rSG/strings.xml b/auth/src/main/res/values-en-rSG/strings.xml
index 1a3b4860e1..f73f72711e 100755
--- a/auth/src/main/res/values-en-rSG/strings.xml
+++ b/auth/src/main/res/values-en-rSG/strings.xml
@@ -171,6 +171,5 @@
We sent a verification email to %1$s
- This button is currently disabled because new accounts are not allowed
Multi-factor authentication is currently disabled
diff --git a/auth/src/main/res/values-en-rZA/strings.xml b/auth/src/main/res/values-en-rZA/strings.xml
index 1a3b4860e1..f73f72711e 100755
--- a/auth/src/main/res/values-en-rZA/strings.xml
+++ b/auth/src/main/res/values-en-rZA/strings.xml
@@ -171,6 +171,5 @@
We sent a verification email to %1$s
- This button is currently disabled because new accounts are not allowed
Multi-factor authentication is currently disabled
diff --git a/auth/src/main/res/values-es-rAR/strings.xml b/auth/src/main/res/values-es-rAR/strings.xml
index 8bf944c458..f20ebae00e 100755
--- a/auth/src/main/res/values-es-rAR/strings.xml
+++ b/auth/src/main/res/values-es-rAR/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rBO/strings.xml b/auth/src/main/res/values-es-rBO/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rBO/strings.xml
+++ b/auth/src/main/res/values-es-rBO/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rCL/strings.xml b/auth/src/main/res/values-es-rCL/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rCL/strings.xml
+++ b/auth/src/main/res/values-es-rCL/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rCO/strings.xml b/auth/src/main/res/values-es-rCO/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rCO/strings.xml
+++ b/auth/src/main/res/values-es-rCO/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rCR/strings.xml b/auth/src/main/res/values-es-rCR/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rCR/strings.xml
+++ b/auth/src/main/res/values-es-rCR/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rDO/strings.xml b/auth/src/main/res/values-es-rDO/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rDO/strings.xml
+++ b/auth/src/main/res/values-es-rDO/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rEC/strings.xml b/auth/src/main/res/values-es-rEC/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rEC/strings.xml
+++ b/auth/src/main/res/values-es-rEC/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rGT/strings.xml b/auth/src/main/res/values-es-rGT/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rGT/strings.xml
+++ b/auth/src/main/res/values-es-rGT/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rHN/strings.xml b/auth/src/main/res/values-es-rHN/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rHN/strings.xml
+++ b/auth/src/main/res/values-es-rHN/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rMX/strings.xml b/auth/src/main/res/values-es-rMX/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rMX/strings.xml
+++ b/auth/src/main/res/values-es-rMX/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rNI/strings.xml b/auth/src/main/res/values-es-rNI/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rNI/strings.xml
+++ b/auth/src/main/res/values-es-rNI/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rPA/strings.xml b/auth/src/main/res/values-es-rPA/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rPA/strings.xml
+++ b/auth/src/main/res/values-es-rPA/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rPE/strings.xml b/auth/src/main/res/values-es-rPE/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rPE/strings.xml
+++ b/auth/src/main/res/values-es-rPE/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rPR/strings.xml b/auth/src/main/res/values-es-rPR/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rPR/strings.xml
+++ b/auth/src/main/res/values-es-rPR/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rPY/strings.xml b/auth/src/main/res/values-es-rPY/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rPY/strings.xml
+++ b/auth/src/main/res/values-es-rPY/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rSV/strings.xml b/auth/src/main/res/values-es-rSV/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rSV/strings.xml
+++ b/auth/src/main/res/values-es-rSV/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rUS/strings.xml b/auth/src/main/res/values-es-rUS/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rUS/strings.xml
+++ b/auth/src/main/res/values-es-rUS/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rUY/strings.xml b/auth/src/main/res/values-es-rUY/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rUY/strings.xml
+++ b/auth/src/main/res/values-es-rUY/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es-rVE/strings.xml b/auth/src/main/res/values-es-rVE/strings.xml
index 95195a514f..87af2ee76c 100755
--- a/auth/src/main/res/values-es-rVE/strings.xml
+++ b/auth/src/main/res/values-es-rVE/strings.xml
@@ -189,6 +189,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-es/strings.xml b/auth/src/main/res/values-es/strings.xml
index ba0a84efe3..f937887cce 100755
--- a/auth/src/main/res/values-es/strings.xml
+++ b/auth/src/main/res/values-es/strings.xml
@@ -196,6 +196,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
La autenticaciĂłn multifactor estĂĄ actualmente desactivada
diff --git a/auth/src/main/res/values-fa/strings.xml b/auth/src/main/res/values-fa/strings.xml
index 16f3338fc2..5d21a20e3e 100755
--- a/auth/src/main/res/values-fa/strings.xml
+++ b/auth/src/main/res/values-fa/strings.xml
@@ -179,6 +179,5 @@
اŰŮ
ŰŮ ŘŞŘŁŰŰŘŻ ب٠%1$s اعسا٠ڊعدŰŮ
- This button is currently disabled because new accounts are not allowed
اŘعاز ŮŮŰŘŞ ÚŮŘŻ Ů
ŘąŘŮŮâŘ§Ű ŘŻŘą Řا٠Řا؜ع ŘşŰŘąŮؚا٠است
diff --git a/auth/src/main/res/values-fi/strings.xml b/auth/src/main/res/values-fi/strings.xml
index 9cfb2ce03e..c12c36c378 100755
--- a/auth/src/main/res/values-fi/strings.xml
+++ b/auth/src/main/res/values-fi/strings.xml
@@ -178,6 +178,5 @@
Lähetimme vahvistussähkÜpostin osoitteeseen %1$s
- This button is currently disabled because new accounts are not allowed
Monivaiheinen todennus on tällä hetkellä poistettu käytÜstä
diff --git a/auth/src/main/res/values-fil/strings.xml b/auth/src/main/res/values-fil/strings.xml
index 1db48118c2..8e28c1bd9f 100755
--- a/auth/src/main/res/values-fil/strings.xml
+++ b/auth/src/main/res/values-fil/strings.xml
@@ -178,6 +178,5 @@
Nagpadala kami ng verification email sa %1$s
- This button is currently disabled because new accounts are not allowed
Kasalukuyang naka-disable ang multi-factor authentication
diff --git a/auth/src/main/res/values-fr-rCH/strings.xml b/auth/src/main/res/values-fr-rCH/strings.xml
index 173450749b..178fb4e2bf 100755
--- a/auth/src/main/res/values-fr-rCH/strings.xml
+++ b/auth/src/main/res/values-fr-rCH/strings.xml
@@ -190,6 +190,5 @@
Se rĂŠauthentifier
- This button is currently disabled because new accounts are not allowed
L\'authentification multifacteur est actuellement dĂŠsactivĂŠe
diff --git a/auth/src/main/res/values-fr/strings.xml b/auth/src/main/res/values-fr/strings.xml
index d92ef24e11..22f5091572 100755
--- a/auth/src/main/res/values-fr/strings.xml
+++ b/auth/src/main/res/values-fr/strings.xml
@@ -196,6 +196,5 @@
Se rĂŠauthentifier
- This button is currently disabled because new accounts are not allowed
L\'authentification multifacteur est actuellement dĂŠsactivĂŠe
diff --git a/auth/src/main/res/values-gsw/strings.xml b/auth/src/main/res/values-gsw/strings.xml
index 0b97606b8c..6accbd34c7 100755
--- a/auth/src/main/res/values-gsw/strings.xml
+++ b/auth/src/main/res/values-gsw/strings.xml
@@ -178,6 +178,5 @@
Mir hend e Verifizierigs-E-Mail an %1$s gschickt
- This button is currently disabled because new accounts are not allowed
D\'Multi-Faktor-Authentifizierig isch zurziit deaktiviert
diff --git a/auth/src/main/res/values-gu/strings.xml b/auth/src/main/res/values-gu/strings.xml
index d83ee48136..f80face1bb 100755
--- a/auth/src/main/res/values-gu/strings.xml
+++ b/auth/src/main/res/values-gu/strings.xml
@@ -179,6 +179,5 @@
ŕŞ
મૠ%1$s પર ŕŞŕŞŕŞžŕŞ¸ŕŞŁŕŤ ŕŞŕŞŽŕŤŕŞŕŞ˛ ઎ŕŤŕŞŕŞ˛ŕŤŕŞŻŕŤ
- This button is currently disabled because new accounts are not allowed
઎લŕŤŕŞŕŞż-ઍŕŤŕŞŕŤŕŞŕް પŕŤŕŞ°ŕŞŽŕŞžŕŞŁŕŤŕŞŕŞ°ŕŞŁ ચઞલ઎ઞઠŕŞ
ŕŞŕŤŕŞˇŕŞŽ ŕŞŕŤ
diff --git a/auth/src/main/res/values-hi/strings.xml b/auth/src/main/res/values-hi/strings.xml
index 53339be6e1..361aaf69a7 100755
--- a/auth/src/main/res/values-hi/strings.xml
+++ b/auth/src/main/res/values-hi/strings.xml
@@ -179,6 +179,5 @@
चऎन༠%1$s पर ŕ¤ŕ¤ सतŕĽŕ¤Żŕ¤žŕ¤Şŕ¤¨ ŕ¤ŕ¤ŽŕĽŕ¤˛ ŕ¤ŕĽŕ¤ŕ¤ž
- This button is currently disabled because new accounts are not allowed
ऎलŕĽŕ¤ŕĽ-ऍŕĽŕ¤ŕĽŕ¤ŕ¤° पŕĽŕ¤°ŕ¤Žŕ¤žŕ¤ŁŕĽŕ¤ŕ¤°ŕ¤Ł ारŕĽŕ¤¤ŕ¤Žŕ¤žŕ¤¨ ऎŕĽŕ¤ ŕ¤
ŕ¤ŕĽŕ¤ˇŕ¤Ž चŕĽ
diff --git a/auth/src/main/res/values-hr/strings.xml b/auth/src/main/res/values-hr/strings.xml
index e2516dfe01..d3db464d15 100755
--- a/auth/src/main/res/values-hr/strings.xml
+++ b/auth/src/main/res/values-hr/strings.xml
@@ -178,6 +178,5 @@
Poslali smo e-poĹĄtu za provjeru na %1$s
- This button is currently disabled because new accounts are not allowed
ViĹĄefaktorska autentifikacija trenutno je onemoguÄena
diff --git a/auth/src/main/res/values-hu/strings.xml b/auth/src/main/res/values-hu/strings.xml
index e10f495b40..7e0f61cebb 100755
--- a/auth/src/main/res/values-hu/strings.xml
+++ b/auth/src/main/res/values-hu/strings.xml
@@ -178,6 +178,5 @@
EllenĹrzĹ e-mailt kĂźldtĂźnk a kĂśvetkezĹ cĂmre: %1$s
- This button is currently disabled because new accounts are not allowed
A tĂśbbfaktoros hitelesĂtĂŠs jelenleg le van tiltva
diff --git a/auth/src/main/res/values-in/strings.xml b/auth/src/main/res/values-in/strings.xml
index cc6b134197..de450ae526 100755
--- a/auth/src/main/res/values-in/strings.xml
+++ b/auth/src/main/res/values-in/strings.xml
@@ -179,6 +179,5 @@
Kami telah mengirim email verifikasi ke %1$s
- This button is currently disabled because new accounts are not allowed
Autentikasi multifaktor saat ini dinonaktifkan
diff --git a/auth/src/main/res/values-it/strings.xml b/auth/src/main/res/values-it/strings.xml
index a0efdc6d32..5f39e4d285 100755
--- a/auth/src/main/res/values-it/strings.xml
+++ b/auth/src/main/res/values-it/strings.xml
@@ -178,6 +178,5 @@
Abbiamo inviato un\'email di verifica a %1$s
- This button is currently disabled because new accounts are not allowed
L\'autenticazione a piÚ fattori è attualmente disabilitata
diff --git a/auth/src/main/res/values-iw/strings.xml b/auth/src/main/res/values-iw/strings.xml
index 10f0db9c13..5d2864c7ff 100755
--- a/auth/src/main/res/values-iw/strings.xml
+++ b/auth/src/main/res/values-iw/strings.xml
@@ -179,6 +179,5 @@
׊××× × ×××××× ××××ת ×× %1$s
- This button is currently disabled because new accounts are not allowed
××××ת ר×-××ר×× ××׊×ת ×עת
diff --git a/auth/src/main/res/values-ja/strings.xml b/auth/src/main/res/values-ja/strings.xml
index 620c2cb119..9faa6db476 100755
--- a/auth/src/main/res/values-ja/strings.xml
+++ b/auth/src/main/res/values-ja/strings.xml
@@ -178,6 +178,5 @@
%1$s ăŤç˘şčŞăĄăźăŤăé俥ăăžăă
- This button is currently disabled because new accounts are not allowed
ĺ¤čŚç´ čŞč¨źăŻçžĺ¨çĄĺšăŤăŞăŁăŚăăžă
diff --git a/auth/src/main/res/values-kn/strings.xml b/auth/src/main/res/values-kn/strings.xml
index 12f649a315..fd3730c06f 100755
--- a/auth/src/main/res/values-kn/strings.xml
+++ b/auth/src/main/res/values-kn/strings.xml
@@ -179,6 +179,5 @@
ನಞಾೠ%1$s ŕ˛ŕł ಪರಿಜŕłŕ˛˛ŕ˛¨ŕł ŕ˛ŕ˛Žŕłŕ˛˛ŕł ŕ˛ŕ˛łŕłŕ˛šŕ˛żŕ˛¸ŕ˛żŕ˛Śŕłŕ˛Śŕłŕ˛ľŕł
- This button is currently disabled because new accounts are not allowed
ಎಲŕłŕ˛ŕ˛ż-಍ŕłŕ˛Żŕ˛žŕ˛ŕłŕ˛ŕ˛°ŕł ಌŕłŕ˛˘ŕłŕ˛ŕ˛°ŕ˛Łŕ˛ľŕł ಪŕłŕ˛°ŕ˛¸ŕłŕ˛¤ŕłŕ˛¤ ನಿಡŕłŕ˛ŕłŕ˛°ŕ˛żŕ˛Żŕ˛ŕłŕ˛ŕ˛Ąŕ˛żŕ˛Śŕł
diff --git a/auth/src/main/res/values-ko/strings.xml b/auth/src/main/res/values-ko/strings.xml
index d829ffe62e..574d81fe77 100755
--- a/auth/src/main/res/values-ko/strings.xml
+++ b/auth/src/main/res/values-ko/strings.xml
@@ -177,6 +177,5 @@
%1$s(ěź)ëĄ íě¸ ě´ëŠěźě ëł´ëěľëë¤
- This button is currently disabled because new accounts are not allowed
ë¤ë¨ęł ě¸ěŚě´ íěŹ ëšíěąíëě´ ěěľëë¤
diff --git a/auth/src/main/res/values-ln/strings.xml b/auth/src/main/res/values-ln/strings.xml
index 6304e1f104..832335a0d8 100755
--- a/auth/src/main/res/values-ln/strings.xml
+++ b/auth/src/main/res/values-ln/strings.xml
@@ -179,6 +179,5 @@
Totindi e-mail ya vĂŠrification na %1$s
- This button is currently disabled because new accounts are not allowed
Bondimisami ya makambo mingi ezali sikoyo te
diff --git a/auth/src/main/res/values-lt/strings.xml b/auth/src/main/res/values-lt/strings.xml
index b08c4dbb1e..1c5270c7b5 100755
--- a/auth/src/main/res/values-lt/strings.xml
+++ b/auth/src/main/res/values-lt/strings.xml
@@ -179,6 +179,5 @@
IĹĄsiuntÄme patvirtinimo el. laiĹĄkÄ
adresu %1$s
- This button is currently disabled because new accounts are not allowed
Daugiafaktoris tapatybÄs nustatymas ĹĄiuo metu iĹĄjungtas
diff --git a/auth/src/main/res/values-lv/strings.xml b/auth/src/main/res/values-lv/strings.xml
index 21aa5fc1d6..6e2fa60974 100755
--- a/auth/src/main/res/values-lv/strings.xml
+++ b/auth/src/main/res/values-lv/strings.xml
@@ -179,6 +179,5 @@
NosĹŤtÄŤjÄm verifikÄcijas e-pastu uz %1$s
- This button is currently disabled because new accounts are not allowed
Daudzfaktoru autentifikÄcija paĹĄlaik ir atspÄjota
diff --git a/auth/src/main/res/values-mo/strings.xml b/auth/src/main/res/values-mo/strings.xml
index 717eb65ca6..4d9f9fef42 100755
--- a/auth/src/main/res/values-mo/strings.xml
+++ b/auth/src/main/res/values-mo/strings.xml
@@ -179,6 +179,5 @@
Am trimis un e-mail de verificare la %1$s
- This button is currently disabled because new accounts are not allowed
Autentificarea cu mai mulČi factori este dezactivatÄ ĂŽn prezent
diff --git a/auth/src/main/res/values-mr/strings.xml b/auth/src/main/res/values-mr/strings.xml
index c95fef12a6..d4075f456a 100755
--- a/auth/src/main/res/values-mr/strings.xml
+++ b/auth/src/main/res/values-mr/strings.xml
@@ -179,6 +179,5 @@
ŕ¤ŕ¤ŽŕĽŕ¤šŕĽ %1$s ार सतŕĽŕ¤Żŕ¤žŕ¤Şŕ¤¨ ŕ¤ŕ¤ŽŕĽŕ¤˛ पञठालञ
- This button is currently disabled because new accounts are not allowed
ऎलŕĽŕ¤ŕĽ-ऍŕĽ
ŕ¤ŕĽŕ¤ŕ¤° ŕ¤ŕ¤ĽŕĽŕ¤ŕ¤ŕ¤żŕ¤ŕĽŕ¤śŕ¤¨ सधŕĽŕ¤Żŕ¤ž ŕ¤
ŕ¤ŕĽŕ¤ˇŕ¤Ž ŕ¤ŕ¤šŕĽ
diff --git a/auth/src/main/res/values-ms/strings.xml b/auth/src/main/res/values-ms/strings.xml
index e35e224762..55876519cb 100755
--- a/auth/src/main/res/values-ms/strings.xml
+++ b/auth/src/main/res/values-ms/strings.xml
@@ -179,6 +179,5 @@
Kami menghantar e-mel pengesahan ke %1$s
- This button is currently disabled because new accounts are not allowed
Pengesahan berbilang faktor dilumpuhkan buat masa ini
diff --git a/auth/src/main/res/values-nb/strings.xml b/auth/src/main/res/values-nb/strings.xml
index 5018c19528..4ea4f79c19 100755
--- a/auth/src/main/res/values-nb/strings.xml
+++ b/auth/src/main/res/values-nb/strings.xml
@@ -178,6 +178,5 @@
Vi sendte en bekreftelsese-post til %1$s
- This button is currently disabled because new accounts are not allowed
Flerfaktorautentisering er for øyeblikket deaktivert
diff --git a/auth/src/main/res/values-nl/strings.xml b/auth/src/main/res/values-nl/strings.xml
index 328497cf50..c71fc63c31 100755
--- a/auth/src/main/res/values-nl/strings.xml
+++ b/auth/src/main/res/values-nl/strings.xml
@@ -178,6 +178,5 @@
We hebben een verificatie-e-mail verzonden naar %1$s
- This button is currently disabled because new accounts are not allowed
Multi-factorauthenticatie is momenteel uitgeschakeld
diff --git a/auth/src/main/res/values-no/strings.xml b/auth/src/main/res/values-no/strings.xml
index 7833bccd9f..3e3fe493ae 100755
--- a/auth/src/main/res/values-no/strings.xml
+++ b/auth/src/main/res/values-no/strings.xml
@@ -179,6 +179,5 @@
Vi sendte en bekreftelsese-post til %1$s
- This button is currently disabled because new accounts are not allowed
Flerfaktorautentisering er for øyeblikket deaktivert
diff --git a/auth/src/main/res/values-pl/strings.xml b/auth/src/main/res/values-pl/strings.xml
index 9c4bcadbfa..8bcb36d6a2 100755
--- a/auth/src/main/res/values-pl/strings.xml
+++ b/auth/src/main/res/values-pl/strings.xml
@@ -178,6 +178,5 @@
WysĹaliĹmy e-mail weryfikacyjny na adres %1$s
- This button is currently disabled because new accounts are not allowed
Uwierzytelnianie wieloskĹadnikowe jest obecnie wyĹÄ
czone
diff --git a/auth/src/main/res/values-pt-rBR/strings.xml b/auth/src/main/res/values-pt-rBR/strings.xml
index f576f884ca..38a96c93fc 100755
--- a/auth/src/main/res/values-pt-rBR/strings.xml
+++ b/auth/src/main/res/values-pt-rBR/strings.xml
@@ -197,6 +197,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
A autenticação multifator estå atualmente desativada
diff --git a/auth/src/main/res/values-pt-rPT/strings.xml b/auth/src/main/res/values-pt-rPT/strings.xml
index bc6c216d4c..36f1ed7142 100755
--- a/auth/src/main/res/values-pt-rPT/strings.xml
+++ b/auth/src/main/res/values-pt-rPT/strings.xml
@@ -197,6 +197,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
A autenticação multifator estå atualmente desativada
diff --git a/auth/src/main/res/values-pt/strings.xml b/auth/src/main/res/values-pt/strings.xml
index fbd9e7b373..0541291cc6 100755
--- a/auth/src/main/res/values-pt/strings.xml
+++ b/auth/src/main/res/values-pt/strings.xml
@@ -196,6 +196,5 @@
Reautenticar
- This button is currently disabled because new accounts are not allowed
A autenticação multifator estå atualmente desativada
diff --git a/auth/src/main/res/values-ro/strings.xml b/auth/src/main/res/values-ro/strings.xml
index 1b5b8cafb0..bdd7bece44 100755
--- a/auth/src/main/res/values-ro/strings.xml
+++ b/auth/src/main/res/values-ro/strings.xml
@@ -178,6 +178,5 @@
Am trimis un e-mail de verificare la %1$s
- This button is currently disabled because new accounts are not allowed
Autentificarea cu mai mulČi factori este dezactivatÄ ĂŽn prezent
diff --git a/auth/src/main/res/values-ru/strings.xml b/auth/src/main/res/values-ru/strings.xml
index e2d654da92..c68934e03c 100755
--- a/auth/src/main/res/values-ru/strings.xml
+++ b/auth/src/main/res/values-ru/strings.xml
@@ -178,6 +178,5 @@
ĐŃ ĐžŃĐżŃавиНи пиŃŃПО пОдŃвоŃĐśĐ´ĐľĐ˝Đ¸Ń Đ˝Đ° %1$s
- This button is currently disabled because new accounts are not allowed
ĐнОгОŃакŃĐžŃĐ˝Đ°Ń Đ°ŃŃонŃиŃикаŃĐ¸Ń Đ˛ наŃŃĐžŃŃоо вŃĐľĐźŃ ĐžŃкНŃŃона
diff --git a/auth/src/main/res/values-sk/strings.xml b/auth/src/main/res/values-sk/strings.xml
index 49c93e6e10..52f1b1845f 100755
--- a/auth/src/main/res/values-sk/strings.xml
+++ b/auth/src/main/res/values-sk/strings.xml
@@ -178,6 +178,5 @@
Poslali sme overovacĂ e-mail na adresu %1$s
- This button is currently disabled because new accounts are not allowed
ViacfaktorovĂŠ overovanie je momentĂĄlne zakĂĄzanĂŠ
diff --git a/auth/src/main/res/values-sl/strings.xml b/auth/src/main/res/values-sl/strings.xml
index be839d41b6..fc81a8e4a8 100755
--- a/auth/src/main/res/values-sl/strings.xml
+++ b/auth/src/main/res/values-sl/strings.xml
@@ -179,6 +179,5 @@
Poslali smo e-sporoÄilo za preverjanje na %1$s
- This button is currently disabled because new accounts are not allowed
VeÄfaktorska avtentikacija je trenutno onemogoÄena
diff --git a/auth/src/main/res/values-sr/strings.xml b/auth/src/main/res/values-sr/strings.xml
index be4fd78b24..24ecfc2084 100755
--- a/auth/src/main/res/values-sr/strings.xml
+++ b/auth/src/main/res/values-sr/strings.xml
@@ -179,6 +179,5 @@
ĐĐžŃНаНи ŃПО иПоŃĐť Са воŃиŃикаŃиŃŃ Đ˝Đ° %1$s
- This button is currently disabled because new accounts are not allowed
ĐиŃĐľŃакŃĐžŃŃка аŃŃонŃиŃикаŃиŃа ŃĐľ ŃŃонŃŃнО ОноПОгŃŃона
diff --git a/auth/src/main/res/values-sv/strings.xml b/auth/src/main/res/values-sv/strings.xml
index 616d43dd21..b32888d051 100755
--- a/auth/src/main/res/values-sv/strings.xml
+++ b/auth/src/main/res/values-sv/strings.xml
@@ -178,6 +178,5 @@
Vi har skickat ett verifieringsmail till %1$s
- This button is currently disabled because new accounts are not allowed
Multifaktorautentisering är fÜr närvarande inaktiverad
diff --git a/auth/src/main/res/values-ta/strings.xml b/auth/src/main/res/values-ta/strings.xml
index 378ced01fb..c81b3054c4 100755
--- a/auth/src/main/res/values-ta/strings.xml
+++ b/auth/src/main/res/values-ta/strings.xml
@@ -179,6 +179,5 @@
%1$s ŕŽŕŻŕŽŕŻ ŕŽŕŽ°ŕŽżŕŽŞŕŽžŕŽ°ŕŻŕŽŞŕŻŕŽŞŕŻ ŕŽŽŕŽżŕŽŠŕŻŕŽŠŕŽŕŻŕŽŕŽ˛ŕŻ ŕŽ
னŕŻŕŽŞŕŻŕŽŞŕŽżŕŽŻŕŻŕŽłŕŻŕŽłŕŻŕŽŽŕŻ
- This button is currently disabled because new accounts are not allowed
பல-ŕŽŕŽžŕŽ°ŕŽŁŕŽż ŕŽ
ŕŽŕŻŕŽŕŻŕŽŕŽžŕŽ°ŕŽŽŕŻ ŕŽ¤ŕŽąŕŻŕŽŞŕŻŕŽ¤ŕŻ ŕŽŽŕŻŕŽŕŽŕŻŕŽŕŽŞŕŻŕŽŞŕŽŕŻŕŽŕŻŕŽłŕŻŕŽłŕŽ¤ŕŻ
diff --git a/auth/src/main/res/values-th/strings.xml b/auth/src/main/res/values-th/strings.xml
index 859b5fc362..ec9196f9f2 100755
--- a/auth/src/main/res/values-th/strings.xml
+++ b/auth/src/main/res/values-th/strings.xml
@@ -179,6 +179,5 @@
ŕšŕ¸Łŕ¸˛ŕ¸Şŕšŕ¸ŕ¸ŕ¸ľŕšŕ¸Ąŕ¸Ľŕ¸˘ŕ¸ˇŕ¸ŕ¸˘ŕ¸ąŕ¸ŕšŕ¸ŕ¸ŕ¸ľŕš %1$s ŕšŕ¸Ľŕšŕ¸§
- This button is currently disabled because new accounts are not allowed
ŕ¸ŕ¸˛ŕ¸Łŕ¸Łŕ¸ąŕ¸ŕ¸Łŕ¸ŕ¸ŕ¸ŕ¸§ŕ¸˛ŕ¸Ąŕ¸ŕ¸šŕ¸ŕ¸ŕšŕ¸ŕ¸ŕšŕ¸ŕ¸ŕ¸Ťŕ¸Ľŕ¸˛ŕ¸˘ŕ¸ŕ¸ąŕ¸ŕ¸ŕ¸ąŕ¸˘ŕ¸ŕ¸šŕ¸ŕ¸ŕ¸´ŕ¸ŕšŕ¸ŕšŕ¸ŕ¸˛ŕ¸ŕšŕ¸ŕ¸ŕ¸ŕ¸°ŕ¸ŕ¸ľŕš
diff --git a/auth/src/main/res/values-tl/strings.xml b/auth/src/main/res/values-tl/strings.xml
index e5f5b11e53..ccd438d35a 100755
--- a/auth/src/main/res/values-tl/strings.xml
+++ b/auth/src/main/res/values-tl/strings.xml
@@ -178,6 +178,5 @@
Nagpadala kami ng verification email sa %1$s
- This button is currently disabled because new accounts are not allowed
Kasalukuyang naka-disable ang multi-factor authentication
diff --git a/auth/src/main/res/values-tr/strings.xml b/auth/src/main/res/values-tr/strings.xml
index 7885d361ed..bbefc69177 100755
--- a/auth/src/main/res/values-tr/strings.xml
+++ b/auth/src/main/res/values-tr/strings.xml
@@ -179,6 +179,5 @@
%1$s adresine bir doÄrulama e-postasÄą gĂśnderdik
- This button is currently disabled because new accounts are not allowed
Ăok faktĂśrlĂź kimlik doÄrulama Ĺu anda devre dÄąĹÄą
diff --git a/auth/src/main/res/values-uk/strings.xml b/auth/src/main/res/values-uk/strings.xml
index 5c99137d3e..1fad8a98d4 100755
--- a/auth/src/main/res/values-uk/strings.xml
+++ b/auth/src/main/res/values-uk/strings.xml
@@ -179,6 +179,5 @@
Đи надŃŃНаНи НиŃŃ ĐżŃĐ´ŃвоŃĐ´ĐśĐľĐ˝Đ˝Ń Đ˝Đ° %1$s
- This button is currently disabled because new accounts are not allowed
ĐагаŃĐžŃакŃĐžŃна авŃонŃиŃŃкаŃŃŃ Đ˝Đ°ŃĐ°ĐˇŃ Đ˛Đ¸ĐźĐşĐ˝ĐľĐ˝Đ°
diff --git a/auth/src/main/res/values-ur/strings.xml b/auth/src/main/res/values-ur/strings.xml
index f73956b291..1394f2c1f8 100755
--- a/auth/src/main/res/values-ur/strings.xml
+++ b/auth/src/main/res/values-ur/strings.xml
@@ -179,6 +179,5 @@
ŰŮ
ŮŰ %1$s ڊ٠تؾدŰŮŰ Ř§Ű Ů
Ű٠بڞŰŘŹŰ
- This button is currently disabled because new accounts are not allowed
Ů
ŮŮšŰ ŮŰڊٚع تؾدŰŮ ŮŰ Ř§ŮŘا٠غŰŘą Ůؚا٠ŰŰ
diff --git a/auth/src/main/res/values-vi/strings.xml b/auth/src/main/res/values-vi/strings.xml
index e77e08e6d3..53266567bf 100755
--- a/auth/src/main/res/values-vi/strings.xml
+++ b/auth/src/main/res/values-vi/strings.xml
@@ -179,6 +179,5 @@
ChĂşng tĂ´i ÄĂŁ gáťi email xĂĄc minh Äáşżn %1$s
- This button is currently disabled because new accounts are not allowed
XĂĄc tháťąc Äa yáşżu táť hiáťn Äang báť vĂ´ hiáťu hĂła
diff --git a/auth/src/main/res/values-zh-rCN/strings.xml b/auth/src/main/res/values-zh-rCN/strings.xml
index a4b5e61fff..61721caa58 100755
--- a/auth/src/main/res/values-zh-rCN/strings.xml
+++ b/auth/src/main/res/values-zh-rCN/strings.xml
@@ -179,6 +179,5 @@
ć䝏塲ĺ %1$s ĺéäşéŞčŻéŽäťś
- This button is currently disabled because new accounts are not allowed
ĺ¤é躍䝽éŞčŻĺ˝ĺ塲çŚç¨
diff --git a/auth/src/main/res/values-zh-rHK/strings.xml b/auth/src/main/res/values-zh-rHK/strings.xml
index f002ae7b6f..3cd99ad3bc 100755
--- a/auth/src/main/res/values-zh-rHK/strings.xml
+++ b/auth/src/main/res/values-zh-rHK/strings.xml
@@ -179,6 +179,5 @@
ćĺ塲ĺ %1$s çźéäşéŠčéťéľ
- This button is currently disabled because new accounts are not allowed
ĺ¤é躍䝽éŞčŻĺ˝ĺ塲çŚç¨
diff --git a/auth/src/main/res/values-zh-rTW/strings.xml b/auth/src/main/res/values-zh-rTW/strings.xml
index 094052f939..fd247fb40f 100755
--- a/auth/src/main/res/values-zh-rTW/strings.xml
+++ b/auth/src/main/res/values-zh-rTW/strings.xml
@@ -179,6 +179,5 @@
ćĺ塲ĺłééŠčéľäťśčł %1$s
- This button is currently disabled because new accounts are not allowed
ĺ¤é躍䝽éŞčŻĺ˝ĺ塲çŚç¨
diff --git a/auth/src/main/res/values-zh/strings.xml b/auth/src/main/res/values-zh/strings.xml
index 55f8cb92b8..a1f514f8f7 100755
--- a/auth/src/main/res/values-zh/strings.xml
+++ b/auth/src/main/res/values-zh/strings.xml
@@ -178,6 +178,5 @@
ć䝏塲ĺ %1$s ĺéäşéŞčŻéŽäťś
- This button is currently disabled because new accounts are not allowed
ĺ¤é躍䝽éŞčŻĺ˝ĺ塲çŚç¨
diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml
index ad2e50279e..6217412de9 100644
--- a/auth/src/main/res/values/strings.xml
+++ b/auth/src/main/res/values/strings.xml
@@ -284,6 +284,5 @@
An error occurred during enrollment. Please try again.
- This button is currently disabled because new accounts are not allowed
Multi-factor authentication is currently disabled
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 a949994397..5b2ee24b09 100644
--- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt
@@ -153,6 +153,16 @@ class FirebaseAuthActivityTest {
assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_CANCELED)
}
+ @Test
+ fun `activity sets filterTouchesWhenObscured on window after onCreate`() {
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration)
+ val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+
+ val activity = controller.create().get()
+
+ assertThat(activity.window.decorView.filterTouchesWhenObscured).isTrue()
+ }
+
// =============================================================================================
// Configuration Extraction Tests
// =============================================================================================
diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt
index 8a4715c971..78e7f0dd3e 100644
--- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt
@@ -318,6 +318,53 @@ class FirebaseAuthUIAuthStateTest {
assertThat(states[2]).isEqualTo(AuthState.Cancelled) // After second update
}
+ // =============================================================================================
+ // Stale one-off AuthState regression tests
+ // =============================================================================================
+
+ @Test
+ fun `Error does not leak to a fresh collector after being consumed`() = runBlocking {
+ `when`(mockFirebaseAuth.currentUser).thenReturn(null)
+
+ authUI.updateAuthState(AuthState.Error(Exception("boom")))
+ authUI.updateAuthState(AuthState.Idle)
+
+ // A brand-new collector (simulating a freshly created Activity) must see Idle.
+ assertThat(authUI.authStateFlow().first()).isEqualTo(AuthState.Idle)
+ }
+
+ @Test
+ fun `Cancelled does not leak to a fresh collector after being consumed`() = runBlocking {
+ `when`(mockFirebaseAuth.currentUser).thenReturn(null)
+
+ authUI.updateAuthState(AuthState.Cancelled)
+ authUI.updateAuthState(AuthState.Idle)
+
+ assertThat(authUI.authStateFlow().first()).isEqualTo(AuthState.Idle)
+ }
+
+ @Test
+ fun `SMSAutoVerified does not leak to a fresh collector after being consumed`() = runBlocking {
+ `when`(mockFirebaseAuth.currentUser).thenReturn(null)
+ val credential = mock(com.google.firebase.auth.PhoneAuthCredential::class.java)
+
+ authUI.updateAuthState(AuthState.SMSAutoVerified(credential))
+ authUI.updateAuthState(AuthState.Idle)
+
+ assertThat(authUI.authStateFlow().first()).isEqualTo(AuthState.Idle)
+ }
+
+ @Test
+ fun `Error left uncleared still leaks to a fresh collector (pins down the bug being fixed)`() =
+ runBlocking {
+ `when`(mockFirebaseAuth.currentUser).thenReturn(null)
+
+ // No consuming reset here â documents the pre-fix leaking behavior.
+ authUI.updateAuthState(AuthState.Error(Exception("boom")))
+
+ assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.Error::class.java)
+ }
+
// =============================================================================================
// AuthState Class Tests
// =============================================================================================
diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/theme/AuthUIThemeTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/theme/AuthUIThemeTest.kt
index 46e6a1dc80..caa6f90225 100644
--- a/auth/src/test/java/com/firebase/ui/auth/configuration/theme/AuthUIThemeTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/configuration/theme/AuthUIThemeTest.kt
@@ -7,6 +7,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarColors
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
@@ -313,6 +315,80 @@ class AuthUIThemeTest {
assertThat(observedProviderStyles?.get("google.com")?.backgroundColor).isEqualTo(Color.Red)
}
+ // ========================================================================
+ // topAppBarColors Tests
+ // ========================================================================
+
+ @Test
+ fun `Default theme has null topAppBarColors`() {
+ assertThat(AuthUITheme.Default.topAppBarColors).isNull()
+ }
+
+ @Test
+ fun `Copy with custom topAppBarColors applies correctly`() {
+ lateinit var customColors: TopAppBarColors
+ var observedColors: TopAppBarColors? = null
+
+ composeTestRule.setContent {
+ customColors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Red,
+ titleContentColor = Color.White,
+ navigationIconContentColor = Color.White,
+ )
+ val customTheme = AuthUITheme.Default.copy(topAppBarColors = customColors)
+
+ CompositionLocalProvider(
+ LocalAuthUITheme provides customTheme
+ ) {
+ observedColors = LocalAuthUITheme.current.topAppBarColors
+ }
+ }
+
+ composeTestRule.waitForIdle()
+
+ assertThat(observedColors).isEqualTo(customColors)
+ }
+
+ @Test
+ fun `resolvedTopAppBarColors falls back to default when theme value is null`() {
+ var resolvedColors: TopAppBarColors? = null
+ var defaultColors: TopAppBarColors? = null
+
+ composeTestRule.setContent {
+ AuthUITheme(theme = AuthUITheme.Default) {
+ defaultColors = AuthUITheme.topAppBarColors
+ resolvedColors = AuthUITheme.resolvedTopAppBarColors
+ }
+ }
+
+ composeTestRule.waitForIdle()
+
+ assertThat(resolvedColors).isEqualTo(defaultColors)
+ }
+
+ @Test
+ fun `resolvedTopAppBarColors uses the theme's custom value when set`() {
+ lateinit var customColors: TopAppBarColors
+ var resolvedColors: TopAppBarColors? = null
+
+ composeTestRule.setContent {
+ customColors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Red,
+ titleContentColor = Color.White,
+ navigationIconContentColor = Color.White,
+ )
+ val customTheme = AuthUITheme.Default.copy(topAppBarColors = customColors)
+
+ AuthUITheme(theme = customTheme) {
+ resolvedColors = AuthUITheme.resolvedTopAppBarColors
+ }
+ }
+
+ composeTestRule.waitForIdle()
+
+ assertThat(resolvedColors).isEqualTo(customColors)
+ }
+
// ========================================================================
// fromMaterialTheme Tests
// ========================================================================
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/components/TopLevelDialogControllerTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/components/TopLevelDialogControllerTest.kt
new file mode 100644
index 0000000000..60fa0ba406
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/components/TopLevelDialogControllerTest.kt
@@ -0,0 +1,153 @@
+package com.firebase.ui.auth.ui.components
+
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.core.app.ApplicationProvider
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Unit tests for [TopLevelDialogController] and [rememberTopLevelDialogController].
+ *
+ * These cover the fix for a bug where keying `remember` on the live `authState` value recreated
+ * the controller (and wiped its `shownErrorStates` de-duplication set) on every state change â
+ * which, combined with screens resetting `AuthState` back to `Idle` immediately after consuming
+ * an `Error`, would tear down and discard the just-shown dialog on the very next recomposition.
+ *
+ * @suppress Internal test class
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE, sdk = [34])
+class TopLevelDialogControllerTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private lateinit var stringProvider: DefaultAuthUIStringProvider
+
+ @Test
+ fun `controller survives an authState change instead of being recreated`() {
+ stringProvider = DefaultAuthUIStringProvider(ApplicationProvider.getApplicationContext())
+ var state: AuthState = AuthState.Idle
+ lateinit var controller: TopLevelDialogController
+
+ composeTestRule.setContent {
+ controller = rememberTopLevelDialogController(stringProvider) { state }
+ controller.CurrentDialog()
+ }
+
+ val error = AuthState.Error(Exception("boom"))
+ composeTestRule.runOnIdle {
+ state = error
+ controller.showErrorDialog(
+ exception = AuthException.from(error.exception, stringProvider)
+ )
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists()
+
+ // Mirrors the fixed screens resetting authState right after showing the dialog.
+ composeTestRule.runOnIdle {
+ state = AuthState.Idle
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists()
+ }
+
+ @Test
+ fun `showErrorDialog does not re-show the same error state twice`() {
+ stringProvider = DefaultAuthUIStringProvider(ApplicationProvider.getApplicationContext())
+ var state: AuthState = AuthState.Idle
+ lateinit var controller: TopLevelDialogController
+
+ composeTestRule.setContent {
+ controller = rememberTopLevelDialogController(stringProvider) { state }
+ controller.CurrentDialog()
+ }
+
+ val error = AuthState.Error(Exception("boom"))
+ val exception = AuthException.from(error.exception, stringProvider)
+
+ composeTestRule.runOnIdle {
+ state = error
+ controller.showErrorDialog(exception = exception)
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists()
+
+ composeTestRule.runOnIdle {
+ controller.dismissDialog()
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertDoesNotExist()
+
+ // Same Error instance again â must be a no-op, the de-dup set persists across calls.
+ composeTestRule.runOnIdle {
+ controller.showErrorDialog(exception = exception)
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertDoesNotExist()
+ }
+
+ @Test
+ fun `second observer of the same error does not overwrite the first observer's dialog`() {
+ stringProvider = DefaultAuthUIStringProvider(ApplicationProvider.getApplicationContext())
+ var state: AuthState = AuthState.Idle
+ lateinit var controller: TopLevelDialogController
+
+ composeTestRule.setContent {
+ controller = rememberTopLevelDialogController(stringProvider) { state }
+ controller.CurrentDialog()
+ }
+
+ val error = AuthState.Error(Exception("boom"))
+ val exception = AuthException.from(error.exception, stringProvider)
+
+ var firstOnRetryCalled = false
+ var secondOnRetryCalled = false
+
+ // Observer #1 (e.g. FirebaseAuthScreen's top-level effect): sees the Error, shows the
+ // dialog passing the specific errorState, then immediately resets the live state to
+ // Idle -- mirroring the real screens' consume-then-reset pattern.
+ composeTestRule.runOnIdle {
+ state = error
+ controller.showErrorDialog(
+ exception = exception,
+ errorState = error,
+ onRetry = { firstOnRetryCalled = true }
+ )
+ state = AuthState.Idle
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists()
+
+ // Observer #2 (e.g. EmailAuthScreen's own effect on the same authState emission): its
+ // LaunchedEffect(authState) still holds the same `error` instance locally, so it passes
+ // it as errorState even though the controller's live currentAuthState() now reads Idle.
+ composeTestRule.runOnIdle {
+ controller.showErrorDialog(
+ exception = exception,
+ errorState = error,
+ onRetry = { secondOnRetryCalled = true }
+ )
+ }
+ composeTestRule.waitForIdle()
+
+ // Dedup must key off the passed-in errorState, not the (possibly already-reset) live
+ // state, so observer #2's call is a no-op and observer #1's dialog/callback survives.
+ composeTestRule.onNodeWithText(stringProvider.retryAction).performClick()
+ assert(firstOnRetryCalled && !secondOnRetryCalled) {
+ "Expected observer #1's dialog/callback to survive untouched, but observer #2's " +
+ "call overwrote it (firstOnRetryCalled=$firstOnRetryCalled, " +
+ "secondOnRetryCalled=$secondOnRetryCalled)"
+ }
+ }
+}
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenErrorNavigationTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenErrorNavigationTest.kt
new file mode 100644
index 0000000000..ab3c05270b
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenErrorNavigationTest.kt
@@ -0,0 +1,152 @@
+/*
+ * 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 androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.core.app.ApplicationProvider
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.google.firebase.FirebaseApp
+import com.google.firebase.FirebaseOptions
+import com.google.firebase.auth.FirebaseAuth
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.MockitoAnnotations
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Covers a regression surfaced by code review on the stale-AuthState-reset fix: with multiple
+ * providers configured, an Error occurring on a provider screen (e.g. Email) must not bounce the
+ * user back to the method picker once the error dialog is consumed.
+ *
+ * @suppress Internal test class
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE, sdk = [34])
+class FirebaseAuthScreenErrorNavigationTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Mock
+ private lateinit var mockFirebaseAuth: FirebaseAuth
+
+ private lateinit var authUI: FirebaseAuthUI
+ private lateinit var stringProvider: DefaultAuthUIStringProvider
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.openMocks(this)
+
+ FirebaseAuthUI.clearInstanceCache()
+
+ val context = ApplicationProvider.getApplicationContext()
+ FirebaseApp.getApps(context).forEach { app -> app.delete() }
+
+ val defaultApp = FirebaseApp.initializeApp(
+ context,
+ FirebaseOptions.Builder()
+ .setApiKey("fake-api-key")
+ .setApplicationId("fake-app-id")
+ .setProjectId("fake-project-id")
+ .build()
+ )!!
+
+ `when`(mockFirebaseAuth.app).thenReturn(defaultApp)
+
+ authUI = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth)
+ stringProvider = DefaultAuthUIStringProvider(context)
+ }
+
+ @After
+ fun tearDown() {
+ FirebaseAuthUI.clearInstanceCache()
+
+ val context = ApplicationProvider.getApplicationContext()
+ FirebaseApp.getApps(context).forEach { app -> app.delete() }
+ }
+
+ @Test
+ fun `error on email screen with multiple providers does not navigate back to method picker`() {
+ val configuration = authUIConfiguration {
+ context = ApplicationProvider.getApplicationContext()
+ 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 = {},
+ )
+ }
+
+ // Navigate from the method picker into the Email screen.
+ composeTestRule.onNodeWithText(stringProvider.signInWithEmail)
+ .assertIsDisplayed()
+ .performClick()
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText(stringProvider.signInDefault)
+ .assertIsDisplayed()
+
+ // Trigger a plain error while on the Email screen.
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(AuthState.Error(Exception("boom")))
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText(stringProvider.errorDialogTitle)
+ .assertIsDisplayed()
+
+ // Dismiss the dialog.
+ composeTestRule.onNodeWithText(stringProvider.dismissAction)
+ .performClick()
+ composeTestRule.waitForIdle()
+
+ // We must still be on the Email screen, not bounced back to the method picker.
+ composeTestRule.onNodeWithText(stringProvider.signInDefault)
+ .assertIsDisplayed()
+ composeTestRule.onNodeWithText(stringProvider.signInWithEmail)
+ .assertIsNotDisplayed()
+ }
+}
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt
new file mode 100644
index 0000000000..3bd40b643c
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt
@@ -0,0 +1,146 @@
+/*
+ * 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 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.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.core.app.ApplicationProvider
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.google.firebase.FirebaseApp
+import com.google.firebase.FirebaseOptions
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.UserInfo
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.`when`
+import org.mockito.MockitoAnnotations
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE, sdk = [34])
+class FirebaseAuthScreenReauthIdleResetTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Mock
+ private lateinit var mockFirebaseAuth: FirebaseAuth
+
+ private lateinit var authUI: FirebaseAuthUI
+ private lateinit var stringProvider: DefaultAuthUIStringProvider
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.openMocks(this)
+
+ FirebaseAuthUI.clearInstanceCache()
+
+ val context = ApplicationProvider.getApplicationContext()
+ FirebaseApp.getApps(context).forEach { app -> app.delete() }
+
+ val defaultApp = FirebaseApp.initializeApp(
+ context,
+ FirebaseOptions.Builder()
+ .setApiKey("fake-api-key")
+ .setApplicationId("fake-app-id")
+ .setProjectId("fake-project-id")
+ .build()
+ )!!
+
+ `when`(mockFirebaseAuth.app).thenReturn(defaultApp)
+
+ authUI = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth)
+ stringProvider = DefaultAuthUIStringProvider(context)
+ }
+
+ @After
+ fun tearDown() {
+ FirebaseAuthUI.clearInstanceCache()
+
+ val context = ApplicationProvider.getApplicationContext()
+ FirebaseApp.getApps(context).forEach { app -> app.delete() }
+ }
+
+ @Test
+ fun `wrong password error during reauth does not dismiss the reauth sheet`() {
+ val mockProviderInfo = mock(UserInfo::class.java)
+ `when`(mockProviderInfo.providerId).thenReturn("password")
+ val mockUser = mock(FirebaseUser::class.java)
+ `when`(mockUser.providerData).thenReturn(listOf(mockProviderInfo))
+
+ val configuration = authUIConfiguration {
+ context = ApplicationProvider.getApplicationContext()
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ }
+
+ composeTestRule.setContent {
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ reauthContent = { _, _ ->
+ Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker"))
+ }
+ )
+ }
+
+ // Enter the reauth flow.
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(AuthState.ReauthenticationRequired(mockUser))
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("reauth_marker").assertIsDisplayed()
+
+ // Wrong password entered inside the reauth flow surfaces an Error on the same authUI.
+ composeTestRule.runOnIdle {
+ authUI.updateAuthState(AuthState.Error(Exception("wrong password")))
+ }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertIsDisplayed()
+
+ // Dismiss the error dialog, which self-consumes the Error back to Idle.
+ composeTestRule.onNodeWithText(stringProvider.dismissAction).performClick()
+ composeTestRule.waitForIdle()
+
+ // The reauth sheet must survive the notification-consume Idle.
+ composeTestRule.onNodeWithTag("reauth_marker").assertIsDisplayed()
+ }
+}
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt
index 0c3836ace8..6273b32b4c 100644
--- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt
@@ -15,6 +15,7 @@
package com.firebase.ui.auth.ui.screens
import android.content.Context
+import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
@@ -22,16 +23,22 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.core.app.ApplicationProvider
+import com.firebase.ui.auth.AuthState
import com.firebase.ui.auth.FirebaseAuthUI
import com.firebase.ui.auth.configuration.authUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration
import com.google.firebase.FirebaseApp
import com.google.firebase.FirebaseOptions
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.UserInfo
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.`when`
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@@ -133,6 +140,115 @@ class FirebaseAuthScreenSlotsTest {
composeTestRule.onNodeWithTag("AuthMethodPicker LazyColumn").assertIsDisplayed()
}
+ @Test
+ fun `customMethodPickerLayout provided hides the default method picker chrome`() {
+ 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()
+ // AuthMethodPicker (and with it, the logo/ToS footer it renders) must not exist at all â
+ // customMethodPickerLayout now takes over the entire screen, it doesn't sit alongside them.
+ composeTestRule.onNodeWithTag("AuthMethodPicker LazyColumn").assertDoesNotExist()
+ }
+
+ @Test
+ fun `customMethodPickerTermsConfiguration is ignored when customMethodPickerLayout is 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")
+ )
+ },
+ customMethodPickerTermsConfiguration = MethodPickerTermsConfiguration(
+ content = {
+ Checkbox(
+ checked = false,
+ onCheckedChange = {},
+ modifier = Modifier.testTag("terms_checkbox")
+ )
+ }
+ )
+ )
+ }
+
+ composeTestRule.onNodeWithTag("custom_method_picker").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("terms_checkbox").assertDoesNotExist()
+ }
+
+ @Test
+ fun `customMethodPickerLayout renders full-screen in the reauthentication sheet too`() {
+ val mockProviderInfo = mock(UserInfo::class.java)
+ `when`(mockProviderInfo.providerId).thenReturn("password")
+ val mockUser = mock(FirebaseUser::class.java)
+ `when`(mockUser.providerData).thenReturn(listOf(mockProviderInfo))
+
+ 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 Reauth Picker",
+ modifier = Modifier.testTag("custom_reauth_picker")
+ )
+ }
+ )
+ }
+
+ authUI.updateAuthState(AuthState.ReauthenticationRequired(mockUser))
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag("custom_reauth_picker").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("AuthMethodPicker LazyColumn").assertDoesNotExist()
+ }
+
// =============================================================================================
// emailContent slot tests
// =============================================================================================
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt
new file mode 100644
index 0000000000..149afe878c
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.screens.email
+
+import android.content.Context
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.hasClickAction
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.test.core.app.ApplicationProvider
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import 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
+
+/**
+ * Unit tests for [SignInUI], covering the sign-up button's visibility.
+ *
+ * @suppress Internal test class
+ */
+@Config(sdk = [34])
+@RunWith(RobolectricTestRunner::class)
+class SignInUITest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private lateinit var applicationContext: Context
+ private lateinit var stringProvider: AuthUIStringProvider
+
+ @Before
+ fun setUp() {
+ applicationContext = ApplicationProvider.getApplicationContext()
+ stringProvider = DefaultAuthUIStringProvider(applicationContext)
+ }
+
+ private fun setSignInUIContent(isNewAccountsAllowed: Boolean) {
+ val provider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ isNewAccountsAllowed = isNewAccountsAllowed,
+ passwordValidationRules = emptyList()
+ )
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ }
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) {
+ SignInUI(
+ configuration = configuration,
+ isLoading = false,
+ emailSignInLinkSent = false,
+ email = "",
+ password = "",
+ onEmailChange = { },
+ onPasswordChange = { },
+ onSignInClick = { },
+ onRetrievedCredential = { },
+ onGoToEmailLinkSignIn = { },
+ onGoToSignUp = { },
+ onGoToResetPassword = { },
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `sign up button is hidden when new accounts are not allowed`() {
+ setSignInUIContent(isNewAccountsAllowed = false)
+
+ composeTestRule.onNode(hasText(stringProvider.signupPageTitle.uppercase()) and hasClickAction())
+ .assertDoesNotExist()
+ }
+
+ @Test
+ fun `sign up button is enabled when new accounts are allowed`() {
+ setSignInUIContent(isNewAccountsAllowed = true)
+
+ composeTestRule.onNode(hasText(stringProvider.signupPageTitle.uppercase()) and hasClickAction())
+ .assertIsEnabled()
+ }
+}
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt
new file mode 100644
index 0000000000..ac642adb7b
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.screens.email
+
+import android.content.Context
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.hasClickAction
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performTextInput
+import androidx.test.core.app.ApplicationProvider
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import 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
+
+/**
+ * Unit tests for [SignUpUI], covering form validity logic.
+ *
+ * @suppress Internal test class
+ */
+@Config(sdk = [34])
+@RunWith(RobolectricTestRunner::class)
+class SignUpUITest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private lateinit var applicationContext: Context
+ private lateinit var stringProvider: AuthUIStringProvider
+
+ @Before
+ fun setUp() {
+ applicationContext = ApplicationProvider.getApplicationContext()
+ stringProvider = DefaultAuthUIStringProvider(applicationContext)
+ }
+
+ @Test
+ fun `sign up button becomes enabled when display name is not required and hidden`() {
+ val provider = AuthProvider.Email(
+ isDisplayNameRequired = false,
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ }
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) {
+ var email by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var confirmPassword by remember { mutableStateOf("") }
+
+ SignUpUI(
+ configuration = configuration,
+ isLoading = false,
+ displayName = "",
+ email = email,
+ password = password,
+ confirmPassword = confirmPassword,
+ onDisplayNameChange = { },
+ onEmailChange = { email = it },
+ onPasswordChange = { password = it },
+ onConfirmPasswordChange = { confirmPassword = it },
+ onGoToSignIn = { },
+ onSignUpClick = { }
+ )
+ }
+ }
+
+ // Name field should not be rendered since it isn't required.
+ composeTestRule.onNodeWithText(stringProvider.nameHint).assertDoesNotExist()
+
+ composeTestRule.onNodeWithText(stringProvider.emailHint)
+ .performTextInput("test@example.com")
+ composeTestRule.onNodeWithText(stringProvider.passwordHint)
+ .performTextInput("Password123")
+ composeTestRule.onNodeWithText(stringProvider.confirmPasswordHint)
+ .performTextInput("Password123")
+
+ composeTestRule.waitForIdle()
+
+ // With email/password/confirmPassword all valid and no display name required,
+ // the sign up button should be enabled even though displayName is still "".
+ composeTestRule.onNode(hasText(stringProvider.signupPageTitle.uppercase()) and hasClickAction())
+ .assertIsEnabled()
+ }
+}
diff --git a/e2eTest/database.rules.json b/e2eTest/database.rules.json
new file mode 100644
index 0000000000..4d031995c4
--- /dev/null
+++ b/e2eTest/database.rules.json
@@ -0,0 +1,9 @@
+{
+ "rules": {
+ ".read": true,
+ ".write": true,
+ "database_demo": {
+ ".indexOn": "score"
+ }
+ }
+}
diff --git a/e2eTest/firebase.json b/e2eTest/firebase.json
index 2fb2a16b0d..5d004205b7 100644
--- a/e2eTest/firebase.json
+++ b/e2eTest/firebase.json
@@ -1,8 +1,20 @@
{
+ "database": [
+ {
+ "instance": "flutterfire-e2e-tests-default-rtdb",
+ "rules": "database.rules.json"
+ }
+ ],
"emulators": {
"auth": {
"port": 9099
},
+ "firestore": {
+ "port": 8080
+ },
+ "database": {
+ "port": 8199
+ },
"ui": {
"enabled": true
},
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 743a26db4d..6ef77c0b5a 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
@@ -310,9 +310,13 @@ class AnonymousAuthScreenTest {
}
var currentAuthState: AuthState = AuthState.Idle
+ var capturedFailure: AuthException? = null
composeTestRule.setContent {
- TestAuthScreen(configuration = configuration)
+ TestAuthScreen(
+ configuration = configuration,
+ onSignInFailure = { capturedFailure = it },
+ )
val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
currentAuthState = authState
}
@@ -382,12 +386,18 @@ class AnonymousAuthScreenTest {
composeTestRule.waitForIdle()
shadowOf(Looper.getMainLooper()).idle()
- // Step 5: Wait for error state (AccountLinkingRequiredException)
+ // Step 5: Wait for onSignInFailure to fire with AccountLinkingRequiredException.
+ //
+ // This is captured via the onSignInFailure callback rather than polling authStateFlow():
+ // the screen resets AuthState back to Idle immediately after consuming the Error (so a
+ // second, independent authStateFlow() collector â like polling currentAuthState here â
+ // can miss the transient value entirely per StateFlow's conflation contract), whereas
+ // onSignInFailure is a direct, synchronous call from the same effect, so it can't race.
println("TEST: Waiting for AccountLinkingRequiredException...")
composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
shadowOf(Looper.getMainLooper()).idle()
- println("TEST: Auth state: $currentAuthState")
- currentAuthState is AuthState.Error
+ println("TEST: Captured failure: $capturedFailure")
+ capturedFailure != null
}
// Step 6: Verify ErrorRecoveryDialog is displayed
@@ -396,24 +406,22 @@ class AnonymousAuthScreenTest {
.assertIsDisplayed()
// Verify exception
- assertThat(currentAuthState).isInstanceOf(AuthState.Error::class.java)
- val errorState = currentAuthState as AuthState.Error
- assertThat(errorState.exception).isInstanceOf(AuthException.AccountLinkingRequiredException::class.java)
+ assertThat(capturedFailure).isInstanceOf(AuthException.AccountLinkingRequiredException::class.java)
- val linkingException = errorState.exception as AuthException.AccountLinkingRequiredException
+ val linkingException = capturedFailure as AuthException.AccountLinkingRequiredException
assertThat(linkingException.email).isEqualTo(email)
}
@Composable
- private fun TestAuthScreen(configuration: AuthUIConfiguration) {
- composeTestRule.waitForIdle()
- shadowOf(Looper.getMainLooper()).idle()
-
+ private fun TestAuthScreen(
+ configuration: AuthUIConfiguration,
+ onSignInFailure: (AuthException) -> Unit = {},
+ ) {
FirebaseAuthScreen(
configuration = configuration,
authUI = authUI,
onSignInSuccess = { result -> },
- onSignInFailure = { exception: AuthException -> },
+ onSignInFailure = onSignInFailure,
onSignInCancelled = {},
authenticatedContent = { state, uiContext ->
when (state) {
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 d438eb45bb..ff61ae95f2 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
@@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
@@ -28,6 +29,7 @@ import androidx.credentials.CreatePasswordRequest
import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetCredentialResponse
+import androidx.credentials.exceptions.NoCredentialException
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import com.firebase.ui.auth.AuthState
@@ -138,6 +140,11 @@ class EmailAuthScreenTest {
fun tearDown() {
closeable.close()
+ // Sign out first: the FirebaseAuth SDK instance backing authUI isn't recreated until the
+ // next test's setUp() deletes/reinitializes the FirebaseApp, so a session left signed in
+ // here would otherwise still be live when the next test's composition starts.
+ authUI.auth.signOut()
+
// Clean up after each test to prevent test pollution
FirebaseAuthUI.clearInstanceCache()
@@ -492,21 +499,18 @@ class EmailAuthScreenTest {
println("TEST: Pumping looper after click...")
shadowOf(Looper.getMainLooper()).idle()
- // Wait for auth state to transition to PasswordResetLinkSent
- println("TEST: Waiting for auth state change... Current state: $currentAuthState")
+ // Wait for the dialog rather than polling currentAuthState, which the screen resets to
+ // Idle right after consuming PasswordResetLinkSent (see EmailSignInLinkSent test above).
+ println("TEST: Waiting for password reset link sent dialog...")
composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
shadowOf(Looper.getMainLooper()).idle()
- println("TEST: Auth state during wait: $currentAuthState")
- currentAuthState is AuthState.PasswordResetLinkSent
+ composeAndroidTestRule.onAllNodesWithText(stringProvider.recoverPasswordLinkSentDialogTitle)
+ .fetchSemanticsNodes().isNotEmpty()
}
// Ensure final recomposition is complete before assertions
shadowOf(Looper.getMainLooper()).idle()
- // Verify the auth state and user properties
- println("TEST: Verifying final auth state: $currentAuthState")
- assertThat(currentAuthState)
- .isInstanceOf(AuthState.PasswordResetLinkSent::class.java)
assertThat(authUI.auth.currentUser).isNull()
composeAndroidTestRule.onNodeWithText(stringProvider.recoverPasswordLinkSentDialogTitle)
.assertIsDisplayed()
@@ -585,22 +589,24 @@ class EmailAuthScreenTest {
shadowOf(Looper.getMainLooper()).idle()
composeAndroidTestRule.waitForIdle()
- // Wait for auth state to transition to EmailSignInLinkSent
- println("TEST: Waiting for auth state change... Current state: $currentAuthState")
+ // Wait for the "email link sent" dialog to appear, rather than polling currentAuthState:
+ // the screen resets AuthState back to Idle immediately after consuming
+ // EmailSignInLinkSent (so a second, independent authStateFlow() collector â like
+ // currentAuthState here â can miss the transient value entirely per StateFlow's
+ // conflation contract), whereas the dialog's visibility is latched in local Compose
+ // state that isn't reset the same way, so it's a reliable, non-racy signal.
+ println("TEST: Waiting for email link sent dialog...")
composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
shadowOf(Looper.getMainLooper()).idle()
- println("TEST: Auth state during wait: $currentAuthState")
- currentAuthState is AuthState.EmailSignInLinkSent
+ composeAndroidTestRule.onAllNodesWithText(stringProvider.emailSignInLinkSentDialogTitle)
+ .fetchSemanticsNodes().isNotEmpty()
}
// Ensure final recomposition is complete before assertions
shadowOf(Looper.getMainLooper()).idle()
composeAndroidTestRule.waitForIdle()
- // Verify the auth state and user properties
- println("TEST: Verifying auth state: $currentAuthState")
- assertThat(currentAuthState)
- .isInstanceOf(AuthState.EmailSignInLinkSent::class.java)
+ // Verify the dialog and user properties
assertThat(authUI.auth.currentUser).isNull()
composeAndroidTestRule.onNodeWithText(stringProvider.emailSignInLinkSentDialogTitle)
.assertIsDisplayed()
@@ -842,9 +848,15 @@ class EmailAuthScreenTest {
whenever(mockCredentialManager.createCredential(any(), any()))
.thenReturn(mock())
- // Mock successful credential retrieval
+ // No credential exists yet for this account (sign-up hasn't happened), so the very first
+ // mount's auto-retrieval attempt must find nothing rather than "succeed" with a credential
+ // for an account that doesn't exist â otherwise it triggers a real sign-in failure (and,
+ // now that dialogs correctly persist, a real error dialog) before step 1 even runs.
+ // thenAnswer (not thenThrow) since getCredential's suspend-compiled signature doesn't
+ // declare GetCredentialException, which Mockito otherwise rejects as an invalid checked
+ // exception for the method.
whenever(mockCredentialManager.getCredential(any(), any()))
- .thenReturn(mockCredentialResponse)
+ .thenAnswer { throw NoCredentialException() }
val configuration = authUIConfiguration {
context = applicationContext
@@ -906,6 +918,11 @@ class EmailAuthScreenTest {
verify(mockCredentialManager, times(1)).createCredential(any(), any())
println("TEST: Sign-up complete, credentials saved (createCredential called once)")
+ // Now that the account actually exists, retrieval can start "succeeding" â matching the
+ // real scenario this test verifies (auto-sign-in via a previously-saved credential).
+ whenever(mockCredentialManager.getCredential(any(), any()))
+ .thenReturn(mockCredentialResponse)
+
// STEP 2: Sign out
println("TEST: Signing out...")
authUI.auth.signOut()
diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt
index 2939527acf..a3fb863b7c 100644
--- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt
@@ -308,4 +308,126 @@ class ReauthFlowTest {
assertThat(currentAuthState).isInstanceOf(AuthState.Idle::class.java)
}
+
+ @Test
+ fun `wrong password during reauth does not fire the pending retry operation`() {
+ val email = "reauth-wrong-pw-${System.currentTimeMillis()}@example.com"
+ val password = "test123"
+ val wrongPassword = "wrong-password"
+
+ val user = ensureFreshUser(authUI, email, password)
+ requireNotNull(user) { "Failed to create user" }
+
+ try {
+ verifyEmailInEmulator(authUI, emulatorApi, user)
+ } catch (e: Exception) {
+ Assume.assumeTrue(
+ "Skipping: Firebase Auth Emulator OOB codes not available. Error: ${e.message}",
+ false
+ )
+ }
+
+ authUI.auth.signOut()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ var currentAuthState: AuthState = AuthState.Idle
+ var retryOperationCalled = false
+
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ isCredentialManagerEnabled = false
+ }
+
+ composeAndroidTestRule.setContent {
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides DefaultAuthUIStringProvider(applicationContext)
+ ) {
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ onSignInSuccess = {},
+ onSignInFailure = {},
+ onSignInCancelled = {},
+ ) { state, _ ->
+ if (state is AuthState.Success) Text("AUTHENTICATED") else Text("NOT AUTHENTICATED")
+ }
+ val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
+ currentAuthState = authState
+ }
+ }
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Step 1: complete initial sign-in via the main screen form (correct password).
+ composeAndroidTestRule.onNodeWithText(stringProvider.emailHint)
+ .performScrollTo()
+ .performTextInput(email)
+ composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint)
+ .performScrollTo()
+ .performTextInput(password)
+ composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault.uppercase())
+ .performScrollTo()
+ .performClick()
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
+ shadowOf(Looper.getMainLooper()).idle()
+ currentAuthState is AuthState.Success
+ }
+ composeAndroidTestRule.onNodeWithText("AUTHENTICATED").assertIsDisplayed()
+
+ val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" }
+
+ // Step 2: emit ReauthenticationRequired with a retryOperation.
+ authUI.updateAuthState(
+ AuthState.ReauthenticationRequired(
+ user = signedInUser,
+ reason = "Please verify your identity to continue",
+ retryOperation = { retryOperationCalled = true },
+ )
+ )
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
+ shadowOf(Looper.getMainLooper()).idle()
+ composeAndroidTestRule.onAllNodesWithText(stringProvider.emailHint)
+ .fetchSemanticsNodes().isNotEmpty()
+ }
+
+ // Step 3: enter the WRONG password in the reauth sheet.
+ composeAndroidTestRule.onNodeWithText(stringProvider.emailHint)
+ .performScrollTo()
+ .performTextInput(email)
+ composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint)
+ .performScrollTo()
+ .performTextInput(wrongPassword)
+ composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault.uppercase())
+ .performScrollTo()
+ .performClick()
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // The error dialog surfaces the failed reauth attempt.
+ composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
+ shadowOf(Looper.getMainLooper()).idle()
+ composeAndroidTestRule.onAllNodesWithText(stringProvider.errorDialogTitle)
+ .fetchSemanticsNodes().isNotEmpty()
+ }
+
+ // Dismiss the error dialog, which self-consumes Error -> Idle on the shared authUI.
+ composeAndroidTestRule.onNodeWithText(stringProvider.dismissAction).performClick()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertThat(retryOperationCalled).isFalse()
+ }
}
diff --git a/firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java b/firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java
index 3cec970755..3f28ae451c 100644
--- a/firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java
+++ b/firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java
@@ -1,5 +1,6 @@
package com.firebase.ui.firestore.paging;
+import com.google.android.gms.tasks.TaskCompletionSource;
import com.google.android.gms.tasks.Tasks;
import com.google.firebase.firestore.DocumentSnapshot;
import com.google.firebase.firestore.Query;
@@ -14,12 +15,15 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
import androidx.paging.PagingSource;
import androidx.paging.PagingSource.LoadParams.Append;
import androidx.paging.PagingSource.LoadParams.Refresh;
import androidx.paging.PagingSource.LoadResult.Page;
import androidx.test.ext.junit.runners.AndroidJUnit4;
+import io.reactivex.rxjava3.disposables.Disposable;
+import io.reactivex.rxjava3.plugins.RxJavaPlugins;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
@@ -105,6 +109,33 @@ public void testLoadAfter_failure() {
assertEquals(expected, actual);
}
+ @Test
+ public void testLoadSingle_interruptedWhileDisposed_doesNotThrowUndeliverableException()
+ throws InterruptedException {
+ FirestorePagingSource pagingSource = new FirestorePagingSource(mMockQuery, Source.DEFAULT);
+ TaskCompletionSource taskCompletionSource = new TaskCompletionSource<>();
+ when(mMockQuery.get(Source.DEFAULT)).thenReturn(taskCompletionSource.getTask());
+
+ List undeliverableErrors = new CopyOnWriteArrayList<>();
+ RxJavaPlugins.setErrorHandler(undeliverableErrors::add);
+ try {
+ Refresh refreshRequest = new Refresh<>(null, 2, false);
+ Disposable disposable = pagingSource.loadSingle(refreshRequest)
+ .subscribe(result -> { }, error -> { });
+
+ Thread.sleep(300);
+ disposable.dispose();
+ Thread.sleep(300);
+
+ assertTrue(
+ "Interruption during an in-flight load must not surface as an "
+ + "undeliverable RxJava exception: " + undeliverableErrors,
+ undeliverableErrors.isEmpty());
+ } finally {
+ RxJavaPlugins.setErrorHandler(null);
+ }
+ }
+
private void initMockQuery() {
when(mMockQuery.startAfter(any(DocumentSnapshot.class))).thenReturn(mMockQuery);
when(mMockQuery.endBefore(any(DocumentSnapshot.class))).thenReturn(mMockQuery);
diff --git a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingSource.java b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingSource.java
index d10dd2b1b6..11d858a8a2 100644
--- a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingSource.java
+++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingSource.java
@@ -1,5 +1,7 @@
package com.firebase.ui.firestore.paging;
+import android.util.Log;
+
import com.google.android.gms.tasks.Task;
import com.google.android.gms.tasks.Tasks;
import com.google.firebase.firestore.DocumentSnapshot;
@@ -19,6 +21,8 @@
public class FirestorePagingSource extends RxPagingSource {
+ private static final String TAG = "FirestorePagingSource";
+
private final Query mQuery;
private final Source mSource;
@@ -54,8 +58,15 @@ public Single> loadSingle(@NonNull LoadPar
// Only throw a new Exception when the original
// Throwable cannot be cast to Exception
throw new Exception(e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ Log.w(TAG, "FirestorePagingSource load interrupted", e);
+ return new LoadResult.Error(e);
}
- }).subscribeOn(Schedulers.io()).onErrorReturn(LoadResult.Error::new);
+ }).subscribeOn(Schedulers.io()).onErrorReturn(e -> {
+ Log.e(TAG, "FirestorePagingSource load failed", e);
+ return new LoadResult.Error<>(e);
+ });
}
private LoadResult toLoadResult(
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index aa46a03323..1b1238aa66 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -33,7 +33,7 @@ material = "1.14.0"
paging = "3.5.0"
recyclerview = "1.4.0"
-glide = "5.0.9"
+glide = "5.0.7"
lint = "30.0.0"
junit = "4.13.2"
@@ -105,6 +105,7 @@ facebook-login = { module = "com.facebook.android:facebook-login", version.ref =
# Misc
glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" }
+glide-compiler = { module = "com.github.bumptech.glide:compiler", version.ref = "glide" }
googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "googleid" }
libphonenumber = { module = "com.googlecode.libphonenumber:libphonenumber", version.ref = "libphonenumber" }
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
diff --git a/scripts/translations/export_translations.py b/scripts/translations/export_translations.py
index 5c10b58e20..b2f2bb24e6 100644
--- a/scripts/translations/export_translations.py
+++ b/scripts/translations/export_translations.py
@@ -1,5 +1,6 @@
# coding=UTF-8
+import math
import os
import re
import sys
@@ -9,6 +10,59 @@
PREFIXED_NAME_START = 'name="fui_'
UNPREFIXED_NAME_START = 'name="'
+CHAR_LIMIT_PATTERN = re.compile(r'\[CHAR_LIMIT=\d+\]')
+TRANSLATION_DESC_PATTERN = re.compile(r'(translation_description="[^"]*?)(")')
+XML_TAG_PATTERN = re.compile(r'<[^>]+>')
+STRING_VALUE_PATTERN = re.compile(r'>([^<]*(?:]*>[^<]*[^<]*)*)')
+ITEM_VALUE_PATTERN = re.compile(r'>([^<]*(?:]*>[^<]*[^<]*)*)')
+
+XML_ENTITIES = {'&': '&', '<': '<', '>': '>', '"': '"', ''': "'"}
+ENTITY_PATTERN = re.compile('|'.join(
+ re.escape(k) for k in sorted(XML_ENTITIES, key=len, reverse=True)
+))
+ESCAPE_PATTERN = re.compile(r'\\(u[0-9A-Fa-f]{4}|n|t|\'|")')
+
+
+def _decode_entities(text):
+ """Decode XML entities and Android escape sequences to get true visible length."""
+ text = ENTITY_PATTERN.sub(lambda m: XML_ENTITIES[m.group()], text)
+ text = ESCAPE_PATTERN.sub('X', text)
+ return text
+
+
+def _extract_visible_text(value):
+ """Strip XML tags and decode entities to get the visible text length."""
+ text = XML_TAG_PATTERN.sub('', value).strip()
+ text = _decode_entities(text)
+ text = re.sub(r'\s+', ' ', text).strip()
+ return text
+
+
+def _calculate_char_limit(english_text):
+ """Return ~1.5x the English text length, rounded up to the nearest 5."""
+ length = len(english_text)
+ limit = math.ceil(length * 1.5)
+ return int(math.ceil(limit / 5.0) * 5)
+
+
+def _add_char_limit(text, value_pattern):
+ """Add [CHAR_LIMIT=xxx] to translation_description if missing."""
+ if 'translation_description=' not in text:
+ return text
+ desc_match = TRANSLATION_DESC_PATTERN.search(text)
+ if desc_match and CHAR_LIMIT_PATTERN.search(desc_match.group(1)):
+ return text
+ match = value_pattern.search(text)
+ if not match:
+ return text
+ visible = _extract_visible_text(match.group(1))
+ if not visible:
+ return text
+ limit = _calculate_char_limit(visible)
+ return TRANSLATION_DESC_PATTERN.sub(
+ r'\1 [CHAR_LIMIT=%d]\2' % limit, text, count=1)
+
+
class ExportTranslationsScript(BaseStringScript):
def ProcessTag(self, line, type):
@@ -16,13 +70,21 @@ def ProcessTag(self, line, type):
if PREFIXED_NAME_START in joined:
joined = joined.replace(PREFIXED_NAME_START, UNPREFIXED_NAME_START)
- return joined.split('\n')
- else:
- return line
+
+ if type == self.TYPE_STR:
+ joined = _add_char_limit(joined, STRING_VALUE_PATTERN)
+ elif type == self.TYPE_PLUR:
+ joined = re.sub(
+ r'(- ]*>.*?
)',
+ lambda m: _add_char_limit(m.group(1), ITEM_VALUE_PATTERN),
+ joined,
+ flags=re.DOTALL
+ )
+
+ return joined.split('\n')
def WriteFile(self, file_name, file_contents):
- # Override to just print the contents
- print file_contents
+ print(file_contents)
if __name__ == '__main__':
ets = ExportTranslationsScript()