From 1ecd498fdd4be507d34ea204c7ae1885f887cd4d Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:53:30 +0100 Subject: [PATCH 01/13] fix(auth): validate display name only when required in sign-up form (#2383) * fix(auth): validate display name only when required in sign-up form * fix(auth): use locale-safe matcher for sign-up button in test --- .../ui/auth/ui/screens/email/SignUpUI.kt | 2 +- .../ui/auth/ui/screens/email/SignUpUITest.kt | 115 ++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt 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() + } +} From d7be7ea97af23dce4192c3c464fb69ed4e625d5c Mon Sep 17 00:00:00 2001 From: Oluwatobi Shokunbi Date: Mon, 27 Jul 2026 11:43:47 +0100 Subject: [PATCH 02/13] fix(internal): append CHAR_LIMIT to translation descriptions in export script (#2417) Fixes #1613 --- scripts/translations/export_translations.py | 72 +++++++++++++++++++-- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/scripts/translations/export_translations.py b/scripts/translations/export_translations.py index 5c10b58e2..b2f2bb24e 100644 --- a/scripts/translations/export_translations.py +++ b/scripts/translations/export_translations.py @@ -1,5 +1,6 @@ # coding=UTF-8 +import math import os import re import sys @@ -9,6 +10,59 @@ PREFIXED_NAME_START = 'name="fui_' UNPREFIXED_NAME_START = 'name="' +CHAR_LIMIT_PATTERN = re.compile(r'\[CHAR_LIMIT=\d+\]') +TRANSLATION_DESC_PATTERN = re.compile(r'(translation_description="[^"]*?)(")') +XML_TAG_PATTERN = re.compile(r'<[^>]+>') +STRING_VALUE_PATTERN = re.compile(r'>([^<]*(?:]*>[^<]*[^<]*)*)') +ITEM_VALUE_PATTERN = re.compile(r'>([^<]*(?:]*>[^<]*[^<]*)*)') + +XML_ENTITIES = {'&': '&', '<': '<', '>': '>', '"': '"', ''': "'"} +ENTITY_PATTERN = re.compile('|'.join( + re.escape(k) for k in sorted(XML_ENTITIES, key=len, reverse=True) +)) +ESCAPE_PATTERN = re.compile(r'\\(u[0-9A-Fa-f]{4}|n|t|\'|")') + + +def _decode_entities(text): + """Decode XML entities and Android escape sequences to get true visible length.""" + text = ENTITY_PATTERN.sub(lambda m: XML_ENTITIES[m.group()], text) + text = ESCAPE_PATTERN.sub('X', text) + return text + + +def _extract_visible_text(value): + """Strip XML tags and decode entities to get the visible text length.""" + text = XML_TAG_PATTERN.sub('', value).strip() + text = _decode_entities(text) + text = re.sub(r'\s+', ' ', text).strip() + return text + + +def _calculate_char_limit(english_text): + """Return ~1.5x the English text length, rounded up to the nearest 5.""" + length = len(english_text) + limit = math.ceil(length * 1.5) + return int(math.ceil(limit / 5.0) * 5) + + +def _add_char_limit(text, value_pattern): + """Add [CHAR_LIMIT=xxx] to translation_description if missing.""" + if 'translation_description=' not in text: + return text + desc_match = TRANSLATION_DESC_PATTERN.search(text) + if desc_match and CHAR_LIMIT_PATTERN.search(desc_match.group(1)): + return text + match = value_pattern.search(text) + if not match: + return text + visible = _extract_visible_text(match.group(1)) + if not visible: + return text + limit = _calculate_char_limit(visible) + return TRANSLATION_DESC_PATTERN.sub( + r'\1 [CHAR_LIMIT=%d]\2' % limit, text, count=1) + + class ExportTranslationsScript(BaseStringScript): def ProcessTag(self, line, type): @@ -16,13 +70,21 @@ def ProcessTag(self, line, type): if PREFIXED_NAME_START in joined: joined = joined.replace(PREFIXED_NAME_START, UNPREFIXED_NAME_START) - return joined.split('\n') - else: - return line + + if type == self.TYPE_STR: + joined = _add_char_limit(joined, STRING_VALUE_PATTERN) + elif type == self.TYPE_PLUR: + joined = re.sub( + r'(]*>.*?)', + lambda m: _add_char_limit(m.group(1), ITEM_VALUE_PATTERN), + joined, + flags=re.DOTALL + ) + + return joined.split('\n') def WriteFile(self, file_name, file_contents): - # Override to just print the contents - print file_contents + print(file_contents) if __name__ == '__main__': ets = ExportTranslationsScript() From 17b962050ee4ce1f6fadd4c70d59e0577affcbbe Mon Sep 17 00:00:00 2001 From: Oluwatobi Shokunbi Date: Mon, 27 Jul 2026 11:47:06 +0100 Subject: [PATCH 03/13] fix(auth): mitigate tapjacking on the auth activity window (#2416) Fixes #2041 --- .../java/com/firebase/ui/auth/FirebaseAuthActivity.kt | 1 + .../ui/auth/ui/components/ErrorRecoveryDialog.kt | 4 ++++ .../ui/auth/ui/components/ReauthenticationDialog.kt | 4 ++++ .../com/firebase/ui/auth/FirebaseAuthActivityTest.kt | 10 ++++++++++ 4 files changed, 19 insertions(+) diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt index 32e9eacd9..b936d1cb2 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt @@ -77,6 +77,7 @@ class FirebaseAuthActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + window.decorView.filterTouchesWhenObscured = true enableEdgeToEdge() // Extract configuration and auth instance from cache using UUID key diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt index ffb7baa8d..d0d707bda 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt @@ -19,7 +19,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.window.DialogProperties import com.firebase.ui.auth.AuthException @@ -80,6 +82,8 @@ fun ErrorRecoveryDialog( AlertDialog( onDismissRequest = onDismiss, title = { + val view = LocalView.current + SideEffect { view.rootView.filterTouchesWhenObscured = true } Text( text = stringProvider.errorDialogTitle, style = MaterialTheme.typography.headlineSmall diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt index 08c9ed89c..4622de957 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -39,6 +40,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -75,6 +77,8 @@ fun ReauthenticationDialog( AlertDialog( onDismissRequest = { if (!isLoading) onDismiss() }, title = { + val view = LocalView.current + SideEffect { view.rootView.filterTouchesWhenObscured = true } Text( text = stringProvider.reauthDialogTitle, style = MaterialTheme.typography.headlineSmall diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt index a94999439..5b2ee24b0 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt @@ -153,6 +153,16 @@ class FirebaseAuthActivityTest { assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_CANCELED) } + @Test + fun `activity sets filterTouchesWhenObscured on window after onCreate`() { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().get() + + assertThat(activity.window.decorView.filterTouchesWhenObscured).isTrue() + } + // ============================================================================================= // Configuration Extraction Tests // ============================================================================================= From 8b18a4f099db93f7f6dd82f011fe838aca7e7301 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:58:18 +0100 Subject: [PATCH 04/13] sample(app): add Firestore, Database & Storage demos to sample app (#2406) --- app/build.gradle.kts | 6 + app/src/main/AndroidManifest.xml | 48 ++- .../firebaseui/android/demo/MainActivity.kt | 246 +++++---------- .../android/demo/auth/AuthChooserActivity.kt | 285 ++++++++++++++++++ .../AuthFlowControllerDemoActivity.kt | 2 +- .../CredentialLinkingDemoActivity.kt | 2 +- .../CustomMethodPickerDemoActivity.kt | 3 +- .../CustomSlotsThemingDemoActivity.kt | 2 +- .../{ => auth}/EmailAuthSlotDemoActivity.kt | 2 +- .../{ => auth}/HighLevelApiDemoActivity.kt | 3 +- .../{ => auth}/PhoneAuthSlotDemoActivity.kt | 2 +- .../ShapeCustomizationDemoActivity.kt | 2 +- .../demo/database/DatabaseDemoActivity.kt | 182 +++++++++++ .../demo/firestore/FirestoreDemoActivity.kt | 189 ++++++++++++ .../demo/storage/StorageDemoActivity.kt | 204 +++++++++++++ .../demo/storage/StorageGlideModule.kt | 26 ++ e2eTest/database.rules.json | 9 + e2eTest/firebase.json | 12 + .../paging/FirestorePagingSourceTest.java | 31 ++ .../paging/FirestorePagingSource.java | 13 +- gradle/libs.versions.toml | 3 +- 21 files changed, 1074 insertions(+), 198 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}/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}/CustomSlotsThemingDemoActivity.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}/HighLevelApiDemoActivity.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%) create mode 100644 app/src/main/java/com/firebaseui/android/demo/database/DatabaseDemoActivity.kt create mode 100644 app/src/main/java/com/firebaseui/android/demo/firestore/FirestoreDemoActivity.kt 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 create mode 100644 e2eTest/database.rules.json 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..77abc5776 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..131d11cb6 100644 --- a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt @@ -17,44 +17,68 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.util.EmailLinkConstants +import com.firebaseui.android.demo.auth.AuthChooserActivity +import com.firebaseui.android.demo.auth.HighLevelApiDemoActivity +import com.firebaseui.android.demo.database.DatabaseDemoActivity +import com.firebaseui.android.demo.firestore.FirestoreDemoActivity +import com.firebaseui.android.demo.storage.StorageDemoActivity import com.google.firebase.FirebaseApp +import com.google.firebase.database.FirebaseDatabase +import com.google.firebase.firestore.FirebaseFirestore -/** - * Main launcher activity that allows users to choose between different - * authentication API demonstrations. - */ class MainActivity : ComponentActivity() { companion object { - private const val USE_AUTH_EMULATOR = false + internal const val USE_AUTH_EMULATOR = true private const val AUTH_EMULATOR_HOST = "10.0.2.2" private const val AUTH_EMULATOR_PORT = 9099 + + // 10.0.2.2 is the Android emulator's alias for the host machine's localhost. + private const val USE_FIRESTORE_EMULATOR = true + private const val FIRESTORE_EMULATOR_HOST = "10.0.2.2" + private const val FIRESTORE_EMULATOR_PORT = 8080 + + private const val USE_DATABASE_EMULATOR = true + private const val DATABASE_EMULATOR_HOST = "10.0.2.2" + private const val DATABASE_EMULATOR_PORT = 8199 + + // useEmulator() throws once the Firestore/Database client has been used elsewhere in + // the process, so this must only run once per process, not on every onCreate(). + private var emulatorsConfigured = false } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - // Initialize Firebase and configure emulator if needed FirebaseApp.initializeApp(applicationContext) val authUI = FirebaseAuthUI.getInstance() - if (USE_AUTH_EMULATOR) { - authUI.auth.useEmulator(AUTH_EMULATOR_HOST, AUTH_EMULATOR_PORT) + if (!emulatorsConfigured) { + if (USE_AUTH_EMULATOR) { + authUI.auth.useEmulator(AUTH_EMULATOR_HOST, AUTH_EMULATOR_PORT) + } + + if (USE_FIRESTORE_EMULATOR) { + FirebaseFirestore.getInstance() + .useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT) + } + + if (USE_DATABASE_EMULATOR) { + FirebaseDatabase.getInstance() + .useEmulator(DATABASE_EMULATOR_HOST, DATABASE_EMULATOR_PORT) + } + emulatorsConfigured = true } var pendingEmailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK) - if (pendingEmailLink.isNullOrEmpty() && authUI.canHandleIntent(intent)) { pendingEmailLink = intent.data?.toString() } @@ -62,10 +86,7 @@ class MainActivity : ComponentActivity() { Log.d("MainActivity", "Pending email link: $pendingEmailLink") fun launchHighLevelDemo() { - val demoIntent = Intent( - this, - HighLevelApiDemoActivity::class.java - ).apply { + val demoIntent = Intent(this, HighLevelApiDemoActivity::class.java).apply { pendingEmailLink?.let { link -> putExtra(EmailLinkConstants.EXTRA_EMAIL_LINK, link) pendingEmailLink = null @@ -87,17 +108,18 @@ class MainActivity : ComponentActivity() { color = MaterialTheme.colorScheme.background ) { ChooserScreen( - onHighLevelApiClick = ::launchHighLevelDemo, - onLowLevelApiClick = { - startActivity(Intent(this, AuthFlowControllerDemoActivity::class.java)) + onAuthClick = { + startActivity(Intent(this, AuthChooserActivity::class.java)) }, - onCustomSlotsClick = { - startActivity(Intent(this, CustomSlotsThemingDemoActivity::class.java)) + onDatabaseClick = { + startActivity(Intent(this, DatabaseDemoActivity::class.java)) }, - onCredentialLinkingClick = { - startActivity(Intent(this, CredentialLinkingDemoActivity::class.java)) + onFirestoreClick = { + startActivity(Intent(this, FirestoreDemoActivity::class.java)) }, - isEmulatorMode = USE_AUTH_EMULATOR + onStorageClick = { + startActivity(Intent(this, StorageDemoActivity::class.java)) + } ) } } @@ -107,226 +129,100 @@ class MainActivity : ComponentActivity() { @Composable fun ChooserScreen( - onHighLevelApiClick: () -> Unit, - onLowLevelApiClick: () -> Unit, - onCustomSlotsClick: () -> Unit, - onCredentialLinkingClick: () -> Unit = {}, - isEmulatorMode: Boolean = false + onAuthClick: () -> Unit, + onDatabaseClick: () -> Unit, + onFirestoreClick: () -> Unit, + onStorageClick: () -> Unit, ) { - val scrollState = rememberScrollState() - Column( modifier = Modifier .fillMaxSize() - .verticalScroll(scrollState) + .verticalScroll(rememberScrollState()) .systemBarsPadding() .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp) ) { - Spacer(modifier = Modifier.height(16.dp)) - // Header - Text( - text = "Firebase Auth UI Compose", - style = MaterialTheme.typography.headlineLarge, - textAlign = TextAlign.Center - ) + Spacer(modifier = Modifier.height(8.dp)) + Text("FirebaseUI Android", style = MaterialTheme.typography.headlineLarge) Text( - text = "Choose a demo to explore different authentication APIs", + "Choose a module to explore its demos", style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant ) - // Emulator Mode Warning - if (isEmulatorMode) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ) - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "⚠️ Emulator Mode", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onErrorContainer - ) - Text( - text = "Running with Firebase Auth Emulator. Some features like third-party" + - " OAuth providers (Facebook, Twitter, LINE etc.) may not work correctly." + - " Disable Firebase Auth Emulator using" + - " MainActivity.USE_AUTH_EMULATOR = false", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer - ) - } - } - } - - // High-Level API Card - Card( - modifier = Modifier.fillMaxWidth(), - onClick = onHighLevelApiClick - ) { + Card(modifier = Modifier.fillMaxWidth(), onClick = onAuthClick) { Column( modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = "🎨 High-Level API", + text = "Auth", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary ) Text( - text = "FirebaseAuthScreen Composable", - style = MaterialTheme.typography.titleMedium - ) - Text( - text = "Best for: Pure Compose applications that want a complete, ready-to-use authentication UI with minimal setup.", + text = "High-Level API, Low-Level API, Custom Slots & Theming, Credential Linking", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Features:", - style = MaterialTheme.typography.labelLarge - ) - Text( - text = "• Drop-in Composable\n• Automatic navigation\n• State management included\n• Customizable content", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } } - // Low-Level API Card - Card( - modifier = Modifier.fillMaxWidth(), - onClick = onLowLevelApiClick - ) { + Card(modifier = Modifier.fillMaxWidth(), onClick = onDatabaseClick) { Column( modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = "⚙️ Low-Level API", + text = "Database", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary ) Text( - text = "AuthFlowController", - style = MaterialTheme.typography.titleMedium - ) - Text( - text = "Best for: Applications that need fine-grained control over the authentication flow with ActivityResultLauncher integration.", + text = "Paginated list with FirebaseRecyclerPagingAdapter and orderByChild", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Features:", - style = MaterialTheme.typography.labelLarge - ) - Text( - text = "• Lifecycle-safe controller\n• ActivityResultLauncher\n• Observable state with Flow\n• Manual flow control", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } } - // Custom Slots & Theming Card - Card( - modifier = Modifier.fillMaxWidth(), - onClick = onCustomSlotsClick - ) { + Card(modifier = Modifier.fillMaxWidth(), onClick = onFirestoreClick) { Column( modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = "🎨 Custom Slots & Theming", + text = "Firestore", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary ) Text( - text = "Slot APIs & Theme Customization", - style = MaterialTheme.typography.titleMedium - ) - Text( - text = "Best for: Applications that need fully custom UI while leveraging the authentication logic and state management.", + text = "Paginated list with FirestorePagingAdapter and orderBy", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Features:", - style = MaterialTheme.typography.labelLarge - ) - Text( - text = "• Custom email auth UI via slots\n• Custom phone auth UI via slots\n• AuthUITheme.fromMaterialTheme()\n• Custom ProviderStyle examples", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } } - // Credential Linking Card - Card( - modifier = Modifier.fillMaxWidth(), - onClick = onCredentialLinkingClick - ) { + Card(modifier = Modifier.fillMaxWidth(), onClick = onStorageClick) { Column( modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = "🔗 Credential Linking", + text = "Storage", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary ) Text( - text = "isCredentialLinkingEnabled", - style = MaterialTheme.typography.titleMedium - ) - Text( - text = "Sign in with one provider, then add another to the same account without losing your UID.", + text = "Loading images from Firebase Storage with Glide", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } - Spacer(modifier = Modifier.height(16.dp)) - - // Info card - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer - ) - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "💡 Tip", - style = MaterialTheme.typography.labelLarge - ) - Text( - text = "Both APIs provide the same authentication capabilities. Choose based on your app's architecture and control requirements.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(8.dp)) } } diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt new file mode 100644 index 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..a5c6a3546 100644 --- a/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/CustomMethodPickerDemoActivity.kt @@ -1,4 +1,4 @@ -package com.firebaseui.android.demo +package com.firebaseui.android.demo.auth import android.os.Bundle import android.util.Log @@ -53,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/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..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/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/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 aa46a0332..1b1238aa6 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" } From 12762f1881f4dfc3f93831f5382a91aab6aceb39 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:37:06 +0100 Subject: [PATCH 05/13] fix(auth): allow customizing top app bar colors via AuthUITheme (#2391) --- .../demo/auth/HighLevelApiDemoActivity.kt | 223 +++++++++--------- auth/README.md | 20 ++ .../auth/configuration/theme/AuthUITheme.kt | 27 ++- .../auth/ui/screens/MfaEnrollmentDefaults.kt | 4 +- .../auth/ui/screens/email/ResetPasswordUI.kt | 2 +- .../ui/screens/email/SignInEmailLinkUI.kt | 2 +- .../ui/auth/ui/screens/email/SignInUI.kt | 2 +- .../ui/auth/ui/screens/email/SignUpUI.kt | 2 +- .../ui/screens/phone/EnterPhoneNumberUI.kt | 2 +- .../screens/phone/EnterVerificationCodeUI.kt | 2 +- .../configuration/theme/AuthUIThemeTest.kt | 76 ++++++ 11 files changed, 242 insertions(+), 120 deletions(-) diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt index 194ff2ae8..cfa10b93b 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -71,10 +72,6 @@ class HighLevelApiDemoActivity : ComponentActivity() { val authUI = FirebaseAuthUI.getInstance() val emailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK) - val customTheme = AuthUITheme.Default.copy( - providerButtonShape = ShapeDefaults.ExtraLarge - ) - class CustomAuthUIStringProvider( private val defaultProvider: AuthUIStringProvider ) : AuthUIStringProvider by defaultProvider { @@ -86,124 +83,132 @@ class HighLevelApiDemoActivity : ComponentActivity() { val customStringProvider = CustomAuthUIStringProvider(DefaultAuthUIStringProvider(applicationContext)) - val configuration = authUIConfiguration { - context = applicationContext - theme = customTheme - logo = AuthUIAsset.Resource(R.drawable.firebase_auth) - tosUrl = "https://policies.google.com/terms" - privacyPolicyUrl = "https://policies.google.com/privacy" - isAnonymousUpgradeEnabled = false - isMfaEnabled = false - stringProvider = customStringProvider - transitions = AuthUITransitions( - enterTransition = { slideInHorizontally { it } }, - exitTransition = { slideOutHorizontally { -it } }, - popEnterTransition = { slideInHorizontally { -it } }, - popExitTransition = { slideOutHorizontally { it } } + setContent { + val customTheme = AuthUITheme.Adaptive.copy( + providerButtonShape = ShapeDefaults.ExtraLarge, + topAppBarColors = TopAppBarDefaults.topAppBarColors( + containerColor = Color(0xFFFFA000), + scrolledContainerColor = Color(0xFFFFA000), + ) ) - providers { - provider(AuthProvider.Anonymous) - provider( - AuthProvider.Google( - scopes = listOf("email"), - serverClientId = "406099696497-a12gakvts4epfk5pkio7dphc1anjiggc.apps.googleusercontent.com", - ) + + val configuration = authUIConfiguration { + context = applicationContext + theme = customTheme + logo = AuthUIAsset.Resource(R.drawable.firebase_auth) + tosUrl = "https://policies.google.com/terms" + privacyPolicyUrl = "https://policies.google.com/privacy" + isAnonymousUpgradeEnabled = false + isMfaEnabled = false + stringProvider = customStringProvider + transitions = AuthUITransitions( + enterTransition = { slideInHorizontally { it } }, + exitTransition = { slideOutHorizontally { -it } }, + popEnterTransition = { slideInHorizontally { -it } }, + popExitTransition = { slideOutHorizontally { it } } ) - provider( - AuthProvider.Email( - isDisplayNameRequired = true, - isEmailLinkForceSameDeviceEnabled = false, - isEmailLinkSignInEnabled = true, - emailLinkActionCodeSettings = actionCodeSettings { - url = "https://flutterfire-e2e-tests.firebaseapp.com" - handleCodeInApp = true - setAndroidPackageName( - "com.firebaseui.android.demo", - true, - null - ) - }, - isNewAccountsAllowed = true, - minimumPasswordLength = 8, - passwordValidationRules = listOf( - PasswordRule.MinimumLength(8), - PasswordRule.RequireLowercase, - PasswordRule.RequireUppercase, - ), + providers { + provider(AuthProvider.Anonymous) + provider( + AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "406099696497-a12gakvts4epfk5pkio7dphc1anjiggc.apps.googleusercontent.com", + ) ) - ) - provider( - AuthProvider.Phone( - defaultNumber = null, - defaultCountryCode = null, - allowedCountries = emptyList(), - smsCodeLength = 6, - timeout = 120L, - isInstantVerificationEnabled = true + provider( + AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkForceSameDeviceEnabled = false, + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = actionCodeSettings { + url = "https://flutterfire-e2e-tests.firebaseapp.com" + handleCodeInApp = true + setAndroidPackageName( + "com.firebaseui.android.demo", + true, + null + ) + }, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireLowercase, + PasswordRule.RequireUppercase, + ), + ) ) - ) - provider( - AuthProvider.Facebook() - ) - provider( - AuthProvider.Twitter( - customParameters = emptyMap() + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = emptyList(), + smsCodeLength = 6, + timeout = 120L, + isInstantVerificationEnabled = true + ) ) - ) - provider( - AuthProvider.Apple( - customParameters = emptyMap(), - locale = null + provider( + AuthProvider.Facebook() ) - ) - provider( - AuthProvider.Microsoft( - scopes = emptyList(), - tenant = "", - customParameters = emptyMap(), + provider( + AuthProvider.Twitter( + customParameters = emptyMap() + ) ) - ) - provider( - AuthProvider.Github( - scopes = emptyList(), - customParameters = emptyMap(), + provider( + AuthProvider.Apple( + customParameters = emptyMap(), + locale = null + ) ) - ) - provider( - AuthProvider.Yahoo( - scopes = emptyList(), - customParameters = emptyMap(), + provider( + AuthProvider.Microsoft( + scopes = emptyList(), + tenant = "", + customParameters = emptyMap(), + ) ) - ) - provider( - AuthProvider.GenericOAuth( - providerName = "LINE", - providerId = "oidc.line", - scopes = emptyList(), - customParameters = emptyMap(), - buttonLabel = "Sign in with LINE", - buttonIcon = AuthUIAsset.Resource(R.drawable.ic_line_logo_24dp), - buttonColor = Color(0xFF06C755), - contentColor = Color.White + provider( + AuthProvider.Github( + scopes = emptyList(), + customParameters = emptyMap(), + ) ) - ) - provider( - AuthProvider.GenericOAuth( - providerName = "Discord", - providerId = "oidc.discord", - scopes = emptyList(), - customParameters = emptyMap(), - buttonLabel = "Sign in with Discord", - buttonIcon = AuthUIAsset.Resource(R.drawable.ic_discord_24dp), - buttonColor = Color(0xFF5865F2), - contentColor = Color.White + provider( + AuthProvider.Yahoo( + scopes = emptyList(), + customParameters = emptyMap(), + ) ) - ) + provider( + AuthProvider.GenericOAuth( + providerName = "LINE", + providerId = "oidc.line", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = "Sign in with LINE", + buttonIcon = AuthUIAsset.Resource(R.drawable.ic_line_logo_24dp), + buttonColor = Color(0xFF06C755), + contentColor = Color.White + ) + ) + provider( + AuthProvider.GenericOAuth( + providerName = "Discord", + providerId = "oidc.discord", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = "Sign in with Discord", + buttonIcon = AuthUIAsset.Resource(R.drawable.ic_discord_24dp), + buttonColor = Color(0xFF5865F2), + contentColor = Color.White + ) + ) + } } - } - setContent { - AuthUITheme { + AuthUITheme(theme = customTheme) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background diff --git a/auth/README.md b/auth/README.md index e4311d8f3..1cf4c8e00 100644 --- a/auth/README.md +++ b/auth/README.md @@ -1546,6 +1546,26 @@ val configuration = authUIConfiguration { } ``` +### Customizing the Top App Bar + +Override the colors used by the top app bar shown on auth screens: + +```kotlin +val customTheme = AuthUITheme.Default.copy( + topAppBarColors = TopAppBarDefaults.topAppBarColors( + containerColor = Color(0xFF2E7D32), + scrolledContainerColor = Color(0xFF2E7D32), + ) +) + +val configuration = authUIConfiguration { + providers { provider(AuthProvider.Email()) } + theme = customTheme +} +``` + +If left unset (`null`), the top app bar falls back to colors derived from `colorScheme`'s `primary`/`onPrimary`. + ### Screen Transitions Customize the animations when navigating between screens using the `AuthUITransitions` object: diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUITheme.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUITheme.kt index 79e78f80f..79a7db901 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUITheme.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUITheme.kt @@ -16,9 +16,9 @@ package com.firebase.ui.auth.configuration.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Shapes +import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme @@ -81,6 +81,12 @@ class AuthUITheme( * ``` */ val providerButtonShape: Shape? = null, + + /** + * Custom colors for the top app bar shown on auth screens. If null, falls back to + * colors derived from [colorScheme] (see [AuthUITheme.topAppBarColors]). + */ + val topAppBarColors: TopAppBarColors? = null, ) { /** @@ -91,6 +97,7 @@ class AuthUITheme( * @param shapes The shapes to use. Defaults to this theme's shapes. * @param providerStyles Custom styling for individual providers. Defaults to this theme's provider styles. * @param providerButtonShape Default shape for provider buttons. Defaults to this theme's provider button shape. + * @param topAppBarColors Custom top app bar colors. Defaults to this theme's top app bar colors. * @return A new AuthUITheme instance with the specified properties. */ fun copy( @@ -99,13 +106,15 @@ class AuthUITheme( shapes: Shapes = this.shapes, providerStyles: Map = this.providerStyles, providerButtonShape: Shape? = this.providerButtonShape, + topAppBarColors: TopAppBarColors? = this.topAppBarColors, ): AuthUITheme { return AuthUITheme( colorScheme = colorScheme, typography = typography, shapes = shapes, providerStyles = providerStyles, - providerButtonShape = providerButtonShape + providerButtonShape = providerButtonShape, + topAppBarColors = topAppBarColors ) } @@ -118,6 +127,7 @@ class AuthUITheme( if (shapes != other.shapes) return false if (providerStyles != other.providerStyles) return false if (providerButtonShape != other.providerButtonShape) return false + if (topAppBarColors != other.topAppBarColors) return false return true } @@ -128,12 +138,14 @@ class AuthUITheme( result = 31 * result + shapes.hashCode() result = 31 * result + providerStyles.hashCode() result = 31 * result + (providerButtonShape?.hashCode() ?: 0) + result = 31 * result + (topAppBarColors?.hashCode() ?: 0) return result } override fun toString(): String { return "AuthUITheme(colorScheme=$colorScheme, typography=$typography, shapes=$shapes, " + - "providerStyles=$providerStyles, providerButtonShape=$providerButtonShape)" + "providerStyles=$providerStyles, providerButtonShape=$providerButtonShape, " + + "topAppBarColors=$topAppBarColors)" } /** @@ -228,7 +240,6 @@ class AuthUITheme( ) } - @OptIn(ExperimentalMaterial3Api::class) @get:Composable val topAppBarColors get() = TopAppBarDefaults.topAppBarColors( @@ -236,6 +247,14 @@ class AuthUITheme( titleContentColor = MaterialTheme.colorScheme.onPrimary, navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, ) + + /** + * Resolves the top app bar colors to use for the current [LocalAuthUITheme], falling back + * to [topAppBarColors] when the current theme doesn't specify its own. + */ + @get:Composable + val resolvedTopAppBarColors: TopAppBarColors + get() = LocalAuthUITheme.current.topAppBarColors ?: topAppBarColors } } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt index 61f1151a6..1cbb45949 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt @@ -53,6 +53,7 @@ import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.MfaFactor import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.mfa.MfaEnrollmentContentState import com.firebase.ui.auth.mfa.MfaEnrollmentStep import com.firebase.ui.auth.mfa.toMfaErrorMessage @@ -253,7 +254,8 @@ private fun SelectFactorUI( Scaffold( topBar = { TopAppBar( - title = { Text(stringProvider.mfaManageFactorsTitle) } + title = { Text(stringProvider.mfaManageFactorsTitle) }, + colors = AuthUITheme.resolvedTopAppBarColors ) } ) { innerPadding -> diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt index 8b73f2c2d..9d94b6e03 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt @@ -130,7 +130,7 @@ fun ResetPasswordUI( } } }, - colors = AuthUITheme.topAppBarColors + colors = AuthUITheme.resolvedTopAppBarColors ) }, ) { innerPadding -> diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt index 14e045827..0a27749dc 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt @@ -141,7 +141,7 @@ fun SignInEmailLinkUI( } } }, - colors = AuthUITheme.topAppBarColors + colors = AuthUITheme.resolvedTopAppBarColors ) }, ) { innerPadding -> diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt index eabde87a1..3093f23f1 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt @@ -170,7 +170,7 @@ fun SignInUI( } } }, - colors = AuthUITheme.topAppBarColors + colors = AuthUITheme.resolvedTopAppBarColors ) }, ) { innerPadding -> 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 6ff0d19f4..c05a1c03c 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 @@ -120,7 +120,7 @@ fun SignUpUI( } } }, - colors = AuthUITheme.topAppBarColors + colors = AuthUITheme.resolvedTopAppBarColors ) }, ) { innerPadding -> diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt index ef8bfe545..21e0d5ae5 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt @@ -99,7 +99,7 @@ fun EnterPhoneNumberUI( } } }, - colors = AuthUITheme.topAppBarColors + colors = AuthUITheme.resolvedTopAppBarColors ) }, ) { innerPadding -> diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt index 122e73a87..b678ab4fe 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt @@ -103,7 +103,7 @@ fun EnterVerificationCodeUI( } } }, - colors = AuthUITheme.topAppBarColors + colors = AuthUITheme.resolvedTopAppBarColors ) }, ) { innerPadding -> diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/theme/AuthUIThemeTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/theme/AuthUIThemeTest.kt index 46e6a1dc8..caa6f9022 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/theme/AuthUIThemeTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/theme/AuthUIThemeTest.kt @@ -7,6 +7,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Shapes import androidx.compose.material3.ShapeDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme @@ -313,6 +315,80 @@ class AuthUIThemeTest { assertThat(observedProviderStyles?.get("google.com")?.backgroundColor).isEqualTo(Color.Red) } + // ======================================================================== + // topAppBarColors Tests + // ======================================================================== + + @Test + fun `Default theme has null topAppBarColors`() { + assertThat(AuthUITheme.Default.topAppBarColors).isNull() + } + + @Test + fun `Copy with custom topAppBarColors applies correctly`() { + lateinit var customColors: TopAppBarColors + var observedColors: TopAppBarColors? = null + + composeTestRule.setContent { + customColors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Red, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + ) + val customTheme = AuthUITheme.Default.copy(topAppBarColors = customColors) + + CompositionLocalProvider( + LocalAuthUITheme provides customTheme + ) { + observedColors = LocalAuthUITheme.current.topAppBarColors + } + } + + composeTestRule.waitForIdle() + + assertThat(observedColors).isEqualTo(customColors) + } + + @Test + fun `resolvedTopAppBarColors falls back to default when theme value is null`() { + var resolvedColors: TopAppBarColors? = null + var defaultColors: TopAppBarColors? = null + + composeTestRule.setContent { + AuthUITheme(theme = AuthUITheme.Default) { + defaultColors = AuthUITheme.topAppBarColors + resolvedColors = AuthUITheme.resolvedTopAppBarColors + } + } + + composeTestRule.waitForIdle() + + assertThat(resolvedColors).isEqualTo(defaultColors) + } + + @Test + fun `resolvedTopAppBarColors uses the theme's custom value when set`() { + lateinit var customColors: TopAppBarColors + var resolvedColors: TopAppBarColors? = null + + composeTestRule.setContent { + customColors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Red, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + ) + val customTheme = AuthUITheme.Default.copy(topAppBarColors = customColors) + + AuthUITheme(theme = customTheme) { + resolvedColors = AuthUITheme.resolvedTopAppBarColors + } + } + + composeTestRule.waitForIdle() + + assertThat(resolvedColors).isEqualTo(customColors) + } + // ======================================================================== // fromMaterialTheme Tests // ======================================================================== From 76e5a3f48a1872afa7a758a59a0c4c903fb3a1db Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:00:00 +0100 Subject: [PATCH 06/13] fix(auth): handle edge-to-edge insets in MFA challenge and auth screens (#2413) --- .../auth/CustomMethodPickerDemoActivity.kt | 51 +++-- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 59 ++++-- .../auth/ui/screens/MfaChallengeDefaults.kt | 197 ++++++++++-------- .../auth/ui/screens/email/ResetPasswordUI.kt | 4 +- .../ui/screens/email/SignInEmailLinkUI.kt | 4 +- .../ui/auth/ui/screens/email/SignInUI.kt | 4 +- .../ui/auth/ui/screens/email/SignUpUI.kt | 4 +- .../ui/screens/phone/EnterPhoneNumberUI.kt | 4 +- .../screens/phone/EnterVerificationCodeUI.kt | 4 +- .../ui/screens/FirebaseAuthScreenSlotsTest.kt | 116 +++++++++++ 10 files changed, 304 insertions(+), 143 deletions(-) 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 a5c6a3546..5228927d9 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 @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow @@ -51,7 +52,6 @@ import com.firebase.ui.auth.configuration.theme.AuthUIAsset import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults import com.firebase.ui.auth.ui.components.AuthProviderButton -import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen import com.firebaseui.android.demo.R @@ -145,29 +145,11 @@ class CustomMethodPickerDemoActivity : ComponentActivity() { SpotlightMethodPicker( providers = providers, onProviderSelected = onProviderSelected, - enabled = termsAccepted + enabled = termsAccepted, + termsAccepted = termsAccepted, + onTermsAcceptedChange = { termsAccepted = it } ) }, - customMethodPickerTermsConfiguration = MethodPickerTermsConfiguration( - content = { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = termsAccepted, - onCheckedChange = { termsAccepted = it } - ) - Text( - text = "I have read and accept the Terms of Service and Privacy Policy", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(start = 8.dp) - ) - } - }, - accepted = termsAccepted, - disableProvidersUntilAccepted = true, - ), ) } } @@ -180,6 +162,8 @@ fun SpotlightMethodPicker( providers: List, onProviderSelected: (AuthProvider) -> Unit, enabled: Boolean = true, + termsAccepted: Boolean = true, + onTermsAcceptedChange: (Boolean) -> Unit = {}, ) { val stringProvider = LocalAuthUIStringProvider.current @@ -197,7 +181,9 @@ fun SpotlightMethodPicker( val anonymous = groups["anonymous"]?.firstOrNull() LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding(), contentPadding = PaddingValues(vertical = 48.dp), verticalArrangement = Arrangement.spacedBy(12.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -290,6 +276,24 @@ fun SpotlightMethodPicker( } } } + + item { + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = termsAccepted, + onCheckedChange = onTermsAcceptedChange + ) + Text( + text = "I have read and accept the Terms of Service and Privacy Policy", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 8.dp) + ) + } + } } } @@ -347,6 +351,7 @@ private fun styleForProvider(provider: AuthProvider): AuthUITheme.ProviderStyle backgroundColor = provider.buttonColor ?: Color(0xFF666666), contentColor = provider.contentColor ?: Color.White ) + else -> AuthUITheme.ProviderStyle( icon = null, backgroundColor = Color(0xFF666666), diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index fffcf5905..2d40e98e7 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -104,6 +105,14 @@ import kotlinx.coroutines.tasks.await * @param authenticatedContent Optional slot that allows callers to render the authenticated * state themselves. When provided, it receives the current [AuthState] alongside an * [AuthSuccessUiContext] containing common callbacks (sign out, manage MFA, reload user). + * @param customMethodPickerLayout Optional slot that fully replaces the method-picker screen. + * When provided, it renders as the *entire* screen content — edge-to-edge, with no logo, no + * Terms of Service/Privacy Policy footer, and no automatic system-inset handling. The caller is + * responsible for its own insets (e.g. `Modifier.safeDrawingPadding()`) and for displaying any + * required legal disclosures. [customMethodPickerTermsConfiguration] is ignored when this is set. + * @param customMethodPickerTermsConfiguration Optional custom Terms of Service/Privacy Policy + * footer for the *default* method-picker layout. Ignored when [customMethodPickerLayout] is + * provided, since that slot takes over the whole screen. * * @since 10.0.0 */ @@ -207,19 +216,24 @@ fun FirebaseAuthScreen( } ) { composable(AuthRoute.MethodPicker.route) { - Scaffold { innerPadding -> - AuthMethodPicker( - modifier = modifier - .padding(innerPadding), - providers = configuration.providers, - logo = logoAsset, - termsOfServiceUrl = configuration.tosUrl, - privacyPolicyUrl = configuration.privacyPolicyUrl, - lastSignInPreference = lastSignInPreference.value, - customLayout = customMethodPickerLayout, - termsConfiguration = customMethodPickerTermsConfiguration, - onProviderSelected = onProviderSelected, - ) + if (customMethodPickerLayout != null) { + Box(modifier = modifier.fillMaxSize()) { + customMethodPickerLayout(configuration.providers, onProviderSelected) + } + } else { + Scaffold { innerPadding -> + AuthMethodPicker( + modifier = modifier + .padding(innerPadding), + providers = configuration.providers, + logo = logoAsset, + termsOfServiceUrl = configuration.tosUrl, + privacyPolicyUrl = configuration.privacyPolicyUrl, + lastSignInPreference = lastSignInPreference.value, + termsConfiguration = customMethodPickerTermsConfiguration, + onProviderSelected = onProviderSelected, + ) + } } } @@ -928,13 +942,18 @@ private fun ReauthSheetContent( popExitTransition = { fadeOut(animationSpec = tween(700)) }, ) { composable(AuthRoute.MethodPicker.route) { - Scaffold { innerPadding -> - AuthMethodPicker( - modifier = Modifier.padding(innerPadding), - providers = reauthConfig.providers, - customLayout = customMethodPickerLayout, - onProviderSelected = onProviderSelected, - ) + if (customMethodPickerLayout != null) { + Box(modifier = Modifier.fillMaxSize()) { + customMethodPickerLayout(reauthConfig.providers, onProviderSelected) + } + } else { + Scaffold { innerPadding -> + AuthMethodPicker( + modifier = Modifier.padding(innerPadding), + providers = reauthConfig.providers, + onProviderSelected = onProviderSelected, + ) + } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt index bca4bc4c5..0780348ee 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt @@ -27,17 +27,22 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.configuration.validators.VerificationCodeValidator import com.firebase.ui.auth.mfa.MfaChallengeContentState import com.firebase.ui.auth.ui.components.VerificationCodeInputField @@ -50,108 +55,136 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { VerificationCodeValidator(stringProvider) } - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = if (isSms) { - val phoneLabel = state.maskedPhoneNumber ?: "" - stringProvider.enterVerificationCodeTitle(phoneLabel) - } else { - stringProvider.mfaStepVerifyFactorTitle - }, - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center - ) - - if (isSms && state.maskedPhoneNumber != null) { - Text( - text = stringProvider.mfaStepVerifyFactorSmsHelper, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - if (state.error != null) { + Scaffold { innerPadding -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { Text( - text = state.error, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, + text = if (isSms) { + val phoneLabel = state.maskedPhoneNumber ?: "" + stringProvider.enterVerificationCodeTitle(phoneLabel) + } else { + stringProvider.mfaStepVerifyFactorTitle + }, + style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center ) - } - Spacer(modifier = Modifier.height(8.dp)) + if (isSms && state.maskedPhoneNumber != null) { + Text( + text = stringProvider.mfaStepVerifyFactorSmsHelper, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } - VerificationCodeInputField( - modifier = Modifier.align(Alignment.CenterHorizontally), - codeLength = 6, - validator = verificationCodeValidator, - isError = state.error != null, - errorMessage = state.error, - onCodeChange = state.onVerificationCodeChange - ) + if (state.error != null) { + Text( + text = state.error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - if (isSms) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - TextButton( - onClick = { state.onResendCodeClick?.invoke() }, - enabled = state.onResendCodeClick != null && !state.isLoading && state.resendTimer == 0 + VerificationCodeInputField( + modifier = Modifier.align(Alignment.CenterHorizontally), + codeLength = 6, + validator = verificationCodeValidator, + isError = state.error != null, + errorMessage = state.error, + onCodeChange = state.onVerificationCodeChange + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (isSms) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = if (state.resendTimer > 0) { - val minutes = state.resendTimer / 60 - val seconds = state.resendTimer % 60 - val formatted = "$minutes:${String.format(java.util.Locale.ROOT, "%02d", seconds)}" - stringProvider.resendCodeTimer(formatted) - } else { - stringProvider.resendCode - } - ) - } + TextButton( + onClick = { state.onResendCodeClick?.invoke() }, + enabled = state.onResendCodeClick != null && !state.isLoading && state.resendTimer == 0 + ) { + Text( + text = if (state.resendTimer > 0) { + val minutes = state.resendTimer / 60 + val seconds = state.resendTimer % 60 + val formatted = "$minutes:${String.format(java.util.Locale.ROOT, "%02d", seconds)}" + stringProvider.resendCodeTimer(formatted) + } else { + stringProvider.resendCode + } + ) + } - TextButton( + TextButton( + onClick = state.onCancelClick, + enabled = !state.isLoading + ) { + Text(stringProvider.useDifferentMethodAction) + } + } + } else { + OutlinedButton( onClick = state.onCancelClick, - enabled = !state.isLoading + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth() ) { - Text(stringProvider.useDifferentMethodAction) + Text(stringProvider.dismissAction) } } - } else { - OutlinedButton( - onClick = state.onCancelClick, - enabled = !state.isLoading, + + Button( + onClick = state.onVerifyClick, + enabled = state.isValid && !state.isLoading, modifier = Modifier.fillMaxWidth() ) { - Text(stringProvider.dismissAction) + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.padding(end = 8.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } + Text(stringProvider.verifyAction) } } + } +} - Button( - onClick = state.onVerifyClick, - enabled = state.isValid && !state.isLoading, - modifier = Modifier.fillMaxWidth() +/** + * Renders with a simulated status/nav bar (see CP-240) so correct edge-to-edge inset handling + * can be verified in the IDE preview. A plain `@Preview` draws no system chrome at all, so + * inset issues would be invisible there. + */ +@Preview(showSystemUi = true) +@Composable +private fun PreviewDefaultMfaChallengeContentEdgeToEdge() { + val applicationContext = LocalContext.current + val stringProvider = DefaultAuthUIStringProvider(applicationContext) + + AuthUITheme { + CompositionLocalProvider( + LocalAuthUIStringProvider provides stringProvider ) { - if (state.isLoading) { - CircularProgressIndicator( - modifier = Modifier.padding(end = 8.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary + DefaultMfaChallengeContent( + state = MfaChallengeContentState( + factorType = MfaFactor.Sms, + maskedPhoneNumber = "+1••••••890" ) - } - Text(stringProvider.verifyAction) + ) } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt index 9d94b6e03..7d1de8a23 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons @@ -137,8 +136,7 @@ fun ResetPasswordUI( Column( modifier = Modifier .padding(innerPadding) - .safeDrawingPadding() - .padding(horizontal = 16.dp) + .padding(16.dp) .verticalScroll(rememberScrollState()), ) { AuthTextField( diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt index 0a27749dc..f2ec55fa3 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -148,8 +147,7 @@ fun SignInEmailLinkUI( Column( modifier = Modifier .padding(innerPadding) - .safeDrawingPadding() - .padding(horizontal = 16.dp) + .padding(16.dp) .verticalScroll(rememberScrollState()), ) { AuthTextField( diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt index 3093f23f1..4f290c699 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -177,8 +176,7 @@ fun SignInUI( Column( modifier = Modifier .padding(innerPadding) - .safeDrawingPadding() - .padding(horizontal = 16.dp) + .padding(16.dp) .verticalScroll(rememberScrollState()), ) { AuthTextField( diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt index c05a1c03c..7b6ba03c5 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -127,8 +126,7 @@ fun SignUpUI( Column( modifier = Modifier .padding(innerPadding) - .safeDrawingPadding() - .padding(horizontal = 16.dp) + .padding(16.dp) .verticalScroll(rememberScrollState()), ) { if (provider.isDisplayNameRequired) { diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt index 21e0d5ae5..2b9ffc13d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions @@ -106,8 +105,7 @@ fun EnterPhoneNumberUI( Column( modifier = Modifier .padding(innerPadding) - .safeDrawingPadding() - .padding(horizontal = 16.dp) + .padding(16.dp) .verticalScroll(rememberScrollState()), ) { Text(stringProvider.enterPhoneNumberTitle) diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt index b678ab4fe..be90bbf0b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -110,8 +109,7 @@ fun EnterVerificationCodeUI( Column( modifier = Modifier .padding(innerPadding) - .safeDrawingPadding() - .padding(horizontal = 16.dp) + .padding(16.dp) .verticalScroll(rememberScrollState()), ) { Text( diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt index 0c3836ace..6273b32b4 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt @@ -15,6 +15,7 @@ package com.firebase.ui.auth.ui.screens import android.content.Context +import androidx.compose.material3.Checkbox import androidx.compose.material3.Text import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -22,16 +23,22 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.authUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.UserInfo import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -133,6 +140,115 @@ class FirebaseAuthScreenSlotsTest { composeTestRule.onNodeWithTag("AuthMethodPicker LazyColumn").assertIsDisplayed() } + @Test + fun `customMethodPickerLayout provided hides the default method picker chrome`() { + val configuration = authUIConfiguration { + context = this@FirebaseAuthScreenSlotsTest.context + providers { + provider(AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList())) + provider(AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null)) + } + } + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + customMethodPickerLayout = { _, _ -> + Text( + text = "Custom Picker", + modifier = Modifier.testTag("custom_method_picker") + ) + } + ) + } + + composeTestRule.onNodeWithTag("custom_method_picker").assertIsDisplayed() + // AuthMethodPicker (and with it, the logo/ToS footer it renders) must not exist at all — + // customMethodPickerLayout now takes over the entire screen, it doesn't sit alongside them. + composeTestRule.onNodeWithTag("AuthMethodPicker LazyColumn").assertDoesNotExist() + } + + @Test + fun `customMethodPickerTermsConfiguration is ignored when customMethodPickerLayout is provided`() { + val configuration = authUIConfiguration { + context = this@FirebaseAuthScreenSlotsTest.context + providers { + provider(AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList())) + provider(AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null)) + } + } + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + customMethodPickerLayout = { _, _ -> + Text( + text = "Custom Picker", + modifier = Modifier.testTag("custom_method_picker") + ) + }, + customMethodPickerTermsConfiguration = MethodPickerTermsConfiguration( + content = { + Checkbox( + checked = false, + onCheckedChange = {}, + modifier = Modifier.testTag("terms_checkbox") + ) + } + ) + ) + } + + composeTestRule.onNodeWithTag("custom_method_picker").assertIsDisplayed() + composeTestRule.onNodeWithTag("terms_checkbox").assertDoesNotExist() + } + + @Test + fun `customMethodPickerLayout renders full-screen in the reauthentication sheet too`() { + val mockProviderInfo = mock(UserInfo::class.java) + `when`(mockProviderInfo.providerId).thenReturn("password") + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.providerData).thenReturn(listOf(mockProviderInfo)) + + val configuration = authUIConfiguration { + context = this@FirebaseAuthScreenSlotsTest.context + providers { + provider(AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList())) + provider(AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null)) + } + } + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + customMethodPickerLayout = { _, _ -> + Text( + text = "Custom Reauth Picker", + modifier = Modifier.testTag("custom_reauth_picker") + ) + } + ) + } + + authUI.updateAuthState(AuthState.ReauthenticationRequired(mockUser)) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("custom_reauth_picker").assertIsDisplayed() + composeTestRule.onNodeWithTag("AuthMethodPicker LazyColumn").assertDoesNotExist() + } + // ============================================================================================= // emailContent slot tests // ============================================================================================= From cbbd2628b1690501d53b23b3927bb5755f297062 Mon Sep 17 00:00:00 2001 From: Oluwatobi Shokunbi Date: Tue, 28 Jul 2026 11:53:09 +0100 Subject: [PATCH 07/13] fix(auth): use fixed padding for method picker provider buttons (#2424) (#2426) --- .../ui/auth/ui/method_picker/AuthMethodPicker.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt index feb04fb7c..e51c71c52 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt @@ -16,7 +16,6 @@ package com.firebase.ui.auth.ui.method_picker import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -24,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider @@ -128,14 +128,16 @@ fun AuthMethodPicker( customLayout(providers, onProviderSelected) } } else { - BoxWithConstraints( + Box( modifier = Modifier + .fillMaxWidth() .weight(1f), + contentAlignment = Alignment.TopCenter, ) { - val paddingWidth = maxWidth.value * 0.23 LazyColumn( modifier = Modifier - .padding(horizontal = paddingWidth.dp) + .widthIn(max = 400.dp) + .padding(horizontal = 24.dp) .testTag("AuthMethodPicker LazyColumn"), horizontalAlignment = Alignment.CenterHorizontally, ) { From 9b4ab9a231e5d148f4e18c077a87a8b9ed411d71 Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 20 Jul 2026 14:20:17 +0100 Subject: [PATCH 08/13] fix(firestore): catch InterruptedException in FirestorePagingSource --- .../com/firebase/ui/firestore/paging/FirestorePagingSource.java | 1 - 1 file changed, 1 deletion(-) 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 11d858a8a..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 @@ -60,7 +60,6 @@ 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 a3f5dc0b9091c2bfe98c057810367dcf5b98507f Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 21 Jul 2026 12:06:05 +0100 Subject: [PATCH 09/13] fix(firestore): drop load error logging and restore rx error handler in test --- .../ui/firestore/paging/FirestorePagingSourceTest.java | 4 +++- .../ui/firestore/paging/FirestorePagingSource.java | 9 +-------- 2 files changed, 4 insertions(+), 9 deletions(-) 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 3f28ae451..9ba76d27b 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 @@ -23,6 +23,7 @@ import androidx.paging.PagingSource.LoadResult.Page; import androidx.test.ext.junit.runners.AndroidJUnit4; import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.functions.Consumer; import io.reactivex.rxjava3.plugins.RxJavaPlugins; import static org.junit.Assert.assertEquals; @@ -117,6 +118,7 @@ public void testLoadSingle_interruptedWhileDisposed_doesNotThrowUndeliverableExc when(mMockQuery.get(Source.DEFAULT)).thenReturn(taskCompletionSource.getTask()); List undeliverableErrors = new CopyOnWriteArrayList<>(); + Consumer originalErrorHandler = RxJavaPlugins.getErrorHandler(); RxJavaPlugins.setErrorHandler(undeliverableErrors::add); try { Refresh refreshRequest = new Refresh<>(null, 2, false); @@ -132,7 +134,7 @@ public void testLoadSingle_interruptedWhileDisposed_doesNotThrowUndeliverableExc + "undeliverable RxJava exception: " + undeliverableErrors, undeliverableErrors.isEmpty()); } finally { - RxJavaPlugins.setErrorHandler(null); + RxJavaPlugins.setErrorHandler(originalErrorHandler); } } 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..ca96fb90a 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,7 +1,5 @@ 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; @@ -21,8 +19,6 @@ public class FirestorePagingSource extends RxPagingSource { - private static final String TAG = "FirestorePagingSource"; - private final Query mQuery; private final Source mSource; @@ -62,10 +58,7 @@ public Single> loadSingle(@NonNull LoadPar Thread.currentThread().interrupt(); return new LoadResult.Error(e); } - }).subscribeOn(Schedulers.io()).onErrorReturn(e -> { - Log.e(TAG, "FirestorePagingSource load failed", e); - return new LoadResult.Error<>(e); - }); + }).subscribeOn(Schedulers.io()).onErrorReturn(LoadResult.Error::new); } private LoadResult toLoadResult( From df4a17e908e22c14cf2530c8733fbc14a8a4f3f8 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 21 Jul 2026 12:56:03 +0100 Subject: [PATCH 10/13] fix(firestore): return LoadResult.Error for every load failure instead of throwing --- .../ui/firestore/paging/FirestorePagingSource.java | 12 +++++------- .../firestore/paging/FirestorePagingSourceTest.java | 0 2 files changed, 5 insertions(+), 7 deletions(-) rename firestore/src/{androidTest => test}/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java (100%) 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 ca96fb90a..4a6942bf9 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 @@ -47,16 +47,14 @@ public Single> loadSingle(@NonNull LoadPar } return toLoadResult(snapshot.getDocuments(), nextPage); } catch (ExecutionException e) { - if (e.getCause() instanceof Exception) { - // throw the original Exception - throw (Exception) e.getCause(); - } - // Only throw a new Exception when the original - // Throwable cannot be cast to Exception - throw new Exception(e); + // Report the original cause rather than the ExecutionException wrapper. + Throwable cause = e.getCause(); + return new LoadResult.Error(cause == null ? e : cause); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return new LoadResult.Error(e); + } catch (Exception e) { + return new LoadResult.Error(e); } }).subscribeOn(Schedulers.io()).onErrorReturn(LoadResult.Error::new); } diff --git a/firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java b/firestore/src/test/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java similarity index 100% rename from firestore/src/androidTest/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java rename to firestore/src/test/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java From 5f54054b9876fc2422fa5f1fd609641efddf9a9c Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 21 Jul 2026 12:56:03 +0100 Subject: [PATCH 11/13] fix(firestore): fix PageKey.equals NPE on null snapshots and add hashCode --- .../firebase/ui/firestore/paging/PageKey.java | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/firestore/src/main/java/com/firebase/ui/firestore/paging/PageKey.java b/firestore/src/main/java/com/firebase/ui/firestore/paging/PageKey.java index 45d4e7c7a..db3fc6a9a 100644 --- a/firestore/src/main/java/com/firebase/ui/firestore/paging/PageKey.java +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/PageKey.java @@ -3,6 +3,8 @@ import com.google.firebase.firestore.DocumentSnapshot; import com.google.firebase.firestore.Query; +import java.util.Objects; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; @@ -43,18 +45,25 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; PageKey key = (PageKey) o; - if (mStartAfter == null && key.mStartAfter == null && - mEndBefore == null && key.mEndBefore == null) - return true; - return mStartAfter.getId().equals(key.mStartAfter.getId()) && - mEndBefore.getId().equals(key.mEndBefore.getId()); + return Objects.equals(documentId(mStartAfter), documentId(key.mStartAfter)) && + Objects.equals(documentId(mEndBefore), documentId(key.mEndBefore)); + } + + @Override + public int hashCode() { + return Objects.hash(documentId(mStartAfter), documentId(mEndBefore)); + } + + @Nullable + private static String documentId(@Nullable DocumentSnapshot snapshot) { + return snapshot == null ? null : snapshot.getId(); } @Override @NonNull public String toString() { - String startAfter = mStartAfter == null ? null : mStartAfter.getId(); - String endBefore = mEndBefore == null ? null : mEndBefore.getId(); + String startAfter = documentId(mStartAfter); + String endBefore = documentId(mEndBefore); return "PageKey{" + "StartAfter=" + startAfter + ", EndBefore=" + endBefore + From e0c7dd9390b4fd6e83d4ccbce5d34f4d4b92d65e Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 21 Jul 2026 12:56:03 +0100 Subject: [PATCH 12/13] test(firestore): run paging source test in CI via robolectric and replace sleeps with latches --- firestore/build.gradle.kts | 9 +++ .../paging/FirestorePagingSourceTest.java | 78 +++++++++++++++---- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/firestore/build.gradle.kts b/firestore/build.gradle.kts index 6074dab4d..d048e6c63 100644 --- a/firestore/build.gradle.kts +++ b/firestore/build.gradle.kts @@ -19,6 +19,9 @@ android { testOptions { targetSdk = Config.SdkVersions.target + unitTests { + isIncludeAndroidResources = true + } } lint { @@ -64,6 +67,12 @@ dependencies { lintChecks(project(":lint")) + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.test.core) + testImplementation(libs.mockito.core) + testImplementation(libs.androidx.paging) + androidTestImplementation(libs.arch.core.testing) androidTestImplementation(libs.test.core) androidTestImplementation(libs.junit) diff --git a/firestore/src/test/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java b/firestore/src/test/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java index 9ba76d27b..810efb616 100644 --- a/firestore/src/test/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java +++ b/firestore/src/test/java/com/firebase/ui/firestore/paging/FirestorePagingSourceTest.java @@ -1,6 +1,9 @@ package com.firebase.ui.firestore.paging; -import com.google.android.gms.tasks.TaskCompletionSource; +import com.google.android.gms.tasks.OnCanceledListener; +import com.google.android.gms.tasks.OnFailureListener; +import com.google.android.gms.tasks.OnSuccessListener; +import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.Tasks; import com.google.firebase.firestore.DocumentSnapshot; import com.google.firebase.firestore.Query; @@ -12,28 +15,35 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; import androidx.paging.PagingSource; import androidx.paging.PagingSource.LoadParams.Append; import androidx.paging.PagingSource.LoadParams.Refresh; import androidx.paging.PagingSource.LoadResult.Page; -import androidx.test.ext.junit.runners.AndroidJUnit4; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.functions.Consumer; import io.reactivex.rxjava3.plugins.RxJavaPlugins; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@RunWith(AndroidJUnit4.class) +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) public class FirestorePagingSourceTest { @Mock @@ -110,32 +120,51 @@ public void testLoadAfter_failure() { assertEquals(expected, actual); } + /** + * Cancelling a load interrupts the thread blocked in Tasks.await(). The resulting + * InterruptedException must not escape the callable: by then the subscription is disposed, so + * RxJava would report it as an UndeliverableException and crash the app. + * + * See https://github.com/firebase/FirebaseUI-Android/issues/2008 + */ @Test - public void testLoadSingle_interruptedWhileDisposed_doesNotThrowUndeliverableException() - throws InterruptedException { + public void testLoadSingle_interruptedWhileDisposed_doesNotReportUndeliverableException() + throws Exception { FirestorePagingSource pagingSource = new FirestorePagingSource(mMockQuery, Source.DEFAULT); - TaskCompletionSource taskCompletionSource = new TaskCompletionSource<>(); - when(mMockQuery.get(Source.DEFAULT)).thenReturn(taskCompletionSource.getTask()); + + // Counted down once Tasks.await() starts registering listeners, which means the load is + // genuinely running and about to block. Without waiting on this, the test could dispose + // before the callable ever ran — and would then pass even against the unfixed source. + CountDownLatch loadInFlight = new CountDownLatch(1); + mockQueryNeverCompletes(loadInFlight); List undeliverableErrors = new CopyOnWriteArrayList<>(); + CountDownLatch undeliverableReported = new CountDownLatch(1); Consumer originalErrorHandler = RxJavaPlugins.getErrorHandler(); - RxJavaPlugins.setErrorHandler(undeliverableErrors::add); + RxJavaPlugins.setErrorHandler(throwable -> { + undeliverableErrors.add(throwable); + undeliverableReported.countDown(); + }); + try { Refresh refreshRequest = new Refresh<>(null, 2, false); Disposable disposable = pagingSource.loadSingle(refreshRequest) .subscribe(result -> { }, error -> { }); - Thread.sleep(300); + assertTrue("Load never reached Tasks.await(), so nothing was interrupted", + loadInFlight.await(5, TimeUnit.SECONDS)); + + // Interrupts the worker thread blocked in Tasks.await(). disposable.dispose(); - Thread.sleep(300); - assertTrue( - "Interruption during an in-flight load must not surface as an " - + "undeliverable RxJava exception: " + undeliverableErrors, - undeliverableErrors.isEmpty()); + assertFalse("Interrupting an in-flight load must not surface as an undeliverable " + + "RxJava exception: " + undeliverableErrors, + undeliverableReported.await(2, TimeUnit.SECONDS)); } finally { RxJavaPlugins.setErrorHandler(originalErrorHandler); } + + verify(mMockQuery).get(Source.DEFAULT); } private void initMockQuery() { @@ -154,4 +183,25 @@ private void mockQuerySuccess(List snapshots) { private void mockQueryFailure(Exception exception) { when(mMockQuery.get(Source.DEFAULT)).thenReturn(Tasks.forException(exception)); } + + /** + * Stubs the query with a task that never completes, so Tasks.await() blocks until the thread + * is interrupted. {@code inFlight} is counted down once await() has started. + */ + @SuppressWarnings("unchecked") + private void mockQueryNeverCompletes(CountDownLatch inFlight) { + Task neverCompletes = mock(Task.class); + when(neverCompletes.isComplete()).thenReturn(false); + when(neverCompletes.addOnSuccessListener(any(Executor.class), any(OnSuccessListener.class))) + .thenAnswer(invocation -> { + inFlight.countDown(); + return neverCompletes; + }); + when(neverCompletes.addOnFailureListener(any(Executor.class), any(OnFailureListener.class))) + .thenReturn(neverCompletes); + when(neverCompletes.addOnCanceledListener(any(Executor.class), any(OnCanceledListener.class))) + .thenReturn(neverCompletes); + + when(mMockQuery.get(Source.DEFAULT)).thenReturn(neverCompletes); + } } From 6da622d457955129d77362895ed3e8a50fe757bf Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 27 Jul 2026 09:36:03 +0100 Subject: [PATCH 13/13] fix(firestore): log genuine load failures while keeping cancellations silent --- .../ui/firestore/paging/FirestorePagingSource.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 4a6942bf9..5176e3ecd 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; @@ -49,11 +53,16 @@ public Single> loadSingle(@NonNull LoadPar } catch (ExecutionException e) { // Report the original cause rather than the ExecutionException wrapper. Throwable cause = e.getCause(); - return new LoadResult.Error(cause == null ? e : cause); + Throwable error = cause == null ? e : cause; + Log.e(TAG, "FirestorePagingSource load failed", error); + return new LoadResult.Error(error); } catch (InterruptedException e) { + // The load was cancelled (the subscription was disposed mid-await), not a real + // failure — restore the interrupt flag and return quietly without logging. Thread.currentThread().interrupt(); return new LoadResult.Error(e); } catch (Exception e) { + Log.e(TAG, "FirestorePagingSource load failed", e); return new LoadResult.Error(e); } }).subscribeOn(Schedulers.io()).onErrorReturn(LoadResult.Error::new);