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/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/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/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..2d40e98e76 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
*/
@@ -207,19 +216,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,
+ )
+ }
}
}
@@ -928,13 +942,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/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..4f290c699c 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
@@ -170,15 +169,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(
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/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/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/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/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/firestore/build.gradle.kts b/firestore/build.gradle.kts
index 6074dab4d2..d048e6c631 100644
--- a/firestore/build.gradle.kts
+++ b/firestore/build.gradle.kts
@@ -19,6 +19,9 @@ android {
testOptions {
targetSdk = Config.SdkVersions.target
+ unitTests {
+ isIncludeAndroidResources = true
+ }
}
lint {
@@ -64,6 +67,12 @@ dependencies {
lintChecks(project(":lint"))
+ testImplementation(libs.junit)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.test.core)
+ testImplementation(libs.mockito.core)
+ testImplementation(libs.androidx.paging)
+
androidTestImplementation(libs.arch.core.testing)
androidTestImplementation(libs.test.core)
androidTestImplementation(libs.junit)
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..5176e3ecdd 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;
@@ -47,13 +51,19 @@ public Single> loadSingle(@NonNull LoadPar
}
return toLoadResult(snapshot.getDocuments(), nextPage);
} catch (ExecutionException e) {
- if (e.getCause() instanceof Exception) {
- // throw the original Exception
- throw (Exception) e.getCause();
- }
- // Only throw a new Exception when the original
- // Throwable cannot be cast to Exception
- throw new Exception(e);
+ // Report the original cause rather than the ExecutionException wrapper.
+ Throwable cause = e.getCause();
+ Throwable error = cause == null ? e : cause;
+ Log.e(TAG, "FirestorePagingSource load failed", error);
+ return new LoadResult.Error(error);
+ } catch (InterruptedException e) {
+ // The load was cancelled (the subscription was disposed mid-await), not a real
+ // failure — restore the interrupt flag and return quietly without logging.
+ Thread.currentThread().interrupt();
+ return new LoadResult.Error(e);
+ } catch (Exception e) {
+ Log.e(TAG, "FirestorePagingSource load failed", e);
+ return new LoadResult.Error(e);
}
}).subscribeOn(Schedulers.io()).onErrorReturn(LoadResult.Error::new);
}
diff --git a/firestore/src/main/java/com/firebase/ui/firestore/paging/PageKey.java b/firestore/src/main/java/com/firebase/ui/firestore/paging/PageKey.java
index 45d4e7c7aa..db3fc6a9a1 100644
--- a/firestore/src/main/java/com/firebase/ui/firestore/paging/PageKey.java
+++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/PageKey.java
@@ -3,6 +3,8 @@
import com.google.firebase.firestore.DocumentSnapshot;
import com.google.firebase.firestore.Query;
+import java.util.Objects;
+
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
@@ -43,18 +45,25 @@ public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PageKey key = (PageKey) o;
- if (mStartAfter == null && key.mStartAfter == null &&
- mEndBefore == null && key.mEndBefore == null)
- return true;
- return mStartAfter.getId().equals(key.mStartAfter.getId()) &&
- mEndBefore.getId().equals(key.mEndBefore.getId());
+ return Objects.equals(documentId(mStartAfter), documentId(key.mStartAfter)) &&
+ Objects.equals(documentId(mEndBefore), documentId(key.mEndBefore));
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(documentId(mStartAfter), documentId(mEndBefore));
+ }
+
+ @Nullable
+ private static String documentId(@Nullable DocumentSnapshot snapshot) {
+ return snapshot == null ? null : snapshot.getId();
}
@Override
@NonNull
public String toString() {
- String startAfter = mStartAfter == null ? null : mStartAfter.getId();
- String endBefore = mEndBefore == null ? null : mEndBefore.getId();
+ String startAfter = documentId(mStartAfter);
+ String endBefore = documentId(mEndBefore);
return "PageKey{" +
"StartAfter=" + startAfter +
", EndBefore=" + endBefore +
diff --git a/firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java b/firestore/src/test/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java
similarity index 53%
rename from firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java
rename to firestore/src/test/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java
index 3cec970755..810efb6163 100644
--- a/firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java
+++ b/firestore/src/test/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java
@@ -1,5 +1,9 @@
package com.firebase.ui.firestore.paging;
+import com.google.android.gms.tasks.OnCanceledListener;
+import com.google.android.gms.tasks.OnFailureListener;
+import com.google.android.gms.tasks.OnSuccessListener;
+import com.google.android.gms.tasks.Task;
import com.google.android.gms.tasks.Tasks;
import com.google.firebase.firestore.DocumentSnapshot;
import com.google.firebase.firestore.Query;
@@ -11,24 +15,35 @@
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
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.functions.Consumer;
+import io.reactivex.rxjava3.plugins.RxJavaPlugins;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
-@RunWith(AndroidJUnit4.class)
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
public class FirestorePagingSourceTest {
@Mock
@@ -105,6 +120,53 @@ public void testLoadAfter_failure() {
assertEquals(expected, actual);
}
+ /**
+ * Cancelling a load interrupts the thread blocked in Tasks.await(). The resulting
+ * InterruptedException must not escape the callable: by then the subscription is disposed, so
+ * RxJava would report it as an UndeliverableException and crash the app.
+ *
+ * See https://github.com/firebase/FirebaseUI-Android/issues/2008
+ */
+ @Test
+ public void testLoadSingle_interruptedWhileDisposed_doesNotReportUndeliverableException()
+ throws Exception {
+ FirestorePagingSource pagingSource = new FirestorePagingSource(mMockQuery, Source.DEFAULT);
+
+ // Counted down once Tasks.await() starts registering listeners, which means the load is
+ // genuinely running and about to block. Without waiting on this, the test could dispose
+ // before the callable ever ran — and would then pass even against the unfixed source.
+ CountDownLatch loadInFlight = new CountDownLatch(1);
+ mockQueryNeverCompletes(loadInFlight);
+
+ List undeliverableErrors = new CopyOnWriteArrayList<>();
+ CountDownLatch undeliverableReported = new CountDownLatch(1);
+ Consumer super Throwable> originalErrorHandler = RxJavaPlugins.getErrorHandler();
+ RxJavaPlugins.setErrorHandler(throwable -> {
+ undeliverableErrors.add(throwable);
+ undeliverableReported.countDown();
+ });
+
+ try {
+ Refresh refreshRequest = new Refresh<>(null, 2, false);
+ Disposable disposable = pagingSource.loadSingle(refreshRequest)
+ .subscribe(result -> { }, error -> { });
+
+ assertTrue("Load never reached Tasks.await(), so nothing was interrupted",
+ loadInFlight.await(5, TimeUnit.SECONDS));
+
+ // Interrupts the worker thread blocked in Tasks.await().
+ disposable.dispose();
+
+ assertFalse("Interrupting an in-flight load must not surface as an undeliverable "
+ + "RxJava exception: " + undeliverableErrors,
+ undeliverableReported.await(2, TimeUnit.SECONDS));
+ } finally {
+ RxJavaPlugins.setErrorHandler(originalErrorHandler);
+ }
+
+ verify(mMockQuery).get(Source.DEFAULT);
+ }
+
private void initMockQuery() {
when(mMockQuery.startAfter(any(DocumentSnapshot.class))).thenReturn(mMockQuery);
when(mMockQuery.endBefore(any(DocumentSnapshot.class))).thenReturn(mMockQuery);
@@ -121,4 +183,25 @@ private void mockQuerySuccess(List snapshots) {
private void mockQueryFailure(Exception exception) {
when(mMockQuery.get(Source.DEFAULT)).thenReturn(Tasks.forException(exception));
}
+
+ /**
+ * Stubs the query with a task that never completes, so Tasks.await() blocks until the thread
+ * is interrupted. {@code inFlight} is counted down once await() has started.
+ */
+ @SuppressWarnings("unchecked")
+ private void mockQueryNeverCompletes(CountDownLatch inFlight) {
+ Task neverCompletes = mock(Task.class);
+ when(neverCompletes.isComplete()).thenReturn(false);
+ when(neverCompletes.addOnSuccessListener(any(Executor.class), any(OnSuccessListener.class)))
+ .thenAnswer(invocation -> {
+ inFlight.countDown();
+ return neverCompletes;
+ });
+ when(neverCompletes.addOnFailureListener(any(Executor.class), any(OnFailureListener.class)))
+ .thenReturn(neverCompletes);
+ when(neverCompletes.addOnCanceledListener(any(Executor.class), any(OnCanceledListener.class)))
+ .thenReturn(neverCompletes);
+
+ when(mMockQuery.get(Source.DEFAULT)).thenReturn(neverCompletes);
+ }
}
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()