diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 8458193f9..bb74b0a75 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 ca5cda2d8..4bc5abcfe 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 b1fbd486e..8faa13f7e 100644
--- a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt
@@ -17,35 +17,43 @@ 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
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
- // Initialize Firebase and configure emulator if needed
FirebaseApp.initializeApp(applicationContext)
val authUI = FirebaseAuthUI.getInstance()
@@ -53,8 +61,17 @@ class MainActivity : ComponentActivity() {
authUI.auth.useEmulator(AUTH_EMULATOR_HOST, AUTH_EMULATOR_PORT)
}
- var pendingEmailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK)
+ 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)
+ }
+
+ var pendingEmailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK)
if (pendingEmailLink.isNullOrEmpty() && authUI.canHandleIntent(intent)) {
pendingEmailLink = intent.data?.toString()
}
@@ -62,10 +79,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 +101,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 +122,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 000000000..a418427da
--- /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 ed6de15a6..7d82c1a18 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 c901084cc..afced8665 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 99%
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 54eadccc3..fd2e60aa6 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,7 +1,8 @@
-package com.firebaseui.android.demo
+package com.firebaseui.android.demo.auth
import android.os.Bundle
import android.util.Log
+import com.firebaseui.android.demo.R
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
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 4b824eed5..df069091f 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 cb9605621..5c5e6e13c 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 99%
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 8aabec3b0..14d9e1f47 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
@@ -58,6 +58,7 @@ import com.firebase.ui.auth.configuration.theme.AuthUITheme
import com.firebase.ui.auth.ui.screens.AuthSuccessUiContext
import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen
import com.firebase.ui.auth.util.EmailLinkConstants
+import com.firebaseui.android.demo.R
import com.firebase.ui.auth.util.displayIdentifier
import com.firebase.ui.auth.util.getDisplayEmail
import com.google.firebase.auth.actionCodeSettings
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 9639beefb..a36c567b5 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 5faba7336..77419b210 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 000000000..4e99cbfd3
--- /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 000000000..91d07abb9
--- /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 000000000..5959a5aa4
--- /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 000000000..499dfe253
--- /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/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 dfc413bc6..6ff0d19f4 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
@@ -95,7 +95,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)
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 000000000..ac642adb7
--- /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 000000000..4d031995c
--- /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 2fb2a16b0..5d004205b 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/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java b/firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java
index 3cec97075..3f28ae451 100644
--- a/firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java
+++ b/firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java
@@ -1,5 +1,6 @@
package com.firebase.ui.firestore.paging;
+import com.google.android.gms.tasks.TaskCompletionSource;
import com.google.android.gms.tasks.Tasks;
import com.google.firebase.firestore.DocumentSnapshot;
import com.google.firebase.firestore.Query;
@@ -14,12 +15,15 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
import androidx.paging.PagingSource;
import androidx.paging.PagingSource.LoadParams.Append;
import androidx.paging.PagingSource.LoadParams.Refresh;
import androidx.paging.PagingSource.LoadResult.Page;
import androidx.test.ext.junit.runners.AndroidJUnit4;
+import io.reactivex.rxjava3.disposables.Disposable;
+import io.reactivex.rxjava3.plugins.RxJavaPlugins;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
@@ -105,6 +109,33 @@ public void testLoadAfter_failure() {
assertEquals(expected, actual);
}
+ @Test
+ public void testLoadSingle_interruptedWhileDisposed_doesNotThrowUndeliverableException()
+ throws InterruptedException {
+ FirestorePagingSource pagingSource = new FirestorePagingSource(mMockQuery, Source.DEFAULT);
+ TaskCompletionSource taskCompletionSource = new TaskCompletionSource<>();
+ when(mMockQuery.get(Source.DEFAULT)).thenReturn(taskCompletionSource.getTask());
+
+ List undeliverableErrors = new CopyOnWriteArrayList<>();
+ RxJavaPlugins.setErrorHandler(undeliverableErrors::add);
+ try {
+ Refresh refreshRequest = new Refresh<>(null, 2, false);
+ Disposable disposable = pagingSource.loadSingle(refreshRequest)
+ .subscribe(result -> { }, error -> { });
+
+ Thread.sleep(300);
+ disposable.dispose();
+ Thread.sleep(300);
+
+ assertTrue(
+ "Interruption during an in-flight load must not surface as an "
+ + "undeliverable RxJava exception: " + undeliverableErrors,
+ undeliverableErrors.isEmpty());
+ } finally {
+ RxJavaPlugins.setErrorHandler(null);
+ }
+ }
+
private void initMockQuery() {
when(mMockQuery.startAfter(any(DocumentSnapshot.class))).thenReturn(mMockQuery);
when(mMockQuery.endBefore(any(DocumentSnapshot.class))).thenReturn(mMockQuery);
diff --git a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingSource.java b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingSource.java
index d10dd2b1b..11d858a8a 100644
--- a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingSource.java
+++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingSource.java
@@ -1,5 +1,7 @@
package com.firebase.ui.firestore.paging;
+import android.util.Log;
+
import com.google.android.gms.tasks.Task;
import com.google.android.gms.tasks.Tasks;
import com.google.firebase.firestore.DocumentSnapshot;
@@ -19,6 +21,8 @@
public class FirestorePagingSource extends RxPagingSource {
+ private static final String TAG = "FirestorePagingSource";
+
private final Query mQuery;
private final Source mSource;
@@ -54,8 +58,15 @@ public Single> loadSingle(@NonNull LoadPar
// Only throw a new Exception when the original
// Throwable cannot be cast to Exception
throw new Exception(e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ Log.w(TAG, "FirestorePagingSource load interrupted", e);
+ return new LoadResult.Error(e);
}
- }).subscribeOn(Schedulers.io()).onErrorReturn(LoadResult.Error::new);
+ }).subscribeOn(Schedulers.io()).onErrorReturn(e -> {
+ Log.e(TAG, "FirestorePagingSource load failed", e);
+ return new LoadResult.Error<>(e);
+ });
}
private LoadResult toLoadResult(
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 758473cef..83a6e27b6 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/storage/build.gradle.kts b/storage/build.gradle.kts
index 7ac13aeba..d3cfc2d40 100644
--- a/storage/build.gradle.kts
+++ b/storage/build.gradle.kts
@@ -57,4 +57,4 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockito.core)
-}
\ No newline at end of file
+}