diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index 66df263a8..8e6d5304e 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") + alias(libs.plugins.compose.compiler) } 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..ef8bb0771 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt @@ -0,0 +1,357 @@ +/* + * 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 +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 { + private val providers = mutableListOf() + + fun provider(provider: AuthProvider) { + providers.add(provider) + } + + 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 abstract 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 abstract class for authentication providers. + */ +abstract class AuthProvider(open val providerId: String) { + /** + * Email/Password authentication provider configuration. + */ + class Email( + /** + * Requires the user to provide a display name. Defaults to true. + */ + val isDisplayNameRequired: Boolean = true, + + /** + * Enables email link sign-in, Defaults to false. + */ + val isEmailLinkSignInEnabled: Boolean = false, + + /** + * Settings for email link actions. + */ + val actionCodeSettings: ActionCodeSettings?, + + /** + * Allows new accounts to be created. Defaults to true. + */ + val isNewAccountsAllowed: 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(providerId = Provider.EMAIL.id) { + fun validate() { + if (isEmailLinkSignInEnabled) { + 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. + */ + 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 isInstantVerificationEnabled: Boolean = true, + + /** + * Enables automatic retrieval of the SMS code. Defaults to true. + */ + val isAutoRetrievalEnabled: Boolean = true + ) : AuthProvider(providerId = Provider.PHONE.id) + + /** + * Google Sign-In provider configuration. + */ + class Google( + /** + * The list of scopes to request. + */ + override 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, + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map = emptyMap() + ) : OAuthProvider( + providerId = Provider.GOOGLE.id, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Facebook Login provider configuration. + */ + class Facebook( + /** + * The list of scopes (permissions) to request. Defaults to email and public_profile. + */ + override val scopes: List = listOf("email", "public_profile"), + + /** + * if true, enable limited login mode. Defaults to false. + */ + val limitedLogin: Boolean = false, + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map = emptyMap() + ) : OAuthProvider( + providerId = Provider.FACEBOOK.id, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Twitter/X authentication provider configuration. + */ + class Twitter( + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map + ) : OAuthProvider( + providerId = Provider.TWITTER.id, + customParameters = customParameters + ) + + /** + * Github authentication provider configuration. + */ + class Github( + /** + * The list of scopes to request. Defaults to user:email. + */ + override val scopes: List = listOf("user:email"), + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map + ) : OAuthProvider( + providerId = Provider.GITHUB.id, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Microsoft authentication provider configuration. + */ + class Microsoft( + /** + * The list of scopes to request. Defaults to openid, profile, email. + */ + override val scopes: List = listOf("openid", "profile", "email"), + + /** + * The tenant ID for Azure Active Directory. + */ + val tenant: String?, + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map + ) : OAuthProvider( + providerId = Provider.MICROSOFT.id, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Yahoo authentication provider configuration. + */ + class Yahoo( + /** + * The list of scopes to request. Defaults to openid, profile, email. + */ + override val scopes: List = listOf("openid", "profile", "email"), + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map + ) : OAuthProvider( + providerId = Provider.YAHOO.id, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Apple Sign-In provider configuration. + */ + class Apple( + /** + * The list of scopes to request. Defaults to name and email. + */ + override val scopes: List = listOf("name", "email"), + + /** + * The locale for the sign-in page. + */ + val locale: String?, + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map + ) : OAuthProvider( + providerId = Provider.APPLE.id, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Anonymous authentication provider. It has no configurable properties. + */ + object Anonymous : AuthProvider(providerId = Provider.ANONYMOUS.id) + + /** + * A generic OAuth provider for any unsupported provider. + */ + class GenericOAuth( + /** + * The provider ID as configured in the Firebase console. + */ + override val providerId: String, + + /** + * The list of scopes to request. + */ + override val scopes: List, + + /** + * A map of custom OAuth parameters. + */ + override 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? + ) : OAuthProvider( + providerId = providerId, + scopes = scopes, + customParameters = customParameters + ) +} \ 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..aca1ccf9e --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt @@ -0,0 +1,192 @@ +/* + * 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 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 isNewEmailAccountsAllowed: Boolean = true + var isDisplayNameRequired: Boolean = true + var isProviderChoiceAlwaysShown: 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, + isCredentialManagerEnabled = isCredentialManagerEnabled, + isMfaEnabled = isMfaEnabled, + isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled, + tosUrl = tosUrl, + privacyPolicyUrl = privacyPolicyUrl, + logo = logo, + actionCodeSettings = actionCodeSettings, + isNewEmailAccountsAllowed = isNewEmailAccountsAllowed, + isDisplayNameRequired = isDisplayNameRequired, + isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown + ) + } + + 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 + } + } + } +} + +/** + * Configuration object for the authentication flow. + */ +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 isCredentialManagerEnabled: Boolean = true, + + /** + * Enables Multi-Factor Authentication support. Defaults to true. + */ + val isMfaEnabled: Boolean = true, + + /** + * Allows upgrading an anonymous user to a new credential. + */ + val isAnonymousUpgradeEnabled: 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 isNewEmailAccountsAllowed: Boolean = true, + + /** + * Requires the user to provide a display name on sign-up. Defaults to true. + */ + val isDisplayNameRequired: Boolean = true, + + /** + * Always shows the provider selection screen, even if only one is enabled. + */ + val isProviderChoiceAlwaysShown: 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..d2ae7032d --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt @@ -0,0 +1,205 @@ +/* + * 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. + */ +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 class nested within AuthUITheme that defines the visual appearance of a specific + * provider button, allowing for per-provider branding and customization. + */ + 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(), + providerStyles = defaultProviderStyles + ) + + /** + * 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 + ) + } + + 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 + ) + } + } + } + } + } +} + +@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..242ea6e83 --- /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 + +/** + * 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 { + /** + * Requires the password to have at least a certain number of characters. + */ + 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. + */ + 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..c8e627ff5 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -0,0 +1,300 @@ +/* + * 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.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.isNewEmailAccountsAllowed).isTrue() + assertThat(config.isDisplayNameRequired).isTrue() + assertThat(config.isProviderChoiceAlwaysShown).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 + isCredentialManagerEnabled = false + isMfaEnabled = false + isAnonymousUpgradeEnabled = true + tosUrl = "https://example.com/tos" + privacyPolicyUrl = "https://example.com/privacy" + logo = Icons.Default.AccountCircle + actionCodeSettings = customActionCodeSettings + 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.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.isNewEmailAccountsAllowed).isFalse() + assertThat(config.isDisplayNameRequired).isFalse() + assertThat(config.isProviderChoiceAlwaysShown).isTrue() + } + + // =========================================================================================== + // Validation Tests + // =========================================================================================== + + @Test(expected = IllegalArgumentException::class) + fun `authUIConfiguration throws when no providers configured`() { + authUIConfiguration { } + } + + @Test + fun `validation accepts all supported providers`() { + val config = authUIConfiguration { + providers { + 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) + } + + @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( + isEmailLinkSignInEnabled = 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( + isEmailLinkSignInEnabled = true, + actionCodeSettings = customActionCodeSettings, + passwordValidationRules = listOf() + )) + } + } + } + + // =========================================================================================== + // 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() + ) + ) + } + isCredentialManagerEnabled = true + } + + 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", + "isCredentialManagerEnabled", + "isMfaEnabled", + "isAnonymousUpgradeEnabled", + "tosUrl", + "privacyPolicyUrl", + "logo", + "actionCodeSettings", + "isNewEmailAccountsAllowed", + "isDisplayNameRequired", + "isProviderChoiceAlwaysShown" + ) + + val actualProperties = allProperties.map { it.name }.toSet() + + assertThat(actualProperties).containsExactlyElementsIn(expectedProperties) + } +} 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 d5e15edfe..7e9352e85 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -2,11 +2,11 @@ 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 = 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 { 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