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