From 485734f8c60f9d034572257d3b5ecacb2f1538f4 Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 17 Jun 2026 12:31:29 +0100 Subject: [PATCH 01/13] sample(storage): add storage image loading demo and modularise app demo structure --- app/build.gradle.kts | 3 + app/src/main/AndroidManifest.xml | 24 +- .../firebaseui/android/demo/MainActivity.kt | 226 ++-------------- .../android/demo/auth/AuthChooserActivity.kt | 253 ++++++++++++++++++ .../AuthFlowControllerDemoActivity.kt | 2 +- .../CustomSlotsThemingDemoActivity.kt | 2 +- .../{ => auth}/HighLevelApiDemoActivity.kt | 3 +- .../demo/storage/StorageDemoActivity.kt | 200 ++++++++++++++ .../demo/storage/StorageGlideModule.kt | 26 ++ 9 files changed, 526 insertions(+), 213 deletions(-) create mode 100644 app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/AuthFlowControllerDemoActivity.kt (99%) rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/CustomSlotsThemingDemoActivity.kt (99%) rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/HighLevelApiDemoActivity.kt (99%) create mode 100644 app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt create mode 100644 app/src/main/java/com/firebaseui/android/demo/storage/StorageGlideModule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8458193f9..719475ebb 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,8 @@ kotlin { dependencies { implementation(project(":auth")) + implementation(project(":storage")) + kapt(Config.Libs.Misc.glideCompiler) 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..29b273eff 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,14 +19,9 @@ android:theme="@style/Theme.FirebaseUIAndroid"> - - - - - @@ -38,20 +33,27 @@ + + + @@ -86,6 +88,12 @@ android:exported="false" android:theme="@style/Theme.FirebaseUIAndroid" /> + + 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..1bb6be4e7 100644 --- a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt @@ -17,23 +17,19 @@ 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.storage.StorageDemoActivity import com.google.firebase.FirebaseApp -/** - * 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 @@ -45,7 +41,6 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() - // Initialize Firebase and configure emulator if needed FirebaseApp.initializeApp(applicationContext) val authUI = FirebaseAuthUI.getInstance() @@ -54,7 +49,6 @@ class MainActivity : ComponentActivity() { } var pendingEmailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK) - if (pendingEmailLink.isNullOrEmpty() && authUI.canHandleIntent(intent)) { pendingEmailLink = intent.data?.toString() } @@ -62,10 +56,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 +78,12 @@ class MainActivity : ComponentActivity() { color = MaterialTheme.colorScheme.background ) { ChooserScreen( - onHighLevelApiClick = ::launchHighLevelDemo, - onLowLevelApiClick = { - startActivity(Intent(this, AuthFlowControllerDemoActivity::class.java)) - }, - onCustomSlotsClick = { - startActivity(Intent(this, CustomSlotsThemingDemoActivity::class.java)) - }, - onCredentialLinkingClick = { - startActivity(Intent(this, CredentialLinkingDemoActivity::class.java)) + onAuthClick = { + startActivity(Intent(this, AuthChooserActivity::class.java)) }, - isEmulatorMode = USE_AUTH_EMULATOR + onStorageClick = { + startActivity(Intent(this, StorageDemoActivity::class.java)) + } ) } } @@ -107,226 +93,62 @@ class MainActivity : ComponentActivity() { @Composable fun ChooserScreen( - onHighLevelApiClick: () -> Unit, - onLowLevelApiClick: () -> Unit, - onCustomSlotsClick: () -> Unit, - onCredentialLinkingClick: () -> Unit = {}, - isEmulatorMode: Boolean = false + onAuthClick: () -> 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 - ) { - 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 - ) { + Card(modifier = Modifier.fillMaxWidth(), onClick = onAuthClick) { 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) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = "🎨 Custom Slots & Theming", + text = "Auth", 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 = "High-Level API, Low-Level API, Custom Slots & Theming", 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..488233e9d --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt @@ -0,0 +1,253 @@ +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 + +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)) + } + ) + } + } + } + } +} + +@Composable +fun AuthChooserScreen( + onHighLevelApiClick: () -> Unit, + onLowLevelApiClick: () -> Unit, + onCustomSlotsClick: () -> 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 + ) + } + } + + 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/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/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..194ff2ae8 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 @@ -60,6 +60,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() { 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..8faa4c35e --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt @@ -0,0 +1,200 @@ +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.mutableIntStateOf +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("gs://your-project.appspot.com/path/to/image.png") } + var stringStatus by remember { mutableStateOf("Not loaded") } + var stringLoadKey by remember { mutableIntStateOf(0) } + var refStatus by remember { mutableStateOf("Not loaded") } + var refLoadKey by remember { mutableIntStateOf(0) } + + 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") }, + 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 = { + stringStatus = "Loading..." + stringLoadKey++ + }) { Text("Load") } + StatusText(stringStatus) + AndroidView( + factory = { ImageView(it) }, + update = { view -> + if (stringLoadKey > 0) { + GlideApp.with(view) + .load(gsUrl) + .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 = { + refStatus = "Loading..." + refLoadKey++ + }) { Text("Load") } + StatusText(refStatus) + AndroidView( + factory = { ImageView(it) }, + update = { view -> + if (refLoadKey > 0) { + runCatching { + FirebaseStorage.getInstance().getReferenceFromUrl(gsUrl) + }.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() + ) + } +} From 7e84a89b739a70d76fa78983ee4ae1d6abe4464c Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 17 Jun 2026 12:46:21 +0100 Subject: [PATCH 02/13] feat(storage): update StorageDemoScreen to include placeholder for gs:// URL input --- .../com/firebaseui/android/demo/storage/StorageDemoActivity.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 8faa4c35e..74b034654 100644 --- a/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt @@ -52,7 +52,7 @@ class StorageDemoActivity : ComponentActivity() { @Composable fun StorageDemoScreen() { - var gsUrl by remember { mutableStateOf("gs://your-project.appspot.com/path/to/image.png") } + var gsUrl by remember { mutableStateOf("") } var stringStatus by remember { mutableStateOf("Not loaded") } var stringLoadKey by remember { mutableIntStateOf(0) } var refStatus by remember { mutableStateOf("Not loaded") } @@ -77,6 +77,7 @@ fun StorageDemoScreen() { 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 ) From 4860ae85ed8c24dab17cc41f81d80591db9d4252 Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 17 Jun 2026 18:05:57 +0100 Subject: [PATCH 03/13] sample(database): add database paging demo to app module --- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 7 + .../firebaseui/android/demo/MainActivity.kt | 23 +++ .../demo/database/DatabaseDemoActivity.kt | 131 ++++++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 719475ebb..688139f1a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,7 +54,9 @@ kotlin { dependencies { implementation(project(":auth")) + implementation(project(":database")) implementation(project(":storage")) + implementation(Config.Libs.Androidx.paging) kapt(Config.Libs.Misc.glideCompiler) implementation(libs.kotlin.stdlib) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 29b273eff..9631e7ec0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -88,6 +88,13 @@ android:exported="false" android:theme="@style/Theme.FirebaseUIAndroid" /> + + + Unit, + onDatabaseClick: () -> Unit, onStorageClick: () -> Unit, ) { Column( @@ -131,6 +136,24 @@ fun ChooserScreen( } } + Card(modifier = Modifier.fillMaxWidth(), onClick = onDatabaseClick) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Database", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Paginated list with FirebaseRecyclerPagingAdapter and orderByChild", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Card(modifier = Modifier.fillMaxWidth(), onClick = onStorageClick) { Column( modifier = Modifier.padding(20.dp), 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..7dc122b33 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt @@ -0,0 +1,131 @@ +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.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.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +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.database.DatabaseReference +import com.google.firebase.database.FirebaseDatabase + +class DatabaseDemoActivity : ComponentActivity() { + + private lateinit var adapter: ScoreAdapter + + 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) + + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + DatabaseDemoScreen( + adapter = adapter, + onSeedData = { seedData(ref) }, + onRefresh = { adapter.refresh() } + ) + } + } + } + } + + private fun seedData(ref: DatabaseReference) { + repeat(20) { 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 + ) + setPadding(48, 24, 48, 24) + 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, + 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("Seed data") } + OutlinedButton(onClick = onRefresh) { Text("Refresh") } + } + + AndroidView( + factory = { context -> + RecyclerView(context).apply { + layoutManager = LinearLayoutManager(context) + addItemDecoration( + DividerItemDecoration(context, DividerItemDecoration.VERTICAL) + ) + setAdapter(adapter) + } + }, + modifier = Modifier.fillMaxSize() + ) + } +} From f2ce2d4db38344c2261d0040d2e7345054d1ac0f Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 17 Jun 2026 20:11:15 +0100 Subject: [PATCH 04/13] sample(database): add page counter and seed 50 items for visible pagination --- .../demo/database/DatabaseDemoActivity.kt | 56 +++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) 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 index 7dc122b33..46ea1923e 100644 --- a/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt @@ -10,6 +10,7 @@ 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 @@ -18,21 +19,29 @@ 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) @@ -47,12 +56,26 @@ class DatabaseDemoActivity : ComponentActivity() { 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, - onSeedData = { seedData(ref) }, + currentPage = currentPage, + onSeedData = { signInThenSeed(ref) }, onRefresh = { adapter.refresh() } ) } @@ -60,8 +83,18 @@ class DatabaseDemoActivity : ComponentActivity() { } } + 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(20) { i -> + repeat(50) { i -> ref.push().setValue(ScoreItem("Item ${i + 1}", (1..100).random())) } } @@ -93,6 +126,7 @@ class ScoreAdapter(options: DatabasePagingOptions) : @Composable fun DatabaseDemoScreen( adapter: ScoreAdapter, + currentPage: Int, onSeedData: () -> Unit, onRefresh: () -> Unit, ) { @@ -111,10 +145,22 @@ fun DatabaseDemoScreen( ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onSeedData) { Text("Seed data") } + 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 { @@ -125,7 +171,9 @@ fun DatabaseDemoScreen( setAdapter(adapter) } }, - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxWidth() + .weight(1f) ) } } From 781820961de136d943aab1e3cfb08c0d93e7b5ed Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 20 Jul 2026 14:20:17 +0100 Subject: [PATCH 05/13] fix(firestore): catch InterruptedException in FirestorePagingSource --- .../paging/FirestorePagingSourceTest.java | 31 +++++++++++++++++++ .../paging/FirestorePagingSource.java | 12 ++++++- 2 files changed, 42 insertions(+), 1 deletion(-) 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..f23301669 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,14 @@ 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(); + 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( From 2362b863fc8e42dc09d0abfa899f67b8bb0bd848 Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 20 Jul 2026 19:01:17 +0100 Subject: [PATCH 06/13] chore(app): pin glide to 5.0.7 for compileSdk 36 compatibility --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aa46a0332..de5c4f169 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" From a2fc4c98dbafeb44703cd6c98343363ce3ed76cf Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 20 Jul 2026 19:16:32 +0100 Subject: [PATCH 07/13] sample(firestore): add firestore paging demo to app module --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 7 + .../firebaseui/android/demo/MainActivity.kt | 23 ++ .../demo/firestore/FirestoreDemoActivity.kt | 199 ++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 688139f1a..bef4c25f5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,6 +55,7 @@ kotlin { dependencies { implementation(project(":auth")) implementation(project(":database")) + implementation(project(":firestore")) implementation(project(":storage")) implementation(Config.Libs.Androidx.paging) kapt(Config.Libs.Misc.glideCompiler) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9631e7ec0..c155a8d5a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -95,6 +95,13 @@ android:exported="false" android:theme="@style/Theme.FirebaseUIAndroid" /> + + + Unit, onDatabaseClick: () -> Unit, + onFirestoreClick: () -> Unit, onStorageClick: () -> Unit, ) { Column( @@ -154,6 +159,24 @@ fun ChooserScreen( } } + Card(modifier = Modifier.fillMaxWidth(), onClick = onFirestoreClick) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Firestore", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Paginated list with FirestorePagingAdapter and orderBy", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Card(modifier = Modifier.fillMaxWidth(), onClick = onStorageClick) { Column( modifier = Modifier.padding(20.dp), 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..1743b019f --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt @@ -0,0 +1,199 @@ +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() { + + companion object { + // Point the app at a local Firestore emulator (firebase emulators:start). + // 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 var emulatorConfigured = false + } + + 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 firestore = FirebaseFirestore.getInstance() + if (USE_FIRESTORE_EMULATOR && !emulatorConfigured) { + firestore.useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT) + emulatorConfigured = true + } + + val collection = firestore.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) { + for (i in 1..50) { + collection.add(ScoreItem("Item $i", (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 + ) + setPadding(48, 24, 48, 24) + 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) + ) + } +} From ec55aa234bb5947eb1bedb6a57fbd5fa08fc3d2c Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 20 Jul 2026 19:42:39 +0100 Subject: [PATCH 08/13] sample(app): move auth demos into auth subpackage and add firestore emulator toggle --- app/src/main/AndroidManifest.xml | 10 +++++----- .../firebaseui/android/demo/MainActivity.kt | 13 ++++++++++++- .../CredentialLinkingDemoActivity.kt | 2 +- .../CustomMethodPickerDemoActivity.kt | 3 ++- .../{ => auth}/EmailAuthSlotDemoActivity.kt | 2 +- .../{ => auth}/PhoneAuthSlotDemoActivity.kt | 2 +- .../ShapeCustomizationDemoActivity.kt | 2 +- .../demo/firestore/FirestoreDemoActivity.kt | 18 +----------------- e2eTest/firebase.json | 6 ++++++ 9 files changed, 30 insertions(+), 28 deletions(-) rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/CredentialLinkingDemoActivity.kt (99%) rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/CustomMethodPickerDemoActivity.kt (99%) rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/EmailAuthSlotDemoActivity.kt (99%) rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/PhoneAuthSlotDemoActivity.kt (99%) rename app/src/main/java/com/firebaseui/android/demo/{ => auth}/ShapeCustomizationDemoActivity.kt (99%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c155a8d5a..4bc5abcfe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -59,31 +59,31 @@ android:theme="@style/Theme.FirebaseUIAndroid" /> 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 780d71ef3..49713c354 100644 --- a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt @@ -31,12 +31,18 @@ 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.firestore.FirebaseFirestore class MainActivity : ComponentActivity() { companion object { - private const val USE_AUTH_EMULATOR = false + private 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 } override fun onCreate(savedInstanceState: Bundle?) { @@ -50,6 +56,11 @@ class MainActivity : ComponentActivity() { authUI.auth.useEmulator(AUTH_EMULATOR_HOST, AUTH_EMULATOR_PORT) } + if (USE_FIRESTORE_EMULATOR) { + FirebaseFirestore.getInstance() + .useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT) + } + var pendingEmailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK) if (pendingEmailLink.isNullOrEmpty() && authUI.canHandleIntent(intent)) { pendingEmailLink = intent.data?.toString() 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/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/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/firestore/FirestoreDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt index 1743b019f..5c379ed0b 100644 --- a/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt @@ -40,16 +40,6 @@ import com.google.firebase.firestore.Query class FirestoreDemoActivity : ComponentActivity() { - companion object { - // Point the app at a local Firestore emulator (firebase emulators:start). - // 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 var emulatorConfigured = false - } - private lateinit var adapter: ScoreAdapter private var currentPage by mutableIntStateOf(1) private var prevAppendWasLoading = false @@ -58,13 +48,7 @@ class FirestoreDemoActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() - val firestore = FirebaseFirestore.getInstance() - if (USE_FIRESTORE_EMULATOR && !emulatorConfigured) { - firestore.useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT) - emulatorConfigured = true - } - - val collection = firestore.collection("firestore_demo") + val collection = FirebaseFirestore.getInstance().collection("firestore_demo") // Query must only contain where()/orderBy() — the paging library adds limit(). val query: Query = collection.orderBy("score") diff --git a/e2eTest/firebase.json b/e2eTest/firebase.json index 2fb2a16b0..23ea2b1f1 100644 --- a/e2eTest/firebase.json +++ b/e2eTest/firebase.json @@ -3,6 +3,12 @@ "auth": { "port": 9099 }, + "firestore": { + "port": 8080 + }, + "database": { + "port": 8199 + }, "ui": { "enabled": true }, From 7cfac5db24317b91fae8053e08eef9c05ed84dbc Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 20 Jul 2026 21:09:11 +0100 Subject: [PATCH 09/13] sample(firestore): log interrupted paging load at warn level --- .../com/firebase/ui/firestore/paging/FirestorePagingSource.java | 1 + 1 file changed, 1 insertion(+) 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 f23301669..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 @@ -60,6 +60,7 @@ public Single> loadSingle(@NonNull LoadPar 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(e -> { From cd77dc1fb6447f628a071a34a9fbd3797b942131 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 21 Jul 2026 13:55:19 +0100 Subject: [PATCH 10/13] sample(app): address gemini review feedback on demo app --- .../firebaseui/android/demo/MainActivity.kt | 14 ++++++-- .../android/demo/auth/AuthChooserActivity.kt | 34 ++++++++++++++++++- .../demo/database/DatabaseDemoActivity.kt | 5 ++- .../demo/firestore/FirestoreDemoActivity.kt | 10 ++++-- .../demo/storage/StorageDemoActivity.kt | 25 ++++++++------ 5 files changed, 71 insertions(+), 17 deletions(-) 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 49713c354..8faa13f7e 100644 --- a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt @@ -31,11 +31,12 @@ 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 class MainActivity : ComponentActivity() { companion object { - private const val USE_AUTH_EMULATOR = true + internal const val USE_AUTH_EMULATOR = true private const val AUTH_EMULATOR_HOST = "10.0.2.2" private const val AUTH_EMULATOR_PORT = 9099 @@ -43,6 +44,10 @@ class MainActivity : ComponentActivity() { 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?) { @@ -61,6 +66,11 @@ class MainActivity : ComponentActivity() { .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() @@ -145,7 +155,7 @@ fun ChooserScreen( color = MaterialTheme.colorScheme.primary ) Text( - text = "High-Level API, Low-Level API, Custom Slots & Theming", + text = "High-Level API, Low-Level API, Custom Slots & Theming, Credential Linking", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) 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 index 488233e9d..a418427da 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt @@ -25,6 +25,7 @@ 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?) { @@ -45,7 +46,11 @@ class AuthChooserActivity : ComponentActivity() { }, onCustomSlotsClick = { startActivity(Intent(this, CustomSlotsThemingDemoActivity::class.java)) - } + }, + onCredentialLinkingClick = { + startActivity(Intent(this, CredentialLinkingDemoActivity::class.java)) + }, + isEmulatorMode = MainActivity.USE_AUTH_EMULATOR ) } } @@ -58,6 +63,7 @@ fun AuthChooserScreen( onHighLevelApiClick: () -> Unit, onLowLevelApiClick: () -> Unit, onCustomSlotsClick: () -> Unit, + onCredentialLinkingClick: () -> Unit = {}, isEmulatorMode: Boolean = false ) { val scrollState = rememberScrollState() @@ -223,6 +229,32 @@ fun AuthChooserScreen( } } + // Credential Linking Card + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onCredentialLinkingClick + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "🔗 Credential Linking", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "isCredentialLinkingEnabled", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Sign in with one provider, then add another to the same account without losing your UID.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) // Info card diff --git a/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt index 46ea1923e..4e99cbfd3 100644 --- a/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt @@ -108,7 +108,10 @@ class ScoreViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder( RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT ) - setPadding(48, 24, 48, 24) + val density = context.resources.displayMetrics.density + val hPadding = (16 * density).toInt() + val vPadding = (8 * density).toInt() + setPadding(hPadding, vPadding, hPadding, vPadding) textSize = 16f } ) 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 index 5c379ed0b..91d07abb9 100644 --- a/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt @@ -98,9 +98,12 @@ class FirestoreDemoActivity : ComponentActivity() { } private fun seedData(collection: CollectionReference) { + val batch = collection.firestore.batch() for (i in 1..50) { - collection.add(ScoreItem("Item $i", (1..100).random())) + val docRef = collection.document() + batch.set(docRef, ScoreItem("Item $i", (1..100).random())) } + batch.commit() } } @@ -112,7 +115,10 @@ class ScoreViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder( RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT ) - setPadding(48, 24, 48, 24) + val density = context.resources.displayMetrics.density + val hPadding = (16 * density).toInt() + val vPadding = (8 * density).toInt() + setPadding(hPadding, vPadding, hPadding, vPadding) textSize = 16f } ) 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 index 74b034654..5959a5aa4 100644 --- a/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/storage/StorageDemoActivity.kt @@ -23,7 +23,6 @@ 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -54,9 +53,9 @@ class StorageDemoActivity : ComponentActivity() { fun StorageDemoScreen() { var gsUrl by remember { mutableStateOf("") } var stringStatus by remember { mutableStateOf("Not loaded") } - var stringLoadKey by remember { mutableIntStateOf(0) } + var stringUrlToLoad by remember { mutableStateOf("") } var refStatus by remember { mutableStateOf("Not loaded") } - var refLoadKey by remember { mutableIntStateOf(0) } + var refUrlToLoad by remember { mutableStateOf("") } Column( modifier = Modifier @@ -96,16 +95,18 @@ fun StorageDemoScreen() { color = MaterialTheme.colorScheme.onSurfaceVariant ) Button(onClick = { - stringStatus = "Loading..." - stringLoadKey++ + if (gsUrl.isNotEmpty()) { + stringStatus = "Loading..." + stringUrlToLoad = gsUrl + } }) { Text("Load") } StatusText(stringStatus) AndroidView( factory = { ImageView(it) }, update = { view -> - if (stringLoadKey > 0) { + if (stringUrlToLoad.isNotEmpty()) { GlideApp.with(view) - .load(gsUrl) + .load(stringUrlToLoad) .listener(glideListener { success, error -> stringStatus = if (success) "Loaded" else "Error: $error" }) @@ -133,16 +134,18 @@ fun StorageDemoScreen() { color = MaterialTheme.colorScheme.onSurfaceVariant ) Button(onClick = { - refStatus = "Loading..." - refLoadKey++ + if (gsUrl.isNotEmpty()) { + refStatus = "Loading..." + refUrlToLoad = gsUrl + } }) { Text("Load") } StatusText(refStatus) AndroidView( factory = { ImageView(it) }, update = { view -> - if (refLoadKey > 0) { + if (refUrlToLoad.isNotEmpty()) { runCatching { - FirebaseStorage.getInstance().getReferenceFromUrl(gsUrl) + FirebaseStorage.getInstance().getReferenceFromUrl(refUrlToLoad) }.onSuccess { ref -> GlideApp.with(view) .load(ref) From b03cf2b5a22f942296326acf037901afcf1dc5f3 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 21 Jul 2026 17:21:10 +0100 Subject: [PATCH 11/13] sample(database): fix RTDB paging demo silently failing to render seeded data --- e2eTest/.firebaserc | 2 +- e2eTest/database.rules.json | 9 +++++++++ e2eTest/firebase.json | 3 +++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 e2eTest/database.rules.json diff --git a/e2eTest/.firebaserc b/e2eTest/.firebaserc index c4ee98c1e..6aa25141c 100644 --- a/e2eTest/.firebaserc +++ b/e2eTest/.firebaserc @@ -1,5 +1,5 @@ { "projects": { - "default": "fake-project-id" + "default": "flutterfire-e2e-tests" } } 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 23ea2b1f1..f7a4d6ed5 100644 --- a/e2eTest/firebase.json +++ b/e2eTest/firebase.json @@ -1,4 +1,7 @@ { + "database": { + "rules": "database.rules.json" + }, "emulators": { "auth": { "port": 9099 From 537760d9fdcac440e5f4571f0b1c30288f35ad04 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 21 Jul 2026 19:26:11 +0100 Subject: [PATCH 12/13] sample(database): fix e2e CI auth project mismatch from RTDB namespace fix --- e2eTest/.firebaserc | 2 +- e2eTest/firebase.json | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/e2eTest/.firebaserc b/e2eTest/.firebaserc index 6aa25141c..c4ee98c1e 100644 --- a/e2eTest/.firebaserc +++ b/e2eTest/.firebaserc @@ -1,5 +1,5 @@ { "projects": { - "default": "flutterfire-e2e-tests" + "default": "fake-project-id" } } diff --git a/e2eTest/firebase.json b/e2eTest/firebase.json index f7a4d6ed5..5d004205b 100644 --- a/e2eTest/firebase.json +++ b/e2eTest/firebase.json @@ -1,7 +1,10 @@ { - "database": { - "rules": "database.rules.json" - }, + "database": [ + { + "instance": "flutterfire-e2e-tests-default-rtdb", + "rules": "database.rules.json" + } + ], "emulators": { "auth": { "port": 9099 From 1778d6cefd945f8c4c057c01b5a98aec8f4532fc Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 27 Jul 2026 10:26:45 +0100 Subject: [PATCH 13/13] updates --- app/build.gradle.kts | 4 +-- app/src/main/AndroidManifest.xml | 10 +++---- .../firebaseui/android/demo/MainActivity.kt | 27 ++++++++++++------- .../auth/CustomMethodPickerDemoActivity.kt | 2 +- gradle/libs.versions.toml | 1 + 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bef4c25f5..bb74b0a75 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -57,8 +57,8 @@ dependencies { implementation(project(":database")) implementation(project(":firestore")) implementation(project(":storage")) - implementation(Config.Libs.Androidx.paging) - kapt(Config.Libs.Misc.glideCompiler) + 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 4bc5abcfe..77abc5776 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -59,31 +59,31 @@ android:theme="@style/Theme.FirebaseUIAndroid" /> 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 8faa13f7e..131d11cb6 100644 --- a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt @@ -48,6 +48,10 @@ class MainActivity : ComponentActivity() { 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?) { @@ -57,18 +61,21 @@ class MainActivity : ComponentActivity() { 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_FIRESTORE_EMULATOR) { + FirebaseFirestore.getInstance() + .useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT) + } - if (USE_DATABASE_EMULATOR) { - FirebaseDatabase.getInstance() - .useEmulator(DATABASE_EMULATOR_HOST, DATABASE_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) diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/CustomMethodPickerDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/CustomMethodPickerDemoActivity.kt index fd2e60aa6..a5c6a3546 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/CustomMethodPickerDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/CustomMethodPickerDemoActivity.kt @@ -2,7 +2,6 @@ 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 @@ -54,6 +53,7 @@ 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?) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index de5c4f169..1b1238aa6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }