From a62097d6aa8d62d0ba46e3c4606189d64bbec5fa Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Mon, 15 Sep 2025 15:59:23 +0100 Subject: [PATCH 01/13] feat(AuthUIConfiguration): implement configuration model, DSL builder and tests --- auth/build.gradle.kts | 13 + .../compose/configuration/AuthProvider.kt | 270 ++++++++++++++++++ .../configuration/AuthUIConfiguration.kt | 157 ++++++++++ .../configuration/AuthUIStringProvider.kt | 41 +++ .../auth/compose/configuration/AuthUITheme.kt | 134 +++++++++ .../compose/configuration/PasswordRule.kt | 52 ++++ .../configuration/AuthUIConfigurationTest.kt | 233 +++++++++++++++ buildSrc/src/main/kotlin/Config.kt | 18 +- 8 files changed, 915 insertions(+), 3 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index 66df263a8..c027e0848 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -4,6 +4,7 @@ plugins { id("com.android.library") id("com.vanniktech.maven.publish") id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" } android { @@ -67,9 +68,20 @@ android { kotlinOptions { jvmTarget = "17" } + buildFeatures { + compose = true + } } dependencies { + implementation(platform(Config.Libs.Androidx.Compose.bom)) + implementation(Config.Libs.Androidx.Compose.ui) + implementation(Config.Libs.Androidx.Compose.uiGraphics) + implementation(Config.Libs.Androidx.Compose.material3) + implementation(Config.Libs.Androidx.Compose.foundation) + implementation(Config.Libs.Androidx.Compose.tooling) + implementation(Config.Libs.Androidx.Compose.toolingPreview) + implementation(Config.Libs.Androidx.Compose.activityCompose) implementation(Config.Libs.Androidx.materialDesign) implementation(Config.Libs.Androidx.activity) // The new activity result APIs force us to include Fragment 1.3.0 @@ -101,6 +113,7 @@ dependencies { testImplementation(Config.Libs.Test.mockito) testImplementation(Config.Libs.Test.core) testImplementation(Config.Libs.Test.robolectric) + testImplementation(Config.Libs.Test.kotlinReflect) testImplementation(Config.Libs.Provider.facebook) debugImplementation(project(":internal:lintchecks")) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt new file mode 100644 index 000000000..36daa70a9 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt @@ -0,0 +1,270 @@ +/* + * 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.compose.configuration + +import android.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import com.google.firebase.auth.ActionCodeSettings + +@AuthUIConfigurationDsl +class AuthProvidersBuilder { + private val providers = mutableListOf() + + fun provider(provider: AuthProvider) { + providers.add(provider) + } + + internal fun build(): List = providers.toList() +} + +/** + * Base sealed class for authentication providers. + */ +sealed class AuthProvider() { + /** + * Email/Password authentication provider configuration. + */ + data class Email( + /** + * Requires the user to provide a display name. Defaults to true. + */ + val requireDisplayName: Boolean = true, + + /** + * Enables email link sign-in, Defaults to false. + */ + val enableEmailLinkSignIn: Boolean = false, + + /** + * Settings for email link actions. + */ + val actionCodeSettings: ActionCodeSettings?, + + /** + * Allows new accounts to be created. Defaults to true. + */ + val allowNewAccounts: Boolean = true, + + /** + * The minimum length for a password. Defaults to 6. + */ + val minimumPasswordLength: Int = 6, + + /** + * A list of custom password validation rules. + */ + val passwordValidationRules: List + ) : AuthProvider() + + /** + * Phone number authentication provider configuration. + */ + data class Phone( + /** + * The default country code to pre-select. + */ + val defaultCountryCode: String?, + + /** + * A list of allowed country codes. + */ + val allowedCountries: List?, + + /** + * The expected length of the SMS verification code. Defaults to 6. + */ + val smsCodeLength: Int = 6, + + /** + * The timeout in seconds for receiving the SMS. Defaults to 60L. + */ + val timeout: Long = 60L, + + /** + * Enables instant verification of the phone number. Defaults to true. + */ + val enableInstantVerification: Boolean = true, + + /** + * Enables automatic retrieval of the SMS code. Defaults to true. + */ + val enableAutoRetrieval: Boolean = true + ) : AuthProvider() + + /** + * Google Sign-In provider configuration. + */ + data class Google( + /** + * The list of scopes to request. + */ + val scopes: List, + + /** + * The OAuth 2.0 client ID for your server. + */ + val serverClientId: String?, + + /** + * Requests an ID token. Default to true. + */ + val requestIdToken: Boolean = true, + + /** + * Requests the user's profile information. Defaults to true. + */ + val requestProfile: Boolean = true, + + /** + * Requests the user's email address. Defaults to true. + */ + val requestEmail: Boolean = true + ) : AuthProvider() + + /** + * Facebook Login provider configuration. + */ + data class Facebook( + /** + * The list of scopes (permissions) to request. Defaults to email and public_profile. + */ + val scopes: List = listOf("email", "public_profile"), + + /** + * if true, enable limited login mode. Defaults to false. + */ + val limitedLogin: Boolean = false + ) : AuthProvider() + + /** + * Twitter/X authentication provider configuration. + */ + data class Twitter( + /** + * A map of custom OAuth parameters. + */ + val customParameters: Map + ) : AuthProvider() + + /** + * Github authentication provider configuration. + */ + data class Github( + /** + * The list of scopes to request. Defaults to user:email. + */ + val scopes: List = listOf("user:email"), + + /** + * A map of custom OAuth parameters. + */ + val customParameters: Map + ) : AuthProvider() + + /** + * Microsoft authentication provider configuration. + */ + data class Microsoft( + /** + * The list of scopes to request. Defaults to openid, profile, email. + */ + val scopes: List = listOf("openid", "profile", "email"), + + /** + * The tenant ID for Azure Active Directory. + */ + val tenant: String?, + + /** + * A map of custom OAuth parameters. + */ + val customParameters: Map + ) : AuthProvider() + + /** + * Yahoo authentication provider configuration. + */ + data class Yahoo( + /** + * The list of scopes to request. Defaults to openid, profile, email. + */ + val scopes: List = listOf("openid", "profile", "email"), + + /** + * A map of custom OAuth parameters. + */ + val customParameters: Map + ) : AuthProvider() + + /** + * Apple Sign-In provider configuration. + */ + data class Apple( + /** + * The list of scopes to request. Defaults to name and email. + */ + val scopes: List = listOf("name", "email"), + + /** + * The locale for the sign-in page. + */ + val locale: String?, + + /** + * A map of custom OAuth parameters. + */ + val customParameters: Map + ) : AuthProvider() + + /** + * Anonymous authentication provider. It has no configurable properties. + */ + object Anonymous : AuthProvider() + + /** + * A generic OAuth provider for any unsupported provider. + */ + data class GenericOAuth( + /** + * The provider ID as configured in the Firebase console. + */ + val providerId: String, + + /** + * The list of scopes to request. + */ + val scopes: List, + + /** + * A map of custom OAuth parameters. + */ + val customParameters: Map, + + /** + * The text to display on the provider button. + */ + val buttonLabel: String, + + /** + * An optional icon for the provider button. + */ + val buttonIcon: ImageVector?, + + /** + * An optional background color for the provider button. + */ + val buttonColor: Color? + ) : AuthProvider() +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt new file mode 100644 index 000000000..e1de6b0ad --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt @@ -0,0 +1,157 @@ +/* + * 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.compose.configuration + +import java.util.Locale +import com.google.firebase.auth.ActionCodeSettings +import androidx.compose.ui.graphics.vector.ImageVector + +fun actionCodeSettings( + block: ActionCodeSettings.Builder.() -> Unit +) = ActionCodeSettings.newBuilder().apply(block).build() + +fun authUIConfiguration(block: AuthUIConfigurationBuilder.() -> Unit): AuthUIConfiguration { + val builder = AuthUIConfigurationBuilder() + builder.block() + return builder.build() +} + +@DslMarker +annotation class AuthUIConfigurationDsl + +@AuthUIConfigurationDsl +class AuthUIConfigurationBuilder { + private val providers = mutableListOf() + var theme: AuthUITheme = AuthUITheme.Default + var stringProvider: AuthUIStringProvider? = null + var locale: Locale? = null + var enableCredentialManager: Boolean = true + var enableMfa: Boolean = true + var enableAnonymousUpgrade: Boolean = false + var tosUrl: String? = null + var privacyPolicyUrl: String? = null + var logo: ImageVector? = null + var actionCodeSettings: ActionCodeSettings? = null + var allowNewEmailAccounts: Boolean = true + var requireDisplayName: Boolean = true + var alwaysShowProviderChoice: Boolean = false + + fun providers(block: AuthProvidersBuilder.() -> Unit) { + val builder = AuthProvidersBuilder() + builder.block() + providers.addAll(builder.build()) + } + + internal fun build(): AuthUIConfiguration { + validate() + return AuthUIConfiguration( + providers = providers.toList(), + theme = theme, + stringProvider = stringProvider, + locale = locale, + enableCredentialManager = enableCredentialManager, + enableMfa = enableMfa, + enableAnonymousUpgrade = enableAnonymousUpgrade, + tosUrl = tosUrl, + privacyPolicyUrl = privacyPolicyUrl, + logo = logo, + actionCodeSettings = actionCodeSettings, + allowNewEmailAccounts = allowNewEmailAccounts, + requireDisplayName = requireDisplayName, + alwaysShowProviderChoice = alwaysShowProviderChoice + ) + } + + private fun validate() { + if (providers.isEmpty()) { + throw IllegalArgumentException("At least one provider must be configured") + } + } +} + +/** + * Configuration object for the authentication flow. + */ +data class AuthUIConfiguration( + /** + * The list of enabled authentication providers. + */ + val providers: List = emptyList(), + + /** + * The theming configuration for the UI. Default to [AuthUITheme.Default]. + */ + val theme: AuthUITheme = AuthUITheme.Default, + + /** + * A custom provider for localized strings. + */ + val stringProvider: AuthUIStringProvider? = null, + + /** + * The locale for internationalization. + */ + val locale: Locale? = null, + + /** + * Enables integration with Android's Credential Manager API. Defaults to true. + */ + val enableCredentialManager: Boolean = true, + + /** + * Enables Multi-Factor Authentication support. Defaults to true. + */ + val enableMfa: Boolean = true, + + /** + * Allows upgrading an anonymous user to a new credential. + */ + val enableAnonymousUpgrade: Boolean = false, + + /** + * The URL for the terms of service. + */ + val tosUrl: String? = null, + + /** + * The URL for the privacy policy. + */ + val privacyPolicyUrl: String? = null, + + /** + * The logo to display on the authentication screens. + */ + val logo: ImageVector? = null, + + /** + * Configuration for email link sign-in. + */ + val actionCodeSettings: ActionCodeSettings? = null, + + /** + * Allows new email accounts to be created. Defaults to true. + */ + val allowNewEmailAccounts: Boolean = true, + + /** + * Requires the user to provide a display name on sign-up. Defaults to true. + */ + val requireDisplayName: Boolean = true, + + /** + * Always shows the provider selection screen, even if only one is enabled. + */ + val alwaysShowProviderChoice: Boolean = false, +) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt new file mode 100644 index 000000000..fe5bbf302 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt @@ -0,0 +1,41 @@ +/* + * 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.compose.configuration + +import android.content.Context +import com.firebase.ui.auth.R + +/** + * An interface for providing localized string resources. This interface defines methods for all + * user-facing strings, such as initializing(), signInWithGoogle(), invalidEmail(), + * passwordsDoNotMatch(), etc., allowing for complete localization of the UI. + */ +interface AuthUIStringProvider { + fun initializing(): String + fun signInWithGoogle(): String + fun invalidEmail(): String + fun passwordsDoNotMatch(): String +} + +class DefaultAuthUIStringProvider(private val context: Context) : AuthUIStringProvider { + override fun initializing(): String = "" + + override fun signInWithGoogle(): String = + context.getString(R.string.fui_sign_in_with_google) + + override fun invalidEmail(): String = "" + + override fun passwordsDoNotMatch(): String = "" +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt new file mode 100644 index 000000000..0230f4ebc --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt @@ -0,0 +1,134 @@ +/* + * 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.compose.configuration + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private val LocalAuthUITheme = staticCompositionLocalOf { AuthUITheme.Default } + +/** + * Theming configuration for the entire Auth UI. + */ +data class AuthUITheme( + /** + * The color scheme to use. + */ + val colorScheme: ColorScheme, + + /** + * The typography to use. + */ + val typography: Typography, + + /** + * The shapes to use for UI elements. + */ + val shapes: Shapes, + + /** + * A map of provider IDs to custom styling. + */ + val providerStyles: Map = emptyMap() +) { + + /** + * A data class nested within AuthUITheme that defines the visual appearance of a specific + * provider button, allowing for per-provider branding and customization. + */ + data class ProviderStyle( + /** + * The background color of the button. + */ + val backgroundColor: Color, + + /** + * The color of the text label on the button. + */ + val contentColor: Color, + + /** + * An optional tint color for the provider's icon. If null, + * the icon's intrinsic color is used. + */ + var iconTint: Color? = null, + + /** + * The shape of the button container. Defaults to RoundedCornerShape(4.dp). + */ + val shape: Shape = RoundedCornerShape(4.dp), + + /** + * The shadow elevation for the button. Defaults to 2.dp. + */ + val elevation: Dp = 2.dp + ) + + companion object { + /** + * A standard light theme with Material 3 defaults and + * pre-configured provider styles. + */ + val Default = AuthUITheme( + colorScheme = lightColorScheme(), + typography = Typography(), + shapes = Shapes(), + // TODO(demolaf): do we provide default styles for each provider? + providerStyles = mapOf( + "google.com" to ProviderStyle( + backgroundColor = Color.White, + contentColor = Color.Black + ) + ) + ) + + /** + * Creates a theme inheriting the app's current Material + * Theme settings. + */ + @Composable + fun fromMaterialTheme( + providerStyles: Map = Default.providerStyles + ): AuthUITheme { + return AuthUITheme( + colorScheme = MaterialTheme.colorScheme, + typography = MaterialTheme.typography, + shapes = MaterialTheme.shapes, + providerStyles = providerStyles + ) + } + } +} + +@Composable +fun AuthUITheme( + theme: AuthUITheme = AuthUITheme.Default, + content: @Composable () -> Unit +) { + CompositionLocalProvider(LocalAuthUITheme provides theme) { + content() + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt new file mode 100644 index 000000000..1fc36475d --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt @@ -0,0 +1,52 @@ +/* + * 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.compose.configuration + +/** + * A sealed class representing a set of validation rules that can be applied to a password field, + * typically within the [AuthProvider.Email] configuration. + */ +sealed class PasswordRule { + /** + * Requires the password to have at least a certain number of characters. + */ + data class MinimumLength(val value: Int) : PasswordRule() + + /** + * Requires the password to contain at least one uppercase letter (A-Z). + */ + object RequireUppercase : PasswordRule() + + /** + * Requires the password to contain at least one lowercase letter (a-z). + */ + object RequireLowercase: PasswordRule() + + /** + * Requires the password to contain at least one numeric digit (0-9). + */ + object RequireDigit: PasswordRule() + + /** + * Requires the password to contain at least one special character (e.g., !@#$%^&*). + */ + object RequireSpecialCharacter: PasswordRule() + + /** + * Defines a custom validation rule using a regular expression and provides a specific error + * message on failure. + */ + data class Custom(val regex: Regex, val errorMessage: String) +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt new file mode 100644 index 000000000..9bcf059a5 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -0,0 +1,233 @@ +/* + * 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.compose.configuration + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import com.google.common.truth.Truth.assertThat +import com.google.firebase.auth.actionCodeSettings +import org.junit.Assert.assertThrows +import org.junit.Test +import java.util.Locale +import kotlin.reflect.KMutableProperty +import kotlin.reflect.full.memberProperties + +class AuthUIConfigurationTest { + + @Test + fun `authUIConfiguration with minimal setup uses correct defaults`() { + val config = authUIConfiguration { + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "" + ) + ) + } + } + + assertThat(config.providers).hasSize(1) + assertThat(config.theme).isEqualTo(AuthUITheme.Default) + assertThat(config.stringProvider).isNull() + assertThat(config.locale).isNull() + assertThat(config.enableCredentialManager).isTrue() + assertThat(config.enableMfa).isTrue() + assertThat(config.enableAnonymousUpgrade).isFalse() + assertThat(config.tosUrl).isNull() + assertThat(config.privacyPolicyUrl).isNull() + assertThat(config.logo).isNull() + assertThat(config.actionCodeSettings).isNull() + assertThat(config.allowNewEmailAccounts).isTrue() + assertThat(config.requireDisplayName).isTrue() + assertThat(config.alwaysShowProviderChoice).isFalse() + } + + @Test + fun `authUIConfiguration with all fields overridden uses custom values`() { + val customTheme = AuthUITheme.Default + val customStringProvider = object : AuthUIStringProvider { + override fun initializing(): String = "" + override fun signInWithGoogle(): String = "" + override fun invalidEmail(): String = "" + override fun passwordsDoNotMatch(): String = "" + } + val customLocale = Locale.US + val customActionCodeSettings = actionCodeSettings { + url = "https://example.com/verify" + handleCodeInApp = true + } + + val config = authUIConfiguration { + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "" + ) + ) + provider( + AuthProvider.Github( + customParameters = mapOf() + ) + ) + } + theme = customTheme + stringProvider = customStringProvider + locale = customLocale + enableCredentialManager = false + enableMfa = false + enableAnonymousUpgrade = true + tosUrl = "https://example.com/tos" + privacyPolicyUrl = "https://example.com/privacy" + logo = Icons.Default.AccountCircle + actionCodeSettings = customActionCodeSettings + allowNewEmailAccounts = false + requireDisplayName = false + alwaysShowProviderChoice = true + } + + assertThat(config.providers).hasSize(2) + assertThat(config.theme).isEqualTo(customTheme) + assertThat(config.stringProvider).isEqualTo(customStringProvider) + assertThat(config.locale).isEqualTo(customLocale) + assertThat(config.enableCredentialManager).isFalse() + assertThat(config.enableMfa).isFalse() + assertThat(config.enableAnonymousUpgrade).isTrue() + assertThat(config.tosUrl).isEqualTo("https://example.com/tos") + assertThat(config.privacyPolicyUrl).isEqualTo("https://example.com/privacy") + assertThat(config.logo).isEqualTo(Icons.Default.AccountCircle) + assertThat(config.actionCodeSettings).isEqualTo(customActionCodeSettings) + assertThat(config.allowNewEmailAccounts).isFalse() + assertThat(config.requireDisplayName).isFalse() + assertThat(config.alwaysShowProviderChoice).isTrue() + } + + // =========================================================================================== + // Validation Tests + // =========================================================================================== + + @Test(expected = IllegalArgumentException::class) + fun `authUIConfiguration throws when no providers configured`() { + authUIConfiguration { } + } + + @Test + fun `authUIConfiguration succeeds with single provider`() { + val config = authUIConfiguration { + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "" + ) + ) + } + } + + assertThat(config.providers).hasSize(1) + } + + // =========================================================================================== + // Provider Configuration Tests + // =========================================================================================== + + @Test + fun `providers block can be called multiple times and accumulates providers`() { + val config = authUIConfiguration { + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "" + ) + ) + } + + providers { + provider( + AuthProvider.Github( + customParameters = mapOf() + ) + ) + } + + enableCredentialManager = true + enableCredentialManager = false + } + + assertThat(config.providers).hasSize(2) + } + + // =========================================================================================== + // Builder Immutability Tests + // =========================================================================================== + + @Test + fun `authUIConfiguration providers list is immutable`() { + val config = authUIConfiguration { + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "" + ) + ) + } + } + + val originalSize = config.providers.size + + assertThrows(UnsupportedOperationException::class.java) { + (config.providers as MutableList).add( + AuthProvider.Twitter(customParameters = mapOf()) + ) + } + + assertThat(config.providers.size).isEqualTo(originalSize) + } + + @Test + fun `authUIConfiguration creates immutable configuration`() { + val kClass = AuthUIConfiguration::class + + val allProperties = kClass.memberProperties + + allProperties.forEach { + assertThat(it).isNotInstanceOf(KMutableProperty::class.java) + } + + val expectedProperties = setOf( + "providers", + "theme", + "stringProvider", + "locale", + "enableCredentialManager", + "enableMfa", + "enableAnonymousUpgrade", + "tosUrl", + "privacyPolicyUrl", + "logo", + "actionCodeSettings", + "allowNewEmailAccounts", + "requireDisplayName", + "alwaysShowProviderChoice" + ) + + val actualProperties = allProperties.map { it.name }.toSet() + + assertThat(actualProperties).containsExactlyElementsIn(expectedProperties) + } +} diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index d5e15edfe..1eed42446 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -5,8 +5,8 @@ object Config { private const val kotlinVersion = "2.1.0" object SdkVersions { - const val compile = 34 - const val target = 34 + const val compile = 35 + const val target = 35 const val min = 23 } @@ -40,8 +40,18 @@ object Config { const val paging = "androidx.paging:paging-runtime:3.0.0" const val pagingRxJava = "androidx.paging:paging-rxjava3:3.0.0" const val recyclerView = "androidx.recyclerview:recyclerview:1.2.1" - const val materialDesign = "com.google.android.material:material:1.4.0" + + object Compose { + const val bom = "androidx.compose:compose-bom:2025.08.00" + const val ui = "androidx.compose.ui:ui" + const val uiGraphics = "androidx.compose.ui:ui-graphics" + const val toolingPreview = "androidx.compose.ui:ui-tooling-preview" + const val tooling = "androidx.compose.ui:ui-tooling" + const val foundation = "androidx.compose.foundation:foundation" + const val material3 = "androidx.compose.material3:material3" + const val activityCompose = "androidx.activity:activity-compose" + } } object Firebase { @@ -83,6 +93,8 @@ object Config { const val archCoreTesting = "androidx.arch.core:core-testing:2.1.0" const val runner = "androidx.test:runner:1.5.0" const val rules = "androidx.test:rules:1.5.0" + + const val kotlinReflect = "org.jetbrains.kotlin:kotlin-reflect" } object Lint { From 2c563bd3c1d16b2605c67bb4e7fe5635d249546a Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Tue, 16 Sep 2025 11:13:52 +0100 Subject: [PATCH 02/13] refactor: use OAuthProvider base class for common properties --- .../compose/configuration/AuthProvider.kt | 106 +++++++++++++----- 1 file changed, 78 insertions(+), 28 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt index 36daa70a9..5bb8c72dd 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt @@ -32,7 +32,16 @@ class AuthProvidersBuilder { /** * Base sealed class for authentication providers. */ -sealed class AuthProvider() { +sealed class AuthProvider(open val providerId: String) { + /** + * Base class for OAuth authentication providers with common properties. + */ + abstract class OAuthProvider( + override val providerId: String, + open val scopes: List = emptyList(), + open val customParameters: Map = emptyMap() + ) : AuthProvider(providerId) + /** * Email/Password authentication provider configuration. */ @@ -66,7 +75,7 @@ sealed class AuthProvider() { * A list of custom password validation rules. */ val passwordValidationRules: List - ) : AuthProvider() + ) : AuthProvider(providerId = if (enableEmailLinkSignIn) "emailLink" else "password") /** * Phone number authentication provider configuration. @@ -101,7 +110,7 @@ sealed class AuthProvider() { * Enables automatic retrieval of the SMS code. Defaults to true. */ val enableAutoRetrieval: Boolean = true - ) : AuthProvider() + ) : AuthProvider(providerId = "phone") /** * Google Sign-In provider configuration. @@ -110,7 +119,7 @@ sealed class AuthProvider() { /** * The list of scopes to request. */ - val scopes: List, + override val scopes: List, /** * The OAuth 2.0 client ID for your server. @@ -130,8 +139,17 @@ sealed class AuthProvider() { /** * Requests the user's email address. Defaults to true. */ - val requestEmail: Boolean = true - ) : AuthProvider() + val requestEmail: Boolean = true, + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map = emptyMap() + ) : OAuthProvider( + providerId = "google.com", + scopes = scopes, + customParameters = customParameters + ) /** * Facebook Login provider configuration. @@ -140,13 +158,22 @@ sealed class AuthProvider() { /** * The list of scopes (permissions) to request. Defaults to email and public_profile. */ - val scopes: List = listOf("email", "public_profile"), + override val scopes: List = listOf("email", "public_profile"), /** * if true, enable limited login mode. Defaults to false. */ - val limitedLogin: Boolean = false - ) : AuthProvider() + val limitedLogin: Boolean = false, + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map = emptyMap() + ) : OAuthProvider( + providerId = "facebook.com", + scopes = scopes, + customParameters = customParameters + ) /** * Twitter/X authentication provider configuration. @@ -155,8 +182,11 @@ sealed class AuthProvider() { /** * A map of custom OAuth parameters. */ - val customParameters: Map - ) : AuthProvider() + override val customParameters: Map + ) : OAuthProvider( + providerId = "twitter.com", + customParameters = customParameters + ) /** * Github authentication provider configuration. @@ -165,13 +195,17 @@ sealed class AuthProvider() { /** * The list of scopes to request. Defaults to user:email. */ - val scopes: List = listOf("user:email"), + override val scopes: List = listOf("user:email"), /** * A map of custom OAuth parameters. */ - val customParameters: Map - ) : AuthProvider() + override val customParameters: Map + ) : OAuthProvider( + providerId = "github.com", + scopes = scopes, + customParameters = customParameters + ) /** * Microsoft authentication provider configuration. @@ -180,7 +214,7 @@ sealed class AuthProvider() { /** * The list of scopes to request. Defaults to openid, profile, email. */ - val scopes: List = listOf("openid", "profile", "email"), + override val scopes: List = listOf("openid", "profile", "email"), /** * The tenant ID for Azure Active Directory. @@ -190,8 +224,12 @@ sealed class AuthProvider() { /** * A map of custom OAuth parameters. */ - val customParameters: Map - ) : AuthProvider() + override val customParameters: Map + ) : OAuthProvider( + providerId = "microsoft.com", + scopes = scopes, + customParameters = customParameters + ) /** * Yahoo authentication provider configuration. @@ -200,13 +238,17 @@ sealed class AuthProvider() { /** * The list of scopes to request. Defaults to openid, profile, email. */ - val scopes: List = listOf("openid", "profile", "email"), + override val scopes: List = listOf("openid", "profile", "email"), /** * A map of custom OAuth parameters. */ - val customParameters: Map - ) : AuthProvider() + override val customParameters: Map + ) : OAuthProvider( + providerId = "yahoo.com", + scopes = scopes, + customParameters = customParameters + ) /** * Apple Sign-In provider configuration. @@ -215,7 +257,7 @@ sealed class AuthProvider() { /** * The list of scopes to request. Defaults to name and email. */ - val scopes: List = listOf("name", "email"), + override val scopes: List = listOf("name", "email"), /** * The locale for the sign-in page. @@ -225,13 +267,17 @@ sealed class AuthProvider() { /** * A map of custom OAuth parameters. */ - val customParameters: Map - ) : AuthProvider() + override val customParameters: Map + ) : OAuthProvider( + providerId = "apple.com", + scopes = scopes, + customParameters = customParameters + ) /** * Anonymous authentication provider. It has no configurable properties. */ - object Anonymous : AuthProvider() + object Anonymous : AuthProvider(providerId = "anonymous") /** * A generic OAuth provider for any unsupported provider. @@ -240,17 +286,17 @@ sealed class AuthProvider() { /** * The provider ID as configured in the Firebase console. */ - val providerId: String, + override val providerId: String, /** * The list of scopes to request. */ - val scopes: List, + override val scopes: List, /** * A map of custom OAuth parameters. */ - val customParameters: Map, + override val customParameters: Map, /** * The text to display on the provider button. @@ -266,5 +312,9 @@ sealed class AuthProvider() { * An optional background color for the provider button. */ val buttonColor: Color? - ) : AuthProvider() + ) : OAuthProvider( + providerId = providerId, + scopes = scopes, + customParameters = customParameters + ) } \ No newline at end of file From 1ba20aea8245d341ab4b079a9203b7f877571261 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Tue, 16 Sep 2025 12:54:10 +0100 Subject: [PATCH 03/13] feat: add Provider enum class for provider ids --- .../compose/configuration/AuthProvider.kt | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt index 5bb8c72dd..509da0d03 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt @@ -17,6 +17,12 @@ package com.firebase.ui.auth.compose.configuration import android.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import com.google.firebase.auth.ActionCodeSettings +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FacebookAuthProvider +import com.google.firebase.auth.GithubAuthProvider +import com.google.firebase.auth.GoogleAuthProvider +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.TwitterAuthProvider @AuthUIConfigurationDsl class AuthProvidersBuilder { @@ -29,6 +35,22 @@ class AuthProvidersBuilder { internal fun build(): List = providers.toList() } +/** + * Enum class to represent all possible providers. + */ +internal enum class Provider(val id: String) { + GOOGLE(GoogleAuthProvider.PROVIDER_ID), + FACEBOOK(FacebookAuthProvider.PROVIDER_ID), + TWITTER(TwitterAuthProvider.PROVIDER_ID), + GITHUB(GithubAuthProvider.PROVIDER_ID), + EMAIL(EmailAuthProvider.PROVIDER_ID), + PHONE(PhoneAuthProvider.PROVIDER_ID), + ANONYMOUS("anonymous"), + MICROSOFT("microsoft.com"), + YAHOO("yahoo.com"), + APPLE("apple.com"), +} + /** * Base sealed class for authentication providers. */ @@ -75,7 +97,7 @@ sealed class AuthProvider(open val providerId: String) { * A list of custom password validation rules. */ val passwordValidationRules: List - ) : AuthProvider(providerId = if (enableEmailLinkSignIn) "emailLink" else "password") + ) : AuthProvider(providerId = Provider.EMAIL.id) /** * Phone number authentication provider configuration. @@ -110,7 +132,7 @@ sealed class AuthProvider(open val providerId: String) { * Enables automatic retrieval of the SMS code. Defaults to true. */ val enableAutoRetrieval: Boolean = true - ) : AuthProvider(providerId = "phone") + ) : AuthProvider(providerId = Provider.PHONE.id) /** * Google Sign-In provider configuration. @@ -146,7 +168,7 @@ sealed class AuthProvider(open val providerId: String) { */ override val customParameters: Map = emptyMap() ) : OAuthProvider( - providerId = "google.com", + providerId = Provider.GOOGLE.id, scopes = scopes, customParameters = customParameters ) @@ -170,7 +192,7 @@ sealed class AuthProvider(open val providerId: String) { */ override val customParameters: Map = emptyMap() ) : OAuthProvider( - providerId = "facebook.com", + providerId = Provider.FACEBOOK.id, scopes = scopes, customParameters = customParameters ) @@ -184,7 +206,7 @@ sealed class AuthProvider(open val providerId: String) { */ override val customParameters: Map ) : OAuthProvider( - providerId = "twitter.com", + providerId = Provider.TWITTER.id, customParameters = customParameters ) @@ -202,7 +224,7 @@ sealed class AuthProvider(open val providerId: String) { */ override val customParameters: Map ) : OAuthProvider( - providerId = "github.com", + providerId = Provider.GITHUB.id, scopes = scopes, customParameters = customParameters ) @@ -226,7 +248,7 @@ sealed class AuthProvider(open val providerId: String) { */ override val customParameters: Map ) : OAuthProvider( - providerId = "microsoft.com", + providerId = Provider.MICROSOFT.id, scopes = scopes, customParameters = customParameters ) @@ -245,7 +267,7 @@ sealed class AuthProvider(open val providerId: String) { */ override val customParameters: Map ) : OAuthProvider( - providerId = "yahoo.com", + providerId = Provider.YAHOO.id, scopes = scopes, customParameters = customParameters ) @@ -269,7 +291,7 @@ sealed class AuthProvider(open val providerId: String) { */ override val customParameters: Map ) : OAuthProvider( - providerId = "apple.com", + providerId = Provider.APPLE.id, scopes = scopes, customParameters = customParameters ) @@ -277,7 +299,7 @@ sealed class AuthProvider(open val providerId: String) { /** * Anonymous authentication provider. It has no configurable properties. */ - object Anonymous : AuthProvider(providerId = "anonymous") + object Anonymous : AuthProvider(providerId = Provider.ANONYMOUS.id) /** * A generic OAuth provider for any unsupported provider. From 63c8e1897635d87fd80d458161f089246ce9b3f5 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Tue, 16 Sep 2025 12:55:22 +0100 Subject: [PATCH 04/13] feat: setup default provider styles for each provider --- .../auth/compose/configuration/AuthUITheme.kt | 85 +++++++++++++++++-- 1 file changed, 78 insertions(+), 7 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt index 0230f4ebc..dab0b6660 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt @@ -96,13 +96,7 @@ data class AuthUITheme( colorScheme = lightColorScheme(), typography = Typography(), shapes = Shapes(), - // TODO(demolaf): do we provide default styles for each provider? - providerStyles = mapOf( - "google.com" to ProviderStyle( - backgroundColor = Color.White, - contentColor = Color.Black - ) - ) + providerStyles = defaultProviderStyles ) /** @@ -120,6 +114,83 @@ data class AuthUITheme( providerStyles = providerStyles ) } + + internal val defaultProviderStyles + get(): Map { + return Provider.entries.associate { provider -> + when (provider) { + Provider.GOOGLE -> { + provider.id to ProviderStyle( + backgroundColor = Color.White, + contentColor = Color(0xFF757575) + ) + } + + Provider.FACEBOOK -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFF3B5998), + contentColor = Color.White + ) + } + + Provider.TWITTER -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFF5BAAF4), + contentColor = Color.White + ) + } + + Provider.GITHUB -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFF24292E), + contentColor = Color.White + ) + } + + Provider.EMAIL -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFFD0021B), + contentColor = Color.White + ) + } + + Provider.PHONE -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFF43C5A5), + contentColor = Color.White + ) + } + + Provider.ANONYMOUS -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFFF4B400), + contentColor = Color.White + ) + } + + Provider.MICROSOFT -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFF2F2F2F), + contentColor = Color.White + ) + } + + Provider.YAHOO -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFF720E9E), + contentColor = Color.White + ) + } + + Provider.APPLE -> { + provider.id to ProviderStyle( + backgroundColor = Color.Black, + contentColor = Color.White + ) + } + } + } + } } } From a5a944ad2f5a4b1d4eb8fc09f68ec01ee2a809c6 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Tue, 16 Sep 2025 21:01:35 +0100 Subject: [PATCH 05/13] test: added builder validation logic from old auth library and tests --- auth/build.gradle.kts | 2 +- .../compose/configuration/AuthProvider.kt | 36 +++++--- .../configuration/AuthUIConfiguration.kt | 35 ++++++++ .../configuration/AuthUIConfigurationTest.kt | 85 +++++++++++++++++-- build.gradle.kts | 1 + buildSrc/src/main/kotlin/Config.kt | 2 +- gradle/libs.versions.toml | 7 ++ 7 files changed, 148 insertions(+), 20 deletions(-) create mode 100644 gradle/libs.versions.toml diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index c027e0848..8e6d5304e 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -4,7 +4,7 @@ plugins { id("com.android.library") id("com.vanniktech.maven.publish") id("org.jetbrains.kotlin.android") - id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" + alias(libs.plugins.compose.compiler) } android { diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt index 509da0d03..f79392926 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt @@ -16,6 +16,7 @@ package com.firebase.ui.auth.compose.configuration import android.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import com.firebase.ui.auth.util.data.ProviderAvailability import com.google.firebase.auth.ActionCodeSettings import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FacebookAuthProvider @@ -51,19 +52,19 @@ internal enum class Provider(val id: String) { APPLE("apple.com"), } +/** + * Base class for OAuth authentication providers with common properties. + */ +abstract class OAuthProvider( + override val providerId: String, + open val scopes: List = emptyList(), + open val customParameters: Map = emptyMap() +) : AuthProvider(providerId) + /** * Base sealed class for authentication providers. */ sealed class AuthProvider(open val providerId: String) { - /** - * Base class for OAuth authentication providers with common properties. - */ - abstract class OAuthProvider( - override val providerId: String, - open val scopes: List = emptyList(), - open val customParameters: Map = emptyMap() - ) : AuthProvider(providerId) - /** * Email/Password authentication provider configuration. */ @@ -97,7 +98,22 @@ sealed class AuthProvider(open val providerId: String) { * A list of custom password validation rules. */ val passwordValidationRules: List - ) : AuthProvider(providerId = Provider.EMAIL.id) + ) : AuthProvider(providerId = Provider.EMAIL.id) { + fun validate() { + if (enableEmailLinkSignIn) { + val actionCodeSettings = actionCodeSettings + ?: requireNotNull(actionCodeSettings) { + "ActionCodeSettings cannot be null when using " + + "email link sign in." + } + + check(actionCodeSettings.canHandleCodeInApp()) { + "You must set canHandleCodeInApp in your " + + "ActionCodeSettings to true for Email-Link Sign-in." + } + } + } + } /** * Phone number authentication provider configuration. diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt index e1de6b0ad..2133b3506 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt @@ -75,9 +75,44 @@ class AuthUIConfigurationBuilder { } private fun validate() { + // At least one provider if (providers.isEmpty()) { throw IllegalArgumentException("At least one provider must be configured") } + + // No unsupported providers + val supportedProviderIds = Provider.entries.map { it.id }.toSet() + val unknownProviders = providers.filter { it.providerId !in supportedProviderIds } + require(unknownProviders.isEmpty()) { + "Unknown providers: ${unknownProviders.joinToString { it.providerId }}" + } + + // Cannot have only anonymous provider + if (providers.size == 1 && providers.first() is AuthProvider.Anonymous) { + throw IllegalStateException( + "Sign in as guest cannot be the only sign in method. " + + "In this case, sign the user in anonymously your self; no UI is needed." + ) + } + + // Check for duplicate providers + val providerIds = providers.map { it.providerId } + val duplicates = providerIds.groupingBy { it }.eachCount().filter { it.value > 1 } + + require(duplicates.isEmpty()) { + val message = duplicates.keys.joinToString(", ") + throw IllegalArgumentException( + "Each provider can only be set once. Duplicates: $message" + ) + } + + // Provider specific validations + providers.forEach { provider -> + when (provider) { + is AuthProvider.Email -> provider.validate() + else -> null + } + } } } diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt index 9bcf059a5..13a7935a2 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -125,19 +125,88 @@ class AuthUIConfigurationTest { } @Test - fun `authUIConfiguration succeeds with single provider`() { + fun `validation accepts all supported providers`() { val config = authUIConfiguration { providers { - provider( - AuthProvider.Google( - scopes = listOf(), - serverClientId = "" - ) - ) + provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) + provider(AuthProvider.Facebook()) + provider(AuthProvider.Twitter(customParameters = mapOf())) + provider(AuthProvider.Github(customParameters = mapOf())) + provider(AuthProvider.Microsoft(customParameters = mapOf(), tenant = null)) + provider(AuthProvider.Yahoo(customParameters = mapOf())) + provider(AuthProvider.Apple(customParameters = mapOf(), locale = null)) + provider(AuthProvider.Phone(defaultCountryCode = null, allowedCountries = null)) + provider(AuthProvider.Email(actionCodeSettings = null, passwordValidationRules = listOf())) } } + assertThat(config.providers).hasSize(9) + } - assertThat(config.providers).hasSize(1) + @Test(expected = IllegalArgumentException::class) + fun `validation throws for unsupported provider`() { + val mockProvider = AuthProvider.GenericOAuth( + providerId = "unsupported.provider", + scopes = listOf(), + customParameters = mapOf(), + buttonLabel = "Test", + buttonIcon = null, + buttonColor = null + ) + + authUIConfiguration { + providers { + provider(mockProvider) + } + } + } + + @Test(expected = IllegalStateException::class) + fun `validate throws when only anonymous provider is configured`() { + authUIConfiguration { + providers { + provider(AuthProvider.Anonymous) + } + } + } + + @Test(expected = IllegalArgumentException::class) + fun `validate throws for duplicate providers`() { + authUIConfiguration { + providers { + provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) + provider(AuthProvider.Google(scopes = listOf("email"), serverClientId = "different")) + } + } + } + + @Test(expected = IllegalArgumentException::class) + fun `validate throws for enableEmailLinkSignIn true when actionCodeSettings is null`() { + authUIConfiguration { + providers { + provider(AuthProvider.Email( + enableEmailLinkSignIn = true, + actionCodeSettings = null, + passwordValidationRules = listOf() + )) + } + } + } + + @Test(expected = IllegalStateException::class) + fun `validate throws for enableEmailLinkSignIn true when actionCodeSettings canHandleCodeInApp false`() { + val customActionCodeSettings = actionCodeSettings { + url = "https://example.com" + handleCodeInApp = false + } + authUIConfiguration { + providers { + provider(AuthProvider.Email( + enableEmailLinkSignIn = true, + actionCodeSettings = customActionCodeSettings, + passwordValidationRules = listOf() + )) + } + } } // =========================================================================================== diff --git a/build.gradle.kts b/build.gradle.kts index c87edeee9..105624a8b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,6 +19,7 @@ buildscript { plugins { id("com.github.ben-manes.versions") version "0.20.0" + alias(libs.plugins.compose.compiler) apply false } allprojects { diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 1eed42446..7e9352e85 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -2,7 +2,7 @@ object Config { const val version = "10.0.0-SNAPSHOT" val submodules = listOf("auth", "common", "firestore", "database", "storage") - private const val kotlinVersion = "2.1.0" + private const val kotlinVersion = "2.2.0" object SdkVersions { const val compile = 35 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..379fe1b5b --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,7 @@ +[versions] +kotlin = "2.2.0" + +[libraries] + +[plugins] +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } \ No newline at end of file From 19787dfee18a8417c5a55d3efd8807d1472ac741 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Thu, 18 Sep 2025 09:58:15 +0100 Subject: [PATCH 06/13] refactor: changes in API design docs replaced sealed with abstract class, data with regular class use isXX prefix for booleans --- .../compose/configuration/AuthProvider.kt | 39 ++++++++-------- .../configuration/AuthUIConfiguration.kt | 38 ++++++++-------- .../auth/compose/configuration/AuthUITheme.kt | 6 +-- .../compose/configuration/PasswordRule.kt | 8 ++-- .../configuration/AuthUIConfigurationTest.kt | 44 +++++++++---------- 5 files changed, 66 insertions(+), 69 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt index f79392926..ef8bb0771 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt @@ -16,7 +16,6 @@ package com.firebase.ui.auth.compose.configuration import android.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import com.firebase.ui.auth.util.data.ProviderAvailability import com.google.firebase.auth.ActionCodeSettings import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FacebookAuthProvider @@ -53,7 +52,7 @@ internal enum class Provider(val id: String) { } /** - * Base class for OAuth authentication providers with common properties. + * Base abstract class for OAuth authentication providers with common properties. */ abstract class OAuthProvider( override val providerId: String, @@ -62,22 +61,22 @@ abstract class OAuthProvider( ) : AuthProvider(providerId) /** - * Base sealed class for authentication providers. + * Base abstract class for authentication providers. */ -sealed class AuthProvider(open val providerId: String) { +abstract class AuthProvider(open val providerId: String) { /** * Email/Password authentication provider configuration. */ - data class Email( + class Email( /** * Requires the user to provide a display name. Defaults to true. */ - val requireDisplayName: Boolean = true, + val isDisplayNameRequired: Boolean = true, /** * Enables email link sign-in, Defaults to false. */ - val enableEmailLinkSignIn: Boolean = false, + val isEmailLinkSignInEnabled: Boolean = false, /** * Settings for email link actions. @@ -87,7 +86,7 @@ sealed class AuthProvider(open val providerId: String) { /** * Allows new accounts to be created. Defaults to true. */ - val allowNewAccounts: Boolean = true, + val isNewAccountsAllowed: Boolean = true, /** * The minimum length for a password. Defaults to 6. @@ -100,7 +99,7 @@ sealed class AuthProvider(open val providerId: String) { val passwordValidationRules: List ) : AuthProvider(providerId = Provider.EMAIL.id) { fun validate() { - if (enableEmailLinkSignIn) { + if (isEmailLinkSignInEnabled) { val actionCodeSettings = actionCodeSettings ?: requireNotNull(actionCodeSettings) { "ActionCodeSettings cannot be null when using " + @@ -118,7 +117,7 @@ sealed class AuthProvider(open val providerId: String) { /** * Phone number authentication provider configuration. */ - data class Phone( + class Phone( /** * The default country code to pre-select. */ @@ -142,18 +141,18 @@ sealed class AuthProvider(open val providerId: String) { /** * Enables instant verification of the phone number. Defaults to true. */ - val enableInstantVerification: Boolean = true, + val isInstantVerificationEnabled: Boolean = true, /** * Enables automatic retrieval of the SMS code. Defaults to true. */ - val enableAutoRetrieval: Boolean = true + val isAutoRetrievalEnabled: Boolean = true ) : AuthProvider(providerId = Provider.PHONE.id) /** * Google Sign-In provider configuration. */ - data class Google( + class Google( /** * The list of scopes to request. */ @@ -192,7 +191,7 @@ sealed class AuthProvider(open val providerId: String) { /** * Facebook Login provider configuration. */ - data class Facebook( + class Facebook( /** * The list of scopes (permissions) to request. Defaults to email and public_profile. */ @@ -216,7 +215,7 @@ sealed class AuthProvider(open val providerId: String) { /** * Twitter/X authentication provider configuration. */ - data class Twitter( + class Twitter( /** * A map of custom OAuth parameters. */ @@ -229,7 +228,7 @@ sealed class AuthProvider(open val providerId: String) { /** * Github authentication provider configuration. */ - data class Github( + class Github( /** * The list of scopes to request. Defaults to user:email. */ @@ -248,7 +247,7 @@ sealed class AuthProvider(open val providerId: String) { /** * Microsoft authentication provider configuration. */ - data class Microsoft( + class Microsoft( /** * The list of scopes to request. Defaults to openid, profile, email. */ @@ -272,7 +271,7 @@ sealed class AuthProvider(open val providerId: String) { /** * Yahoo authentication provider configuration. */ - data class Yahoo( + class Yahoo( /** * The list of scopes to request. Defaults to openid, profile, email. */ @@ -291,7 +290,7 @@ sealed class AuthProvider(open val providerId: String) { /** * Apple Sign-In provider configuration. */ - data class Apple( + class Apple( /** * The list of scopes to request. Defaults to name and email. */ @@ -320,7 +319,7 @@ sealed class AuthProvider(open val providerId: String) { /** * A generic OAuth provider for any unsupported provider. */ - data class GenericOAuth( + class GenericOAuth( /** * The provider ID as configured in the Firebase console. */ diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt index 2133b3506..aca1ccf9e 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt @@ -37,16 +37,16 @@ class AuthUIConfigurationBuilder { var theme: AuthUITheme = AuthUITheme.Default var stringProvider: AuthUIStringProvider? = null var locale: Locale? = null - var enableCredentialManager: Boolean = true - var enableMfa: Boolean = true - var enableAnonymousUpgrade: Boolean = false + var isCredentialManagerEnabled: Boolean = true + var isMfaEnabled: Boolean = true + var isAnonymousUpgradeEnabled: Boolean = false var tosUrl: String? = null var privacyPolicyUrl: String? = null var logo: ImageVector? = null var actionCodeSettings: ActionCodeSettings? = null - var allowNewEmailAccounts: Boolean = true - var requireDisplayName: Boolean = true - var alwaysShowProviderChoice: Boolean = false + var isNewEmailAccountsAllowed: Boolean = true + var isDisplayNameRequired: Boolean = true + var isProviderChoiceAlwaysShown: Boolean = false fun providers(block: AuthProvidersBuilder.() -> Unit) { val builder = AuthProvidersBuilder() @@ -61,16 +61,16 @@ class AuthUIConfigurationBuilder { theme = theme, stringProvider = stringProvider, locale = locale, - enableCredentialManager = enableCredentialManager, - enableMfa = enableMfa, - enableAnonymousUpgrade = enableAnonymousUpgrade, + isCredentialManagerEnabled = isCredentialManagerEnabled, + isMfaEnabled = isMfaEnabled, + isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled, tosUrl = tosUrl, privacyPolicyUrl = privacyPolicyUrl, logo = logo, actionCodeSettings = actionCodeSettings, - allowNewEmailAccounts = allowNewEmailAccounts, - requireDisplayName = requireDisplayName, - alwaysShowProviderChoice = alwaysShowProviderChoice + isNewEmailAccountsAllowed = isNewEmailAccountsAllowed, + isDisplayNameRequired = isDisplayNameRequired, + isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown ) } @@ -119,7 +119,7 @@ class AuthUIConfigurationBuilder { /** * Configuration object for the authentication flow. */ -data class AuthUIConfiguration( +class AuthUIConfiguration( /** * The list of enabled authentication providers. */ @@ -143,17 +143,17 @@ data class AuthUIConfiguration( /** * Enables integration with Android's Credential Manager API. Defaults to true. */ - val enableCredentialManager: Boolean = true, + val isCredentialManagerEnabled: Boolean = true, /** * Enables Multi-Factor Authentication support. Defaults to true. */ - val enableMfa: Boolean = true, + val isMfaEnabled: Boolean = true, /** * Allows upgrading an anonymous user to a new credential. */ - val enableAnonymousUpgrade: Boolean = false, + val isAnonymousUpgradeEnabled: Boolean = false, /** * The URL for the terms of service. @@ -178,15 +178,15 @@ data class AuthUIConfiguration( /** * Allows new email accounts to be created. Defaults to true. */ - val allowNewEmailAccounts: Boolean = true, + val isNewEmailAccountsAllowed: Boolean = true, /** * Requires the user to provide a display name on sign-up. Defaults to true. */ - val requireDisplayName: Boolean = true, + val isDisplayNameRequired: Boolean = true, /** * Always shows the provider selection screen, even if only one is enabled. */ - val alwaysShowProviderChoice: Boolean = false, + val isProviderChoiceAlwaysShown: Boolean = false, ) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt index dab0b6660..d2ae7032d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt @@ -33,7 +33,7 @@ private val LocalAuthUITheme = staticCompositionLocalOf { AuthUITheme.Default } /** * Theming configuration for the entire Auth UI. */ -data class AuthUITheme( +class AuthUITheme( /** * The color scheme to use. */ @@ -56,10 +56,10 @@ data class AuthUITheme( ) { /** - * A data class nested within AuthUITheme that defines the visual appearance of a specific + * A class nested within AuthUITheme that defines the visual appearance of a specific * provider button, allowing for per-provider branding and customization. */ - data class ProviderStyle( + class ProviderStyle( /** * The background color of the button. */ diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt index 1fc36475d..242ea6e83 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt @@ -15,14 +15,14 @@ package com.firebase.ui.auth.compose.configuration /** - * A sealed class representing a set of validation rules that can be applied to a password field, + * An abstract class representing a set of validation rules that can be applied to a password field, * typically within the [AuthProvider.Email] configuration. */ -sealed class PasswordRule { +abstract class PasswordRule { /** * Requires the password to have at least a certain number of characters. */ - data class MinimumLength(val value: Int) : PasswordRule() + class MinimumLength(val value: Int) : PasswordRule() /** * Requires the password to contain at least one uppercase letter (A-Z). @@ -48,5 +48,5 @@ sealed class PasswordRule { * Defines a custom validation rule using a regular expression and provides a specific error * message on failure. */ - data class Custom(val regex: Regex, val errorMessage: String) + class Custom(val regex: Regex, val errorMessage: String) } \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt index 13a7935a2..53f2a87e5 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -43,16 +43,16 @@ class AuthUIConfigurationTest { assertThat(config.theme).isEqualTo(AuthUITheme.Default) assertThat(config.stringProvider).isNull() assertThat(config.locale).isNull() - assertThat(config.enableCredentialManager).isTrue() - assertThat(config.enableMfa).isTrue() - assertThat(config.enableAnonymousUpgrade).isFalse() + assertThat(config.isCredentialManagerEnabled).isTrue() + assertThat(config.isMfaEnabled).isTrue() + assertThat(config.isAnonymousUpgradeEnabled).isFalse() assertThat(config.tosUrl).isNull() assertThat(config.privacyPolicyUrl).isNull() assertThat(config.logo).isNull() assertThat(config.actionCodeSettings).isNull() - assertThat(config.allowNewEmailAccounts).isTrue() - assertThat(config.requireDisplayName).isTrue() - assertThat(config.alwaysShowProviderChoice).isFalse() + assertThat(config.isNewEmailAccountsAllowed).isTrue() + assertThat(config.isDisplayNameRequired).isTrue() + assertThat(config.isProviderChoiceAlwaysShown).isFalse() } @Test @@ -87,32 +87,32 @@ class AuthUIConfigurationTest { theme = customTheme stringProvider = customStringProvider locale = customLocale - enableCredentialManager = false - enableMfa = false - enableAnonymousUpgrade = true + isCredentialManagerEnabled = false + isMfaEnabled = false + isAnonymousUpgradeEnabled = true tosUrl = "https://example.com/tos" privacyPolicyUrl = "https://example.com/privacy" logo = Icons.Default.AccountCircle actionCodeSettings = customActionCodeSettings - allowNewEmailAccounts = false - requireDisplayName = false - alwaysShowProviderChoice = true + isNewEmailAccountsAllowed = false + isDisplayNameRequired = false + isProviderChoiceAlwaysShown = true } assertThat(config.providers).hasSize(2) assertThat(config.theme).isEqualTo(customTheme) assertThat(config.stringProvider).isEqualTo(customStringProvider) assertThat(config.locale).isEqualTo(customLocale) - assertThat(config.enableCredentialManager).isFalse() - assertThat(config.enableMfa).isFalse() - assertThat(config.enableAnonymousUpgrade).isTrue() + assertThat(config.isCredentialManagerEnabled).isFalse() + assertThat(config.isMfaEnabled).isFalse() + assertThat(config.isAnonymousUpgradeEnabled).isTrue() assertThat(config.tosUrl).isEqualTo("https://example.com/tos") assertThat(config.privacyPolicyUrl).isEqualTo("https://example.com/privacy") assertThat(config.logo).isEqualTo(Icons.Default.AccountCircle) assertThat(config.actionCodeSettings).isEqualTo(customActionCodeSettings) - assertThat(config.allowNewEmailAccounts).isFalse() - assertThat(config.requireDisplayName).isFalse() - assertThat(config.alwaysShowProviderChoice).isTrue() + assertThat(config.isNewEmailAccountsAllowed).isFalse() + assertThat(config.isDisplayNameRequired).isFalse() + assertThat(config.isProviderChoiceAlwaysShown).isTrue() } // =========================================================================================== @@ -184,7 +184,7 @@ class AuthUIConfigurationTest { authUIConfiguration { providers { provider(AuthProvider.Email( - enableEmailLinkSignIn = true, + isEmailLinkSignInEnabled = true, actionCodeSettings = null, passwordValidationRules = listOf() )) @@ -201,7 +201,7 @@ class AuthUIConfigurationTest { authUIConfiguration { providers { provider(AuthProvider.Email( - enableEmailLinkSignIn = true, + isEmailLinkSignInEnabled = true, actionCodeSettings = customActionCodeSettings, passwordValidationRules = listOf() )) @@ -232,9 +232,7 @@ class AuthUIConfigurationTest { ) ) } - - enableCredentialManager = true - enableCredentialManager = false + isCredentialManagerEnabled = true } assertThat(config.providers).hasSize(2) From 7e010e42294110e567d622ec8cbc5191116bc5e1 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Thu, 18 Sep 2025 10:02:00 +0100 Subject: [PATCH 07/13] test: fix AuthUIConfiguration constructor test --- .../compose/configuration/AuthUIConfigurationTest.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt index 53f2a87e5..c8e627ff5 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -281,16 +281,16 @@ class AuthUIConfigurationTest { "theme", "stringProvider", "locale", - "enableCredentialManager", - "enableMfa", - "enableAnonymousUpgrade", + "isCredentialManagerEnabled", + "isMfaEnabled", + "isAnonymousUpgradeEnabled", "tosUrl", "privacyPolicyUrl", "logo", "actionCodeSettings", - "allowNewEmailAccounts", - "requireDisplayName", - "alwaysShowProviderChoice" + "isNewEmailAccountsAllowed", + "isDisplayNameRequired", + "isProviderChoiceAlwaysShown" ) val actualProperties = allProperties.map { it.name }.toSet() From fc07d45de1ceb77ad4504c215a3e089c2cdad9c9 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Fri, 19 Sep 2025 02:49:08 +0100 Subject: [PATCH 08/13] wip: email validator and password validator --- .../compose/configuration/PasswordRule.kt | 97 ++++++++++++++++--- .../validators/EmailValidator.kt | 46 +++++++++ .../validators/FieldValidator.kt | 26 +++++ .../validators/PasswordValidator.kt | 51 ++++++++++ .../validators/ValidationStatus.kt | 20 ++++ 5 files changed, 229 insertions(+), 11 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/ValidationStatus.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt index 242ea6e83..8db723159 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt @@ -14,39 +14,114 @@ package com.firebase.ui.auth.compose.configuration +import android.content.Context +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.validators.ValidationStatus + /** - * An abstract class representing a set of validation rules that can be applied to a password field, - * typically within the [AuthProvider.Email] configuration. + * A sealed class representing password validation rules with embedded validation logic. */ -abstract class PasswordRule { +sealed class PasswordRule { /** * Requires the password to have at least a certain number of characters. */ - class MinimumLength(val value: Int) : PasswordRule() + data class MinimumLength(val value: Int) : PasswordRule() { + override fun validate(context: Context, password: String): ValidationStatus { + return if (password.length >= value) { + ValidationStatus(hasError = false) + } else { + ValidationStatus( + hasError = true, + errorMessage = context.getString(R.string.fui_error_invalid_password) + ) + } + } + } /** * Requires the password to contain at least one uppercase letter (A-Z). */ - object RequireUppercase : PasswordRule() + object RequireUppercase : PasswordRule() { + override fun validate(context: Context, password: String): ValidationStatus { + return if (password.any { it.isUpperCase() }) { + ValidationStatus(hasError = false) + } else { + ValidationStatus( + hasError = true, + errorMessage = context.getString(R.string.fui_error_invalid_password) + ) + } + } + } /** * Requires the password to contain at least one lowercase letter (a-z). */ - object RequireLowercase: PasswordRule() + object RequireLowercase : PasswordRule() { + override fun validate(context: Context, password: String): ValidationStatus { + return if (password.any { it.isLowerCase() }) { + ValidationStatus(hasError = false) + } else { + ValidationStatus( + hasError = true, + errorMessage = context.getString(R.string.fui_error_invalid_password) + ) + } + } + } /** * Requires the password to contain at least one numeric digit (0-9). */ - object RequireDigit: PasswordRule() + object RequireDigit : PasswordRule() { + override fun validate(context: Context, password: String): ValidationStatus { + return if (password.any { it.isDigit() }) { + ValidationStatus(hasError = false) + } else { + ValidationStatus( + hasError = true, + errorMessage = context.getString(R.string.fui_error_invalid_password) + ) + } + } + } /** * Requires the password to contain at least one special character (e.g., !@#$%^&*). */ - object RequireSpecialCharacter: PasswordRule() + object RequireSpecialCharacter : PasswordRule() { + private val specialCharacters = "!@#$%^&*()_+-=[]{}|;:,.<>?".toSet() + + override fun validate(context: Context, password: String): ValidationStatus { + return if (password.any { it in specialCharacters }) { + ValidationStatus(hasError = false) + } else { + ValidationStatus( + hasError = true, + errorMessage = context.getString(R.string.fui_error_invalid_password) + ) + } + } + } /** - * Defines a custom validation rule using a regular expression and provides a specific error - * message on failure. + * Defines a custom validation rule using a regular expression. */ - class Custom(val regex: Regex, val errorMessage: String) + data class Custom( + val regex: Regex, + val errorMessage: String + ) : PasswordRule() { + override fun validate(context: Context, password: String): ValidationStatus { + return if (regex.matches(password)) { + ValidationStatus(hasError = false) + } else { + ValidationStatus( + hasError = true, + errorMessage = errorMessage + ) + } + } + } + + abstract fun validate(context: Context, password: String): ValidationStatus } \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt new file mode 100644 index 000000000..b2cf75814 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt @@ -0,0 +1,46 @@ +/* + * 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.compose.configuration.validators + +import android.content.Context +import com.firebase.ui.auth.R + +class EmailValidator(override val context: Context) : FieldValidator { + override var validationStatus: ValidationStatus = ValidationStatus(hasError = false) + private set + + override fun validate(value: String): ValidationStatus { + val result = when { + value.isEmpty() -> { + ValidationStatus( + hasError = true, + errorMessage = context.getString(R.string.fui_missing_email_address) + ) + } + + !android.util.Patterns.EMAIL_ADDRESS.matcher(value).matches() -> { + ValidationStatus( + hasError = true, + errorMessage = context.getString(R.string.fui_invalid_email_address) + ) + } + + else -> ValidationStatus(hasError = false) + } + + validationStatus = result + return result + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt new file mode 100644 index 000000000..8aa26874a --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt @@ -0,0 +1,26 @@ +/* + * 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.compose.configuration.validators + +import android.content.Context + +/** + * An interface for validating input fields. + */ +interface FieldValidator { + val context: Context + val validationStatus: ValidationStatus + fun validate(value: String): ValidationStatus +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt new file mode 100644 index 000000000..500a78ba7 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt @@ -0,0 +1,51 @@ +/* + * 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.compose.configuration.validators + +import android.content.Context +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.PasswordRule + +class PasswordValidator( + override val context: Context, + private val rules: List +) : FieldValidator { + override var validationStatus: ValidationStatus = ValidationStatus(hasError = false) + private set + + override fun validate(value: String): ValidationStatus { + val result = when { + value.isEmpty() -> { + ValidationStatus( + hasError = true, + errorMessage = context.getString(R.string.fui_error_invalid_password) + ) + } + + else -> { + for (rule in rules) { + val result = rule.validate(context, value) + if (result.hasError) { + return result + } + } + return ValidationStatus(hasError = false) + } + } + + validationStatus = result + return result + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/ValidationStatus.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/ValidationStatus.kt new file mode 100644 index 000000000..b6932f1d5 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/ValidationStatus.kt @@ -0,0 +1,20 @@ +/* + * 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.compose.configuration.validators + +data class ValidationStatus( + val hasError: Boolean, + val errorMessage: String? = null, +) From f5b25ae5b932aabac36750df558f22c0a6c6582d Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Fri, 19 Sep 2025 10:20:06 +0100 Subject: [PATCH 09/13] feat: added password rules validations, FieldValidator interface added email and password validator --- .../configuration/AuthUIStringProvider.kt | 62 +++++++++++++++---- .../compose/configuration/PasswordRule.kt | 53 +++++++++++----- .../validators/EmailValidator.kt | 9 ++- .../validators/FieldValidator.kt | 9 ++- .../validators/PasswordValidator.kt | 11 ++-- .../validators/ValidationStatus.kt | 16 ++++- auth/src/main/res/values/strings.xml | 7 +++ 7 files changed, 124 insertions(+), 43 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt index fe5bbf302..0e7080722 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt @@ -23,19 +23,59 @@ import com.firebase.ui.auth.R * passwordsDoNotMatch(), etc., allowing for complete localization of the UI. */ interface AuthUIStringProvider { - fun initializing(): String - fun signInWithGoogle(): String - fun invalidEmail(): String - fun passwordsDoNotMatch(): String -} + /** Loading text displayed during initialization or processing states */ + val initializing: String + + /** Button text for Google sign-in option */ + val signInWithGoogle: String + + /** Error message when email address field is empty */ + val missingEmailAddress: String + + /** Error message when email address format is invalid */ + val invalidEmailAddress: String + + /** Generic error message for incorrect password during sign-in */ + val invalidPassword: String -class DefaultAuthUIStringProvider(private val context: Context) : AuthUIStringProvider { - override fun initializing(): String = "" + /** Error message when password confirmation doesn't match the original password */ + val passwordsDoNotMatch: String - override fun signInWithGoogle(): String = - context.getString(R.string.fui_sign_in_with_google) + /** Error message when password doesn't meet minimum length requirement. Should support string formatting with minimum length parameter. */ + val passwordTooShort: String - override fun invalidEmail(): String = "" + /** Error message when password is missing at least one uppercase letter (A-Z) */ + val passwordMissingUppercase: String + + /** Error message when password is missing at least one lowercase letter (a-z) */ + val passwordMissingLowercase: String + + /** Error message when password is missing at least one numeric digit (0-9) */ + val passwordMissingDigit: String + + /** Error message when password is missing at least one special character */ + val passwordMissingSpecialCharacter: String +} - override fun passwordsDoNotMatch(): String = "" +internal class DefaultAuthUIStringProvider(private val context: Context) : AuthUIStringProvider { + override val initializing: String get() = "" + override val signInWithGoogle: String + get() = context.getString(R.string.fui_sign_in_with_google) + override val missingEmailAddress: String + get() = context.getString(R.string.fui_missing_email_address) + override val invalidEmailAddress: String + get() = context.getString(R.string.fui_invalid_email_address) + override val invalidPassword: String + get() = context.getString(R.string.fui_error_invalid_password) + override val passwordsDoNotMatch: String get() = "" + override val passwordTooShort: String + get() = context.getString(R.string.fui_error_password_too_short) + override val passwordMissingUppercase: String + get() = context.getString(R.string.fui_error_password_missing_uppercase) + override val passwordMissingLowercase: String + get() = context.getString(R.string.fui_error_password_missing_lowercase) + override val passwordMissingDigit: String + get() = context.getString(R.string.fui_error_password_missing_digit) + override val passwordMissingSpecialCharacter: String + get() = context.getString(R.string.fui_error_password_missing_special_character) } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt index 8db723159..1efb1570d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt @@ -14,8 +14,6 @@ package com.firebase.ui.auth.compose.configuration -import android.content.Context -import com.firebase.ui.auth.R import com.firebase.ui.auth.compose.configuration.validators.ValidationStatus /** @@ -25,14 +23,17 @@ sealed class PasswordRule { /** * Requires the password to have at least a certain number of characters. */ - data class MinimumLength(val value: Int) : PasswordRule() { - override fun validate(context: Context, password: String): ValidationStatus { - return if (password.length >= value) { + class MinimumLength(val value: Int) : PasswordRule() { + override fun validate( + stringProvider: AuthUIStringProvider, + password: String + ): ValidationStatus { + return if (password.length >= this@MinimumLength.value) { ValidationStatus(hasError = false) } else { ValidationStatus( hasError = true, - errorMessage = context.getString(R.string.fui_error_invalid_password) + errorMessage = stringProvider.passwordTooShort.format(this@MinimumLength.value) ) } } @@ -42,13 +43,16 @@ sealed class PasswordRule { * Requires the password to contain at least one uppercase letter (A-Z). */ object RequireUppercase : PasswordRule() { - override fun validate(context: Context, password: String): ValidationStatus { + override fun validate( + stringProvider: AuthUIStringProvider, + password: String + ): ValidationStatus { return if (password.any { it.isUpperCase() }) { ValidationStatus(hasError = false) } else { ValidationStatus( hasError = true, - errorMessage = context.getString(R.string.fui_error_invalid_password) + errorMessage = stringProvider.passwordMissingUppercase ) } } @@ -58,13 +62,16 @@ sealed class PasswordRule { * Requires the password to contain at least one lowercase letter (a-z). */ object RequireLowercase : PasswordRule() { - override fun validate(context: Context, password: String): ValidationStatus { + override fun validate( + stringProvider: AuthUIStringProvider, + password: String + ): ValidationStatus { return if (password.any { it.isLowerCase() }) { ValidationStatus(hasError = false) } else { ValidationStatus( hasError = true, - errorMessage = context.getString(R.string.fui_error_invalid_password) + errorMessage = stringProvider.passwordMissingLowercase ) } } @@ -74,13 +81,16 @@ sealed class PasswordRule { * Requires the password to contain at least one numeric digit (0-9). */ object RequireDigit : PasswordRule() { - override fun validate(context: Context, password: String): ValidationStatus { + override fun validate( + stringProvider: AuthUIStringProvider, + password: String + ): ValidationStatus { return if (password.any { it.isDigit() }) { ValidationStatus(hasError = false) } else { ValidationStatus( hasError = true, - errorMessage = context.getString(R.string.fui_error_invalid_password) + errorMessage = stringProvider.passwordMissingDigit ) } } @@ -92,13 +102,16 @@ sealed class PasswordRule { object RequireSpecialCharacter : PasswordRule() { private val specialCharacters = "!@#$%^&*()_+-=[]{}|;:,.<>?".toSet() - override fun validate(context: Context, password: String): ValidationStatus { + override fun validate( + stringProvider: AuthUIStringProvider, + password: String + ): ValidationStatus { return if (password.any { it in specialCharacters }) { ValidationStatus(hasError = false) } else { ValidationStatus( hasError = true, - errorMessage = context.getString(R.string.fui_error_invalid_password) + errorMessage = stringProvider.passwordMissingSpecialCharacter ) } } @@ -107,11 +120,14 @@ sealed class PasswordRule { /** * Defines a custom validation rule using a regular expression. */ - data class Custom( + class Custom( val regex: Regex, val errorMessage: String ) : PasswordRule() { - override fun validate(context: Context, password: String): ValidationStatus { + override fun validate( + stringProvider: AuthUIStringProvider, + password: String + ): ValidationStatus { return if (regex.matches(password)) { ValidationStatus(hasError = false) } else { @@ -123,5 +139,8 @@ sealed class PasswordRule { } } - abstract fun validate(context: Context, password: String): ValidationStatus + internal abstract fun validate( + stringProvider: AuthUIStringProvider, + password: String + ): ValidationStatus } \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt index b2cf75814..3230f8e07 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt @@ -14,10 +14,9 @@ package com.firebase.ui.auth.compose.configuration.validators -import android.content.Context -import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.AuthUIStringProvider -class EmailValidator(override val context: Context) : FieldValidator { +internal class EmailValidator(override val stringProvider: AuthUIStringProvider) : FieldValidator { override var validationStatus: ValidationStatus = ValidationStatus(hasError = false) private set @@ -26,14 +25,14 @@ class EmailValidator(override val context: Context) : FieldValidator { value.isEmpty() -> { ValidationStatus( hasError = true, - errorMessage = context.getString(R.string.fui_missing_email_address) + errorMessage = stringProvider.missingEmailAddress ) } !android.util.Patterns.EMAIL_ADDRESS.matcher(value).matches() -> { ValidationStatus( hasError = true, - errorMessage = context.getString(R.string.fui_invalid_email_address) + errorMessage = stringProvider.invalidEmailAddress ) } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt index 8aa26874a..049caea4b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt @@ -14,13 +14,16 @@ package com.firebase.ui.auth.compose.configuration.validators -import android.content.Context +import com.firebase.ui.auth.compose.configuration.AuthUIStringProvider /** * An interface for validating input fields. */ -interface FieldValidator { - val context: Context +internal interface FieldValidator { + val stringProvider: AuthUIStringProvider val validationStatus: ValidationStatus + /** + * Runs validation on a value and returns true if valid. + */ fun validate(value: String): ValidationStatus } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt index 500a78ba7..8780e33c1 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt @@ -14,12 +14,11 @@ package com.firebase.ui.auth.compose.configuration.validators -import android.content.Context -import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.PasswordRule -class PasswordValidator( - override val context: Context, +internal class PasswordValidator( + override val stringProvider: AuthUIStringProvider, private val rules: List ) : FieldValidator { override var validationStatus: ValidationStatus = ValidationStatus(hasError = false) @@ -30,13 +29,13 @@ class PasswordValidator( value.isEmpty() -> { ValidationStatus( hasError = true, - errorMessage = context.getString(R.string.fui_error_invalid_password) + errorMessage = stringProvider.invalidPassword ) } else -> { for (rule in rules) { - val result = rule.validate(context, value) + val result = rule.validate(stringProvider, value) if (result.hasError) { return result } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/ValidationStatus.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/ValidationStatus.kt index b6932f1d5..ae4926a01 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/ValidationStatus.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/ValidationStatus.kt @@ -14,7 +14,21 @@ package com.firebase.ui.auth.compose.configuration.validators -data class ValidationStatus( +/** + * Represents the result of a validation operation, containing both the validation state + * and any associated error message. + * + * This class is used throughout the authentication validation system to communicate + * validation results between validators and UI components. + */ +internal class ValidationStatus( + /** + * Returns true if the last validation failed. + */ val hasError: Boolean, + + /** + * The error message for the current state. + */ val errorMessage: String? = null, ) diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 52314a505..71b87f547 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -93,6 +93,13 @@ An unknown error occurred. Incorrect password. + + Password must be at least %1$d characters long + Password must contain at least one uppercase letter + Password must contain at least one lowercase letter + Password must contain at least one number + Password must contain at least one special character + App logo From c7f9a926c445d26ad86eaab26a9e2fce99ccb798 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Fri, 19 Sep 2025 15:20:19 +0100 Subject: [PATCH 10/13] test: EmailValidator, PasswordValidator and PasswordRule --- .../compose/configuration/PasswordRule.kt | 133 +++---- .../validators/EmailValidator.kt | 47 +-- ...tionStatus.kt => FieldValidationStatus.kt} | 9 +- .../validators/FieldValidator.kt | 16 +- .../validators/PasswordValidator.kt | 42 ++- .../configuration/AuthUIConfigurationTest.kt | 68 ++-- .../compose/configuration/PasswordRuleTest.kt | 336 ++++++++++++++++++ .../validators/EmailValidatorTest.kt | 110 ++++++ .../validators/PasswordValidatorTest.kt | 277 +++++++++++++++ 9 files changed, 882 insertions(+), 156 deletions(-) rename auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/{ValidationStatus.kt => FieldValidationStatus.kt} (73%) create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt index 1efb1570d..a7c8f4ada 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt @@ -14,28 +14,20 @@ package com.firebase.ui.auth.compose.configuration -import com.firebase.ui.auth.compose.configuration.validators.ValidationStatus - /** - * A sealed class representing password validation rules with embedded validation logic. + * A abstract class representing password validation rules with embedded validation logic. */ -sealed class PasswordRule { +abstract class PasswordRule { /** * Requires the password to have at least a certain number of characters. */ class MinimumLength(val value: Int) : PasswordRule() { - override fun validate( - stringProvider: AuthUIStringProvider, - password: String - ): ValidationStatus { - return if (password.length >= this@MinimumLength.value) { - ValidationStatus(hasError = false) - } else { - ValidationStatus( - hasError = true, - errorMessage = stringProvider.passwordTooShort.format(this@MinimumLength.value) - ) - } + override fun isValid(password: String): Boolean { + return password.length >= this@MinimumLength.value + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordTooShort.format(value) } } @@ -43,18 +35,12 @@ sealed class PasswordRule { * Requires the password to contain at least one uppercase letter (A-Z). */ object RequireUppercase : PasswordRule() { - override fun validate( - stringProvider: AuthUIStringProvider, - password: String - ): ValidationStatus { - return if (password.any { it.isUpperCase() }) { - ValidationStatus(hasError = false) - } else { - ValidationStatus( - hasError = true, - errorMessage = stringProvider.passwordMissingUppercase - ) - } + override fun isValid(password: String): Boolean { + return password.any { it.isUpperCase() } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingUppercase } } @@ -62,18 +48,12 @@ sealed class PasswordRule { * Requires the password to contain at least one lowercase letter (a-z). */ object RequireLowercase : PasswordRule() { - override fun validate( - stringProvider: AuthUIStringProvider, - password: String - ): ValidationStatus { - return if (password.any { it.isLowerCase() }) { - ValidationStatus(hasError = false) - } else { - ValidationStatus( - hasError = true, - errorMessage = stringProvider.passwordMissingLowercase - ) - } + override fun isValid(password: String): Boolean { + return password.any { it.isLowerCase() } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingLowercase } } @@ -81,18 +61,12 @@ sealed class PasswordRule { * Requires the password to contain at least one numeric digit (0-9). */ object RequireDigit : PasswordRule() { - override fun validate( - stringProvider: AuthUIStringProvider, - password: String - ): ValidationStatus { - return if (password.any { it.isDigit() }) { - ValidationStatus(hasError = false) - } else { - ValidationStatus( - hasError = true, - errorMessage = stringProvider.passwordMissingDigit - ) - } + override fun isValid(password: String): Boolean { + return password.any { it.isDigit() } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingDigit } } @@ -102,18 +76,12 @@ sealed class PasswordRule { object RequireSpecialCharacter : PasswordRule() { private val specialCharacters = "!@#$%^&*()_+-=[]{}|;:,.<>?".toSet() - override fun validate( - stringProvider: AuthUIStringProvider, - password: String - ): ValidationStatus { - return if (password.any { it in specialCharacters }) { - ValidationStatus(hasError = false) - } else { - ValidationStatus( - hasError = true, - errorMessage = stringProvider.passwordMissingSpecialCharacter - ) - } + override fun isValid(password: String): Boolean { + return password.any { it in specialCharacters } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingSpecialCharacter } } @@ -124,23 +92,28 @@ sealed class PasswordRule { val regex: Regex, val errorMessage: String ) : PasswordRule() { - override fun validate( - stringProvider: AuthUIStringProvider, - password: String - ): ValidationStatus { - return if (regex.matches(password)) { - ValidationStatus(hasError = false) - } else { - ValidationStatus( - hasError = true, - errorMessage = errorMessage - ) - } + override fun isValid(password: String): Boolean { + return regex.matches(password) + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return errorMessage } } - internal abstract fun validate( - stringProvider: AuthUIStringProvider, - password: String - ): ValidationStatus + /** + * Validates whether the given password meets this rule's requirements. + * + * @param password The password to validate + * @return true if the password meets this rule's requirements, false otherwise + */ + internal abstract fun isValid(password: String): Boolean + + /** + * Returns the appropriate error message for this rule when validation fails. + * + * @param stringProvider The string provider for localized error messages + * @return The localized error message for this rule + */ + internal abstract fun getErrorMessage(stringProvider: AuthUIStringProvider): String } \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt index 3230f8e07..c927ee4aa 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt @@ -17,29 +17,32 @@ package com.firebase.ui.auth.compose.configuration.validators import com.firebase.ui.auth.compose.configuration.AuthUIStringProvider internal class EmailValidator(override val stringProvider: AuthUIStringProvider) : FieldValidator { - override var validationStatus: ValidationStatus = ValidationStatus(hasError = false) - private set - - override fun validate(value: String): ValidationStatus { - val result = when { - value.isEmpty() -> { - ValidationStatus( - hasError = true, - errorMessage = stringProvider.missingEmailAddress - ) - } - - !android.util.Patterns.EMAIL_ADDRESS.matcher(value).matches() -> { - ValidationStatus( - hasError = true, - errorMessage = stringProvider.invalidEmailAddress - ) - } - - else -> ValidationStatus(hasError = false) + private var validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + + override val hasError: Boolean + get() = validationStatus.hasError + + override val errorMessage: String + get() = validationStatus.errorMessage ?: "" + + override fun validate(value: String): Boolean { + if (value.isEmpty()) { + validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.missingEmailAddress + ) + return false + } + + if (!android.util.Patterns.EMAIL_ADDRESS.matcher(value).matches()) { + validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.invalidEmailAddress + ) + return false } - validationStatus = result - return result + validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + return true } } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/ValidationStatus.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidationStatus.kt similarity index 73% rename from auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/ValidationStatus.kt rename to auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidationStatus.kt index ae4926a01..a1edb5fb9 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/ValidationStatus.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidationStatus.kt @@ -14,14 +14,7 @@ package com.firebase.ui.auth.compose.configuration.validators -/** - * Represents the result of a validation operation, containing both the validation state - * and any associated error message. - * - * This class is used throughout the authentication validation system to communicate - * validation results between validators and UI components. - */ -internal class ValidationStatus( +internal class FieldValidationStatus( /** * Returns true if the last validation failed. */ diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt index 049caea4b..a26741897 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt @@ -19,11 +19,21 @@ import com.firebase.ui.auth.compose.configuration.AuthUIStringProvider /** * An interface for validating input fields. */ -internal interface FieldValidator { +interface FieldValidator { val stringProvider: AuthUIStringProvider - val validationStatus: ValidationStatus + + /** + * Returns true if the last validation failed. + */ + val hasError: Boolean + + /** + * The error message for the current state. + */ + val errorMessage: String + /** * Runs validation on a value and returns true if valid. */ - fun validate(value: String): ValidationStatus + fun validate(value: String): Boolean } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt index 8780e33c1..ec1df877b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt @@ -21,30 +21,34 @@ internal class PasswordValidator( override val stringProvider: AuthUIStringProvider, private val rules: List ) : FieldValidator { - override var validationStatus: ValidationStatus = ValidationStatus(hasError = false) - private set + private var validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) - override fun validate(value: String): ValidationStatus { - val result = when { - value.isEmpty() -> { - ValidationStatus( + override val hasError: Boolean + get() = validationStatus.hasError + + override val errorMessage: String + get() = validationStatus.errorMessage ?: "" + + override fun validate(value: String): Boolean { + if (value.isEmpty()) { + validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.invalidPassword + ) + return false + } + + for (rule in rules) { + if (!rule.isValid(value)) { + validationStatus = FieldValidationStatus( hasError = true, - errorMessage = stringProvider.invalidPassword + errorMessage = rule.getErrorMessage(stringProvider) ) - } - - else -> { - for (rule in rules) { - val result = rule.validate(stringProvider, value) - if (result.hasError) { - return result - } - } - return ValidationStatus(hasError = false) + return false } } - validationStatus = result - return result + validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + return true } } diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt index c8e627ff5..0233848e4 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -20,12 +20,23 @@ import com.google.common.truth.Truth.assertThat import com.google.firebase.auth.actionCodeSettings import org.junit.Assert.assertThrows import org.junit.Test +import org.mockito.Mockito.mock import java.util.Locale import kotlin.reflect.KMutableProperty import kotlin.reflect.full.memberProperties +/** + * Unit tests for [AuthUIConfiguration] covering configuration builder behavior, + * validation rules, provider setup, and immutability guarantees. + * + * @suppress Internal test class + */ class AuthUIConfigurationTest { + // ============================================================================================= + // Basic Configuration Tests + // ============================================================================================= + @Test fun `authUIConfiguration with minimal setup uses correct defaults`() { val config = authUIConfiguration { @@ -58,12 +69,7 @@ class AuthUIConfigurationTest { @Test fun `authUIConfiguration with all fields overridden uses custom values`() { val customTheme = AuthUITheme.Default - val customStringProvider = object : AuthUIStringProvider { - override fun initializing(): String = "" - override fun signInWithGoogle(): String = "" - override fun invalidEmail(): String = "" - override fun passwordsDoNotMatch(): String = "" - } + val customStringProvider = mock(AuthUIStringProvider::class.java) val customLocale = Locale.US val customActionCodeSettings = actionCodeSettings { url = "https://example.com/verify" @@ -115,9 +121,9 @@ class AuthUIConfigurationTest { assertThat(config.isProviderChoiceAlwaysShown).isTrue() } - // =========================================================================================== + // ============================================================================================= // Validation Tests - // =========================================================================================== + // ============================================================================================= @Test(expected = IllegalArgumentException::class) fun `authUIConfiguration throws when no providers configured`() { @@ -136,7 +142,12 @@ class AuthUIConfigurationTest { provider(AuthProvider.Yahoo(customParameters = mapOf())) provider(AuthProvider.Apple(customParameters = mapOf(), locale = null)) provider(AuthProvider.Phone(defaultCountryCode = null, allowedCountries = null)) - provider(AuthProvider.Email(actionCodeSettings = null, passwordValidationRules = listOf())) + provider( + AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = listOf() + ) + ) } } assertThat(config.providers).hasSize(9) @@ -174,7 +185,12 @@ class AuthUIConfigurationTest { authUIConfiguration { providers { provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) - provider(AuthProvider.Google(scopes = listOf("email"), serverClientId = "different")) + provider( + AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "different" + ) + ) } } } @@ -183,11 +199,13 @@ class AuthUIConfigurationTest { fun `validate throws for enableEmailLinkSignIn true when actionCodeSettings is null`() { authUIConfiguration { providers { - provider(AuthProvider.Email( - isEmailLinkSignInEnabled = true, - actionCodeSettings = null, - passwordValidationRules = listOf() - )) + provider( + AuthProvider.Email( + isEmailLinkSignInEnabled = true, + actionCodeSettings = null, + passwordValidationRules = listOf() + ) + ) } } } @@ -200,18 +218,20 @@ class AuthUIConfigurationTest { } authUIConfiguration { providers { - provider(AuthProvider.Email( - isEmailLinkSignInEnabled = true, - actionCodeSettings = customActionCodeSettings, - passwordValidationRules = listOf() - )) + provider( + AuthProvider.Email( + isEmailLinkSignInEnabled = true, + actionCodeSettings = customActionCodeSettings, + passwordValidationRules = listOf() + ) + ) } } } - // =========================================================================================== + // ============================================================================================= // Provider Configuration Tests - // =========================================================================================== + // ============================================================================================= @Test fun `providers block can be called multiple times and accumulates providers`() { @@ -238,9 +258,9 @@ class AuthUIConfigurationTest { assertThat(config.providers).hasSize(2) } - // =========================================================================================== + // ============================================================================================= // Builder Immutability Tests - // =========================================================================================== + // ============================================================================================= @Test fun `authUIConfiguration providers list is immutable`() { diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt new file mode 100644 index 000000000..d3cacb488 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt @@ -0,0 +1,336 @@ +/* + * 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.compose.configuration + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [PasswordRule] implementations covering validation logic + * and error message generation for each password rule type. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class PasswordRuleTest { + + private lateinit var stringProvider: DefaultAuthUIStringProvider + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + } + + // ============================================================================================= + // MinimumLength Rule Tests + // ============================================================================================= + + @Test + fun `MinimumLength isValid returns true for password meeting length requirement`() { + val rule = PasswordRule.MinimumLength(8) + + val isValid = rule.isValid("password123") + + assertThat(isValid).isTrue() + } + + @Test + fun `MinimumLength isValid returns false for password shorter than requirement`() { + val rule = PasswordRule.MinimumLength(8) + + val isValid = rule.isValid("short") + + assertThat(isValid).isFalse() + } + + @Test + fun `MinimumLength getErrorMessage returns formatted message`() { + val context = ApplicationProvider.getApplicationContext() + val rule = PasswordRule.MinimumLength(10) + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(context.getString(R.string.fui_error_password_too_short, 10)) + } + + // ============================================================================================= + // RequireUppercase Rule Tests + // ============================================================================================= + + @Test + fun `RequireUppercase isValid returns true for password with uppercase letter`() { + val rule = PasswordRule.RequireUppercase + + val isValid = rule.isValid("Password123") + + assertThat(isValid).isTrue() + } + + @Test + fun `RequireUppercase isValid returns false for password without uppercase letter`() { + val rule = PasswordRule.RequireUppercase + + val isValid = rule.isValid("password123") + + assertThat(isValid).isFalse() + } + + @Test + fun `RequireUppercase getErrorMessage returns correct message`() { + val context = ApplicationProvider.getApplicationContext() + val rule = PasswordRule.RequireUppercase + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(context.getString(R.string.fui_error_password_missing_uppercase)) + } + + // ============================================================================================= + // RequireLowercase Rule Tests + // ============================================================================================= + + @Test + fun `RequireLowercase isValid returns true for password with lowercase letter`() { + val rule = PasswordRule.RequireLowercase + + val isValid = rule.isValid("Password123") + + assertThat(isValid).isTrue() + } + + @Test + fun `RequireLowercase isValid returns false for password without lowercase letter`() { + val rule = PasswordRule.RequireLowercase + + val isValid = rule.isValid("PASSWORD123") + + assertThat(isValid).isFalse() + } + + @Test + fun `RequireLowercase getErrorMessage returns correct message`() { + val context = ApplicationProvider.getApplicationContext() + val rule = PasswordRule.RequireLowercase + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(context.getString(R.string.fui_error_password_missing_lowercase)) + } + + // ============================================================================================= + // RequireDigit Rule Tests + // ============================================================================================= + + @Test + fun `RequireDigit isValid returns true for password with digit`() { + val rule = PasswordRule.RequireDigit + + val isValid = rule.isValid("Password123") + + assertThat(isValid).isTrue() + } + + @Test + fun `RequireDigit isValid returns false for password without digit`() { + val rule = PasswordRule.RequireDigit + + val isValid = rule.isValid("Password") + + assertThat(isValid).isFalse() + } + + @Test + fun `RequireDigit getErrorMessage returns correct message`() { + val context = ApplicationProvider.getApplicationContext() + val rule = PasswordRule.RequireDigit + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(context.getString(R.string.fui_error_password_missing_digit)) + } + + // ============================================================================================= + // RequireSpecialCharacter Rule Tests + // ============================================================================================= + + @Test + fun `RequireSpecialCharacter isValid returns true for password with special character`() { + val rule = PasswordRule.RequireSpecialCharacter + + val isValid = rule.isValid("Password123!") + + assertThat(isValid).isTrue() + } + + @Test + fun `RequireSpecialCharacter isValid returns false for password without special character`() { + val rule = PasswordRule.RequireSpecialCharacter + + val isValid = rule.isValid("Password123") + + assertThat(isValid).isFalse() + } + + @Test + fun `RequireSpecialCharacter getErrorMessage returns correct message`() { + val context = ApplicationProvider.getApplicationContext() + val rule = PasswordRule.RequireSpecialCharacter + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(context.getString(R.string.fui_error_password_missing_special_character)) + } + + @Test + fun `RequireSpecialCharacter validates various special characters`() { + val rule = PasswordRule.RequireSpecialCharacter + val specialChars = "!@#$%^&*()_+-=[]{}|;:,.<>?" + + specialChars.forEach { char -> + val isValid = rule.isValid("Password123$char") + assertThat(isValid).isTrue() + } + } + + // ============================================================================================= + // Custom Rule Tests + // ============================================================================================= + + @Test + fun `Custom rule isValid works with provided regex`() { + val rule = PasswordRule.Custom( + regex = Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$"), + errorMessage = "Custom validation failed" + ) + + val validPassword = rule.isValid("Password123") + val invalidPassword = rule.isValid("weak") + + assertThat(validPassword).isTrue() + assertThat(invalidPassword).isFalse() + } + + @Test + fun `Custom rule getErrorMessage returns custom message`() { + val customMessage = "Custom validation failed" + val rule = PasswordRule.Custom( + regex = Regex(".*"), + errorMessage = customMessage + ) + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(customMessage) + } + + @Test + fun `Custom rule with complex regex works correctly`() { + // Must contain at least one letter, one number, and be 6+ characters + val rule = PasswordRule.Custom( + regex = Regex("^(?=.*[a-zA-Z])(?=.*\\d).{6,}$"), + errorMessage = "Must contain letter and number, min 6 chars" + ) + + assertThat(rule.isValid("abc123")).isTrue() + assertThat(rule.isValid("password1")).isTrue() + assertThat(rule.isValid("123456")).isFalse() // No letter + assertThat(rule.isValid("abcdef")).isFalse() // No number + assertThat(rule.isValid("abc12")).isFalse() // Too short + } + + // ============================================================================================= + // Rule Extensibility Tests + // ============================================================================================= + + @Test + fun `custom password rule by extending PasswordRule works`() { + val customRule = object : PasswordRule() { + override fun isValid(password: String): Boolean { + return password.contains("test") + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return "Password must contain 'test'" + } + } + + val validResult = customRule.isValid("testing123") + val invalidResult = customRule.isValid("invalid") + val errorMessage = customRule.getErrorMessage(stringProvider) + + assertThat(validResult).isTrue() + assertThat(invalidResult).isFalse() + assertThat(errorMessage).isEqualTo("Password must contain 'test'") + } + + @Test + fun `multiple custom rules can be created independently`() { + val rule1 = object : PasswordRule() { + override fun isValid(password: String): Boolean = password.startsWith("prefix") + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String = "Must start with 'prefix'" + } + + val rule2 = object : PasswordRule() { + override fun isValid(password: String): Boolean = password.endsWith("suffix") + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String = "Must end with 'suffix'" + } + + assertThat(rule1.isValid("prefixPassword")).isTrue() + assertThat(rule1.isValid("passwordsuffix")).isFalse() + + assertThat(rule2.isValid("passwordsuffix")).isTrue() + assertThat(rule2.isValid("prefixPassword")).isFalse() + + assertThat(rule1.getErrorMessage(stringProvider)).isEqualTo("Must start with 'prefix'") + assertThat(rule2.getErrorMessage(stringProvider)).isEqualTo("Must end with 'suffix'") + } + + // ============================================================================================= + // Edge Case Tests + // ============================================================================================= + + @Test + fun `all rules handle empty password correctly`() { + val rules = listOf( + PasswordRule.MinimumLength(1), + PasswordRule.RequireUppercase, + PasswordRule.RequireLowercase, + PasswordRule.RequireDigit, + PasswordRule.RequireSpecialCharacter + ) + + rules.forEach { rule -> + val isValid = rule.isValid("") + assertThat(isValid).isFalse() + } + } + + @Test + fun `MinimumLength with zero length allows any password`() { + val rule = PasswordRule.MinimumLength(0) + + assertThat(rule.isValid("")).isTrue() + assertThat(rule.isValid("any")).isTrue() + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt new file mode 100644 index 000000000..7eeb45065 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt @@ -0,0 +1,110 @@ +/* + * 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.compose.configuration.validators + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.DefaultAuthUIStringProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Integration tests for [EmailValidator] covering email validation logic, + * error state management, and integration with [DefaultAuthUIStringProvider] + * using real Android string resources. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class EmailValidatorTest { + + @Mock + private lateinit var stringProvider: DefaultAuthUIStringProvider + + private lateinit var emailValidator: EmailValidator + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + emailValidator = EmailValidator(stringProvider) + } + + // ============================================================================================= + // Initial State Tests + // ============================================================================================= + + @Test + fun `validator initial state has no error`() { + assertThat(emailValidator.hasError).isFalse() + assertThat(emailValidator.errorMessage).isEmpty() + } + + // ============================================================================================= + // Validation Logic Tests + // ============================================================================================= + + @Test + fun `validate returns false and sets error for empty email`() { + val context = ApplicationProvider.getApplicationContext() + + val isValid = emailValidator.validate("") + + assertThat(isValid).isFalse() + assertThat(emailValidator.hasError).isTrue() + assertThat(emailValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_missing_email_address)) + } + + @Test + fun `validate returns false and sets error for invalid email format`() { + val context = ApplicationProvider.getApplicationContext() + + val isValid = emailValidator.validate("invalid-email") + + assertThat(isValid).isFalse() + assertThat(emailValidator.hasError).isTrue() + assertThat(emailValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_invalid_email_address)) + } + + @Test + fun `validate returns true and clears error for valid email`() { + val isValid = emailValidator.validate("test@example.com") + + assertThat(isValid).isTrue() + assertThat(emailValidator.hasError).isFalse() + assertThat(emailValidator.errorMessage).isEmpty() + } + + @Test + fun `validate clears previous error when valid email provided`() { + emailValidator.validate("invalid") + assertThat(emailValidator.hasError).isTrue() + + val isValid = emailValidator.validate("valid@example.com") + + assertThat(isValid).isTrue() + assertThat(emailValidator.hasError).isFalse() + assertThat(emailValidator.errorMessage).isEmpty() + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt new file mode 100644 index 000000000..4e8d2e440 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt @@ -0,0 +1,277 @@ +/* + * 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.compose.configuration.validators + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Integration tests for [PasswordValidator] covering password validation logic, + * password rule enforcement, error state management, and integration with + * [DefaultAuthUIStringProvider] using real Android string resources. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class PasswordValidatorTest { + + private lateinit var stringProvider: DefaultAuthUIStringProvider + private lateinit var passwordValidator: PasswordValidator + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + } + + // ============================================================================================= + // Initial State Tests + // ============================================================================================= + + @Test + fun `validator initial state has no error`() { + passwordValidator = PasswordValidator(stringProvider, emptyList()) + + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } + + // ============================================================================================= + // Empty Password Validation Tests + // ============================================================================================= + + @Test + fun `validate returns false and sets error for empty password`() { + val context = ApplicationProvider.getApplicationContext() + passwordValidator = PasswordValidator(stringProvider, emptyList()) + + val isValid = passwordValidator.validate("") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_invalid_password)) + } + + // ============================================================================================= + // Minimum Length Rule Tests + // ============================================================================================= + + @Test + fun `validate returns false for password shorter than minimum length`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf(PasswordRule.MinimumLength(8)) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("short") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_too_short, 8)) + } + + @Test + fun `validate returns true for password meeting minimum length`() { + val rules = listOf(PasswordRule.MinimumLength(8)) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("password123") + + assertThat(isValid).isTrue() + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } + + // ============================================================================================= + // Character Requirement Tests + // ============================================================================================= + + @Test + fun `validate returns false for password missing uppercase letter`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf(PasswordRule.RequireUppercase) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("password123") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_missing_uppercase)) + } + + @Test + fun `validate returns true for password with uppercase letter`() { + val rules = listOf(PasswordRule.RequireUppercase) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("Password123") + + assertThat(isValid).isTrue() + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } + + @Test + fun `validate returns false for password missing lowercase letter`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf(PasswordRule.RequireLowercase) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("PASSWORD123") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_missing_lowercase)) + } + + @Test + fun `validate returns false for password missing digit`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf(PasswordRule.RequireDigit) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("Password") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_missing_digit)) + } + + @Test + fun `validate returns false for password missing special character`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf(PasswordRule.RequireSpecialCharacter) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("Password123") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_missing_special_character)) + } + + // ============================================================================================= + // Multiple Rules Tests + // ============================================================================================= + + @Test + fun `validate returns false and shows first failing rule error`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireUppercase, + PasswordRule.RequireDigit + ) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("short") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + // Should show the first failing rule (MinimumLength) + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_too_short, 8)) + } + + @Test + fun `validate returns true for password meeting all rules`() { + val rules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireUppercase, + PasswordRule.RequireLowercase, + PasswordRule.RequireDigit, + PasswordRule.RequireSpecialCharacter + ) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("Password123!") + + assertThat(isValid).isTrue() + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } + + // ============================================================================================= + // Custom Rule Tests + // ============================================================================================= + + @Test + fun `validate works with custom regex rule`() { + val customRule = PasswordRule.Custom( + // Valid (has upper, lower, digit, 8+ chars, only letters/digits) + regex = Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$"), + errorMessage = "Custom validation failed" + ) + val rules = listOf(customRule) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("Password123") + + assertThat(isValid).isTrue() + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } + + @Test + fun `validate returns custom error message for failing custom rule`() { + val customRule = PasswordRule.Custom( + // Valid (has upper, lower, digit, 8+ chars, only letters/digits) + regex = Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$"), + errorMessage = "Custom validation failed" + ) + val rules = listOf(customRule) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("weak") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage).isEqualTo("Custom validation failed") + } + + // ============================================================================================= + // Error State Management Tests + // ============================================================================================= + + @Test + fun `validate clears previous error when password becomes valid`() { + val rules = listOf(PasswordRule.MinimumLength(8)) + passwordValidator = PasswordValidator(stringProvider, rules) + + passwordValidator.validate("short") + assertThat(passwordValidator.hasError).isTrue() + + val isValid = passwordValidator.validate("longenough") + + assertThat(isValid).isTrue() + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } +} \ No newline at end of file From c3b2b48ce31b463916eaea71152f74c58573132b Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Fri, 19 Sep 2025 15:27:41 +0100 Subject: [PATCH 11/13] docs: update PasswordRule comments --- .../com/firebase/ui/auth/compose/configuration/PasswordRule.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt index a7c8f4ada..13bdd4d85 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt @@ -15,7 +15,8 @@ package com.firebase.ui.auth.compose.configuration /** - * A abstract class representing password validation rules with embedded validation logic. + * An abstract class representing a set of validation rules that can be applied to a password field, + * typically within the [AuthProvider.Email] configuration. */ abstract class PasswordRule { /** From 82fe0868f0f76b0b96c5d51f4d0563fd275746fc Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Fri, 19 Sep 2025 15:40:05 +0100 Subject: [PATCH 12/13] docs: update PasswordRule comments --- .../ui/auth/compose/configuration/PasswordRule.kt | 3 ++- .../configuration/validators/EmailValidator.kt | 12 ++++++------ .../validators/FieldValidationStatus.kt | 11 ++++------- .../configuration/validators/PasswordValidator.kt | 12 ++++++------ 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt index 13bdd4d85..8f53822f2 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt @@ -87,7 +87,8 @@ abstract class PasswordRule { } /** - * Defines a custom validation rule using a regular expression. + * Defines a custom validation rule using a regular expression and provides a specific error + * message on failure. */ class Custom( val regex: Regex, diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt index c927ee4aa..d6b66194f 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt @@ -17,17 +17,17 @@ package com.firebase.ui.auth.compose.configuration.validators import com.firebase.ui.auth.compose.configuration.AuthUIStringProvider internal class EmailValidator(override val stringProvider: AuthUIStringProvider) : FieldValidator { - private var validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) override val hasError: Boolean - get() = validationStatus.hasError + get() = _validationStatus.hasError override val errorMessage: String - get() = validationStatus.errorMessage ?: "" + get() = _validationStatus.errorMessage ?: "" override fun validate(value: String): Boolean { if (value.isEmpty()) { - validationStatus = FieldValidationStatus( + _validationStatus = FieldValidationStatus( hasError = true, errorMessage = stringProvider.missingEmailAddress ) @@ -35,14 +35,14 @@ internal class EmailValidator(override val stringProvider: AuthUIStringProvider) } if (!android.util.Patterns.EMAIL_ADDRESS.matcher(value).matches()) { - validationStatus = FieldValidationStatus( + _validationStatus = FieldValidationStatus( hasError = true, errorMessage = stringProvider.invalidEmailAddress ) return false } - validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) return true } } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidationStatus.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidationStatus.kt index a1edb5fb9..7a681c921 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidationStatus.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidationStatus.kt @@ -14,14 +14,11 @@ package com.firebase.ui.auth.compose.configuration.validators +/** + * Class for encapsulating [hasError] and [errorMessage] properties in + * internal FieldValidator subclasses. + */ internal class FieldValidationStatus( - /** - * Returns true if the last validation failed. - */ val hasError: Boolean, - - /** - * The error message for the current state. - */ val errorMessage: String? = null, ) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt index ec1df877b..35605818e 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt @@ -21,17 +21,17 @@ internal class PasswordValidator( override val stringProvider: AuthUIStringProvider, private val rules: List ) : FieldValidator { - private var validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) override val hasError: Boolean - get() = validationStatus.hasError + get() = _validationStatus.hasError override val errorMessage: String - get() = validationStatus.errorMessage ?: "" + get() = _validationStatus.errorMessage ?: "" override fun validate(value: String): Boolean { if (value.isEmpty()) { - validationStatus = FieldValidationStatus( + _validationStatus = FieldValidationStatus( hasError = true, errorMessage = stringProvider.invalidPassword ) @@ -40,7 +40,7 @@ internal class PasswordValidator( for (rule in rules) { if (!rule.isValid(value)) { - validationStatus = FieldValidationStatus( + _validationStatus = FieldValidationStatus( hasError = true, errorMessage = rule.getErrorMessage(stringProvider) ) @@ -48,7 +48,7 @@ internal class PasswordValidator( } } - validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) return true } } From 89141835c452a257a4679bd5c1c471db0adbc397 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Fri, 19 Sep 2025 22:52:34 +0100 Subject: [PATCH 13/13] fix: remove mock annotation --- .../auth/compose/configuration/validators/EmailValidatorTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt index 7eeb45065..520908181 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt @@ -37,7 +37,6 @@ import org.robolectric.annotation.Config @Config(manifest = Config.NONE) class EmailValidatorTest { - @Mock private lateinit var stringProvider: DefaultAuthUIStringProvider private lateinit var emailValidator: EmailValidator