From 9af10d2b9ccb921f98ec063b50fbed5ef9817348 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Tue, 30 Sep 2025 00:55:06 +0100 Subject: [PATCH 01/43] feat: AuthMethodPicker, logo and provider theme style --- .../ui/auth/compose/AuthMethodPicker.kt | 178 ++++++++++++++++++ .../ui/auth/compose/AuthProviderButton.kt | 26 ++- 2 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/AuthMethodPicker.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthMethodPicker.kt new file mode 100644 index 000000000..7553dfdcd --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthMethodPicker.kt @@ -0,0 +1,178 @@ +package com.firebase.ui.auth.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.AuthProviderButton +import com.firebase.ui.auth.compose.configuration.AuthProvider +import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset + +//AuthMethodPicker( +// providers = listOf(GoogleAuthProvider(), EmailAuthProvider()), +// onProviderSelected = { provider -> /* ... */ } +//) + +/** + * Renders the provider selection screen. + * + * **Example usage:** + * ```kotlin + * AuthMethodPicker( + * providers = listOf( + * AuthProvider.Google(), + * AuthProvider.Email(), + * ), + * onProviderSelected = { provider -> /* ... */ } + * ) + * ``` + * + * @param modifier A modifier for the screen layout. + * @param providers The list of providers to display. + * @param logo An optional logo to display. + * @param onProviderSelected A callback when a provider is selected. + * @param customLayout An optional custom layout composable for the provider buttons. + * + * @since 10.0.0 + */ +@Composable +fun AuthMethodPicker( + modifier: Modifier = Modifier, + providers: List, + logo: AuthUIAsset? = null, + onProviderSelected: (AuthProvider) -> Unit, + customLayout: @Composable ((List, (AuthProvider) -> Unit) -> Unit)? = null, +) { + val context = LocalContext.current + + Column( + modifier = modifier + .fillMaxSize() + .safeDrawingPadding(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + logo?.let { + Image( + modifier = Modifier + .weight(0.4f), + painter = it.painter, + contentDescription = "AuthMethodPicker logo", + ) + } + if (customLayout != null) { + customLayout(providers, onProviderSelected) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = PaddingValues(bottom = 64.dp) // Space for text + ) { + items(providers.size) { index -> + val provider = providers[index] + Box( + modifier = Modifier + .padding(bottom = 16.dp) + ) { + AuthProviderButton( + onClick = { + onProviderSelected(provider) + }, + provider = provider, + stringProvider = DefaultAuthUIStringProvider(context) + ) + } + } + } + Text( + "By continuing, you are indicating that you accept our " + + "Terms of Service and Privacy Policy.", + textAlign = TextAlign.Center, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 16.dp) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewAuthMethodPicker() { + AuthMethodPicker( + providers = listOf( + AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ), + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + ), + AuthProvider.Google( + scopes = emptyList(), + serverClientId = null + ), + AuthProvider.Facebook(), + AuthProvider.Twitter( + customParameters = emptyMap() + ), + AuthProvider.Github( + customParameters = emptyMap() + ), + AuthProvider.Microsoft( + tenant = null, + customParameters = emptyMap() + ), + AuthProvider.Yahoo( + customParameters = emptyMap() + ), + AuthProvider.Apple( + locale = null, + customParameters = emptyMap() + ), + AuthProvider.Anonymous, + AuthProvider.GenericOAuth( + providerId = "google.com", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = "Generic Provider", + buttonIcon = AuthUIAsset.Vector(Icons.Default.Star), + buttonColor = Color.Gray, + contentColor = Color.White + ) + ), + logo = AuthUIAsset.Resource(R.drawable.fui_ic_check_circle_black_128dp), + onProviderSelected = { provider -> + + }, + ) +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthProviderButton.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthProviderButton.kt index da6b2bbd9..213b5be8f 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/AuthProviderButton.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthProviderButton.kt @@ -4,10 +4,13 @@ import androidx.compose.foundation.Image import androidx.compose.material3.Icon import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star @@ -19,6 +22,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.firebase.ui.auth.compose.configuration.AuthProvider @@ -63,10 +68,12 @@ fun AuthProviderButton( stringProvider: AuthUIStringProvider, ) { val providerStyle = resolveProviderStyle(provider, style) - val providerText = resolveProviderLabel(provider, stringProvider) + val providerLabel = resolveProviderLabel(provider, stringProvider) Button( - modifier = modifier, + modifier = modifier + .width(208.dp), + contentPadding = PaddingValues(horizontal = 12.dp), colors = ButtonDefaults.buttonColors( containerColor = providerStyle.backgroundColor, contentColor = providerStyle.contentColor, @@ -79,7 +86,10 @@ fun AuthProviderButton( enabled = enabled, ) { Row( - verticalAlignment = Alignment.CenterVertically + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start ) { val providerIcon = providerStyle.icon if (providerIcon != null) { @@ -87,19 +97,21 @@ fun AuthProviderButton( if (iconTint != null) { Icon( painter = providerIcon.painter, - contentDescription = providerText, + contentDescription = providerLabel, tint = iconTint ) } else { Image( painter = providerIcon.painter, - contentDescription = providerText + contentDescription = providerLabel ) } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(12.dp)) } Text( - text = providerText + text = providerLabel, + overflow = TextOverflow.Ellipsis, + maxLines = 1, ) } } From b6f7bdc028a8907c80118c9f4c284b3c51214a2f Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Tue, 30 Sep 2025 12:57:41 +0100 Subject: [PATCH 02/43] chore: organize folder structure --- .../ui/auth/compose/configuration/AuthUIConfiguration.kt | 4 ++-- .../ui/auth/compose/configuration/PasswordRule.kt | 2 +- .../AuthUIStringProvider.kt | 2 +- .../AuthUIStringProviderSample.kt | 2 +- .../DefaultAuthUIStringProvider.kt | 2 +- .../compose/configuration/validators/EmailValidator.kt | 2 +- .../compose/configuration/validators/FieldValidator.kt | 2 +- .../compose/configuration/validators/PasswordValidator.kt | 2 +- .../compose/{ => ui/components}/AuthProviderButton.kt | 8 +++----- .../compose/{ => ui/components}/ErrorRecoveryDialog.kt | 5 +++-- .../firebase/ui/auth/compose/AuthProviderButtonTest.kt | 6 ++++-- .../ui/auth/compose/ErrorRecoveryDialogLogicTest.kt | 4 ++-- .../auth/compose/configuration/AuthUIConfigurationTest.kt | 4 ++-- .../ui/auth/compose/configuration/PasswordRuleTest.kt | 4 ++-- .../configuration/validators/EmailValidatorTest.kt | 4 ++-- .../configuration/validators/PasswordValidatorTest.kt | 4 ++-- 16 files changed, 29 insertions(+), 28 deletions(-) rename auth/src/main/java/com/firebase/ui/auth/compose/configuration/{stringprovider => string_provider}/AuthUIStringProvider.kt (99%) rename auth/src/main/java/com/firebase/ui/auth/compose/configuration/{stringprovider => string_provider}/AuthUIStringProviderSample.kt (97%) rename auth/src/main/java/com/firebase/ui/auth/compose/configuration/{stringprovider => string_provider}/DefaultAuthUIStringProvider.kt (99%) rename auth/src/main/java/com/firebase/ui/auth/compose/{ => ui/components}/AuthProviderButton.kt (97%) rename auth/src/main/java/com/firebase/ui/auth/compose/{ => ui/components}/ErrorRecoveryDialog.kt (97%) 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 4d9bc280d..6fb0202de 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 @@ -18,8 +18,8 @@ import android.content.Context import java.util.Locale import com.google.firebase.auth.ActionCodeSettings import androidx.compose.ui.graphics.vector.ImageVector -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme fun actionCodeSettings(block: ActionCodeSettings.Builder.() -> Unit) = 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 5c5d5b125..7073fbe6e 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,7 +14,7 @@ package com.firebase.ui.auth.compose.configuration -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider /** * An abstract class representing a set of validation rules that can be applied to a password field, diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProvider.kt similarity index 99% rename from auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProvider.kt rename to auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProvider.kt index ea88485e3..f81ead323 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProvider.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.compose.configuration.stringprovider +package com.firebase.ui.auth.compose.configuration.string_provider /** * An interface for providing localized string resources. This interface defines methods for all diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProviderSample.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProviderSample.kt similarity index 97% rename from auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProviderSample.kt rename to auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProviderSample.kt index 7ddf64522..af0c830cc 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProviderSample.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProviderSample.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.compose.configuration.stringprovider +package com.firebase.ui.auth.compose.configuration.string_provider import android.content.Context import com.firebase.ui.auth.compose.configuration.AuthProvider diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/DefaultAuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt similarity index 99% rename from auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/DefaultAuthUIStringProvider.kt rename to auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt index 7df16d9ca..5eba036af 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/DefaultAuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.compose.configuration.stringprovider +package com.firebase.ui.auth.compose.configuration.string_provider import android.content.Context import android.content.res.Configuration 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 7acfc8bc1..30582a309 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,7 +14,7 @@ package com.firebase.ui.auth.compose.configuration.validators -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider internal class EmailValidator(override val stringProvider: AuthUIStringProvider) : FieldValidator { private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) 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 efa72188f..88cf98875 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,7 +14,7 @@ package com.firebase.ui.auth.compose.configuration.validators -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider /** * An interface for validating input fields. 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 67cb7d376..2d9efafc1 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,7 +14,7 @@ package com.firebase.ui.auth.compose.configuration.validators -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.PasswordRule internal class PasswordValidator( diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthProviderButton.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt similarity index 97% rename from auth/src/main/java/com/firebase/ui/auth/compose/AuthProviderButton.kt rename to auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt index 213b5be8f..e23d98fc7 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/AuthProviderButton.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt @@ -1,4 +1,4 @@ -package com.firebase.ui.auth.compose +package com.firebase.ui.auth.compose.ui.components import androidx.compose.foundation.Image import androidx.compose.material3.Icon @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star @@ -22,14 +21,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.firebase.ui.auth.compose.configuration.AuthProvider import com.firebase.ui.auth.compose.configuration.Provider -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt similarity index 97% rename from auth/src/main/java/com/firebase/ui/auth/compose/ErrorRecoveryDialog.kt rename to auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt index 3b9cc0b57..732a48662 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ErrorRecoveryDialog.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.compose +package com.firebase.ui.auth.compose.ui.components import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme @@ -22,7 +22,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.window.DialogProperties -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider /** * A composable dialog for displaying authentication errors with recovery options. diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/AuthProviderButtonTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/AuthProviderButtonTest.kt index 255fd53af..1bd889fc5 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/AuthProviderButtonTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/AuthProviderButtonTest.kt @@ -29,8 +29,8 @@ import androidx.compose.ui.test.performClick import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.compose.configuration.AuthProvider import com.firebase.ui.auth.compose.configuration.Provider -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme import com.google.common.truth.Truth.assertThat @@ -41,6 +41,8 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.ui.components.AuthProviderButton +import com.firebase.ui.auth.compose.ui.components.resolveProviderStyle /** * Unit tests for [AuthProviderButton] covering UI interactions, styling, diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ErrorRecoveryDialogLogicTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ErrorRecoveryDialogLogicTest.kt index f3c730b05..480e4ea14 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/ErrorRecoveryDialogLogicTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ErrorRecoveryDialogLogicTest.kt @@ -14,7 +14,7 @@ package com.firebase.ui.auth.compose -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith @@ -24,7 +24,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * Unit tests for [ErrorRecoveryDialog] logic functions. + * Unit tests for [com.firebase.ui.auth.compose.ui.components.ErrorRecoveryDialog] logic functions. */ @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) 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 e8bf0fd4a..f08be227f 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,8 +20,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme import com.google.common.truth.Truth.assertThat import com.google.firebase.auth.actionCodeSettings 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 index a4d5139a6..dbaccbcfb 100644 --- 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 @@ -17,8 +17,8 @@ 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.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test 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 3253cfb1c..3715d63ec 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 @@ -17,8 +17,8 @@ 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.stringprovider.AuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test 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 index a3993bd36..27d34b6a6 100644 --- 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 @@ -17,8 +17,8 @@ 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.stringprovider.AuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.PasswordRule import com.google.common.truth.Truth.assertThat import org.junit.Before From 96ffa30021de2da3760c650cc68103b797d5c701 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Tue, 30 Sep 2025 12:59:56 +0100 Subject: [PATCH 03/43] feat: TOS and PP footer, ui tests for AuthMethodPicker --- .../method_picker/AnnotatedStringResource.kt | 76 +++++ .../method_picker}/AuthMethodPicker.kt | 71 ++-- auth/src/main/res/values/strings.xml | 1 + .../ui/auth/compose/AuthMethodPickerTest.kt | 323 ++++++++++++++++++ 4 files changed, 428 insertions(+), 43 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt rename auth/src/main/java/com/firebase/ui/auth/compose/{ => ui/method_picker}/AuthMethodPicker.kt (68%) create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/AuthMethodPickerTest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt new file mode 100644 index 000000000..4c98be9ac --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt @@ -0,0 +1,76 @@ +package com.firebase.ui.auth.compose.ui.method_picker + +import android.content.Context +import android.content.Intent +import androidx.annotation.StringRes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.core.net.toUri + +@Composable +internal fun AnnotatedStringResource( + context: Context, + modifier: Modifier = Modifier, + @StringRes id: Int, + vararg links: Pair, + inPreview: Boolean = false, + previewText: String? = null, +) { + val labels = links.map { it.first }.toTypedArray() + + val template = if (inPreview && previewText != null) { + previewText + } else { + stringResource(id = id, *labels) + } + + val annotated = buildAnnotatedString { + var currentIndex = 0 + + links.forEach { (label, url) -> + val start = template.indexOf(label, currentIndex).takeIf { it >= 0 } ?: return@forEach + + append(template.substring(currentIndex, start)) + + withLink( + LinkAnnotation.Url( + url, + styles = TextLinkStyles( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ) + ) + ) { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + context.startActivity(intent) + } + ) { + append(label) + } + + currentIndex = start + label.length + } + + if (currentIndex < template.length) { + append(template.substring(currentIndex)) + } + } + + Text( + modifier = modifier, + text = annotated, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt similarity index 68% rename from auth/src/main/java/com/firebase/ui/auth/compose/AuthMethodPicker.kt rename to auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt index 7553dfdcd..91aedc2fd 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/AuthMethodPicker.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt @@ -1,44 +1,26 @@ -package com.firebase.ui.auth.compose +package com.firebase.ui.auth.compose.ui.method_picker import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.Star -import androidx.compose.material3.Icon -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.AuthProviderButton import com.firebase.ui.auth.compose.configuration.AuthProvider -import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset - -//AuthMethodPicker( -// providers = listOf(GoogleAuthProvider(), EmailAuthProvider()), -// onProviderSelected = { provider -> /* ... */ } -//) +import com.firebase.ui.auth.compose.ui.components.AuthProviderButton /** * Renders the provider selection screen. @@ -59,6 +41,8 @@ import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset * @param logo An optional logo to display. * @param onProviderSelected A callback when a provider is selected. * @param customLayout An optional custom layout composable for the provider buttons. + * @param termsOfServiceUrl The URL for the Terms of Service. + * @param privacyPolicyUrl The URL for the Privacy Policy. * * @since 10.0.0 */ @@ -69,8 +53,11 @@ fun AuthMethodPicker( logo: AuthUIAsset? = null, onProviderSelected: (AuthProvider) -> Unit, customLayout: @Composable ((List, (AuthProvider) -> Unit) -> Unit)? = null, + termsOfServiceUrl: String? = null, + privacyPolicyUrl: String? = null, ) { val context = LocalContext.current + val inPreview = LocalInspectionMode.current Column( modifier = modifier @@ -83,7 +70,8 @@ fun AuthMethodPicker( modifier = Modifier .weight(0.4f), painter = it.painter, - contentDescription = "AuthMethodPicker logo", + contentDescription = if (inPreview) "" + else stringResource(R.string.fui_auth_method_picker_logo) ) } if (customLayout != null) { @@ -92,9 +80,9 @@ fun AuthMethodPicker( LazyColumn( modifier = Modifier .fillMaxSize() - .weight(1f), + .weight(1f) + .testTag("AuthMethodPicker LazyColumn"), horizontalAlignment = Alignment.CenterHorizontally, - contentPadding = PaddingValues(bottom = 64.dp) // Space for text ) { items(providers.size) { index -> val provider = providers[index] @@ -112,14 +100,18 @@ fun AuthMethodPicker( } } } - Text( - "By continuing, you are indicating that you accept our " + - "Terms of Service and Privacy Policy.", - textAlign = TextAlign.Center, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 16.dp) - ) } + AnnotatedStringResource( + context = context, + inPreview = inPreview, + previewText = "By continuing, you accept our Terms of Service and Privacy Policy.", + modifier = Modifier.padding(vertical = 16.dp), + id = R.string.fui_tos_and_pp, + links = arrayOf( + "Terms of Service" to (termsOfServiceUrl ?: ""), + "Privacy Policy" to (privacyPolicyUrl ?: "") + ) + ) } } @@ -160,19 +152,12 @@ fun PreviewAuthMethodPicker() { customParameters = emptyMap() ), AuthProvider.Anonymous, - AuthProvider.GenericOAuth( - providerId = "google.com", - scopes = emptyList(), - customParameters = emptyMap(), - buttonLabel = "Generic Provider", - buttonIcon = AuthUIAsset.Vector(Icons.Default.Star), - buttonColor = Color.Gray, - contentColor = Color.White - ) ), logo = AuthUIAsset.Resource(R.drawable.fui_ic_check_circle_black_128dp), onProviderSelected = { provider -> }, + termsOfServiceUrl = "https://example.com/terms", + privacyPolicyUrl = "https://example.com/privacy" ) } \ No newline at end of file diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 75060012d..5790ca8d2 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ Email + "Auth method picker logo" Sign in with Google Sign in with Facebook Sign in with Twitter diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/AuthMethodPickerTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/AuthMethodPickerTest.kt new file mode 100644 index 000000000..3c9f383bc --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/AuthMethodPickerTest.kt @@ -0,0 +1,323 @@ +/* + * 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 + +import android.content.Context +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.AuthProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.compose.ui.method_picker.AuthMethodPicker +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [AuthMethodPicker] covering UI interactions, provider selection, + * scroll tests, logo display, and custom layouts. + * + * @suppress Internal test class + */ +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class AuthMethodPickerTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var context: Context + private var selectedProvider: AuthProvider? = null + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + selectedProvider = null + } + + // ============================================================================================= + // Basic UI Tests + // ============================================================================================= + + @Test + fun `AuthMethodPicker displays all providers`() { + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null), + AuthProvider.Facebook(), + AuthProvider.Email(actionCodeSettings = null, passwordValidationRules = emptyList()) + ) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it } + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_facebook)) + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_email)) + .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun `AuthMethodPicker displays terms of service text`() { + val context = ApplicationProvider.getApplicationContext() + val links = arrayOf("Terms of Service" to "", "Privacy Policy" to "") + val labels = links.map { it.first }.toTypedArray() + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it } + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_tos_and_pp, *labels)) + .assertIsDisplayed() + } + + @Test + fun `AuthMethodPicker displays logo when provided`() { + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + logo = AuthUIAsset.Resource(R.drawable.fui_ic_check_circle_black_128dp), + onProviderSelected = { selectedProvider = it } + ) + } + + composeTestRule + .onNodeWithContentDescription(context.getString(R.string.fui_auth_method_picker_logo)) + .assertIsDisplayed() + } + + @Test + fun `AuthMethodPicker does not display logo when null`() { + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + logo = null, + onProviderSelected = { selectedProvider = it } + ) + } + + composeTestRule + .onNodeWithContentDescription(context.getString(R.string.fui_auth_method_picker_logo)) + .assertIsNotDisplayed() + } + + @Test + fun `AuthMethodPicker displays logo and providers together`() { + val context = ApplicationProvider.getApplicationContext() + val links = arrayOf("Terms of Service" to "", "Privacy Policy" to "") + val labels = links.map { it.first }.toTypedArray() + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + logo = AuthUIAsset.Resource(R.drawable.fui_ic_check_circle_black_128dp), + onProviderSelected = { selectedProvider = it } + ) + } + + composeTestRule + .onNodeWithContentDescription(context.getString(R.string.fui_auth_method_picker_logo)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_tos_and_pp, *labels)) + .assertIsDisplayed() + } + + @Test + fun `AuthMethodPicker calls onProviderSelected when Provider is clicked`() { + val googleProvider = AuthProvider.Google(scopes = emptyList(), serverClientId = null) + val providers = listOf(googleProvider) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it } + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .performClick() + + assertThat(selectedProvider).isEqualTo(googleProvider) + } + + // ============================================================================================= + // Custom Layout Tests + // ============================================================================================= + + @Test + fun `AuthMethodPicker uses custom layout when provided`() { + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + var customLayoutCalled = false + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it }, + customLayout = { _, _ -> + customLayoutCalled = true + Text("Custom Layout") + } + ) + } + + assertThat(customLayoutCalled).isTrue() + composeTestRule + .onNodeWithText("Custom Layout") + .assertIsDisplayed() + } + + @Test + fun `AuthMethodPicker custom layout receives providers list`() { + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null), + AuthProvider.Facebook() + ) + var receivedProviders: List? = null + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it }, + customLayout = { providersList, _ -> + receivedProviders = providersList + } + ) + } + + assertThat(receivedProviders).isEqualTo(providers) + } + + @Test + fun `AuthMethodPicker custom layout can trigger provider selection`() { + val googleProvider = AuthProvider.Google(scopes = emptyList(), serverClientId = null) + val providers = listOf(googleProvider) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it }, + customLayout = { providersList, onSelected -> + Button(onClick = { onSelected(providersList[0]) }) { + Text("Custom Button") + } + } + ) + } + + composeTestRule + .onNodeWithText("Custom Button") + .performClick() + + assertThat(selectedProvider).isEqualTo(googleProvider) + } + + // ============================================================================================= + // Scrolling Tests + // ============================================================================================= + + @Test + fun `AuthMethodPicker allows scrolling through many providers`() { + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null), + AuthProvider.Facebook(), + AuthProvider.Twitter(customParameters = emptyMap()), + AuthProvider.Github(customParameters = emptyMap()), + AuthProvider.Microsoft(tenant = null, customParameters = emptyMap()), + AuthProvider.Yahoo(customParameters = emptyMap()), + AuthProvider.Apple(locale = null, customParameters = emptyMap()), + AuthProvider.Email(actionCodeSettings = null, passwordValidationRules = emptyList()), + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ), + AuthProvider.Anonymous + ) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it } + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithTag("AuthMethodPicker LazyColumn") + .performScrollToNode(hasText(context.getString(R.string.fui_sign_in_anonymously))) + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_anonymously)) + .assertIsDisplayed() + } +} \ No newline at end of file From bfd056c43b64a2ad0cf82a777481d6f5fc0c3c28 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Tue, 30 Sep 2025 13:06:39 +0100 Subject: [PATCH 04/43] chore: tests folder structure --- .../components}/AuthProviderButtonTest.kt | 4 +- .../ErrorRecoveryDialogLogicTest.kt | 86 ++++++++----------- .../method_picker}/AuthMethodPickerTest.kt | 27 ++---- 3 files changed, 43 insertions(+), 74 deletions(-) rename auth/src/test/java/com/firebase/ui/auth/compose/{ => ui/components}/AuthProviderButtonTest.kt (99%) rename auth/src/test/java/com/firebase/ui/auth/compose/{ => ui/components}/ErrorRecoveryDialogLogicTest.kt (72%) rename auth/src/test/java/com/firebase/ui/auth/compose/{ => ui/method_picker}/AuthMethodPickerTest.kt (91%) diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/AuthProviderButtonTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt similarity index 99% rename from auth/src/test/java/com/firebase/ui/auth/compose/AuthProviderButtonTest.kt rename to auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt index 1bd889fc5..faae2cf48 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/AuthProviderButtonTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.compose +package com.firebase.ui.auth.compose.ui.components import android.content.Context import androidx.compose.material.icons.Icons @@ -41,8 +41,6 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.ui.components.AuthProviderButton -import com.firebase.ui.auth.compose.ui.components.resolveProviderStyle /** * Unit tests for [AuthProviderButton] covering UI interactions, styling, diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ErrorRecoveryDialogLogicTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialogLogicTest.kt similarity index 72% rename from auth/src/test/java/com/firebase/ui/auth/compose/ErrorRecoveryDialogLogicTest.kt rename to auth/src/test/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialogLogicTest.kt index 480e4ea14..6a4f5df2f 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/ErrorRecoveryDialogLogicTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialogLogicTest.kt @@ -1,49 +1,35 @@ -/* - * 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 +package com.firebase.ui.auth.compose.ui.components +import com.firebase.ui.auth.compose.AuthException import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider -import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito.mock -import org.mockito.Mockito.`when` +import org.mockito.Mockito import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * Unit tests for [com.firebase.ui.auth.compose.ui.components.ErrorRecoveryDialog] logic functions. + * Unit tests for [ErrorRecoveryDialog] logic functions. */ @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) class ErrorRecoveryDialogLogicTest { - private val mockStringProvider = mock(AuthUIStringProvider::class.java).apply { - `when`(retryAction).thenReturn("Try again") - `when`(continueText).thenReturn("Continue") - `when`(signInDefault).thenReturn("Sign in") - `when`(networkErrorRecoveryMessage).thenReturn("Network error, check your internet connection.") - `when`(invalidCredentialsRecoveryMessage).thenReturn("Incorrect password.") - `when`(userNotFoundRecoveryMessage).thenReturn("That email address doesn't match an existing account") - `when`(weakPasswordRecoveryMessage).thenReturn("Password not strong enough. Use at least 6 characters and a mix of letters and numbers") - `when`(emailAlreadyInUseRecoveryMessage).thenReturn("Email account registration unsuccessful") - `when`(tooManyRequestsRecoveryMessage).thenReturn("This phone number has been used too many times") - `when`(mfaRequiredRecoveryMessage).thenReturn("Additional verification required. Please complete multi-factor authentication.") - `when`(accountLinkingRequiredRecoveryMessage).thenReturn("Account needs to be linked. Please try a different sign-in method.") - `when`(authCancelledRecoveryMessage).thenReturn("Authentication was cancelled. Please try again when ready.") - `when`(unknownErrorRecoveryMessage).thenReturn("An unknown error occurred.") + private val mockStringProvider = Mockito.mock(AuthUIStringProvider::class.java).apply { + Mockito.`when`(retryAction).thenReturn("Try again") + Mockito.`when`(continueText).thenReturn("Continue") + Mockito.`when`(signInDefault).thenReturn("Sign in") + Mockito.`when`(networkErrorRecoveryMessage).thenReturn("Network error, check your internet connection.") + Mockito.`when`(invalidCredentialsRecoveryMessage).thenReturn("Incorrect password.") + Mockito.`when`(userNotFoundRecoveryMessage).thenReturn("That email address doesn't match an existing account") + Mockito.`when`(weakPasswordRecoveryMessage).thenReturn("Password not strong enough. Use at least 6 characters and a mix of letters and numbers") + Mockito.`when`(emailAlreadyInUseRecoveryMessage).thenReturn("Email account registration unsuccessful") + Mockito.`when`(tooManyRequestsRecoveryMessage).thenReturn("This phone number has been used too many times") + Mockito.`when`(mfaRequiredRecoveryMessage).thenReturn("Additional verification required. Please complete multi-factor authentication.") + Mockito.`when`(accountLinkingRequiredRecoveryMessage).thenReturn("Account needs to be linked. Please try a different sign-in method.") + Mockito.`when`(authCancelledRecoveryMessage).thenReturn("Authentication was cancelled. Please try again when ready.") + Mockito.`when`(unknownErrorRecoveryMessage).thenReturn("An unknown error occurred.") } // ============================================================================================= @@ -59,7 +45,7 @@ class ErrorRecoveryDialogLogicTest { val message = getRecoveryMessage(error, mockStringProvider) // Assert - assertThat(message).isEqualTo("Network error, check your internet connection.") + Truth.assertThat(message).isEqualTo("Network error, check your internet connection.") } @Test @@ -71,7 +57,7 @@ class ErrorRecoveryDialogLogicTest { val message = getRecoveryMessage(error, mockStringProvider) // Assert - assertThat(message).isEqualTo("Incorrect password.") + Truth.assertThat(message).isEqualTo("Incorrect password.") } @Test @@ -83,7 +69,7 @@ class ErrorRecoveryDialogLogicTest { val message = getRecoveryMessage(error, mockStringProvider) // Assert - assertThat(message).isEqualTo("That email address doesn't match an existing account") + Truth.assertThat(message).isEqualTo("That email address doesn't match an existing account") } @Test @@ -99,7 +85,7 @@ class ErrorRecoveryDialogLogicTest { val message = getRecoveryMessage(error, mockStringProvider) // Assert - assertThat(message).isEqualTo("Password not strong enough. Use at least 6 characters and a mix of letters and numbers\n\nReason: Password should be at least 8 characters") + Truth.assertThat(message).isEqualTo("Password not strong enough. Use at least 6 characters and a mix of letters and numbers\n\nReason: Password should be at least 8 characters") } @Test @@ -111,7 +97,7 @@ class ErrorRecoveryDialogLogicTest { val message = getRecoveryMessage(error, mockStringProvider) // Assert - assertThat(message).isEqualTo("Password not strong enough. Use at least 6 characters and a mix of letters and numbers") + Truth.assertThat(message).isEqualTo("Password not strong enough. Use at least 6 characters and a mix of letters and numbers") } @Test @@ -127,7 +113,7 @@ class ErrorRecoveryDialogLogicTest { val message = getRecoveryMessage(error, mockStringProvider) // Assert - assertThat(message).isEqualTo("Email account registration unsuccessful (test@example.com)") + Truth.assertThat(message).isEqualTo("Email account registration unsuccessful (test@example.com)") } @Test @@ -139,7 +125,7 @@ class ErrorRecoveryDialogLogicTest { val message = getRecoveryMessage(error, mockStringProvider) // Assert - assertThat(message).isEqualTo("Email account registration unsuccessful") + Truth.assertThat(message).isEqualTo("Email account registration unsuccessful") } // ============================================================================================= @@ -155,7 +141,7 @@ class ErrorRecoveryDialogLogicTest { val actionText = getRecoveryActionText(error, mockStringProvider) // Assert - assertThat(actionText).isEqualTo("Try again") + Truth.assertThat(actionText).isEqualTo("Try again") } @Test @@ -167,7 +153,7 @@ class ErrorRecoveryDialogLogicTest { val actionText = getRecoveryActionText(error, mockStringProvider) // Assert - assertThat(actionText).isEqualTo("Continue") + Truth.assertThat(actionText).isEqualTo("Continue") } @Test @@ -179,7 +165,7 @@ class ErrorRecoveryDialogLogicTest { val actionText = getRecoveryActionText(error, mockStringProvider) // Assert - assertThat(actionText).isEqualTo("Sign in") + Truth.assertThat(actionText).isEqualTo("Sign in") } @Test @@ -191,7 +177,7 @@ class ErrorRecoveryDialogLogicTest { val actionText = getRecoveryActionText(error, mockStringProvider) // Assert - assertThat(actionText).isEqualTo("Continue") + Truth.assertThat(actionText).isEqualTo("Continue") } @Test @@ -203,7 +189,7 @@ class ErrorRecoveryDialogLogicTest { val actionText = getRecoveryActionText(error, mockStringProvider) // Assert - assertThat(actionText).isEqualTo("Continue") + Truth.assertThat(actionText).isEqualTo("Continue") } // ============================================================================================= @@ -216,7 +202,7 @@ class ErrorRecoveryDialogLogicTest { val error = AuthException.NetworkException("Network error") // Act & Assert - assertThat(isRecoverable(error)).isTrue() + Truth.assertThat(isRecoverable(error)).isTrue() } @Test @@ -225,7 +211,7 @@ class ErrorRecoveryDialogLogicTest { val error = AuthException.InvalidCredentialsException("Invalid credentials") // Act & Assert - assertThat(isRecoverable(error)).isTrue() + Truth.assertThat(isRecoverable(error)).isTrue() } @Test @@ -234,7 +220,7 @@ class ErrorRecoveryDialogLogicTest { val error = AuthException.TooManyRequestsException("Too many requests") // Act & Assert - assertThat(isRecoverable(error)).isFalse() + Truth.assertThat(isRecoverable(error)).isFalse() } @Test @@ -243,7 +229,7 @@ class ErrorRecoveryDialogLogicTest { val error = AuthException.MfaRequiredException("MFA required") // Act & Assert - assertThat(isRecoverable(error)).isTrue() + Truth.assertThat(isRecoverable(error)).isTrue() } @Test @@ -252,7 +238,7 @@ class ErrorRecoveryDialogLogicTest { val error = AuthException.UnknownException("Unknown error") // Act & Assert - assertThat(isRecoverable(error)).isTrue() + Truth.assertThat(isRecoverable(error)).isTrue() } // Helper functions to test the private functions - we need to make them internal for testing diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/AuthMethodPickerTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt similarity index 91% rename from auth/src/test/java/com/firebase/ui/auth/compose/AuthMethodPickerTest.kt rename to auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt index 3c9f383bc..17d736ca7 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/AuthMethodPickerTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt @@ -1,18 +1,4 @@ -/* - * 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 +package com.firebase.ui.auth.compose.ui.method_picker import android.content.Context import androidx.compose.material3.Button @@ -31,8 +17,7 @@ import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R import com.firebase.ui.auth.compose.configuration.AuthProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset -import com.firebase.ui.auth.compose.ui.method_picker.AuthMethodPicker -import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth import org.junit.Before import org.junit.Rule import org.junit.Test @@ -202,7 +187,7 @@ class AuthMethodPickerTest { .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) .performClick() - assertThat(selectedProvider).isEqualTo(googleProvider) + Truth.assertThat(selectedProvider).isEqualTo(googleProvider) } // ============================================================================================= @@ -227,7 +212,7 @@ class AuthMethodPickerTest { ) } - assertThat(customLayoutCalled).isTrue() + Truth.assertThat(customLayoutCalled).isTrue() composeTestRule .onNodeWithText("Custom Layout") .assertIsDisplayed() @@ -251,7 +236,7 @@ class AuthMethodPickerTest { ) } - assertThat(receivedProviders).isEqualTo(providers) + Truth.assertThat(receivedProviders).isEqualTo(providers) } @Test @@ -275,7 +260,7 @@ class AuthMethodPickerTest { .onNodeWithText("Custom Button") .performClick() - assertThat(selectedProvider).isEqualTo(googleProvider) + Truth.assertThat(selectedProvider).isEqualTo(googleProvider) } // ============================================================================================= From 4c3a59becb6c470adbe822cd5e904630871993ca Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Tue, 30 Sep 2025 21:36:01 +0100 Subject: [PATCH 05/43] chore: use version catalog for compose deps --- auth/build.gradle.kts | 17 +++++++++-------- buildSrc/src/main/kotlin/Config.kt | 11 ----------- gradle/libs.versions.toml | 13 +++++++++++++ 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index b26051d1c..57462420d 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -74,16 +74,17 @@ android { } 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(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.activity.compose) implementation(Config.Libs.Androidx.materialDesign) implementation(Config.Libs.Androidx.activity) + implementation(libs.androidx.compose.material.icons.extended) // The new activity result APIs force us to include Fragment 1.3.0 // See https://issuetracker.google.com/issues/152554847 implementation(Config.Libs.Androidx.fragment) diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 8b2317698..b24d297ea 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -41,17 +41,6 @@ object Config { 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 { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 44eb15432..befb470ce 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,20 @@ [versions] kotlin = "2.2.0" +androidxComposeBom = "2025.08.00" +androidxActivityCompose = "1.9.0" [libraries] +# Compose +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } + # Testing androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } From a8407161021ebca24a2c86f98fa59821c6bee3a1 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Tue, 30 Sep 2025 21:36:36 +0100 Subject: [PATCH 06/43] feat: AuthTextField with validation --- .../firebase/ui/auth/compose/AuthTextField.kt | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/AuthTextField.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthTextField.kt new file mode 100644 index 000000000..dc9d2a1d6 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthTextField.kt @@ -0,0 +1,193 @@ +package com.firebase.ui.auth.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.configuration.validators.FieldValidator +import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator + +/** + * A customizable input field with built-in validation display. + * + * **Example usage:** + * ```kotlin + * AuthTextField( + * value = email, + * onValueChange = { email = it }, + * label = "Email", + * validator = EmailValidator() + * ) + * ``` + * + * @param modifier A modifier for the field. + * @param value The current value of the text field. + * @param onValueChange A callback when the value changes. + * @param label The label for the text field. + * @param enabled If the field is enabled. + * @param isError Manually set the error state. + * @param errorMessage A custom error message to display. + * @param validator A validator to automatically handle error state and messages. + * @param keyboardOptions Keyboard options for the field. + * @param keyboardActions Keyboard actions for the field. + * @param visualTransformation Visual transformation for the input (e.g., password). + * @param leadingIcon An optional icon to display at the start of the field. + * @param trailingIcon An optional icon to display at the start of the field. + */ +@Composable +fun AuthTextField( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit, + label: @Composable (() -> Unit)? = null, + enabled: Boolean = true, + isError: Boolean? = null, + errorMessage: String? = null, + validator: FieldValidator? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, +) { + val isSecureTextField = validator is PasswordValidator + var passwordVisible by remember { mutableStateOf(false) } + + TextField( + modifier = modifier, + value = value, + onValueChange = { newValue -> + onValueChange(newValue) + validator?.validate(newValue) + }, + label = label, + singleLine = true, + enabled = enabled, + isError = isError ?: validator?.hasError ?: false, + supportingText = { + if (validator?.hasError ?: false) { + Text(text = errorMessage ?: validator.errorMessage) + } + }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = if (isSecureTextField && !passwordVisible) + PasswordVisualTransformation() else visualTransformation, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon ?: { + if (isSecureTextField) { + IconButton( + onClick = { + passwordVisible = !passwordVisible + } + ) { + Icon( + imageVector = if (passwordVisible) + Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +internal fun PreviewAuthTextField() { + val context = LocalContext.current + val nameTextValue = remember { mutableStateOf("") } + val emailTextValue = remember { mutableStateOf("") } + val passwordTextValue = remember { mutableStateOf("") } + val emailValidator = remember { + EmailValidator(stringProvider = DefaultAuthUIStringProvider(context),) + } + val passwordValidator = remember { + PasswordValidator( + stringProvider = DefaultAuthUIStringProvider(context), + rules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireUppercase, + PasswordRule.RequireLowercase, + ) + ) + } + + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AuthTextField( + value = nameTextValue.value, + label = { + Text("Name") + }, + onValueChange = { text -> + nameTextValue.value = text + }, + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = emailTextValue.value, + validator = emailValidator, + label = { + Text("Email") + }, + onValueChange = { text -> + emailTextValue.value = text + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = passwordTextValue.value, + validator = passwordValidator, + label = { + Text("Password") + }, + onValueChange = { text -> + passwordTextValue.value = text + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "" + ) + } + ) + } +} \ No newline at end of file From 58145d678b0e299d0dab55b698fc10bf4cc016a9 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Wed, 1 Oct 2025 11:43:15 +0100 Subject: [PATCH 07/43] test: AuthTextField and field validations --- .../{ => ui/components}/AuthTextField.kt | 4 +- .../ui/components/AuthTextFieldTest.kt | 449 ++++++++++++++++++ 2 files changed, 451 insertions(+), 2 deletions(-) rename auth/src/main/java/com/firebase/ui/auth/compose/{ => ui/components}/AuthTextField.kt (99%) create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt similarity index 99% rename from auth/src/main/java/com/firebase/ui/auth/compose/AuthTextField.kt rename to auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt index dc9d2a1d6..620b6e2d8 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/AuthTextField.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt @@ -1,4 +1,4 @@ -package com.firebase.ui.auth.compose +package com.firebase.ui.auth.compose.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -127,7 +127,7 @@ internal fun PreviewAuthTextField() { val emailTextValue = remember { mutableStateOf("") } val passwordTextValue = remember { mutableStateOf("") } val emailValidator = remember { - EmailValidator(stringProvider = DefaultAuthUIStringProvider(context),) + EmailValidator(stringProvider = DefaultAuthUIStringProvider(context)) } val passwordValidator = remember { PasswordValidator( diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt new file mode 100644 index 000000000..21629a1a2 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt @@ -0,0 +1,449 @@ +/* + * 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.ui.components + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [AuthTextField] covering UI interactions, validation, + * password visibility toggle, and error states. + * + * @suppress Internal test class + */ +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class AuthTextFieldTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var context: Context + private lateinit var stringProvider: AuthUIStringProvider + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + } + + // ============================================================================================= + // Basic UI Tests + // ============================================================================================= + + @Test + fun `AuthTextField displays correctly with basic configuration`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Name") } + ) + } + + composeTestRule + .onNodeWithText("Name") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField displays initial value`() { + composeTestRule.setContent { + AuthTextField( + value = "test@example.com", + onValueChange = { }, + label = { Text("Email") } + ) + } + + composeTestRule + .onNodeWithText("Email") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("test@example.com") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField updates value on text input`() { + composeTestRule.setContent { + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Email") } + ) + } + + composeTestRule + .onNodeWithText("Email") + .performTextInput("test@example.com") + + composeTestRule + .onNodeWithText("Email") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("test@example.com") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField respects enabled state`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") }, + enabled = false + ) + } + + composeTestRule + .onNodeWithText("Email") + .assertIsNotEnabled() + } + + @Test + fun `AuthTextField is enabled by default`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") } + ) + } + + composeTestRule + .onNodeWithText("Email") + .assertIsEnabled() + } + + @Test + fun `AuthTextField displays leading icon when provided`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Email Icon" + ) + } + ) + } + + composeTestRule + .onNodeWithContentDescription("Email Icon") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField displays custom trailing icon when provided`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") }, + trailingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Custom Trailing Icon" + ) + } + ) + } + + composeTestRule + .onNodeWithContentDescription("Custom Trailing Icon") + .assertIsDisplayed() + } + + // ============================================================================================= + // Validation Tests + // ============================================================================================= + + @Test + fun `AuthTextField validates email correctly with EmailValidator`() { + composeTestRule.setContent { + val emailValidator = remember { + EmailValidator(stringProvider = stringProvider) + } + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Email") }, + validator = emailValidator + ) + } + + composeTestRule + .onNodeWithText("Email") + .performTextInput("invalid-email") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.invalidEmailAddress) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Email") + .performTextClearance() + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.missingEmailAddress) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Email") + .performTextInput("valid@example.com") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.missingEmailAddress) + .assertIsNotDisplayed() + composeTestRule + .onNodeWithText(stringProvider.invalidEmailAddress) + .assertIsNotDisplayed() + } + + @Test + fun `AuthTextField displays custom error message when provided`() { + composeTestRule.setContent { + val emailValidator = remember { + EmailValidator(stringProvider = stringProvider) + } + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Email") }, + validator = emailValidator, + errorMessage = "Custom error message" + ) + } + + composeTestRule + .onNodeWithText("Email") + .performTextInput("invalid") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText("Custom error message") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField validates password with PasswordValidator`() { + composeTestRule.setContent { + val passwordValidator = remember { + PasswordValidator( + stringProvider = stringProvider, + rules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireUppercase, + PasswordRule.RequireLowercase + ) + ) + } + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Password") }, + validator = passwordValidator + ) + } + + composeTestRule + .onNodeWithText("Password") + .performTextInput("short") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.passwordTooShort.format(8)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Password") + .performTextClearance() + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.invalidPassword) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Password") + .performTextInput("pass@1234") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.passwordMissingUppercase) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Password") + .performTextInput("ValidPass123") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.passwordTooShort.format(8)) + .assertIsNotDisplayed() + composeTestRule + .onNodeWithText(stringProvider.passwordMissingLowercase) + .assertIsNotDisplayed() + composeTestRule + .onNodeWithText(stringProvider.passwordMissingUppercase) + .assertIsNotDisplayed() + } + + // ============================================================================================= + // Password Visibility Toggle Tests + // ============================================================================================= + + @Test + fun `AuthTextField shows password visibility toggle for PasswordValidator`() { + composeTestRule.setContent { + AuthTextField( + value = "password123", + onValueChange = { }, + label = { Text("Password") }, + validator = PasswordValidator( + stringProvider = stringProvider, + rules = emptyList() + ) + ) + } + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField toggles password visibility when icon is clicked`() { + composeTestRule.setContent { + AuthTextField( + value = "password123", + onValueChange = { }, + label = { Text("Password") }, + validator = PasswordValidator( + stringProvider = stringProvider, + rules = emptyList() + ) + ) + } + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertIsDisplayed() + .performClick() + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithContentDescription("Hide password") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField hides password visibility toggle for non-password fields`() { + composeTestRule.setContent { + AuthTextField( + value = "test@example.com", + onValueChange = { }, + label = { Text("Email") }, + ) + } + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertDoesNotExist() + + composeTestRule + .onNodeWithContentDescription("Hide password") + .assertDoesNotExist() + } + + @Test + fun `AuthTextField respects custom trailing icon over password toggle`() { + composeTestRule.setContent { + AuthTextField( + value = "password123", + onValueChange = { }, + label = { Text("Password") }, + validator = PasswordValidator( + stringProvider = stringProvider, + rules = emptyList() + ), + trailingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Custom Icon" + ) + } + ) + } + + composeTestRule + .onNodeWithContentDescription("Custom Icon") + .assertIsDisplayed() + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertDoesNotExist() + } +} From 24c687c257830bcc84c2648739d39de446e135b6 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Wed, 1 Oct 2025 12:49:46 +0100 Subject: [PATCH 08/43] chore: update doc comments --- .../auth/compose/ui/components/AuthTextField.kt | 16 ++++++++++++---- .../compose/ui/method_picker/AuthMethodPicker.kt | 4 ++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt index 620b6e2d8..66f2b475e 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt @@ -39,11 +39,19 @@ import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator * * **Example usage:** * ```kotlin + * val emailTextValue = remember { mutableStateOf("") } + * + * val emailValidator = remember { + * EmailValidator(stringProvider = DefaultAuthUIStringProvider(context)) + * } + * * AuthTextField( - * value = email, - * onValueChange = { email = it }, - * label = "Email", - * validator = EmailValidator() + * value = emailTextValue, + * onValueChange = { emailTextValue.value = it }, + * label = { + * Text("Email") + * }, + * validator = emailValidator * ) * ``` * diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt index 91aedc2fd..52906fd30 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt @@ -29,8 +29,8 @@ import com.firebase.ui.auth.compose.ui.components.AuthProviderButton * ```kotlin * AuthMethodPicker( * providers = listOf( - * AuthProvider.Google(), - * AuthProvider.Email(), + * AuthProvider.Google(), + * AuthProvider.Email(), * ), * onProviderSelected = { provider -> /* ... */ } * ) From 76923bc13d36716b763d3837ff3a95993eb382d7 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Thu, 2 Oct 2025 12:40:32 +0100 Subject: [PATCH 09/43] wip: Email Provider integration --- auth/build.gradle.kts | 1 + .../ui/auth/compose/FirebaseAuthUI.kt | 263 +++++++++++++++++- .../compose/configuration/AuthProvider.kt | 63 ++++- .../configuration/AuthUIConfiguration.kt | 3 - .../ui/auth/compose/FirebaseAuthUITest.kt | 51 ++++ gradle/libs.versions.toml | 4 + 6 files changed, 379 insertions(+), 6 deletions(-) diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index 57462420d..97bdcac4f 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -85,6 +85,7 @@ dependencies { implementation(Config.Libs.Androidx.materialDesign) implementation(Config.Libs.Androidx.activity) implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.datastore.preferences) // The new activity result APIs force us to include Fragment 1.3.0 // See https://issuetracker.google.com/issues/152554847 implementation(Config.Libs.Androidx.fragment) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt index fe1f6cf80..97113163b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt @@ -22,14 +22,29 @@ import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.ktx.auth import com.google.firebase.ktx.Firebase import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.firebase.ui.auth.compose.configuration.AuthProvider +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.util.data.EmailLinkParser +import com.firebase.ui.auth.util.data.SessionUtils +import com.google.firebase.auth.ActionCodeSettings +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.EmailAuthProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.tasks.await import java.util.concurrent.ConcurrentHashMap +val Context.dataStore: DataStore by preferencesDataStore(name = "com.firebase.ui.auth.util.data.EmailLinkPersistenceManager") + /** * The central class that coordinates all authentication operations for Firebase Auth UI Compose. * This class manages UI state and provides methods for signing in, signing up, and managing @@ -168,7 +183,8 @@ class FirebaseAuthUI private constructor( // Check if email verification is required if (!currentUser.isEmailVerified && currentUser.email != null && - currentUser.providerData.any { it.providerId == "password" }) { + currentUser.providerData.any { it.providerId == "password" } + ) { AuthState.RequiresEmailVerification( user = currentUser, email = currentUser.email!! @@ -213,6 +229,249 @@ class FirebaseAuthUI private constructor( _authStateFlow.value = state } + internal suspend fun createOrLinkUserWithEmailAndPassword( + config: AuthUIConfiguration, + provider: AuthProvider.Email, + email: String, + password: String + ) { + try { + updateAuthState(AuthState.Loading("Creating user...")) + if (provider.canUpgradeAnonymous(config, auth)) { + val credential = EmailAuthProvider.getCredential(email, password) + auth.currentUser?.linkWithCredential(credential)?.await() + } else { + auth.createUserWithEmailAndPassword(email, password).await() + } + updateAuthState(AuthState.Idle) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Create or link user with email and password was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } + } + + internal suspend fun signInAndLinkWithCredential( + config: AuthUIConfiguration, + provider: AuthProvider.Email, + credential: AuthCredential + ) { + try { + updateAuthState(AuthState.Loading("Signing in user...")) + if (provider.canUpgradeAnonymous(config, auth)) { + auth.currentUser?.linkWithCredential(credential)?.await() + } else { + auth.signInWithCredential(credential).await() + } + updateAuthState(AuthState.Idle) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in and link with credential was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } + } + + internal suspend fun sendSignInLinkToEmail( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Email, + email: String, + ) { + try { + updateAuthState(AuthState.Loading("Sending sign in email link...")) + + // Get anonymousUserId if can upgrade anonymously else default to empty string. + // NOTE: check for empty string instead of null to validate anonymous user ID matches + // when sign in from email link + val anonymousUserId = + if (provider.canUpgradeAnonymous(config, auth)) (auth.currentUser?.uid + ?: "") else "" + + // Generate sessionId + val sessionId = + SessionUtils.generateRandomAlphaNumericString(AuthProvider.Email.SESSION_ID_LENGTH) + + // Modify actionCodeSettings Url to include sessionId, anonymousUserId, force same + // device flag + val updatedActionCodeSettings = + provider.addSessionInfoToActionCodeSettings(sessionId, anonymousUserId) + + auth.sendSignInLinkToEmail(email, updatedActionCodeSettings).await() + + // Save Email to dataStore for use in signInWithEmailLink + context.dataStore.edit { prefs -> + prefs[AuthProvider.Email.KEY_EMAIL] = email + prefs[AuthProvider.Email.KEY_ANONYMOUS_USER_ID] = anonymousUserId + prefs[AuthProvider.Email.KEY_SESSION_ID] = sessionId + } + updateAuthState(AuthState.Idle) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Send sign in link to email was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } + } + + /** + * Signs in a user using an email link (passwordless authentication). + * + * This method completes the email link sign-in flow after the user clicks the magic link + * sent to their email. It validates the link, extracts session information, and either + * signs in the user normally or upgrades an anonymous account based on configuration. + * + * **Flow:** + * 1. User receives email with magic link + * 2. User clicks link, app opens via deep link + * 3. Activity extracts emailLink from Intent.data + * 4. This method validates and completes sign-in + * + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider The [AuthProvider.Email] configuration with email-link settings + * @param email The email address of the user (retrieved from DataStore or user input) + * @param emailLink The complete deep link URL received from the Intent. + * + * This URL contains: + * - Firebase action code (oobCode) for authentication + * - Session ID (ui_sid) for same-device validation + * - Anonymous user ID (ui_auid) if upgrading anonymous account + * - Force same-device flag (ui_sd) for security enforcement + * + * Example: + * `https://yourapp.page.link/emailSignIn?oobCode=ABC123&continueUrl=...` + * + * @throws AuthException.InvalidCredentialsException if the email link is invalid or expired + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.UnknownException for other errors + * + * @see sendSignInLinkToEmail for sending the initial email link + */ + internal suspend fun signInWithEmailLink( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Email, + email: String, + emailLink: String, + ) { + try { + updateAuthState(AuthState.Loading("Signing in with email link...")) + + // Validate link format + if (!auth.isSignInWithEmailLink(emailLink)) { + throw AuthException.InvalidCredentialsException("Invalid email link") + } + + // Parses email link for session data and returns sessionId, anonymousUserId, + // force same device flag etc. + val parser = EmailLinkParser(emailLink) + val sessionIdFromLink = parser.sessionId + val anonymousUserIdFromLink = parser.anonymousUserId + + // Retrieve stored session id from DataStore + val storedSessionId = context.dataStore.data.first()[AuthProvider.Email.KEY_SESSION_ID] + + // Validate same-device + if (provider.isDifferentDevice( + sessionIdFromLocal = storedSessionId, + sessionIdFromLink = sessionIdFromLink + ) + ) { + if (provider.isEmailLinkForceSameDeviceEnabled + || !anonymousUserIdFromLink.isNullOrEmpty() + ) { + throw AuthException.InvalidCredentialsException( + "Email link must be" + + "opened on the same device" + ) + } + + // TODO(demolaf): handle different device flow - + // would need to prompt user for email and start flow on new device + // Different device flow - prompt for email + // This is a FUTURE ticket - not part of P2 core implementation + // The UI layer needs to handle this by: + // 1. Detecting that email is null/missing from DataStore + // 2. Showing an EmailPromptScreen composable + // 3. User enters email + // 4. Retrying signInWithEmailLink() with user-provided email + + // For now, throw an exception since we don't have the UI + throw AuthException.InvalidCredentialsException( + "Email not found. Please enter your email to complete sign-in." + ) + } + + // Validate anonymous user ID matches + if (!anonymousUserIdFromLink.isNullOrEmpty()) { + val currentUser = auth.currentUser + if (currentUser == null + || !currentUser.isAnonymous + || currentUser.uid != anonymousUserIdFromLink + ) { + throw AuthException.InvalidCredentialsException( + "Anonymous " + + "user mismatch" + ) + } + } + + // Create credential and sign in + val emailLinkCredential = EmailAuthProvider.getCredentialWithLink(email, emailLink) + signInAndLinkWithCredential(config, provider, emailLinkCredential) + + // Clear DataStore after success + context.dataStore.edit { prefs -> + prefs.remove(AuthProvider.Email.KEY_SESSION_ID) + prefs.remove(AuthProvider.Email.KEY_EMAIL) + prefs.remove(AuthProvider.Email.KEY_ANONYMOUS_USER_ID) + } + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with email link was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } + } + /** * Signs out the current user and clears authentication state. * @@ -374,7 +633,7 @@ class FirebaseAuthUI private constructor( } catch (e: IllegalStateException) { throw IllegalStateException( "Default FirebaseApp is not initialized. " + - "Make sure to call FirebaseApp.initializeApp(Context) first.", + "Make sure to call FirebaseApp.initializeApp(Context) first.", e ) } 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 87008cd28..abd5682f4 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 @@ -15,20 +15,26 @@ package com.firebase.ui.auth.compose.configuration import android.content.Context -import androidx.compose.ui.graphics.Color +import android.text.TextUtils import android.util.Log +import androidx.compose.ui.graphics.Color +import androidx.datastore.preferences.core.stringPreferencesKey import com.firebase.ui.auth.R import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset import com.firebase.ui.auth.util.Preconditions +import com.firebase.ui.auth.util.data.ContinueUrlBuilder +import com.firebase.ui.auth.util.data.EmailLinkPersistenceManager.SessionRecord import com.firebase.ui.auth.util.data.PhoneNumberUtils 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 +import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.GithubAuthProvider import com.google.firebase.auth.GoogleAuthProvider import com.google.firebase.auth.PhoneAuthProvider import com.google.firebase.auth.TwitterAuthProvider +import com.google.firebase.auth.actionCodeSettings @AuthUIConfigurationDsl class AuthProvidersBuilder { @@ -118,6 +124,15 @@ abstract class AuthProvider(open val providerId: String) { */ val passwordValidationRules: List ) : AuthProvider(providerId = Provider.EMAIL.id) { + companion object { + val SESSION_ID_LENGTH = 10 + val KEY_EMAIL = stringPreferencesKey("com.firebase.ui.auth.data.client.email") + val KEY_PROVIDER = stringPreferencesKey("com.firebase.ui.auth.data.client.provider") + val KEY_ANONYMOUS_USER_ID = + stringPreferencesKey("com.firebase.ui.auth.data.client.auid") + val KEY_SESSION_ID = stringPreferencesKey("com.firebase.ui.auth.data.client.sid") + } + fun validate() { if (isEmailLinkSignInEnabled) { val actionCodeSettings = requireNotNull(actionCodeSettings) { @@ -131,6 +146,52 @@ abstract class AuthProvider(open val providerId: String) { } } } + + fun canUpgradeAnonymous(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean { + val currentUser = auth.currentUser + return config.isAnonymousUpgradeEnabled + && currentUser != null + && currentUser.isAnonymous + } + + fun addSessionInfoToActionCodeSettings( + sessionId: String, + anonymousUserId: String, + ): ActionCodeSettings { + requireNotNull(actionCodeSettings) { + "ActionCodeSettings is required for email link sign in" + } + + val continueUrl = continueUrl(actionCodeSettings.url) { + appendSessionId(sessionId) + appendAnonymousUserId(anonymousUserId) + appendForceSameDeviceBit(isEmailLinkForceSameDeviceEnabled) + appendProviderId(providerId) + } + + return actionCodeSettings { + url = continueUrl + handleCodeInApp = actionCodeSettings.canHandleCodeInApp() + iosBundleId = actionCodeSettings.iosBundle + setAndroidPackageName( + actionCodeSettings.androidPackageName ?: "", + actionCodeSettings.androidInstallApp, + actionCodeSettings.androidMinimumVersion + ) + } + } + + fun isDifferentDevice( + sessionIdFromLocal: String?, + sessionIdFromLink: String + ): Boolean { + return sessionIdFromLocal == null || sessionIdFromLocal.isEmpty() + || sessionIdFromLink.isEmpty() + || (sessionIdFromLink != sessionIdFromLocal) + } + + private fun continueUrl(continueUrl: String, block: ContinueUrlBuilder.() -> Unit) = + ContinueUrlBuilder(continueUrl).apply(block).build() } /** 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 6fb0202de..66dc34b72 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 @@ -22,9 +22,6 @@ import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringPr import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme -fun actionCodeSettings(block: ActionCodeSettings.Builder.() -> Unit) = - ActionCodeSettings.newBuilder().apply(block).build() - fun authUIConfiguration(block: AuthUIConfigurationBuilder.() -> Unit) = AuthUIConfigurationBuilder().apply(block).build() diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt index 5fd0d201c..9681f5205 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt @@ -14,7 +14,10 @@ package com.firebase.ui.auth.compose +import android.content.Context import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.configuration.AuthProvider +import com.firebase.ui.auth.compose.configuration.authUIConfiguration import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseException @@ -24,7 +27,10 @@ import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException import com.google.firebase.auth.FirebaseUser import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.TaskCompletionSource +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.actionCodeSettings import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before @@ -522,4 +528,49 @@ class FirebaseAuthUITest { assertThat(e.cause).isEqualTo(networkException) } } + + // ============================================================================================= + // Email Provider Tests + // ============================================================================================= + + @Test + fun `Create or link user with email and password without anonymous upgrade should succeed`() = + runTest { + val applicationContext = ApplicationProvider.getApplicationContext() + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.email).thenReturn("test@example.com") + `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`(mockFirebaseAuth.createUserWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + instance.createOrLinkUserWithEmailAndPassword( + config = config, + provider = emailProvider, + email = "test@example.com", + password = "Pass@123" + ) + + verify(mockFirebaseAuth) + .createUserWithEmailAndPassword("test@example.com", "Pass@123") + + val authState = instance.authStateFlow().first() + assertThat(authState) + .isEqualTo(AuthState.Success(result = null, user = mockUser)) + val successState = authState as AuthState.Success + assertThat(successState.user.email).isEqualTo("test@example.com") + } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index befb470ce..c31931752 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +datastorePreferences = "1.1.1" kotlin = "2.2.0" androidxComposeBom = "2025.08.00" androidxActivityCompose = "1.9.0" @@ -15,6 +16,9 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +# Storage +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } + # Testing androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } From 7f73bcf11c3a9836a7fea18a43f6d74f7b9975f6 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Thu, 2 Oct 2025 21:16:38 +0100 Subject: [PATCH 10/43] chore: upgrade mockito, fix: spying mocked objects in new library --- auth/build.gradle.kts | 19 ++++++++++++++++-- .../ui/auth/testhelpers/TestHelper.java | 12 ++++++++--- gradle/libs.versions.toml | 20 ++++++++++++++++++- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index 97bdcac4f..97267737b 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -91,7 +91,7 @@ dependencies { implementation(Config.Libs.Androidx.fragment) implementation(Config.Libs.Androidx.customTabs) implementation(Config.Libs.Androidx.constraint) - implementation("androidx.credentials:credentials:1.3.0") + implementation(libs.androidx.credentials) implementation("androidx.credentials:credentials-play-services-auth:1.3.0") implementation(Config.Libs.Androidx.lifecycleExtensions) @@ -112,12 +112,27 @@ dependencies { testImplementation(Config.Libs.Test.junit) testImplementation(Config.Libs.Test.truth) - 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) testImplementation(libs.androidx.ui.test.junit4) + testImplementation(libs.mockito) + testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.androidx.credentials) debugImplementation(project(":internal:lintchecks")) } + +val mockitoAgent by configurations.creating + +dependencies { + mockitoAgent(libs.mockito) { + isTransitive = false + } +} + +tasks.withType().configureEach { + jvmArgs("-javaagent:${mockitoAgent.asPath}") +} diff --git a/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java b/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java index 2efed02da..1a3265b6c 100644 --- a/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java +++ b/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java @@ -75,11 +75,17 @@ public static void initialize() { } private static void spyContextAndResources() { - CONTEXT = spy(CONTEXT); + // In Mockito 5.x, we need to avoid spying on objects that are already mocks/spies + if (!org.mockito.Mockito.mockingDetails(CONTEXT).isSpy()) { + CONTEXT = spy(CONTEXT); + } when(CONTEXT.getApplicationContext()) .thenReturn(CONTEXT); - Resources spiedResources = spy(CONTEXT.getResources()); - when(CONTEXT.getResources()).thenReturn(spiedResources); + Resources resources = CONTEXT.getResources(); + if (!org.mockito.Mockito.mockingDetails(resources).isSpy()) { + Resources spiedResources = spy(resources); + when(CONTEXT.getResources()).thenReturn(spiedResources); + } } private static void initializeApp(Context context) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c31931752..7b0eb3b69 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,21 @@ [versions] -datastorePreferences = "1.1.1" kotlin = "2.2.0" + +# Compose androidxComposeBom = "2025.08.00" androidxActivityCompose = "1.9.0" +# Authentication +credentials = "1.3.0" + +# Storage +datastorePreferences = "1.1.1" + +# Testing +mockito = "5.19.0" +mockitoInline = "5.2.0" +mockitoKotlin = "6.0.0" + [libraries] # Compose androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } @@ -16,11 +28,17 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +# Authentication +androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "credentials" } + # Storage androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } # Testing androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" } +mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoInline" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" } [plugins] compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } \ No newline at end of file From 70cd57a0580ac1624d4d03672ad32ac02da1bb46 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Thu, 2 Oct 2025 21:17:45 +0100 Subject: [PATCH 11/43] wip: Email provider integration --- .../ui/auth/compose/FirebaseAuthUITest.kt | 63 +++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt index 9681f5205..7e9fc221e 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt @@ -25,10 +25,10 @@ import com.google.firebase.FirebaseOptions import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException import com.google.firebase.auth.FirebaseUser -import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.TaskCompletionSource +import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult -import com.google.firebase.auth.actionCodeSettings +import com.google.firebase.auth.EmailAuthProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -36,12 +36,14 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers import org.mockito.Mock import org.mockito.Mockito.`when` import org.mockito.Mockito.mock import org.mockito.Mockito.verify import org.mockito.Mockito.doNothing import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mockStatic import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -70,7 +72,7 @@ class FirebaseAuthUITest { FirebaseAuthUI.clearInstanceCache() // Clear any existing Firebase apps - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() FirebaseApp.getApps(context).forEach { app -> app.delete() } @@ -534,7 +536,7 @@ class FirebaseAuthUITest { // ============================================================================================= @Test - fun `Create or link user with email and password without anonymous upgrade should succeed`() = + fun `Create user with email and password without anonymous upgrade should succeed`() = runTest { val applicationContext = ApplicationProvider.getApplicationContext() val mockUser = mock(FirebaseUser::class.java) @@ -573,4 +575,57 @@ class FirebaseAuthUITest { val successState = authState as AuthState.Success assertThat(successState.user.email).isEqualTo("test@example.com") } + + @Test + fun `Link user with email and password with anonymous upgrade should succeed`() = runTest { + mockStatic(EmailAuthProvider::class.java).use { mockedProvider -> + val applicationContext = ApplicationProvider.getApplicationContext() + val mockCredential = mock(AuthCredential::class.java) + mockedProvider.`when` { + EmailAuthProvider.getCredential("test@example.com", "Pass@123") + }.thenReturn(mockCredential) + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.email).thenReturn("test@example.com") + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`( + mockFirebaseAuth.currentUser?.linkWithCredential( + ArgumentMatchers.any(AuthCredential::class.java) + ) + ).thenReturn(taskCompletionSource.task) + val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + instance.createOrLinkUserWithEmailAndPassword( + config = config, + provider = emailProvider, + email = "test@example.com", + password = "Pass@123" + ) + + mockedProvider.verify { + EmailAuthProvider.getCredential("test@example.com", "Pass@123") + } + // Verify linkWithCredential was called with the mock credential + verify(mockAnonymousUser).linkWithCredential(mockCredential) + + val authState = instance.authStateFlow().first() + assertThat(authState) + .isEqualTo(AuthState.Success(result = null, user = mockAnonymousUser)) + val successState = authState as AuthState.Success + assertThat(successState.user.email).isEqualTo("test@example.com") + } + } } \ No newline at end of file From 42ebf158157f860c3500111dc38cfeb9b506374c Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Sun, 5 Oct 2025 00:21:46 +0100 Subject: [PATCH 12/43] wip: Email provider integration --- .../firebase/ui/auth/compose/AuthException.kt | 76 +- .../com/firebase/ui/auth/compose/AuthState.kt | 20 + .../ui/auth/compose/FirebaseAuthUI.kt | 258 ------- .../configuration/AuthUIConfiguration.kt | 3 + .../compose/configuration/PasswordRule.kt | 2 +- .../{ => auth_provider}/AuthProvider.kt | 107 ++- .../EmailAuthProvider+FirebaseAuthUI.kt | 562 ++++++++++++++ .../AuthUIStringProviderSample.kt | 2 +- .../theme/ProviderStyleDefaults.kt | 2 +- .../ui/components/AuthProviderButton.kt | 4 +- .../ui/method_picker/AuthMethodPicker.kt | 2 +- .../util/EmailLinkPersistenceManager.kt | 178 +++++ .../ui/auth/compose/FirebaseAuthUITest.kt | 120 +-- .../configuration/AuthUIConfigurationTest.kt | 1 + .../{ => auth_provider}/AuthProviderTest.kt | 16 +- .../EmailAuthProviderFirebaseAuthUITest.kt | 691 ++++++++++++++++++ .../ui/components/AuthProviderButtonTest.kt | 4 +- .../ui/method_picker/AuthMethodPickerTest.kt | 2 +- 18 files changed, 1633 insertions(+), 417 deletions(-) rename auth/src/main/java/com/firebase/ui/auth/compose/configuration/{ => auth_provider}/AuthProvider.kt (81%) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/util/EmailLinkPersistenceManager.kt rename auth/src/test/java/com/firebase/ui/auth/compose/configuration/{ => auth_provider}/AuthProviderTest.kt (95%) create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt index cb2a9480a..a111ae867 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt @@ -14,6 +14,7 @@ package com.firebase.ui.auth.compose +import com.firebase.ui.auth.compose.AuthException.Companion.from import com.google.firebase.FirebaseException import com.google.firebase.auth.FirebaseAuthException import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException @@ -204,6 +205,38 @@ abstract class AuthException( cause: Throwable? = null ) : AuthException(message, cause) + class InvalidEmailLinkException( + cause: Throwable? = null + ) : AuthException("You are are attempting to sign in with an invalid email link", cause) + + class EmailLinkWrongDeviceException( + cause: Throwable? = null + ) : AuthException("You must open the email link on the same device.", cause) + + class EmailLinkCrossDeviceLinkingException( + cause: Throwable? = null + ) : AuthException( + "You must determine if you want to continue linking or " + + "complete the sign in", cause + ) + + class EmailLinkPromptForEmailException( + cause: Throwable? = null + ) : AuthException("Please enter your email to continue signing in", cause) + + class EmailLinkDifferentAnonymousUserException( + cause: Throwable? = null + ) : AuthException( + "The session associated with this sign-in request has either expired or " + + "was cleared", cause + ) + + class EmailMismatchException( + cause: Throwable? = null + ) : AuthException( + "You are are attempting to sign in a different email than previously " + + "provided", cause) + companion object { /** * Creates an appropriate [AuthException] instance from a Firebase authentication exception. @@ -244,22 +277,26 @@ abstract class AuthException( cause = firebaseException ) } + is FirebaseAuthInvalidUserException -> { when (firebaseException.errorCode) { "ERROR_USER_NOT_FOUND" -> UserNotFoundException( message = firebaseException.message ?: "User not found", cause = firebaseException ) + "ERROR_USER_DISABLED" -> InvalidCredentialsException( message = firebaseException.message ?: "User account has been disabled", cause = firebaseException ) + else -> UserNotFoundException( message = firebaseException.message ?: "User account error", cause = firebaseException ) } } + is FirebaseAuthWeakPasswordException -> { WeakPasswordException( message = firebaseException.message ?: "Password is too weak", @@ -267,52 +304,68 @@ abstract class AuthException( reason = firebaseException.reason ) } + is FirebaseAuthUserCollisionException -> { when (firebaseException.errorCode) { "ERROR_EMAIL_ALREADY_IN_USE" -> EmailAlreadyInUseException( - message = firebaseException.message ?: "Email address is already in use", + message = firebaseException.message + ?: "Email address is already in use", cause = firebaseException, email = firebaseException.email ) + "ERROR_ACCOUNT_EXISTS_WITH_DIFFERENT_CREDENTIAL" -> AccountLinkingRequiredException( - message = firebaseException.message ?: "Account already exists with different credentials", + message = firebaseException.message + ?: "Account already exists with different credentials", cause = firebaseException ) + "ERROR_CREDENTIAL_ALREADY_IN_USE" -> AccountLinkingRequiredException( - message = firebaseException.message ?: "Credential is already associated with a different user account", + message = firebaseException.message + ?: "Credential is already associated with a different user account", cause = firebaseException ) + else -> AccountLinkingRequiredException( message = firebaseException.message ?: "Account collision error", cause = firebaseException ) } } + is FirebaseAuthMultiFactorException -> { MfaRequiredException( - message = firebaseException.message ?: "Multi-factor authentication required", + message = firebaseException.message + ?: "Multi-factor authentication required", cause = firebaseException ) } + is FirebaseAuthRecentLoginRequiredException -> { InvalidCredentialsException( - message = firebaseException.message ?: "Recent login required for this operation", + message = firebaseException.message + ?: "Recent login required for this operation", cause = firebaseException ) } + is FirebaseAuthException -> { // Handle FirebaseAuthException and check for specific error codes when (firebaseException.errorCode) { "ERROR_TOO_MANY_REQUESTS" -> TooManyRequestsException( - message = firebaseException.message ?: "Too many requests. Please try again later", + message = firebaseException.message + ?: "Too many requests. Please try again later", cause = firebaseException ) + else -> UnknownException( - message = firebaseException.message ?: "An unknown authentication error occurred", + message = firebaseException.message + ?: "An unknown authentication error occurred", cause = firebaseException ) } } + is FirebaseException -> { // Handle general Firebase exceptions, which include network errors NetworkException( @@ -320,10 +373,15 @@ abstract class AuthException( cause = firebaseException ) } + else -> { // Check for common cancellation patterns - if (firebaseException.message?.contains("cancelled", ignoreCase = true) == true || - firebaseException.message?.contains("canceled", ignoreCase = true) == true) { + if (firebaseException.message?.contains( + "cancelled", + ignoreCase = true + ) == true || + firebaseException.message?.contains("canceled", ignoreCase = true) == true + ) { AuthCancelledException( message = firebaseException.message ?: "Authentication was cancelled", cause = firebaseException diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt index d2163500a..b53cbafc4 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt @@ -14,6 +14,7 @@ package com.firebase.ui.auth.compose +import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactorResolver @@ -204,6 +205,25 @@ abstract class AuthState private constructor() { "AuthState.RequiresProfileCompletion(user=$user, missingFields=$missingFields)" } + class MergeConflict( + val pendingCredential: AuthCredential + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MergeConflict) return false + return pendingCredential == other.pendingCredential + } + + override fun hashCode(): Int { + var result = pendingCredential.hashCode() + result = 31 * result + pendingCredential.hashCode() + return result + } + + override fun toString(): String = + "AuthState.MergeConflict(pendingCredential=$pendingCredential)" + } + companion object { /** * Creates an Idle state instance. diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt index 97113163b..12032abc6 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt @@ -22,29 +22,14 @@ import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.ktx.auth import com.google.firebase.ktx.Firebase import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import com.firebase.ui.auth.compose.configuration.AuthProvider -import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration -import com.firebase.ui.auth.util.data.EmailLinkParser -import com.firebase.ui.auth.util.data.SessionUtils -import com.google.firebase.auth.ActionCodeSettings -import com.google.firebase.auth.AuthCredential -import com.google.firebase.auth.EmailAuthProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.tasks.await import java.util.concurrent.ConcurrentHashMap -val Context.dataStore: DataStore by preferencesDataStore(name = "com.firebase.ui.auth.util.data.EmailLinkPersistenceManager") - /** * The central class that coordinates all authentication operations for Firebase Auth UI Compose. * This class manages UI state and provides methods for signing in, signing up, and managing @@ -229,249 +214,6 @@ class FirebaseAuthUI private constructor( _authStateFlow.value = state } - internal suspend fun createOrLinkUserWithEmailAndPassword( - config: AuthUIConfiguration, - provider: AuthProvider.Email, - email: String, - password: String - ) { - try { - updateAuthState(AuthState.Loading("Creating user...")) - if (provider.canUpgradeAnonymous(config, auth)) { - val credential = EmailAuthProvider.getCredential(email, password) - auth.currentUser?.linkWithCredential(credential)?.await() - } else { - auth.createUserWithEmailAndPassword(email, password).await() - } - updateAuthState(AuthState.Idle) - } catch (e: CancellationException) { - val cancelledException = AuthException.AuthCancelledException( - message = "Create or link user with email and password was cancelled", - cause = e - ) - updateAuthState(AuthState.Error(cancelledException)) - throw cancelledException - } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) - throw e - } catch (e: Exception) { - val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) - throw authException - } - } - - internal suspend fun signInAndLinkWithCredential( - config: AuthUIConfiguration, - provider: AuthProvider.Email, - credential: AuthCredential - ) { - try { - updateAuthState(AuthState.Loading("Signing in user...")) - if (provider.canUpgradeAnonymous(config, auth)) { - auth.currentUser?.linkWithCredential(credential)?.await() - } else { - auth.signInWithCredential(credential).await() - } - updateAuthState(AuthState.Idle) - } catch (e: CancellationException) { - val cancelledException = AuthException.AuthCancelledException( - message = "Sign in and link with credential was cancelled", - cause = e - ) - updateAuthState(AuthState.Error(cancelledException)) - throw cancelledException - } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) - throw e - } catch (e: Exception) { - val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) - throw authException - } - } - - internal suspend fun sendSignInLinkToEmail( - context: Context, - config: AuthUIConfiguration, - provider: AuthProvider.Email, - email: String, - ) { - try { - updateAuthState(AuthState.Loading("Sending sign in email link...")) - - // Get anonymousUserId if can upgrade anonymously else default to empty string. - // NOTE: check for empty string instead of null to validate anonymous user ID matches - // when sign in from email link - val anonymousUserId = - if (provider.canUpgradeAnonymous(config, auth)) (auth.currentUser?.uid - ?: "") else "" - - // Generate sessionId - val sessionId = - SessionUtils.generateRandomAlphaNumericString(AuthProvider.Email.SESSION_ID_LENGTH) - - // Modify actionCodeSettings Url to include sessionId, anonymousUserId, force same - // device flag - val updatedActionCodeSettings = - provider.addSessionInfoToActionCodeSettings(sessionId, anonymousUserId) - - auth.sendSignInLinkToEmail(email, updatedActionCodeSettings).await() - - // Save Email to dataStore for use in signInWithEmailLink - context.dataStore.edit { prefs -> - prefs[AuthProvider.Email.KEY_EMAIL] = email - prefs[AuthProvider.Email.KEY_ANONYMOUS_USER_ID] = anonymousUserId - prefs[AuthProvider.Email.KEY_SESSION_ID] = sessionId - } - updateAuthState(AuthState.Idle) - } catch (e: CancellationException) { - val cancelledException = AuthException.AuthCancelledException( - message = "Send sign in link to email was cancelled", - cause = e - ) - updateAuthState(AuthState.Error(cancelledException)) - throw cancelledException - } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) - throw e - } catch (e: Exception) { - val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) - throw authException - } - } - - /** - * Signs in a user using an email link (passwordless authentication). - * - * This method completes the email link sign-in flow after the user clicks the magic link - * sent to their email. It validates the link, extracts session information, and either - * signs in the user normally or upgrades an anonymous account based on configuration. - * - * **Flow:** - * 1. User receives email with magic link - * 2. User clicks link, app opens via deep link - * 3. Activity extracts emailLink from Intent.data - * 4. This method validates and completes sign-in - * - * @param config The [AuthUIConfiguration] containing authentication settings - * @param provider The [AuthProvider.Email] configuration with email-link settings - * @param email The email address of the user (retrieved from DataStore or user input) - * @param emailLink The complete deep link URL received from the Intent. - * - * This URL contains: - * - Firebase action code (oobCode) for authentication - * - Session ID (ui_sid) for same-device validation - * - Anonymous user ID (ui_auid) if upgrading anonymous account - * - Force same-device flag (ui_sd) for security enforcement - * - * Example: - * `https://yourapp.page.link/emailSignIn?oobCode=ABC123&continueUrl=...` - * - * @throws AuthException.InvalidCredentialsException if the email link is invalid or expired - * @throws AuthException.AuthCancelledException if the operation is cancelled - * @throws AuthException.NetworkException if a network error occurs - * @throws AuthException.UnknownException for other errors - * - * @see sendSignInLinkToEmail for sending the initial email link - */ - internal suspend fun signInWithEmailLink( - context: Context, - config: AuthUIConfiguration, - provider: AuthProvider.Email, - email: String, - emailLink: String, - ) { - try { - updateAuthState(AuthState.Loading("Signing in with email link...")) - - // Validate link format - if (!auth.isSignInWithEmailLink(emailLink)) { - throw AuthException.InvalidCredentialsException("Invalid email link") - } - - // Parses email link for session data and returns sessionId, anonymousUserId, - // force same device flag etc. - val parser = EmailLinkParser(emailLink) - val sessionIdFromLink = parser.sessionId - val anonymousUserIdFromLink = parser.anonymousUserId - - // Retrieve stored session id from DataStore - val storedSessionId = context.dataStore.data.first()[AuthProvider.Email.KEY_SESSION_ID] - - // Validate same-device - if (provider.isDifferentDevice( - sessionIdFromLocal = storedSessionId, - sessionIdFromLink = sessionIdFromLink - ) - ) { - if (provider.isEmailLinkForceSameDeviceEnabled - || !anonymousUserIdFromLink.isNullOrEmpty() - ) { - throw AuthException.InvalidCredentialsException( - "Email link must be" + - "opened on the same device" - ) - } - - // TODO(demolaf): handle different device flow - - // would need to prompt user for email and start flow on new device - // Different device flow - prompt for email - // This is a FUTURE ticket - not part of P2 core implementation - // The UI layer needs to handle this by: - // 1. Detecting that email is null/missing from DataStore - // 2. Showing an EmailPromptScreen composable - // 3. User enters email - // 4. Retrying signInWithEmailLink() with user-provided email - - // For now, throw an exception since we don't have the UI - throw AuthException.InvalidCredentialsException( - "Email not found. Please enter your email to complete sign-in." - ) - } - - // Validate anonymous user ID matches - if (!anonymousUserIdFromLink.isNullOrEmpty()) { - val currentUser = auth.currentUser - if (currentUser == null - || !currentUser.isAnonymous - || currentUser.uid != anonymousUserIdFromLink - ) { - throw AuthException.InvalidCredentialsException( - "Anonymous " + - "user mismatch" - ) - } - } - - // Create credential and sign in - val emailLinkCredential = EmailAuthProvider.getCredentialWithLink(email, emailLink) - signInAndLinkWithCredential(config, provider, emailLinkCredential) - - // Clear DataStore after success - context.dataStore.edit { prefs -> - prefs.remove(AuthProvider.Email.KEY_SESSION_ID) - prefs.remove(AuthProvider.Email.KEY_EMAIL) - prefs.remove(AuthProvider.Email.KEY_ANONYMOUS_USER_ID) - } - } catch (e: CancellationException) { - val cancelledException = AuthException.AuthCancelledException( - message = "Sign in with email link was cancelled", - cause = e - ) - updateAuthState(AuthState.Error(cancelledException)) - throw cancelledException - } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) - throw e - } catch (e: Exception) { - val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) - throw authException - } - } - /** * Signs out the current user and clears authentication state. * 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 66dc34b72..03ce2bc02 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 @@ -18,6 +18,9 @@ import android.content.Context import java.util.Locale import com.google.firebase.auth.ActionCodeSettings import androidx.compose.ui.graphics.vector.ImageVector +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvidersBuilder +import com.firebase.ui.auth.compose.configuration.auth_provider.Provider import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme 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 7073fbe6e..383265501 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 @@ -18,7 +18,7 @@ import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringPr /** * An abstract class representing a set of validation rules that can be applied to a password field, - * typically within the [AuthProvider.Email] configuration. + * typically within the [com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider.Email] configuration. */ abstract class PasswordRule { /** 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/auth_provider/AuthProvider.kt similarity index 81% rename from auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt rename to auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt index abd5682f4..c3b9b8e9d 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/auth_provider/AuthProvider.kt @@ -12,18 +12,20 @@ * limitations under the License. */ -package com.firebase.ui.auth.compose.configuration +package com.firebase.ui.auth.compose.configuration.auth_provider import android.content.Context -import android.text.TextUtils +import android.net.Uri import android.util.Log import androidx.compose.ui.graphics.Color import androidx.datastore.preferences.core.stringPreferencesKey import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.AuthUIConfigurationDsl +import com.firebase.ui.auth.compose.configuration.PasswordRule import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset import com.firebase.ui.auth.util.Preconditions import com.firebase.ui.auth.util.data.ContinueUrlBuilder -import com.firebase.ui.auth.util.data.EmailLinkPersistenceManager.SessionRecord import com.firebase.ui.auth.util.data.PhoneNumberUtils import com.firebase.ui.auth.util.data.ProviderAvailability import com.google.firebase.auth.ActionCodeSettings @@ -34,7 +36,9 @@ import com.google.firebase.auth.GithubAuthProvider import com.google.firebase.auth.GoogleAuthProvider import com.google.firebase.auth.PhoneAuthProvider import com.google.firebase.auth.TwitterAuthProvider +import com.google.firebase.auth.UserProfileChangeRequest import com.google.firebase.auth.actionCodeSettings +import kotlinx.coroutines.tasks.await @AuthUIConfigurationDsl class AuthProvidersBuilder { @@ -82,6 +86,74 @@ abstract class OAuthProvider( * Base abstract class for authentication providers. */ abstract class AuthProvider(open val providerId: String) { + + companion object { + internal fun canUpgradeAnonymous(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean { + val currentUser = auth.currentUser + return config.isAnonymousUpgradeEnabled + && currentUser != null + && currentUser.isAnonymous + } + + /** + * Merges profile information (display name and photo URL) with the current user's profile. + * + * This method updates the user's profile only if the current profile is incomplete + * (missing display name or photo URL). This prevents overwriting existing profile data. + * + * **Use case:** + * After creating a new user account or linking credentials, update the profile with + * information from the sign-up form or social provider. + * + * @param auth The [FirebaseAuth] instance + * @param displayName The display name to set (if current is empty) + * @param photoUri The photo URL to set (if current is null) + * + * **Old library reference:** + * - ProfileMerger.java:34-56 (complete implementation) + * - ProfileMerger.java:39-43 (only update if profile incomplete) + * - ProfileMerger.java:49-55 (updateProfile call) + * + * **Note:** This operation always succeeds to minimize login interruptions. + * Failures are logged but don't prevent sign-in completion. + */ + internal suspend fun mergeProfile( + auth: FirebaseAuth, + displayName: String?, + photoUri: Uri? + ) { + try { + val currentUser = auth.currentUser ?: return + + // Only update if current profile is incomplete + val currentDisplayName = currentUser.displayName + val currentPhotoUrl = currentUser.photoUrl + + if (!currentDisplayName.isNullOrEmpty() && currentPhotoUrl != null) { + // Profile is complete, no need to update + return + } + + // Build profile update with provided values + val nameToSet = if (currentDisplayName.isNullOrEmpty()) displayName else currentDisplayName + val photoToSet = currentPhotoUrl ?: photoUri + + if (nameToSet != null || photoToSet != null) { + val profileUpdates = UserProfileChangeRequest.Builder() + .setDisplayName(nameToSet) + .setPhotoUri(photoToSet) + .build() + + currentUser.updateProfile(profileUpdates).await() + } + } catch (e: Exception) { + // Log error but don't throw - profile update failure shouldn't prevent sign-in + // Old library uses TaskFailureLogger for this + Log.e("AuthProvider.Email", "Error updating profile", e) + } + } + } + /** * Email/Password authentication provider configuration. */ @@ -125,15 +197,17 @@ abstract class AuthProvider(open val providerId: String) { val passwordValidationRules: List ) : AuthProvider(providerId = Provider.EMAIL.id) { companion object { - val SESSION_ID_LENGTH = 10 + const val SESSION_ID_LENGTH = 10 val KEY_EMAIL = stringPreferencesKey("com.firebase.ui.auth.data.client.email") val KEY_PROVIDER = stringPreferencesKey("com.firebase.ui.auth.data.client.provider") val KEY_ANONYMOUS_USER_ID = stringPreferencesKey("com.firebase.ui.auth.data.client.auid") val KEY_SESSION_ID = stringPreferencesKey("com.firebase.ui.auth.data.client.sid") + val KEY_IDP_TOKEN = stringPreferencesKey("com.firebase.ui.auth.data.client.idpToken") + val KEY_IDP_SECRET = stringPreferencesKey("com.firebase.ui.auth.data.client.idpSecret") } - fun validate() { + internal fun validate() { if (isEmailLinkSignInEnabled) { val actionCodeSettings = requireNotNull(actionCodeSettings) { "ActionCodeSettings cannot be null when using " + @@ -147,14 +221,8 @@ abstract class AuthProvider(open val providerId: String) { } } - fun canUpgradeAnonymous(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean { - val currentUser = auth.currentUser - return config.isAnonymousUpgradeEnabled - && currentUser != null - && currentUser.isAnonymous - } - - fun addSessionInfoToActionCodeSettings( + // For Send Email Link + internal fun addSessionInfoToActionCodeSettings( sessionId: String, anonymousUserId: String, ): ActionCodeSettings { @@ -181,7 +249,8 @@ abstract class AuthProvider(open val providerId: String) { } } - fun isDifferentDevice( + // For Sign In With Email Link + internal fun isDifferentDevice( sessionIdFromLocal: String?, sessionIdFromLink: String ): Boolean { @@ -233,7 +302,7 @@ abstract class AuthProvider(open val providerId: String) { */ val isAutoRetrievalEnabled: Boolean = true ) : AuthProvider(providerId = Provider.PHONE.id) { - fun validate() { + internal fun validate() { defaultNumber?.let { check(PhoneNumberUtils.isValid(it)) { "Invalid phone number: $it" @@ -296,7 +365,7 @@ abstract class AuthProvider(open val providerId: String) { scopes = scopes, customParameters = customParameters ) { - fun validate(context: Context) { + internal fun validate(context: Context) { if (serverClientId == null) { Preconditions.checkConfigured( context, @@ -348,7 +417,7 @@ abstract class AuthProvider(open val providerId: String) { scopes = scopes, customParameters = customParameters ) { - fun validate(context: Context) { + internal fun validate(context: Context) { if (!ProviderAvailability.IS_FACEBOOK_AVAILABLE) { throw RuntimeException( "Facebook provider cannot be configured " + @@ -475,7 +544,7 @@ abstract class AuthProvider(open val providerId: String) { * Anonymous authentication provider. It has no configurable properties. */ object Anonymous : AuthProvider(providerId = Provider.ANONYMOUS.id) { - fun validate(providers: List) { + internal fun validate(providers: List) { if (providers.size == 1 && providers.first() is Anonymous) { throw IllegalStateException( "Sign in as guest cannot be the only sign in method. " + @@ -528,7 +597,7 @@ abstract class AuthProvider(open val providerId: String) { scopes = scopes, customParameters = customParameters ) { - fun validate() { + internal fun validate() { require(providerId.isNotBlank()) { "Provider ID cannot be null or empty" } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 000000000..f0b08f123 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,562 @@ +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.content.Context +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager +import com.firebase.ui.auth.data.model.User +import com.firebase.ui.auth.util.data.EmailLinkParser +import com.firebase.ui.auth.util.data.SessionUtils +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.ActionCodeSettings +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FirebaseAuth +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.tasks.await + +/** + * Creates a new user with email and password, or links to an existing anonymous user. + * + * This method handles both new user creation and anonymous user upgrade scenarios. + * After successful account creation, it automatically updates the user's profile with + * the provided display name and photo URI. + * + * **Flow:** + * 1. Check if user is anonymous and upgrade is enabled + * 2. If yes: Link email/password credential to anonymous user + * 3. If no: Create new user with email/password + * 4. Update profile with display name and photo (if provided) + * + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider + * @param newUser Optional User to merge existing user profile + * @param email The email address for the new account + * @param password The password for the new account + * + * @throws AuthException.WeakPasswordException if password doesn't meet requirements + * @throws AuthException.InvalidCredentialsException if email is invalid + * @throws AuthException.EmailAlreadyInUseException if email is already registered + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * + * **Old library reference:** + * - EmailProviderResponseHandler.java:55-58 (createOrLinkUserWithEmailAndPassword call) + * - EmailProviderResponseHandler.java:59 (ProfileMerger continuation) + * - AuthOperationManager.java:64-74 (implementation) + * - RegisterEmailFragment.java:279-285 (UI calling this method) + */ +internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Email, + newUser: User? = null, + email: String, + password: String +) { + try { + if (password.length < provider.minimumPasswordLength) { + throw AuthException.InvalidCredentialsException( + message = context.getString(R.string.fui_error_password_too_short) + .format(provider.minimumPasswordLength) + ) + } + updateAuthState(AuthState.Loading("Creating user...")) + if (AuthProvider.canUpgradeAnonymous(config, auth)) { + val credential = EmailAuthProvider.getCredential(email, password) + auth.currentUser?.linkWithCredential(credential)?.await() + } else { + auth.createUserWithEmailAndPassword(email, password).await() + } + AuthProvider.mergeProfile(auth, newUser?.name, newUser?.photoUri) + updateAuthState(AuthState.Idle) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Create or link user with email and password was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs in a user with email and password, optionally linking a social provider credential. + * + * This method handles normal sign-in and anonymous user upgrade scenarios. If a social + * provider credential (e.g., Google, Facebook) is provided, it will be linked to the + * account after successful sign-in. + * + * **For anonymous upgrade scenarios:** The UI layer should first validate credentials + * and show a merge conflict dialog before calling this method. + * + * **Flow:** + * 1. Check if user is anonymous and upgrade is enabled + * 2. If yes: Link email/password credential to anonymous user + * 3. If no: Sign in with email/password + * 4. If credentialForLinking is provided: Link it to the account + * + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider + * @param existingUser Optional User to merge existing user profile + * @param email The email address of the user + * @param password The password for the account + * @param credentialForLinking Optional [AuthCredential] from a social provider (Google, + * Facebook) that should be linked to the account after sign-in. This is used when a user + * tries to sign in with a social provider but an email/password account with the same email + * already exists. + * + * @throws AuthException.InvalidCredentialsException if email or password is incorrect + * @throws AuthException.UserNotFoundException if no account exists with this email + * @throws AuthException.TooManyRequestsException if too many sign-in attempts + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * + * **Old library reference:** + * - WelcomeBackPasswordHandler.java:96-117 (normal sign-in with credential linking) + * - WelcomeBackPasswordHandler.java:106-108 (linkWithCredential call) + * - WelcomeBackPasswordPrompt.java:183 (UI calling this method) + */ +internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( + config: AuthUIConfiguration, + provider: AuthProvider.Email, + existingUser: User? = null, + email: String, + password: String, + credentialForLinking: AuthCredential? = null, +) { + try { + updateAuthState(AuthState.Loading("Signing in...")) + + if (AuthProvider.canUpgradeAnonymous(config, auth)) { + // Link email/password credential to anonymous user + val credentialToValidate = EmailAuthProvider + .getCredential(email, password) + + val isSocialProvider = provider.providerId in listOf( + Provider.GOOGLE.id, + Provider.FACEBOOK.id, + ) + + // Like scratch auth, this is used to avoid losing the anonymous user state in + // the main auth instance + val clonedAuth = FirebaseAuth + .getInstance(FirebaseApp.getInstance(app.name)) + + // Safe Link + // Add the provider to the same account before triggering a merge failure. + val credentialValidationResult = clonedAuth + .signInWithCredential(credentialToValidate).await() + + // Check to see if we need to link (for social providers with the same email) + if (isSocialProvider && credentialForLinking != null) { + val linkResult = credentialValidationResult + .user?.linkWithCredential(credentialForLinking)?.await() + + if (linkResult?.user != null) { + // Update AuthState with a firebase auth merge failure + return updateAuthState(AuthState.MergeConflict(credentialToValidate)) + } + } else { + // The user has not tried to log in with a federated IDP containing the same email. + // In this case, we just need to verify that the credential they provided is valid. + // No linking is done for non-federated IDPs. + // A merge failure occurs because the account exists and the user is anonymous. + if (credentialValidationResult?.user != null) { + // Update AuthState with a firebase auth merge failure + return updateAuthState(AuthState.MergeConflict(credentialToValidate)) + } + } + } else { + // Normal sign-in + val result = auth.signInWithEmailAndPassword(email, password).await() + + // If there's a credential to link (e.g., from Google/Facebook), + // link it to the account after sign-in + if (credentialForLinking != null) { + result.user?.linkWithCredential(credentialForLinking)?.await() + + // Note: Profile info from social provider (displayName, photoUri) should be + // extracted by the UI layer and passed to provider.mergeProfile() + // For now, this is left to the UI implementation + AuthProvider.mergeProfile(auth, existingUser?.name, existingUser?.photoUri) + } + } + + updateAuthState(AuthState.Idle) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with email and password was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs in with a credential or links it to an existing anonymous user. + * + * **Flow:** + * 1. Check if user is anonymous and upgrade is enabled + * 2. If yes: Link credential to anonymous user + * 3. If no: Sign in with credential + * + * @param config The [AuthUIConfiguration] containing authentication settings + * @param credential The [AuthCredential] to use for authentication. Can be from any provider. + * + * @throws AuthException.InvalidCredentialsException if credential is invalid or expired + * @throws AuthException.EmailAlreadyInUseException if linking and email is already in use + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * + * **Old library reference:** + * - AuthOperationManager.java:76-84 (signInAndLinkWithCredential implementation) + * - EmailLinkSignInHandler.java:217 (calling this with email-link credential) + * - SocialProviderResponseHandler + * - PhoneProviderResponseHandler + */ +internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( + config: AuthUIConfiguration, + credential: AuthCredential +) { + try { + updateAuthState(AuthState.Loading("Signing in user...")) + if (AuthProvider.canUpgradeAnonymous(config, auth)) { + auth.currentUser?.linkWithCredential(credential)?.await() + } else { + auth.signInWithCredential(credential).await() + } + updateAuthState(AuthState.Idle) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in and link with credential was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Sends a passwordless sign-in link to the specified email address. + * + * This method initiates the email-link (passwordless) authentication flow by sending + * an email containing a magic link. The link includes session information for validation + * and security. + * + * **Flow:** + * 1. Generate unique session ID + * 2. Get anonymous user ID if upgrading + * 3. Enrich ActionCodeSettings with session data + * 4. Send email via Firebase Auth + * 5. Save session data to DataStore for later validation + * + * **After this method:** + * - User receives email with magic link + * - User clicks link → app opens via deep link + * - App calls [signInWithEmailLink] to complete sign-in + * + * @param context Android [Context] for DataStore access + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider The [AuthProvider.Email] configuration with [ActionCodeSettings] + * @param email The email address to send the sign-in link to + * + * @throws AuthException.InvalidCredentialsException if email is invalid + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws IllegalStateException if ActionCodeSettings is not configured + * + * **Old library reference:** + * - EmailLinkSendEmailHandler.java:26-55 (complete implementation) + * - EmailLinkSendEmailHandler.java:38-39 (session ID generation) + * - EmailLinkSendEmailHandler.java:47-48 (DataStore persistence) + * + * @see com.google.firebase.auth.FirebaseAuth.signInWithEmailLink + */ +internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Email, + email: String, +) { + try { + updateAuthState(AuthState.Loading("Sending sign in email link...")) + + // Get anonymousUserId if can upgrade anonymously else default to empty string. + // NOTE: check for empty string instead of null to validate anonymous user ID matches + // when sign in from email link + val anonymousUserId = + if (AuthProvider.canUpgradeAnonymous(config, auth)) (auth.currentUser?.uid + ?: "") else "" + + // Generate sessionId + val sessionId = + SessionUtils.generateRandomAlphaNumericString(AuthProvider.Email.SESSION_ID_LENGTH) + + // Modify actionCodeSettings Url to include sessionId, anonymousUserId, force same + // device flag + val updatedActionCodeSettings = + provider.addSessionInfoToActionCodeSettings(sessionId, anonymousUserId) + + auth.sendSignInLinkToEmail(email, updatedActionCodeSettings).await() + + // Save Email to dataStore for use in signInWithEmailLink + EmailLinkPersistenceManager.saveEmail(context, email, sessionId, anonymousUserId) + + updateAuthState(AuthState.Idle) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Send sign in link to email was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs in a user using an email link (passwordless authentication). + * + * This method completes the email link sign-in flow after the user clicks the magic link + * sent to their email. It validates the link, extracts session information, and either + * signs in the user normally or upgrades an anonymous account based on configuration. + * + * **Flow:** + * 1. User receives email with magic link + * 2. User clicks link, app opens via deep link + * 3. Activity extracts emailLink from Intent.data + * 4. This method validates and completes sign-in + * + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider The [AuthProvider.Email] configuration with email-link settings + * @param email The email address of the user (retrieved from DataStore or user input) + * @param emailLink The complete deep link URL received from the Intent. + * + * This URL contains: + * - Firebase action code (oobCode) for authentication + * - Session ID (ui_sid) for same-device validation + * - Anonymous user ID (ui_auid) if upgrading anonymous account + * - Force same-device flag (ui_sd) for security enforcement + * + * Example: + * `https://yourapp.page.link/emailSignIn?oobCode=ABC123&continueUrl=...` + * + * @throws AuthException.InvalidCredentialsException if the email link is invalid or expired + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.UnknownException for other errors + * + * **Old library reference:** + * - EmailLinkSignInHandler.java:43-100 (complete validation and sign-in flow) + * - EmailLinkSignInHandler.java:53-56 (retrieve session from DataStore) + * - EmailLinkSignInHandler.java:58-63 (parse link using EmailLinkParser) + * - EmailLinkSignInHandler.java:65-85 (same-device validation) + * - EmailLinkSignInHandler.java:87-96 (anonymous user ID validation) + * - EmailLinkSignInHandler.java:217 (DataStore cleanup after success) + * + * @see sendSignInLinkToEmail for sending the initial email link + */ +internal suspend fun FirebaseAuthUI.signInWithEmailLink( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Email, + email: String, + emailLink: String, + existingUser: User? = null, +) { + try { + updateAuthState(AuthState.Loading("Signing in with email link...")) + + // Validate link format + if (!auth.isSignInWithEmailLink(emailLink)) { + return updateAuthState( + AuthState.Error( + AuthException.UnknownException("Invalid email link") + ) + ) + } + + // Parses email link for session data and returns sessionId, anonymousUserId, + // force same device flag etc. + val parser = EmailLinkParser(emailLink) + val sessionIdFromLink = parser.sessionId + val anonymousUserIdFromLink = parser.anonymousUserId + val oobCode = parser.oobCode + val providerIdFromLink = parser.providerId + val isEmailLinkForceSameDeviceEnabled = parser.forceSameDeviceBit + + // Retrieve stored session record from DataStore + val sessionRecord = EmailLinkPersistenceManager.retrieveSessionRecord(context) + val storedSessionId = sessionRecord?.sessionId + + // Validate same-device + when (provider.isDifferentDevice( + sessionIdFromLocal = storedSessionId, + sessionIdFromLink = sessionIdFromLink + )) { + true -> { + if (sessionIdFromLink.isNullOrEmpty()) { + return updateAuthState( + AuthState.Error( + AuthException.InvalidEmailLinkException() + ) + ) + } + + if (isEmailLinkForceSameDeviceEnabled + || !anonymousUserIdFromLink.isNullOrEmpty() + ) { + return updateAuthState( + AuthState.Error( + AuthException.EmailLinkWrongDeviceException() + ) + ) + } + + val actionCodeResult = auth.checkActionCode(oobCode).await() + if (actionCodeResult != null) { + if (providerIdFromLink.isNullOrEmpty()) { + return updateAuthState( + AuthState.Error( + AuthException.EmailLinkCrossDeviceLinkingException() + ) + ) + } + + return updateAuthState( + AuthState.Error( + AuthException.EmailLinkPromptForEmailException() + ) + ) + } + } + + false -> { + // Validate anonymous user ID matches + if (!anonymousUserIdFromLink.isNullOrEmpty()) { + val currentUser = auth.currentUser + if (currentUser == null + || !currentUser.isAnonymous + || currentUser.uid != anonymousUserIdFromLink + ) { + return updateAuthState( + AuthState.Error( + AuthException + .EmailLinkDifferentAnonymousUserException() + ) + ) + } + } + + if (email.isEmpty()) { + return updateAuthState( + AuthState.Error( + AuthException.EmailMismatchException() + ) + ) + } + + // Get credential for linking from session record (already retrieved earlier) + val storedCredentialForLink = sessionRecord?.credentialForLinking + + if (storedCredentialForLink == null) { + // Normal Flow + // Create credential and sign in + val emailLinkCredential = + EmailAuthProvider.getCredentialWithLink(email, emailLink) + signInAndLinkWithCredential(config, emailLinkCredential) + } else { + // Linking Flow + // Sign in with email link first, then link the social credential + val emailLinkCredential = + EmailAuthProvider.getCredentialWithLink(email, emailLink) + + if (AuthProvider.canUpgradeAnonymous(config, auth)) { + // Like scratch auth, this is used to avoid losing the anonymous user state in + // the main auth instance + val clonedAuth = FirebaseAuth + .getInstance(FirebaseApp.getInstance(app.name)) + + // Safe Link + // Add the provider to the same account before triggering a merge failure. + val authResult = clonedAuth + .signInWithCredential(emailLinkCredential).await() + if (authResult?.user != null) { + val linkResult = authResult + .user?.linkWithCredential(emailLinkCredential)?.await() + if (linkResult?.user != null) { + // Update AuthState with a firebase auth merge failure + return updateAuthState( + AuthState.MergeConflict( + emailLinkCredential + ) + ) + } + } + } else { + // Sign in with email link + val authResult = auth.signInWithCredential(emailLinkCredential).await() + + // Link the social credential + authResult.user?.linkWithCredential(storedCredentialForLink)?.await() + AuthProvider.mergeProfile( + auth, + existingUser?.name, + existingUser?.photoUri + ) + } + } + + // Clear DataStore after success + EmailLinkPersistenceManager.clear(context) + } + } + updateAuthState(AuthState.Idle) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with email link was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProviderSample.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProviderSample.kt index af0c830cc..c0beeaec9 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProviderSample.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProviderSample.kt @@ -15,7 +15,7 @@ package com.firebase.ui.auth.compose.configuration.string_provider import android.content.Context -import com.firebase.ui.auth.compose.configuration.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration import com.firebase.ui.auth.compose.configuration.authUIConfiguration diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt index 7f053fbd3..ec5bbdd53 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt @@ -16,7 +16,7 @@ package com.firebase.ui.auth.compose.configuration.theme import androidx.compose.ui.graphics.Color import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.configuration.Provider +import com.firebase.ui.auth.compose.configuration.auth_provider.Provider /** * Default provider styling configurations for authentication providers. diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt index e23d98fc7..c1dcc5401 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt @@ -24,8 +24,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.compose.configuration.AuthProvider -import com.firebase.ui.auth.compose.configuration.Provider +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.Provider import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt index 52906fd30..fb165d549 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.configuration.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset import com.firebase.ui.auth.compose.ui.components.AuthProviderButton diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/util/EmailLinkPersistenceManager.kt b/auth/src/main/java/com/firebase/ui/auth/compose/util/EmailLinkPersistenceManager.kt new file mode 100644 index 000000000..47b5e9e1e --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/util/EmailLinkPersistenceManager.kt @@ -0,0 +1,178 @@ +/* + * 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.util + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.FacebookAuthProvider +import com.google.firebase.auth.GoogleAuthProvider +import kotlinx.coroutines.flow.first + +private val Context.dataStore: DataStore by preferencesDataStore(name = "com.firebase.ui.auth.util.data.EmailLinkPersistenceManager") + +/** + * Manages saving/retrieving from DataStore for email link sign in. + * + * This class provides persistence for email link authentication sessions, including: + * - Email address + * - Session ID for same-device validation + * - Anonymous user ID for upgrade flows + * - Social provider credentials for linking flows + * + * **Old library reference:** + * - EmailLinkPersistenceManager.java (complete implementation) + * + * @since 10.0.0 + */ +object EmailLinkPersistenceManager { + + /** + * Saves email and session information to DataStore for email link sign-in. + * + * **Old library reference:** + * - EmailLinkPersistenceManager.java:47-59 (saveEmail method) + * + * @param context Android context for DataStore access + * @param email Email address to save + * @param sessionId Unique session identifier for same-device validation + * @param anonymousUserId Optional anonymous user ID for upgrade flows + */ + suspend fun saveEmail( + context: Context, + email: String, + sessionId: String, + anonymousUserId: String? + ) { + context.dataStore.edit { prefs -> + prefs[AuthProvider.Email.KEY_EMAIL] = email + prefs[AuthProvider.Email.KEY_SESSION_ID] = sessionId + prefs[AuthProvider.Email.KEY_ANONYMOUS_USER_ID] = anonymousUserId ?: "" + } + } + + /** + * Saves social provider credential information to DataStore for linking after email link sign-in. + * + * This is called when a user attempts to sign in with a social provider (Google/Facebook) + * but an email link account with the same email already exists. The credential is saved + * and will be linked after the user completes email link authentication. + * + * **Old library reference:** + * - EmailLinkPersistenceManager.java:61-80 (saveIdpResponseForLinking method) + * - SocialProviderResponseHandler.java:144-152 (caller - redirects to email link flow) + * - EmailActivity.java:92-93 (caller - saves credential before showing email link UI) + * + * @param context Android context for DataStore access + * @param providerType Provider ID ("google.com", "facebook.com", etc.) + * @param idToken ID token from the provider + * @param accessToken Access token from the provider (optional, used by Facebook) + */ + suspend fun saveCredentialForLinking( + context: Context, + providerType: String, + idToken: String?, + accessToken: String? + ) { + context.dataStore.edit { prefs -> + prefs[AuthProvider.Email.KEY_PROVIDER] = providerType + prefs[AuthProvider.Email.KEY_IDP_TOKEN] = idToken ?: "" + prefs[AuthProvider.Email.KEY_IDP_SECRET] = accessToken ?: "" + } + } + + /** + * Retrieves session information from DataStore. + * + * **Old library reference:** + * - EmailLinkPersistenceManager.java:82-110 (retrieveSessionRecord method) + * + * @param context Android context for DataStore access + * @return SessionRecord containing saved session data, or null if no session exists + */ + suspend fun retrieveSessionRecord(context: Context): SessionRecord? { + val prefs = context.dataStore.data.first() + val email = prefs[AuthProvider.Email.KEY_EMAIL] + val sessionId = prefs[AuthProvider.Email.KEY_SESSION_ID] + + if (email == null || sessionId == null) { + return null + } + + val anonymousUserId = prefs[AuthProvider.Email.KEY_ANONYMOUS_USER_ID] + val providerType = prefs[AuthProvider.Email.KEY_PROVIDER] + val idToken = prefs[AuthProvider.Email.KEY_IDP_TOKEN] + val accessToken = prefs[AuthProvider.Email.KEY_IDP_SECRET] + + // Rebuild credential if we have provider data + val credentialForLinking = if (providerType != null && idToken != null) { + when (providerType) { + "google.com" -> GoogleAuthProvider.getCredential(idToken, accessToken) + "facebook.com" -> FacebookAuthProvider.getCredential(accessToken ?: "") + else -> null + } + } else { + null + } + + return SessionRecord( + sessionId = sessionId, + email = email, + anonymousUserId = anonymousUserId, + credentialForLinking = credentialForLinking + ) + } + + /** + * Clears all saved data from DataStore. + * + * **Old library reference:** + * - EmailLinkPersistenceManager.java:112-121 (clearAllData method) + * + * @param context Android context for DataStore access + */ + suspend fun clear(context: Context) { + context.dataStore.edit { prefs -> + prefs.remove(AuthProvider.Email.KEY_SESSION_ID) + prefs.remove(AuthProvider.Email.KEY_EMAIL) + prefs.remove(AuthProvider.Email.KEY_ANONYMOUS_USER_ID) + prefs.remove(AuthProvider.Email.KEY_PROVIDER) + prefs.remove(AuthProvider.Email.KEY_IDP_TOKEN) + prefs.remove(AuthProvider.Email.KEY_IDP_SECRET) + } + } + + /** + * Holds the necessary information to complete the email link sign in flow. + * + * **Old library reference:** + * - EmailLinkPersistenceManager.SessionRecord (lines 123-164) + * + * @property sessionId Unique session identifier for same-device validation + * @property email Email address for sign-in + * @property anonymousUserId Optional anonymous user ID for upgrade flows + * @property credentialForLinking Optional social provider credential to link after sign-in + */ + data class SessionRecord( + val sessionId: String, + val email: String, + val anonymousUserId: String?, + val credentialForLinking: AuthCredential? + ) +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt index 7e9fc221e..3ace65f8a 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt @@ -16,8 +16,9 @@ package com.firebase.ui.auth.compose import android.content.Context import androidx.test.core.app.ApplicationProvider -import com.firebase.ui.auth.compose.configuration.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.createOrLinkUserWithEmailAndPassword import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseException @@ -45,6 +46,9 @@ import org.mockito.Mockito.doNothing import org.mockito.Mockito.doThrow import org.mockito.Mockito.mockStatic import org.mockito.MockitoAnnotations +import org.mockito.kotlin.atMost +import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -354,7 +358,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform sign out instance.signOut(context) @@ -372,7 +376,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform sign out and expect exception try { @@ -393,7 +397,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform sign out and expect cancellation exception try { @@ -422,7 +426,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform delete instance.delete(context) @@ -439,7 +443,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform delete and expect exception try { @@ -467,7 +471,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform delete and expect mapped exception try { @@ -493,7 +497,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform delete and expect cancellation exception try { @@ -519,7 +523,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform delete and expect mapped exception try { @@ -530,102 +534,4 @@ class FirebaseAuthUITest { assertThat(e.cause).isEqualTo(networkException) } } - - // ============================================================================================= - // Email Provider Tests - // ============================================================================================= - - @Test - fun `Create user with email and password without anonymous upgrade should succeed`() = - runTest { - val applicationContext = ApplicationProvider.getApplicationContext() - val mockUser = mock(FirebaseUser::class.java) - `when`(mockUser.email).thenReturn("test@example.com") - `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser) - val taskCompletionSource = TaskCompletionSource() - taskCompletionSource.setResult(null) - `when`(mockFirebaseAuth.createUserWithEmailAndPassword("test@example.com", "Pass@123")) - .thenReturn(taskCompletionSource.task) - - val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - val emailProvider = AuthProvider.Email( - actionCodeSettings = null, - passwordValidationRules = emptyList() - ) - val config = authUIConfiguration { - context = applicationContext - providers { - provider(emailProvider) - } - } - - instance.createOrLinkUserWithEmailAndPassword( - config = config, - provider = emailProvider, - email = "test@example.com", - password = "Pass@123" - ) - - verify(mockFirebaseAuth) - .createUserWithEmailAndPassword("test@example.com", "Pass@123") - - val authState = instance.authStateFlow().first() - assertThat(authState) - .isEqualTo(AuthState.Success(result = null, user = mockUser)) - val successState = authState as AuthState.Success - assertThat(successState.user.email).isEqualTo("test@example.com") - } - - @Test - fun `Link user with email and password with anonymous upgrade should succeed`() = runTest { - mockStatic(EmailAuthProvider::class.java).use { mockedProvider -> - val applicationContext = ApplicationProvider.getApplicationContext() - val mockCredential = mock(AuthCredential::class.java) - mockedProvider.`when` { - EmailAuthProvider.getCredential("test@example.com", "Pass@123") - }.thenReturn(mockCredential) - val mockAnonymousUser = mock(FirebaseUser::class.java) - `when`(mockAnonymousUser.email).thenReturn("test@example.com") - `when`(mockAnonymousUser.isAnonymous).thenReturn(true) - `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) - val taskCompletionSource = TaskCompletionSource() - taskCompletionSource.setResult(null) - `when`( - mockFirebaseAuth.currentUser?.linkWithCredential( - ArgumentMatchers.any(AuthCredential::class.java) - ) - ).thenReturn(taskCompletionSource.task) - val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - val emailProvider = AuthProvider.Email( - actionCodeSettings = null, - passwordValidationRules = emptyList() - ) - val config = authUIConfiguration { - context = applicationContext - providers { - provider(emailProvider) - } - isAnonymousUpgradeEnabled = true - } - - instance.createOrLinkUserWithEmailAndPassword( - config = config, - provider = emailProvider, - email = "test@example.com", - password = "Pass@123" - ) - - mockedProvider.verify { - EmailAuthProvider.getCredential("test@example.com", "Pass@123") - } - // Verify linkWithCredential was called with the mock credential - verify(mockAnonymousUser).linkWithCredential(mockCredential) - - val authState = instance.authStateFlow().first() - assertThat(authState) - .isEqualTo(AuthState.Success(result = null, user = mockAnonymousUser)) - val successState = authState as AuthState.Success - assertThat(successState.user.email).isEqualTo("test@example.com") - } - } } \ 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 f08be227f..31045ba13 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,6 +20,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt similarity index 95% rename from auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt rename to auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt index c473867c4..3e6ab28ca 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt @@ -1,18 +1,4 @@ -/* - * 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 +package com.firebase.ui.auth.compose.configuration.auth_provider import android.content.Context import androidx.test.core.app.ApplicationProvider diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt new file mode 100644 index 000000000..8cf25de1d --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt @@ -0,0 +1,691 @@ +/* + * 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.auth_provider + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.ActionCodeSettings +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.GoogleAuthProvider +import com.google.android.gms.tasks.TaskCompletionSource +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.atMost +import org.mockito.kotlin.never +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for email provider extension methods on [FirebaseAuthUI]. + * + * Tests cover: + * - createOrLinkUserWithEmailAndPassword + * - signInWithEmailAndPassword + * - signInAndLinkWithCredential + * - sendSignInLinkToEmail + * - signInWithEmailLink + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class EmailAuthProviderFirebaseAuthUITest { + + @Mock + private lateinit var mockFirebaseAuth: FirebaseAuth + + private lateinit var applicationContext: Context + private lateinit var defaultApp: FirebaseApp + private lateinit var emailProvider: AuthProvider.Email + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + applicationContext = ApplicationProvider.getApplicationContext() + + // Clear the instance cache + FirebaseAuthUI.clearInstanceCache() + + // Clear any existing Firebase apps + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + // Initialize default FirebaseApp + defaultApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + + // Create email provider + emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + try { + defaultApp.delete() + } catch (_: Exception) { + // Ignore + } + } + + // ============================================================================================= + // createOrLinkUserWithEmailAndPassword Tests + // ============================================================================================= + + @Test + fun `createOrLinkUserWithEmailAndPassword creates new user when not anonymous`() = runBlocking { + // Setup + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.email).thenReturn("test@example.com") + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + val taskCompletionSource = TaskCompletionSource() + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + taskCompletionSource.setResult(mockAuthResult) + + `when`(mockFirebaseAuth.createUserWithEmailAndPassword("test@example.com", "Password123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider) } + } + + // Collect states + val states = mutableListOf() + val job = launch { + instance.authStateFlow().collect { state -> + states.add(state) + } + } + + delay(100) // Allow initial state to be collected + + // Execute + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + email = "test@example.com", + password = "Password123", + newUser = null + ) + + delay(200) // Allow state updates to propagate + job.cancel() + + // Verify method calls + verify(mockFirebaseAuth, atMost(1)) + .createUserWithEmailAndPassword("test@example.com", "Password123") + + // Verify state transitions + assertThat(states.size).isAtLeast(3) + assertThat(states[0]).isEqualTo(AuthState.Idle) // Initial + assertThat(states[1]).isInstanceOf(AuthState.Loading::class.java) + val loadingState = states[1] as AuthState.Loading + assertThat(loadingState.message).isEqualTo("Creating user...") + assertThat(states[2]).isEqualTo(AuthState.Idle) // After completion + } + + @Test + fun `createOrLinkUserWithEmailAndPassword links credential when anonymous upgrade enabled`() = + runTest { + mockStatic(EmailAuthProvider::class.java).use { mockedProvider -> + // Setup + val mockCredential = mock(AuthCredential::class.java) + mockedProvider.`when` { + EmailAuthProvider.getCredential("test@example.com", "Password123") + }.thenReturn(mockCredential) + + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockAnonymousUser.email).thenReturn("test@example.com") + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + + val taskCompletionSource = TaskCompletionSource() + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockAnonymousUser) + taskCompletionSource.setResult(mockAuthResult) + + `when`(mockAnonymousUser.linkWithCredential(any(AuthCredential::class.java))) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider) } + isAnonymousUpgradeEnabled = true + } + + // Execute + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + email = "test@example.com", + password = "Password123", + newUser = null + ) + + // Verify + mockedProvider.verify { + EmailAuthProvider.getCredential("test@example.com", "Password123") + } + verify(mockFirebaseAuth, never()) + .createUserWithEmailAndPassword(any(), any()) + verify(mockAnonymousUser, atMost(1)) + .linkWithCredential(mockCredential) + } + } + + @Test + fun `createOrLinkUserWithEmailAndPassword throws exception for weak password`() = runTest { + val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider) } + } + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + email = "test@example.com", + password = "weak", + newUser = null + ) + assertThat(false).isTrue() + } catch (e: AuthException.InvalidCredentialsException) { + assertThat(e.message).isEqualTo(applicationContext.getString(R.string.fui_error_password_too_short).format(6)) + } + } + + // ============================================================================================= + // signInWithEmailAndPassword Tests + // ============================================================================================= + + @Test + fun `signInWithEmailAndPassword signs in user normally when not anonymous`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.email).thenReturn("test@example.com") + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + val taskCompletionSource = TaskCompletionSource() + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Password123")) + .thenReturn(taskCompletionSource.task) + val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider) } + } + + instance.signInWithEmailAndPassword( + config = config, + provider = emailProvider, + email = "test@example.com", + password = "Password123", + credentialForLinking = null, + existingUser = null + ) + + verify(mockFirebaseAuth, atMost(1)) + .signInWithEmailAndPassword("test@example.com", "Password123") + } + + @Test + fun `signInWithEmailAndPassword links social credential when provided`() = runTest { + // Setup + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.email).thenReturn("test@example.com") + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + val taskCompletionSource = TaskCompletionSource() + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + taskCompletionSource.setResult(mockAuthResult) + + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Password123")) + .thenReturn(taskCompletionSource.task) + + // Mock social credential linking + val mockSocialCredential = mock(AuthCredential::class.java) + val linkTaskCompletionSource = TaskCompletionSource() + linkTaskCompletionSource.setResult(mockAuthResult) + `when`(mockUser.linkWithCredential(mockSocialCredential)) + .thenReturn(linkTaskCompletionSource.task) + + val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider) } + } + + // Execute + instance.signInWithEmailAndPassword( + config = config, + provider = emailProvider, + email = "test@example.com", + password = "Password123", + credentialForLinking = mockSocialCredential, + existingUser = null + ) + + // Verify + verify(mockFirebaseAuth, atMost(1)) + .signInWithEmailAndPassword("test@example.com", "Password123") + verify(mockUser, atMost(1)) + .linkWithCredential(mockSocialCredential) + } + + // ============================================================================================= + // signInAndLinkWithCredential Tests + // ============================================================================================= + + @Test + fun `signInAndLinkWithCredential signs in when not anonymous`() = runTest { + // Setup + val mockCredential = mock(AuthCredential::class.java) + val mockUser = mock(FirebaseUser::class.java) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + val taskCompletionSource = TaskCompletionSource() + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + taskCompletionSource.setResult(mockAuthResult) + + `when`(mockFirebaseAuth.signInWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider) } + } + + // Execute + instance.signInAndLinkWithCredential(config, mockCredential) + + // Verify + verify(mockFirebaseAuth, atMost(1)) + .signInWithCredential(mockCredential) + } + + @Test + fun `signInAndLinkWithCredential links credential when anonymous upgrade enabled`() = runTest { + // Setup + val mockCredential = mock(AuthCredential::class.java) + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + + val taskCompletionSource = TaskCompletionSource() + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockAnonymousUser) + taskCompletionSource.setResult(mockAuthResult) + + `when`(mockAnonymousUser.linkWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider) } + isAnonymousUpgradeEnabled = true + } + + // Execute + instance.signInAndLinkWithCredential(config, mockCredential) + + // Verify + verify(mockFirebaseAuth, never()) + .signInWithCredential(any(AuthCredential::class.java)) + verify(mockAnonymousUser, atMost(1)) + .linkWithCredential(mockCredential) + } + + // ============================================================================================= + // sendSignInLinkToEmail Tests + // ============================================================================================= + + @Test + fun `sendSignInLinkToEmail sends email and saves session to DataStore`() = runTest { + // Setup + val actionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com/emailSignIn") + .setHandleCodeInApp(true) + .build() + + val emailProviderWithSettings = AuthProvider.Email( + actionCodeSettings = actionCodeSettings, + passwordValidationRules = emptyList() + ) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`(mockFirebaseAuth.sendSignInLinkToEmail(any(), any())) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProviderWithSettings) } + } + + // Execute + instance.sendSignInLinkToEmail( + context = applicationContext, + config = config, + provider = emailProviderWithSettings, + email = "test@example.com" + ) + + // Verify + verify(mockFirebaseAuth, atMost(1)) + .sendSignInLinkToEmail(any(), any()) + + // Verify DataStore was saved + val sessionRecord = EmailLinkPersistenceManager.retrieveSessionRecord(applicationContext) + assertThat(sessionRecord).isNotNull() + assertThat(sessionRecord?.email).isEqualTo("test@example.com") + assertThat(sessionRecord?.sessionId).isNotEmpty() + } + + @Test + fun `sendSignInLinkToEmail saves anonymous user ID when upgrade enabled`() = runTest { + // Setup + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockAnonymousUser.uid).thenReturn("anonymous-uid-123") + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + + val actionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com/emailSignIn") + .setHandleCodeInApp(true) + .build() + + val emailProviderWithSettings = AuthProvider.Email( + actionCodeSettings = actionCodeSettings, + passwordValidationRules = emptyList() + ) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + + `when`(mockFirebaseAuth.sendSignInLinkToEmail(any(), any())) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProviderWithSettings) } + isAnonymousUpgradeEnabled = true + } + + // Execute + instance.sendSignInLinkToEmail( + context = applicationContext, + config = config, + provider = emailProviderWithSettings, + email = "test@example.com" + ) + + // Verify + val sessionRecord = EmailLinkPersistenceManager.retrieveSessionRecord(applicationContext) + assertThat(sessionRecord?.anonymousUserId).isEqualTo("anonymous-uid-123") + } + + // ============================================================================================= + // signInWithEmailLink Tests + // ============================================================================================= + + @Test + fun `signInWithEmailLink completes normal sign-in flow`() = runTest { + mockStatic(EmailAuthProvider::class.java).use { mockedProvider -> + // Setup + val emailLink = "https://example.com/emailSignIn?oobCode=ABC123&ui_sid=session123" + + `when`(mockFirebaseAuth.isSignInWithEmailLink(emailLink)).thenReturn(true) + + // Save session to DataStore + EmailLinkPersistenceManager.saveEmail( + context = applicationContext, + email = "test@example.com", + sessionId = "session123", + anonymousUserId = null + ) + + val mockCredential = mock(AuthCredential::class.java) + mockedProvider.`when` { + EmailAuthProvider.getCredentialWithLink("test@example.com", emailLink) + }.thenReturn(mockCredential) + + val mockUser = mock(FirebaseUser::class.java) + val taskCompletionSource = TaskCompletionSource() + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + taskCompletionSource.setResult(mockAuthResult) + + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`(mockFirebaseAuth.signInWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + + val actionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com/emailSignIn") + .setHandleCodeInApp(true) + .build() + + val emailProviderWithSettings = AuthProvider.Email( + actionCodeSettings = actionCodeSettings, + passwordValidationRules = emptyList() + ) + + val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProviderWithSettings) } + } + + // Execute + instance.signInWithEmailLink( + context = applicationContext, + config = config, + provider = emailProviderWithSettings, + email = "test@example.com", + emailLink = emailLink, + existingUser = null + ) + + // Verify + verify(mockFirebaseAuth, atMost(1)) + .signInWithCredential(mockCredential) + + // Verify DataStore was cleared + val sessionRecord = + EmailLinkPersistenceManager.retrieveSessionRecord(applicationContext) + assertThat(sessionRecord).isNull() + } + } + + @Test + fun `signInWithEmailLink links social credential when stored`() = runTest { + mockStatic(EmailAuthProvider::class.java).use { mockedEmailProvider -> + mockStatic(GoogleAuthProvider::class.java).use { mockedGoogleProvider -> + // Setup + val emailLink = "https://example.com/emailSignIn?oobCode=ABC123&ui_sid=session123" + + `when`(mockFirebaseAuth.isSignInWithEmailLink(emailLink)).thenReturn(true) + + // Save session with Google credential to DataStore + EmailLinkPersistenceManager.saveEmail( + context = applicationContext, + email = "test@example.com", + sessionId = "session123", + anonymousUserId = null + ) + EmailLinkPersistenceManager.saveCredentialForLinking( + context = applicationContext, + providerType = "google.com", + idToken = "google-id-token", + accessToken = null + ) + + val mockEmailCredential = mock(AuthCredential::class.java) + mockedEmailProvider.`when` { + EmailAuthProvider.getCredentialWithLink("test@example.com", emailLink) + }.thenReturn(mockEmailCredential) + + val mockGoogleCredential = mock(AuthCredential::class.java) + mockedGoogleProvider.`when` { + GoogleAuthProvider.getCredential("google-id-token", null) + }.thenReturn(mockGoogleCredential) + + val mockUser = mock(FirebaseUser::class.java) + val taskCompletionSource = TaskCompletionSource() + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + taskCompletionSource.setResult(mockAuthResult) + + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`(mockFirebaseAuth.signInWithCredential(mockEmailCredential)) + .thenReturn(taskCompletionSource.task) + + val linkTaskCompletionSource = TaskCompletionSource() + linkTaskCompletionSource.setResult(mockAuthResult) + `when`(mockUser.linkWithCredential(mockGoogleCredential)) + .thenReturn(linkTaskCompletionSource.task) + + val actionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com/emailSignIn") + .setHandleCodeInApp(true) + .build() + + val emailProviderWithSettings = AuthProvider.Email( + actionCodeSettings = actionCodeSettings, + passwordValidationRules = emptyList() + ) + + val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProviderWithSettings) } + } + + // Execute + instance.signInWithEmailLink( + context = applicationContext, + config = config, + provider = emailProviderWithSettings, + email = "test@example.com", + emailLink = emailLink, + existingUser = null + ) + + // Verify + verify(mockFirebaseAuth, atMost(1)) + .signInWithCredential(mockEmailCredential) + verify(mockUser, atMost(1)) + .linkWithCredential(mockGoogleCredential) + + // Verify DataStore was cleared + val sessionRecord = + EmailLinkPersistenceManager.retrieveSessionRecord(applicationContext) + assertThat(sessionRecord).isNull() + } + } + } + + @Test + fun `signInWithEmailLink throws exception for invalid link`() = runTest { + // Setup + val emailLink = "https://example.com/invalid" + + `when`(mockFirebaseAuth.isSignInWithEmailLink(emailLink)).thenReturn(false) + + val actionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com/emailSignIn") + .setHandleCodeInApp(true) + .build() + + val emailProviderWithSettings = AuthProvider.Email( + actionCodeSettings = actionCodeSettings, + passwordValidationRules = emptyList() + ) + + val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProviderWithSettings) } + } + + // Execute + instance.signInWithEmailLink( + context = applicationContext, + config = config, + provider = emailProviderWithSettings, + email = "test@example.com", + emailLink = emailLink, + existingUser = null + ) + + // Verify - method returns early with error state, so we just verify it was called + verify(mockFirebaseAuth, atMost(1)) + .isSignInWithEmailLink(emailLink) + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt index faae2cf48..610add7eb 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt @@ -27,8 +27,8 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.core.app.ApplicationProvider -import com.firebase.ui.auth.compose.configuration.AuthProvider -import com.firebase.ui.auth.compose.configuration.Provider +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.Provider import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt index 17d736ca7..2b500a924 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.configuration.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset import com.google.common.truth.Truth import org.junit.Before From b1d69e71454857b96915e418687919653116b53f Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Mon, 6 Oct 2025 02:11:21 +0100 Subject: [PATCH 13/43] wip: Email provider integration --- .../com/firebase/ui/auth/compose/AuthState.kt | 41 +- .../EmailAuthProvider+FirebaseAuthUI.kt | 828 ++++++++++++++---- .../EmailAuthProviderFirebaseAuthUITest.kt | 691 --------------- 3 files changed, 706 insertions(+), 854 deletions(-) delete mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt index b53cbafc4..c2f9d8a99 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt @@ -14,6 +14,7 @@ package com.firebase.ui.auth.compose +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseUser @@ -205,6 +206,44 @@ abstract class AuthState private constructor() { "AuthState.RequiresProfileCompletion(user=$user, missingFields=$missingFields)" } + /** + * The user needs to sign in with a different provider. + * + * Emitted when a user tries to sign up with an email that already exists + * and needs to use the existing provider to sign in instead. + * + * @property provider The [AuthProvider] the user should sign in with + * @property email The email address of the existing account + */ + class RequiresSignIn( + val provider: AuthProvider, + val email: String + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RequiresSignIn) return false + return provider == other.provider && + email == other.email + } + + override fun hashCode(): Int { + var result = provider.hashCode() + result = 31 * result + email.hashCode() + return result + } + + override fun toString(): String = + "AuthState.RequiresSignIn(provider=$provider, email=$email)" + } + + /** + * Pending credential for an anonymous upgrade merge conflict. + * + * Emitted when an anonymous user attempts to convert to a permanent account but + * Firebase detects that the target email already belongs to another user. The UI can + * prompt the user to resolve the conflict by signing in with the existing account and + * later linking the stored [pendingCredential]. + */ class MergeConflict( val pendingCredential: AuthCredential ) : AuthState() { @@ -239,4 +278,4 @@ abstract class AuthState private constructor() { @JvmStatic val Cancelled: Cancelled = Cancelled() } -} \ No newline at end of file +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index f0b08f123..035b18381 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -7,72 +7,175 @@ import com.firebase.ui.auth.compose.AuthState import com.firebase.ui.auth.compose.FirebaseAuthUI import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager -import com.firebase.ui.auth.data.model.User import com.firebase.ui.auth.util.data.EmailLinkParser import com.firebase.ui.auth.util.data.SessionUtils import com.google.firebase.FirebaseApp import com.google.firebase.auth.ActionCodeSettings import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthUserCollisionException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.tasks.await /** - * Creates a new user with email and password, or links to an existing anonymous user. + * Holds credential information for account linking with email link sign-in. * - * This method handles both new user creation and anonymous user upgrade scenarios. - * After successful account creation, it automatically updates the user's profile with - * the provided display name and photo URI. + * When a user tries to sign in with a social provider (Google, Facebook, etc.) but an + * email link account exists with that email, this data is used to link the accounts + * after email link authentication completes. + * + * @property providerType The provider ID (e.g., "google.com", "facebook.com") + * @property idToken The ID token from the provider (required for Google, optional for Facebook) + * @property accessToken The access token from the provider (required for Facebook, optional for Google) + */ +data class CredentialForLinking( + val providerType: String, + val idToken: String?, + val accessToken: String? +) + +/** + * Creates an email/password account or links the credential to an anonymous user. + * + * Mirrors the legacy email sign-up handler: validates password strength, validates custom + * password rules, checks if new accounts are allowed, chooses between + * `createUserWithEmailAndPassword` and `linkWithCredential`, merges the supplied display name + * into the Firebase profile, and emits [AuthState.MergeConflict] when anonymous upgrade + * encounters an existing account for the email. * * **Flow:** - * 1. Check if user is anonymous and upgrade is enabled - * 2. If yes: Link email/password credential to anonymous user - * 3. If no: Create new user with email/password - * 4. Update profile with display name and photo (if provided) + * 1. Check if new accounts are allowed (for non-upgrade flows) + * 2. Validate password length against [AuthProvider.Email.minimumPasswordLength] + * 3. Validate password against custom [AuthProvider.Email.passwordValidationRules] + * 4. If upgrading anonymous user: link credential to existing anonymous account + * 5. Otherwise: create new account with `createUserWithEmailAndPassword` + * 6. Merge display name into user profile * - * @param config The [AuthUIConfiguration] containing authentication settings - * @param provider - * @param newUser Optional User to merge existing user profile - * @param email The email address for the new account - * @param password The password for the new account + * @param context Android [Context] for localized strings + * @param config Auth UI configuration describing provider settings + * @param provider Email provider configuration + * @param name Optional display name collected during sign-up + * @param email Email address for the new account + * @param password Password for the new account * - * @throws AuthException.WeakPasswordException if password doesn't meet requirements - * @throws AuthException.InvalidCredentialsException if email is invalid - * @throws AuthException.EmailAlreadyInUseException if email is already registered - * @throws AuthException.AuthCancelledException if the operation is cancelled - * @throws AuthException.NetworkException if a network error occurs + * @return [AuthResult] containing the newly created or linked user, or null if failed + * + * @throws AuthException.UserNotFoundException if new accounts are not allowed + * @throws AuthException.WeakPasswordException if the password fails validation rules + * @throws AuthException.InvalidCredentialsException if the email or password is invalid + * @throws AuthException.EmailAlreadyInUseException if the email already exists + * @throws AuthException.AuthCancelledException if the coroutine is cancelled + * @throws AuthException.NetworkException for network-related failures + * + * **Example: Normal sign-up** + * ```kotlin + * try { + * val result = firebaseAuthUI.createOrLinkUserWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * name = "John Doe", + * email = "john@example.com", + * password = "SecurePass123!" + * ) + * // User account created successfully + * } catch (e: AuthException.WeakPasswordException) { + * // Password doesn't meet validation rules + * } catch (e: AuthException.EmailAlreadyInUseException) { + * // Email already exists - redirect to sign-in + * } + * ``` + * + * **Example: Anonymous user upgrade** + * ```kotlin + * // User is currently signed in anonymously + * try { + * val result = firebaseAuthUI.createOrLinkUserWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * name = "Jane Smith", + * email = "jane@example.com", + * password = "MyPassword456" + * ) + * // Anonymous account upgraded to permanent email/password account + * } catch (e: AuthException) { + * // Check if AuthState.MergeConflict was emitted + * // This means email already exists - show merge conflict UI + * } + * ``` * * **Old library reference:** - * - EmailProviderResponseHandler.java:55-58 (createOrLinkUserWithEmailAndPassword call) - * - EmailProviderResponseHandler.java:59 (ProfileMerger continuation) - * - AuthOperationManager.java:64-74 (implementation) - * - RegisterEmailFragment.java:279-285 (UI calling this method) + * - EmailProviderResponseHandler.java:42-84 (startSignIn implementation) + * - AuthOperationManager.java:64-74 (createOrLinkUserWithEmailAndPassword) + * - RegisterEmailFragment.java:270-287 (validation and triggering sign-up) + * - ProfileMerger.java:34-56 (profile merging after sign-up) */ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( context: Context, config: AuthUIConfiguration, provider: AuthProvider.Email, - newUser: User? = null, + name: String?, email: String, password: String -) { +): AuthResult? { + val canUpgrade = AuthProvider.canUpgradeAnonymous(config, auth) + val pendingCredential = + if (canUpgrade) EmailAuthProvider.getCredential(email, password) else null + try { + // Check if new accounts are allowed (only for non-upgrade flows) + if (!canUpgrade && !provider.isNewAccountsAllowed) { + throw AuthException.UserNotFoundException( + message = context.getString(R.string.fui_error_email_does_not_exist) + ) + } + + // Validate minimum password length if (password.length < provider.minimumPasswordLength) { throw AuthException.InvalidCredentialsException( message = context.getString(R.string.fui_error_password_too_short) .format(provider.minimumPasswordLength) ) } + + // Validate password against custom rules + for (rule in provider.passwordValidationRules) { + if (!rule.isValid(password)) { + throw AuthException.WeakPasswordException( + message = rule.getErrorMessage(config.stringProvider), + reason = "Password does not meet custom validation rules" + ) + } + } + updateAuthState(AuthState.Loading("Creating user...")) - if (AuthProvider.canUpgradeAnonymous(config, auth)) { - val credential = EmailAuthProvider.getCredential(email, password) - auth.currentUser?.linkWithCredential(credential)?.await() + val result = if (canUpgrade) { + auth.currentUser?.linkWithCredential(requireNotNull(pendingCredential))?.await() } else { auth.createUserWithEmailAndPassword(email, password).await() + }.also { authResult -> + authResult?.user?.let { + // Merge display name into profile (photoUri is always null for email/password) + AuthProvider.mergeProfile(auth, name, null) + } } - AuthProvider.mergeProfile(auth, newUser?.name, newUser?.photoUri) updateAuthState(AuthState.Idle) + return result + } catch (e: FirebaseAuthUserCollisionException) { + val authException = AuthException.from(e) + if (canUpgrade && pendingCredential != null) { + // Anonymous upgrade collision: emit merge conflict state + updateAuthState(AuthState.MergeConflict(pendingCredential)) + } else { + // Non-upgrade collision: user exists with this email + // TODO: Fetch top provider and emit AuthState.RequiresSignIn(provider, email) + // For now, just emit the error + updateAuthState(AuthState.Error(authException)) + } + throw authException } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Create or link user with email and password was cancelled", @@ -91,109 +194,168 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( } /** - * Signs in a user with email and password, optionally linking a social provider credential. + * Signs in a user with email and password, optionally linking a social credential. * - * This method handles normal sign-in and anonymous user upgrade scenarios. If a social - * provider credential (e.g., Google, Facebook) is provided, it will be linked to the - * account after successful sign-in. - * - * **For anonymous upgrade scenarios:** The UI layer should first validate credentials - * and show a merge conflict dialog before calling this method. + * This method handles both normal sign-in and anonymous upgrade flows. In anonymous upgrade + * scenarios, it validates credentials in a scratch auth instance before emitting a merge + * conflict state. * * **Flow:** - * 1. Check if user is anonymous and upgrade is enabled - * 2. If yes: Link email/password credential to anonymous user - * 3. If no: Sign in with email/password - * 4. If credentialForLinking is provided: Link it to the account + * 1. If anonymous upgrade: + * - Create scratch auth instance to validate credential + * - If linking social provider: sign in with email, then link social credential (safe link) + * - Otherwise: just validate email credential + * - Emit [AuthState.MergeConflict] after successful validation + * 2. If normal sign-in: + * - Sign in with email/password + * - If credential provided: link it and merge profile * - * @param config The [AuthUIConfiguration] containing authentication settings - * @param provider - * @param existingUser Optional User to merge existing user profile - * @param email The email address of the user - * @param password The password for the account - * @param credentialForLinking Optional [AuthCredential] from a social provider (Google, - * Facebook) that should be linked to the account after sign-in. This is used when a user - * tries to sign in with a social provider but an email/password account with the same email - * already exists. + * @param context Android [Context] for creating scratch auth instance + * @param config Auth UI configuration describing provider settings + * @param provider Email provider configuration (not used for provider detection) + * @param email Email address for sign-in + * @param password Password for sign-in + * @param credentialForLinking Optional social provider credential to link after sign-in + * + * @return [AuthResult] containing the signed-in user, or null if validation-only (anonymous upgrade) * * @throws AuthException.InvalidCredentialsException if email or password is incorrect - * @throws AuthException.UserNotFoundException if no account exists with this email - * @throws AuthException.TooManyRequestsException if too many sign-in attempts + * @throws AuthException.UserNotFoundException if the user doesn't exist + * @throws AuthException.UserDisabledException if the user account is disabled * @throws AuthException.AuthCancelledException if the operation is cancelled - * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.NetworkException for network-related failures + * + * **Example: Normal sign-in** + * ```kotlin + * try { + * val result = firebaseAuthUI.signInWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com", + * password = "password123" + * ) + * // User signed in successfully + * } catch (e: AuthException.InvalidCredentialsException) { + * // Wrong password + * } + * ``` + * + * **Example: Sign-in with social credential linking** + * ```kotlin + * // User tried to sign in with Google, but account exists with email/password + * // Prompt for password, then link Google credential + * val googleCredential = GoogleAuthProvider.getCredential(idToken, null) + * + * val result = firebaseAuthUI.signInWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com", + * password = "password123", + * credentialForLinking = googleCredential + * ) + * // User signed in with email/password AND Google is now linked + * // Profile updated with Google display name and photo + * ``` + * + * **Example: Anonymous upgrade validation** + * ```kotlin + * // User is anonymous, wants to upgrade with existing email/password account + * try { + * firebaseAuthUI.signInWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "existing@example.com", + * password = "password123" + * ) + * } catch (e: AuthException) { + * // AuthState.MergeConflict emitted + * // UI shows merge conflict resolution screen + * } + * ``` * * **Old library reference:** - * - WelcomeBackPasswordHandler.java:96-117 (normal sign-in with credential linking) - * - WelcomeBackPasswordHandler.java:106-108 (linkWithCredential call) - * - WelcomeBackPasswordPrompt.java:183 (UI calling this method) + * - WelcomeBackPasswordHandler.java:45-118 (startSignIn implementation) + * - AuthOperationManager.java:76-84 (signInAndLinkWithCredential) + * - AuthOperationManager.java:97-108 (safeLink for social providers) + * - AuthOperationManager.java:92-95 (validateCredential for email-only) */ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( + context: Context, config: AuthUIConfiguration, provider: AuthProvider.Email, - existingUser: User? = null, email: String, password: String, credentialForLinking: AuthCredential? = null, -) { +): AuthResult? { try { updateAuthState(AuthState.Loading("Signing in...")) + return if (AuthProvider.canUpgradeAnonymous(config, auth)) { + // Anonymous upgrade flow: validate credential in scratch auth + val credentialToValidate = EmailAuthProvider.getCredential(email, password) - if (AuthProvider.canUpgradeAnonymous(config, auth)) { - // Link email/password credential to anonymous user - val credentialToValidate = EmailAuthProvider - .getCredential(email, password) + // Check if we're linking a social provider credential + val isSocialProvider = credentialForLinking != null && + credentialForLinking.provider in listOf( + com.google.firebase.auth.GoogleAuthProvider.PROVIDER_ID, + com.google.firebase.auth.FacebookAuthProvider.PROVIDER_ID, + com.google.firebase.auth.TwitterAuthProvider.PROVIDER_ID, + com.google.firebase.auth.GithubAuthProvider.PROVIDER_ID + ) - val isSocialProvider = provider.providerId in listOf( - Provider.GOOGLE.id, - Provider.FACEBOOK.id, + // Create scratch auth instance to avoid losing anonymous user state + val appExplicitlyForValidation = FirebaseApp.initializeApp( + context, + auth.app.options, + "FUIAuthScratchApp_${System.currentTimeMillis()}" ) + val authExplicitlyForValidation = FirebaseAuth + .getInstance(appExplicitlyForValidation) - // Like scratch auth, this is used to avoid losing the anonymous user state in - // the main auth instance - val clonedAuth = FirebaseAuth - .getInstance(FirebaseApp.getInstance(app.name)) - - // Safe Link - // Add the provider to the same account before triggering a merge failure. - val credentialValidationResult = clonedAuth - .signInWithCredential(credentialToValidate).await() - - // Check to see if we need to link (for social providers with the same email) - if (isSocialProvider && credentialForLinking != null) { - val linkResult = credentialValidationResult - .user?.linkWithCredential(credentialForLinking)?.await() - - if (linkResult?.user != null) { - // Update AuthState with a firebase auth merge failure - return updateAuthState(AuthState.MergeConflict(credentialToValidate)) - } + if (isSocialProvider) { + // Safe link: sign in with email, then link social credential + authExplicitlyForValidation + .signInWithCredential(credentialToValidate).await() + .user?.linkWithCredential(credentialForLinking!!)?.await() + .also { + // Emit merge conflict after successful validation + updateAuthState(AuthState.MergeConflict(credentialToValidate)) + } } else { - // The user has not tried to log in with a federated IDP containing the same email. - // In this case, we just need to verify that the credential they provided is valid. - // No linking is done for non-federated IDPs. - // A merge failure occurs because the account exists and the user is anonymous. - if (credentialValidationResult?.user != null) { - // Update AuthState with a firebase auth merge failure - return updateAuthState(AuthState.MergeConflict(credentialToValidate)) - } + // Just validate the email credential + // No linking for non-federated IDPs + authExplicitlyForValidation + .signInWithCredential(credentialToValidate).await() + .also { + // Emit merge conflict after successful validation + // Merge failure occurs because account exists and user is anonymous + updateAuthState(AuthState.MergeConflict(credentialToValidate)) + } } } else { // Normal sign-in - val result = auth.signInWithEmailAndPassword(email, password).await() - - // If there's a credential to link (e.g., from Google/Facebook), - // link it to the account after sign-in - if (credentialForLinking != null) { - result.user?.linkWithCredential(credentialForLinking)?.await() - - // Note: Profile info from social provider (displayName, photoUri) should be - // extracted by the UI layer and passed to provider.mergeProfile() - // For now, this is left to the UI implementation - AuthProvider.mergeProfile(auth, existingUser?.name, existingUser?.photoUri) - } + auth.signInWithEmailAndPassword(email, password).await() + .also { result -> + // If there's a credential to link, link it after sign-in + if (credentialForLinking != null) { + return result.user?.linkWithCredential(credentialForLinking)?.await() + .also { linkResult -> + // Merge profile from social provider + linkResult?.user?.let { user -> + AuthProvider.mergeProfile( + auth, + user.displayName, + user.photoUrl + ) + } + } + } + } + }.also { + updateAuthState(AuthState.Idle) } - - updateAuthState(AuthState.Idle) } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in with email and password was cancelled", @@ -214,37 +376,130 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( /** * Signs in with a credential or links it to an existing anonymous user. * + * This method handles both normal sign-in and anonymous upgrade flows. After successful + * authentication, it merges profile information (display name and photo URL) into the + * Firebase user profile if provided. + * * **Flow:** * 1. Check if user is anonymous and upgrade is enabled * 2. If yes: Link credential to anonymous user * 3. If no: Sign in with credential + * 4. Merge profile information (name, photo) into Firebase user + * 5. Handle collision exceptions by emitting [AuthState.MergeConflict] * * @param config The [AuthUIConfiguration] containing authentication settings * @param credential The [AuthCredential] to use for authentication. Can be from any provider. + * @param displayName Optional display name from the provider to merge into the user profile + * @param photoUrl Optional photo URL from the provider to merge into the user profile + * + * @return [AuthResult] containing the authenticated user * * @throws AuthException.InvalidCredentialsException if credential is invalid or expired * @throws AuthException.EmailAlreadyInUseException if linking and email is already in use * @throws AuthException.AuthCancelledException if the operation is cancelled * @throws AuthException.NetworkException if a network error occurs * + * **Example: Google Sign-In** + * ```kotlin + * val googleCredential = GoogleAuthProvider.getCredential(idToken, null) + * val displayName = "John Doe" // From Google profile + * val photoUrl = Uri.parse("https://...") // From Google profile + * + * val result = firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = googleCredential, + * displayName = displayName, + * photoUrl = photoUrl + * ) + * // User signed in with Google AND profile updated with Google data + * ``` + * + * **Example: Phone Auth** + * ```kotlin + * val phoneCredential = PhoneAuthProvider.getCredential(verificationId, code) + * + * val result = firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = phoneCredential + * ) + * // User signed in with phone number + * ``` + * + * **Example: Phone Auth with Collision (Anonymous Upgrade)** + * ```kotlin + * // User is currently anonymous, trying to link a phone number + * val phoneCredential = PhoneAuthProvider.getCredential(verificationId, code) + * + * try { + * firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = phoneCredential + * ) + * } catch (e: FirebaseAuthUserCollisionException) { + * // Phone number already exists on another account + * // AuthState.MergeConflict emitted with updatedCredential + * // UI can show merge conflict resolution screen + * } + * ``` + * + * **Example: Email Link Sign-In** + * ```kotlin + * val emailLinkCredential = EmailAuthProvider.getCredentialWithLink( + * email = "user@example.com", + * emailLink = emailLink + * ) + * + * val result = firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = emailLinkCredential + * ) + * // User signed in with email link (passwordless) + * ``` + * * **Old library reference:** * - AuthOperationManager.java:76-84 (signInAndLinkWithCredential implementation) - * - EmailLinkSignInHandler.java:217 (calling this with email-link credential) - * - SocialProviderResponseHandler - * - PhoneProviderResponseHandler + * - ProfileMerger.java:34-56 (profile merging after sign-in) + * - SocialProviderResponseHandler.java:69-74 (usage with profile merge) + * - PhoneProviderResponseHandler.java:38-40 (usage for phone auth) + * - EmailLinkSignInHandler.java:217 (usage for email link) */ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( config: AuthUIConfiguration, - credential: AuthCredential -) { + credential: AuthCredential, + displayName: String? = null, + photoUrl: android.net.Uri? = null +): AuthResult? { try { updateAuthState(AuthState.Loading("Signing in user...")) - if (AuthProvider.canUpgradeAnonymous(config, auth)) { + return if (AuthProvider.canUpgradeAnonymous(config, auth)) { auth.currentUser?.linkWithCredential(credential)?.await() } else { auth.signInWithCredential(credential).await() + }.also { result -> + // Merge profile information from the provider + result?.user?.let { + AuthProvider.mergeProfile(auth, displayName, photoUrl) + } + updateAuthState(AuthState.Idle) } - updateAuthState(AuthState.Idle) + } catch (e: FirebaseAuthUserCollisionException) { + // Special handling for collision exceptions + val authException = AuthException.from(e) + + if (AuthProvider.canUpgradeAnonymous(config, auth)) { + // Anonymous upgrade collision: emit merge conflict with updated credential + val updatedCredential = e.updatedCredential + if (updatedCredential != null) { + updateAuthState(AuthState.MergeConflict(updatedCredential)) + } else { + updateAuthState(AuthState.Error(authException)) + } + } else { + // Non-anonymous collision: could be same email different provider + // TODO: Fetch providers and emit AuthState.RequiresSignIn + updateAuthState(AuthState.Error(authException)) + } + throw authException } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in and link with credential was cancelled", @@ -267,42 +522,146 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( * * This method initiates the email-link (passwordless) authentication flow by sending * an email containing a magic link. The link includes session information for validation - * and security. + * and security. Optionally supports account linking when a user tries to sign in with + * a social provider but an email link account exists. * - * **Flow:** - * 1. Generate unique session ID - * 2. Get anonymous user ID if upgrading - * 3. Enrich ActionCodeSettings with session data - * 4. Send email via Firebase Auth - * 5. Save session data to DataStore for later validation + * **How it works:** + * 1. Generates a unique session ID for same-device validation + * 2. Retrieves anonymous user ID if upgrading anonymous account + * 3. Enriches the [ActionCodeSettings] URL with session data (session ID, anonymous user ID, force same-device flag) + * 4. Sends the email via [com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail] + * 5. Saves session data to DataStore for validation when the user clicks the link + * 6. User receives email with a magic link containing the session information + * 7. When user clicks link, app opens via deep link and calls [signInWithEmailLink] to complete authentication * - * **After this method:** - * - User receives email with magic link - * - User clicks link → app opens via deep link - * - App calls [signInWithEmailLink] to complete sign-in + * **Account Linking Support:** + * If a user tries to sign in with a social provider (Google, Facebook) but an email link + * account already exists with that email, you can link the accounts by: + * 1. Catching the [FirebaseAuthUserCollisionException] from the social sign-in attempt + * 2. Calling this method with [credentialForLinking] containing the social provider tokens + * 3. When [signInWithEmailLink] completes, it automatically retrieves and links the saved credential + * + * **Session Security:** + * - **Session ID**: Random 10-character string for same-device validation + * - **Anonymous User ID**: Stored if upgrading anonymous account to prevent account hijacking + * - **Force Same Device**: Can be configured via [AuthProvider.Email.isEmailLinkForceSameDeviceEnabled] + * - All session data is validated in [signInWithEmailLink] before completing authentication * * @param context Android [Context] for DataStore access * @param config The [AuthUIConfiguration] containing authentication settings * @param provider The [AuthProvider.Email] configuration with [ActionCodeSettings] * @param email The email address to send the sign-in link to + * @param credentialForLinking Optional credential linking data. If provided, this credential + * will be automatically linked after email link sign-in completes. Pass null for basic + * email link sign-in without account linking. * * @throws AuthException.InvalidCredentialsException if email is invalid * @throws AuthException.AuthCancelledException if the operation is cancelled * @throws AuthException.NetworkException if a network error occurs * @throws IllegalStateException if ActionCodeSettings is not configured * + * **Example 1: Basic email link sign-in** + * ```kotlin + * // Send the email link + * firebaseAuthUI.sendSignInLinkToEmail( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com" + * ) + * // Show "Check your email" UI to user + * + * // Later, when user clicks the link in their email: + * // (In your deep link handling Activity) + * val emailLink = intent.data.toString() + * firebaseAuthUI.signInWithEmailLink( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com", + * emailLink = emailLink + * ) + * // User is now signed in + * ``` + * + * **Example 2: Complete account linking flow (Google → Email Link)** + * ```kotlin + * // Step 1: User tries to sign in with Google + * try { + * val googleAccount = GoogleSignIn.getLastSignedInAccount(context) + * val googleIdToken = googleAccount?.idToken + * val googleCredential = GoogleAuthProvider.getCredential(googleIdToken, null) + * + * firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = googleCredential + * ) + * } catch (e: FirebaseAuthUserCollisionException) { + * // Email already exists with Email Link provider + * + * // Step 2: Send email link with credential for linking + * firebaseAuthUI.sendSignInLinkToEmail( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = email, + * credentialForLinking = CredentialForLinking( + * providerType = "google.com", + * idToken = googleIdToken, // From GoogleSignInAccount + * accessToken = null + * ) + * ) + * + * // Step 3: Show "Check your email" UI + * } + * + * // Step 4: User clicks email link → App opens + * // (In your deep link handling Activity) + * val emailLink = intent.data.toString() + * firebaseAuthUI.signInWithEmailLink( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = email, + * emailLink = emailLink + * ) + * // signInWithEmailLink automatically: + * // 1. Signs in with email link + * // 2. Retrieves the saved Google credential from DataStore + * // 3. Links the Google credential to the email link account + * // 4. User is now signed in with both Email Link AND Google linked + * ``` + * + * **Example 3: Anonymous user upgrade** + * ```kotlin + * // User is currently signed in anonymously + * // Send email link to upgrade anonymous account to permanent email account + * firebaseAuthUI.sendSignInLinkToEmail( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com" + * ) + * // Session includes anonymous user ID for validation + * // When user clicks link, anonymous account is upgraded to permanent account + * ``` + * * **Old library reference:** * - EmailLinkSendEmailHandler.java:26-55 (complete implementation) * - EmailLinkSendEmailHandler.java:38-39 (session ID generation) * - EmailLinkSendEmailHandler.java:47-48 (DataStore persistence) + * - EmailActivity.java:92-93 (saving credential for linking before sending email) * - * @see com.google.firebase.auth.FirebaseAuth.signInWithEmailLink + * @see signInWithEmailLink + * @see EmailLinkPersistenceManager.saveCredentialForLinking + * @see com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail */ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( context: Context, config: AuthUIConfiguration, provider: AuthProvider.Email, email: String, + credentialForLinking: CredentialForLinking? = null ) { try { updateAuthState(AuthState.Loading("Sending sign in email link...")) @@ -318,6 +677,16 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( val sessionId = SessionUtils.generateRandomAlphaNumericString(AuthProvider.Email.SESSION_ID_LENGTH) + // If credential provided, save it for linking after email link sign-in + if (credentialForLinking != null) { + EmailLinkPersistenceManager.saveCredentialForLinking( + context = context, + providerType = credentialForLinking.providerType, + idToken = credentialForLinking.idToken, + accessToken = credentialForLinking.accessToken + ) + } + // Modify actionCodeSettings Url to include sessionId, anonymousUserId, force same // device flag val updatedActionCodeSettings = @@ -394,18 +763,18 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( provider: AuthProvider.Email, email: String, emailLink: String, - existingUser: User? = null, -) { +): AuthResult? { try { updateAuthState(AuthState.Loading("Signing in with email link...")) // Validate link format if (!auth.isSignInWithEmailLink(emailLink)) { - return updateAuthState( + updateAuthState( AuthState.Error( AuthException.UnknownException("Invalid email link") ) ) + return null } // Parses email link for session data and returns sessionId, anonymousUserId, @@ -422,45 +791,51 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( val storedSessionId = sessionRecord?.sessionId // Validate same-device - when (provider.isDifferentDevice( + return when (provider.isDifferentDevice( sessionIdFromLocal = storedSessionId, sessionIdFromLink = sessionIdFromLink )) { true -> { if (sessionIdFromLink.isNullOrEmpty()) { - return updateAuthState( + updateAuthState( AuthState.Error( AuthException.InvalidEmailLinkException() ) ) + return null } if (isEmailLinkForceSameDeviceEnabled || !anonymousUserIdFromLink.isNullOrEmpty() ) { - return updateAuthState( + updateAuthState( AuthState.Error( AuthException.EmailLinkWrongDeviceException() ) ) + return null } val actionCodeResult = auth.checkActionCode(oobCode).await() if (actionCodeResult != null) { if (providerIdFromLink.isNullOrEmpty()) { - return updateAuthState( + updateAuthState( AuthState.Error( AuthException.EmailLinkCrossDeviceLinkingException() ) ) + return null } - return updateAuthState( + updateAuthState( AuthState.Error( AuthException.EmailLinkPromptForEmailException() ) ) + return null } + + return null } false -> { @@ -471,21 +846,23 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( || !currentUser.isAnonymous || currentUser.uid != anonymousUserIdFromLink ) { - return updateAuthState( + updateAuthState( AuthState.Error( AuthException .EmailLinkDifferentAnonymousUserException() ) ) + return null } } if (email.isEmpty()) { - return updateAuthState( + updateAuthState( AuthState.Error( AuthException.EmailMismatchException() ) ) + return null } // Get credential for linking from session record (already retrieved earlier) @@ -496,7 +873,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( // Create credential and sign in val emailLinkCredential = EmailAuthProvider.getCredentialWithLink(email, emailLink) - signInAndLinkWithCredential(config, emailLinkCredential) + return signInAndLinkWithCredential(config, emailLinkCredential) } else { // Linking Flow // Sign in with email link first, then link the social credential @@ -506,44 +883,68 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( if (AuthProvider.canUpgradeAnonymous(config, auth)) { // Like scratch auth, this is used to avoid losing the anonymous user state in // the main auth instance - val clonedAuth = FirebaseAuth - .getInstance(FirebaseApp.getInstance(app.name)) + val appExplicitlyForValidation = FirebaseApp.initializeApp( + context, + auth.app.options, + "FUIAuthScratchApp_${System.currentTimeMillis()}" + ) + val authExplicitlyForValidation = FirebaseAuth + .getInstance(appExplicitlyForValidation) // Safe Link // Add the provider to the same account before triggering a merge failure. - val authResult = clonedAuth + authExplicitlyForValidation .signInWithCredential(emailLinkCredential).await() - if (authResult?.user != null) { - val linkResult = authResult - .user?.linkWithCredential(emailLinkCredential)?.await() - if (linkResult?.user != null) { - // Update AuthState with a firebase auth merge failure - return updateAuthState( - AuthState.MergeConflict( - emailLinkCredential - ) - ) + .also { result -> + if (result?.user != null) { + val linkResult = result + .user?.linkWithCredential(storedCredentialForLink)?.await() + if (linkResult?.user != null) { + // Update AuthState with a firebase auth merge failure + updateAuthState( + AuthState.MergeConflict( + storedCredentialForLink + ) + ) + } + } } - } } else { // Sign in with email link - val authResult = auth.signInWithCredential(emailLinkCredential).await() - + val emailLinkResult = auth.signInWithCredential(emailLinkCredential).await() // Link the social credential - authResult.user?.linkWithCredential(storedCredentialForLink)?.await() - AuthProvider.mergeProfile( - auth, - existingUser?.name, - existingUser?.photoUri - ) + val linkResult = emailLinkResult.user?.linkWithCredential(storedCredentialForLink)?.await() + + // Merge profile from social credential result + linkResult?.user?.let { user -> + AuthProvider.mergeProfile( + auth, + user.displayName, + user.photoUrl + ) + } + + // Update to success state + if (linkResult?.user != null) { + updateAuthState( + AuthState.Success( + result = linkResult, + user = linkResult.user!!, + isNewUser = linkResult.additionalUserInfo?.isNewUser ?: false + ) + ) + } + + linkResult } + }.also { + // Clear DataStore after success + EmailLinkPersistenceManager.clear(context) } - - // Clear DataStore after success - EmailLinkPersistenceManager.clear(context) } + }.also { + updateAuthState(AuthState.Idle) } - updateAuthState(AuthState.Idle) } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in with email link was cancelled", @@ -559,4 +960,107 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( updateAuthState(AuthState.Error(authException)) throw authException } -} \ No newline at end of file +} + +/** + * Sends a password reset email to the specified email address. + * + * This method initiates the "forgot password" flow by sending an email to the user + * with a link to reset their password. The user will receive an email from Firebase + * containing a link that allows them to set a new password for their account. + * + * **Flow:** + * 1. Validate the email address exists in Firebase Auth + * 2. Send password reset email to the user + * 3. User clicks link in email to reset password + * 4. User is redirected to Firebase-hosted password reset page (or custom URL if configured) + * + * **Error Handling:** + * - If the email doesn't exist: throws [AuthException.UserNotFoundException] + * - If the email is invalid: throws [AuthException.InvalidCredentialsException] + * - If network error occurs: throws [AuthException.NetworkException] + * + * @param email The email address to send the password reset email to + * @param actionCodeSettings Optional [ActionCodeSettings] to configure the password reset link. + * Use this to customize the continue URL, dynamic link domain, and other settings. + * + * @return The email address that the reset link was sent to (useful for confirmation UI) + * + * @throws AuthException.UserNotFoundException if no account exists with this email + * @throws AuthException.InvalidCredentialsException if the email format is invalid + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.UnknownException for other errors + * + * **Example 1: Basic password reset** + * ```kotlin + * try { + * val email = firebaseAuthUI.sendPasswordResetEmail( + * email = "user@example.com" + * ) + * // Show success message: "Password reset email sent to $email" + * } catch (e: AuthException.UserNotFoundException) { + * // Show error: "No account exists with this email" + * } catch (e: AuthException.InvalidCredentialsException) { + * // Show error: "Invalid email address" + * } + * ``` + * + * **Example 2: Custom password reset with ActionCodeSettings** + * ```kotlin + * val actionCodeSettings = ActionCodeSettings.newBuilder() + * .setUrl("https://myapp.com/resetPassword") // Continue URL after reset + * .setHandleCodeInApp(false) // Use Firebase-hosted reset page + * .setAndroidPackageName( + * "com.myapp", + * true, // Install if not available + * null // Minimum version + * ) + * .build() + * + * val email = firebaseAuthUI.sendPasswordResetEmail( + * email = "user@example.com", + * actionCodeSettings = actionCodeSettings + * ) + * // User receives email with custom continue URL + * ``` + * + * **Old library reference:** + * - RecoverPasswordHandler.java:21-33 (startReset method) + * - RecoverPasswordActivity.java:131-133 (resetPassword caller) + * - RecoverPasswordActivity.java:76-91 (error handling for invalid user/credentials) + * + * @see com.google.firebase.auth.ActionCodeSettings + * @since 10.0.0 + */ +internal suspend fun FirebaseAuthUI.sendPasswordResetEmail( + email: String, + actionCodeSettings: ActionCodeSettings? = null +): String { + try { + updateAuthState(AuthState.Loading("Sending password reset email...")) + + if (actionCodeSettings != null) { + auth.sendPasswordResetEmail(email, actionCodeSettings).await() + } else { + auth.sendPasswordResetEmail(email).await() + } + + updateAuthState(AuthState.Idle) + return email + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Send password reset email was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt deleted file mode 100644 index 8cf25de1d..000000000 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt +++ /dev/null @@ -1,691 +0,0 @@ -/* - * 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.auth_provider - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.AuthException -import com.firebase.ui.auth.compose.AuthState -import com.firebase.ui.auth.compose.FirebaseAuthUI -import com.firebase.ui.auth.compose.configuration.authUIConfiguration -import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager -import com.google.common.truth.Truth.assertThat -import com.google.firebase.FirebaseApp -import com.google.firebase.FirebaseOptions -import com.google.firebase.auth.ActionCodeSettings -import com.google.firebase.auth.AuthCredential -import com.google.firebase.auth.AuthResult -import com.google.firebase.auth.EmailAuthProvider -import com.google.firebase.auth.FirebaseAuth -import com.google.firebase.auth.FirebaseUser -import com.google.firebase.auth.GoogleAuthProvider -import com.google.android.gms.tasks.TaskCompletionSource -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.ArgumentMatchers.any -import org.mockito.Mock -import org.mockito.Mockito.`when` -import org.mockito.Mockito.mock -import org.mockito.Mockito.mockStatic -import org.mockito.Mockito.verify -import org.mockito.MockitoAnnotations -import org.mockito.kotlin.atMost -import org.mockito.kotlin.never -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** - * Unit tests for email provider extension methods on [FirebaseAuthUI]. - * - * Tests cover: - * - createOrLinkUserWithEmailAndPassword - * - signInWithEmailAndPassword - * - signInAndLinkWithCredential - * - sendSignInLinkToEmail - * - signInWithEmailLink - * - * @suppress Internal test class - */ -@RunWith(RobolectricTestRunner::class) -@Config(manifest = Config.NONE) -class EmailAuthProviderFirebaseAuthUITest { - - @Mock - private lateinit var mockFirebaseAuth: FirebaseAuth - - private lateinit var applicationContext: Context - private lateinit var defaultApp: FirebaseApp - private lateinit var emailProvider: AuthProvider.Email - - @Before - fun setUp() { - MockitoAnnotations.openMocks(this) - applicationContext = ApplicationProvider.getApplicationContext() - - // Clear the instance cache - FirebaseAuthUI.clearInstanceCache() - - // Clear any existing Firebase apps - FirebaseApp.getApps(applicationContext).forEach { app -> - app.delete() - } - - // Initialize default FirebaseApp - defaultApp = FirebaseApp.initializeApp( - applicationContext, - FirebaseOptions.Builder() - .setApiKey("fake-api-key") - .setApplicationId("fake-app-id") - .setProjectId("fake-project-id") - .build() - ) - - // Create email provider - emailProvider = AuthProvider.Email( - actionCodeSettings = null, - passwordValidationRules = emptyList() - ) - } - - @After - fun tearDown() { - FirebaseAuthUI.clearInstanceCache() - try { - defaultApp.delete() - } catch (_: Exception) { - // Ignore - } - } - - // ============================================================================================= - // createOrLinkUserWithEmailAndPassword Tests - // ============================================================================================= - - @Test - fun `createOrLinkUserWithEmailAndPassword creates new user when not anonymous`() = runBlocking { - // Setup - val mockUser = mock(FirebaseUser::class.java) - `when`(mockUser.email).thenReturn("test@example.com") - `when`(mockFirebaseAuth.currentUser).thenReturn(null) - - val taskCompletionSource = TaskCompletionSource() - val mockAuthResult = mock(AuthResult::class.java) - `when`(mockAuthResult.user).thenReturn(mockUser) - taskCompletionSource.setResult(mockAuthResult) - - `when`(mockFirebaseAuth.createUserWithEmailAndPassword("test@example.com", "Password123")) - .thenReturn(taskCompletionSource.task) - - val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - val config = authUIConfiguration { - context = applicationContext - providers { provider(emailProvider) } - } - - // Collect states - val states = mutableListOf() - val job = launch { - instance.authStateFlow().collect { state -> - states.add(state) - } - } - - delay(100) // Allow initial state to be collected - - // Execute - instance.createOrLinkUserWithEmailAndPassword( - context = applicationContext, - config = config, - provider = emailProvider, - email = "test@example.com", - password = "Password123", - newUser = null - ) - - delay(200) // Allow state updates to propagate - job.cancel() - - // Verify method calls - verify(mockFirebaseAuth, atMost(1)) - .createUserWithEmailAndPassword("test@example.com", "Password123") - - // Verify state transitions - assertThat(states.size).isAtLeast(3) - assertThat(states[0]).isEqualTo(AuthState.Idle) // Initial - assertThat(states[1]).isInstanceOf(AuthState.Loading::class.java) - val loadingState = states[1] as AuthState.Loading - assertThat(loadingState.message).isEqualTo("Creating user...") - assertThat(states[2]).isEqualTo(AuthState.Idle) // After completion - } - - @Test - fun `createOrLinkUserWithEmailAndPassword links credential when anonymous upgrade enabled`() = - runTest { - mockStatic(EmailAuthProvider::class.java).use { mockedProvider -> - // Setup - val mockCredential = mock(AuthCredential::class.java) - mockedProvider.`when` { - EmailAuthProvider.getCredential("test@example.com", "Password123") - }.thenReturn(mockCredential) - - val mockAnonymousUser = mock(FirebaseUser::class.java) - `when`(mockAnonymousUser.isAnonymous).thenReturn(true) - `when`(mockAnonymousUser.email).thenReturn("test@example.com") - `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) - - val taskCompletionSource = TaskCompletionSource() - val mockAuthResult = mock(AuthResult::class.java) - `when`(mockAuthResult.user).thenReturn(mockAnonymousUser) - taskCompletionSource.setResult(mockAuthResult) - - `when`(mockAnonymousUser.linkWithCredential(any(AuthCredential::class.java))) - .thenReturn(taskCompletionSource.task) - - val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - val config = authUIConfiguration { - context = applicationContext - providers { provider(emailProvider) } - isAnonymousUpgradeEnabled = true - } - - // Execute - instance.createOrLinkUserWithEmailAndPassword( - context = applicationContext, - config = config, - provider = emailProvider, - email = "test@example.com", - password = "Password123", - newUser = null - ) - - // Verify - mockedProvider.verify { - EmailAuthProvider.getCredential("test@example.com", "Password123") - } - verify(mockFirebaseAuth, never()) - .createUserWithEmailAndPassword(any(), any()) - verify(mockAnonymousUser, atMost(1)) - .linkWithCredential(mockCredential) - } - } - - @Test - fun `createOrLinkUserWithEmailAndPassword throws exception for weak password`() = runTest { - val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - val config = authUIConfiguration { - context = applicationContext - providers { provider(emailProvider) } - } - - try { - instance.createOrLinkUserWithEmailAndPassword( - context = applicationContext, - config = config, - provider = emailProvider, - email = "test@example.com", - password = "weak", - newUser = null - ) - assertThat(false).isTrue() - } catch (e: AuthException.InvalidCredentialsException) { - assertThat(e.message).isEqualTo(applicationContext.getString(R.string.fui_error_password_too_short).format(6)) - } - } - - // ============================================================================================= - // signInWithEmailAndPassword Tests - // ============================================================================================= - - @Test - fun `signInWithEmailAndPassword signs in user normally when not anonymous`() = runTest { - val mockUser = mock(FirebaseUser::class.java) - `when`(mockUser.email).thenReturn("test@example.com") - `when`(mockFirebaseAuth.currentUser).thenReturn(null) - val taskCompletionSource = TaskCompletionSource() - val mockAuthResult = mock(AuthResult::class.java) - `when`(mockAuthResult.user).thenReturn(mockUser) - taskCompletionSource.setResult(mockAuthResult) - `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Password123")) - .thenReturn(taskCompletionSource.task) - val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - val config = authUIConfiguration { - context = applicationContext - providers { provider(emailProvider) } - } - - instance.signInWithEmailAndPassword( - config = config, - provider = emailProvider, - email = "test@example.com", - password = "Password123", - credentialForLinking = null, - existingUser = null - ) - - verify(mockFirebaseAuth, atMost(1)) - .signInWithEmailAndPassword("test@example.com", "Password123") - } - - @Test - fun `signInWithEmailAndPassword links social credential when provided`() = runTest { - // Setup - val mockUser = mock(FirebaseUser::class.java) - `when`(mockUser.email).thenReturn("test@example.com") - `when`(mockFirebaseAuth.currentUser).thenReturn(null) - - val taskCompletionSource = TaskCompletionSource() - val mockAuthResult = mock(AuthResult::class.java) - `when`(mockAuthResult.user).thenReturn(mockUser) - taskCompletionSource.setResult(mockAuthResult) - - `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Password123")) - .thenReturn(taskCompletionSource.task) - - // Mock social credential linking - val mockSocialCredential = mock(AuthCredential::class.java) - val linkTaskCompletionSource = TaskCompletionSource() - linkTaskCompletionSource.setResult(mockAuthResult) - `when`(mockUser.linkWithCredential(mockSocialCredential)) - .thenReturn(linkTaskCompletionSource.task) - - val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - val config = authUIConfiguration { - context = applicationContext - providers { provider(emailProvider) } - } - - // Execute - instance.signInWithEmailAndPassword( - config = config, - provider = emailProvider, - email = "test@example.com", - password = "Password123", - credentialForLinking = mockSocialCredential, - existingUser = null - ) - - // Verify - verify(mockFirebaseAuth, atMost(1)) - .signInWithEmailAndPassword("test@example.com", "Password123") - verify(mockUser, atMost(1)) - .linkWithCredential(mockSocialCredential) - } - - // ============================================================================================= - // signInAndLinkWithCredential Tests - // ============================================================================================= - - @Test - fun `signInAndLinkWithCredential signs in when not anonymous`() = runTest { - // Setup - val mockCredential = mock(AuthCredential::class.java) - val mockUser = mock(FirebaseUser::class.java) - `when`(mockFirebaseAuth.currentUser).thenReturn(null) - - val taskCompletionSource = TaskCompletionSource() - val mockAuthResult = mock(AuthResult::class.java) - `when`(mockAuthResult.user).thenReturn(mockUser) - taskCompletionSource.setResult(mockAuthResult) - - `when`(mockFirebaseAuth.signInWithCredential(mockCredential)) - .thenReturn(taskCompletionSource.task) - - val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - val config = authUIConfiguration { - context = applicationContext - providers { provider(emailProvider) } - } - - // Execute - instance.signInAndLinkWithCredential(config, mockCredential) - - // Verify - verify(mockFirebaseAuth, atMost(1)) - .signInWithCredential(mockCredential) - } - - @Test - fun `signInAndLinkWithCredential links credential when anonymous upgrade enabled`() = runTest { - // Setup - val mockCredential = mock(AuthCredential::class.java) - val mockAnonymousUser = mock(FirebaseUser::class.java) - `when`(mockAnonymousUser.isAnonymous).thenReturn(true) - `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) - - val taskCompletionSource = TaskCompletionSource() - val mockAuthResult = mock(AuthResult::class.java) - `when`(mockAuthResult.user).thenReturn(mockAnonymousUser) - taskCompletionSource.setResult(mockAuthResult) - - `when`(mockAnonymousUser.linkWithCredential(mockCredential)) - .thenReturn(taskCompletionSource.task) - - val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - val config = authUIConfiguration { - context = applicationContext - providers { provider(emailProvider) } - isAnonymousUpgradeEnabled = true - } - - // Execute - instance.signInAndLinkWithCredential(config, mockCredential) - - // Verify - verify(mockFirebaseAuth, never()) - .signInWithCredential(any(AuthCredential::class.java)) - verify(mockAnonymousUser, atMost(1)) - .linkWithCredential(mockCredential) - } - - // ============================================================================================= - // sendSignInLinkToEmail Tests - // ============================================================================================= - - @Test - fun `sendSignInLinkToEmail sends email and saves session to DataStore`() = runTest { - // Setup - val actionCodeSettings = ActionCodeSettings.newBuilder() - .setUrl("https://example.com/emailSignIn") - .setHandleCodeInApp(true) - .build() - - val emailProviderWithSettings = AuthProvider.Email( - actionCodeSettings = actionCodeSettings, - passwordValidationRules = emptyList() - ) - - val taskCompletionSource = TaskCompletionSource() - taskCompletionSource.setResult(null) - - `when`(mockFirebaseAuth.currentUser).thenReturn(null) - `when`(mockFirebaseAuth.sendSignInLinkToEmail(any(), any())) - .thenReturn(taskCompletionSource.task) - - val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - val config = authUIConfiguration { - context = applicationContext - providers { provider(emailProviderWithSettings) } - } - - // Execute - instance.sendSignInLinkToEmail( - context = applicationContext, - config = config, - provider = emailProviderWithSettings, - email = "test@example.com" - ) - - // Verify - verify(mockFirebaseAuth, atMost(1)) - .sendSignInLinkToEmail(any(), any()) - - // Verify DataStore was saved - val sessionRecord = EmailLinkPersistenceManager.retrieveSessionRecord(applicationContext) - assertThat(sessionRecord).isNotNull() - assertThat(sessionRecord?.email).isEqualTo("test@example.com") - assertThat(sessionRecord?.sessionId).isNotEmpty() - } - - @Test - fun `sendSignInLinkToEmail saves anonymous user ID when upgrade enabled`() = runTest { - // Setup - val mockAnonymousUser = mock(FirebaseUser::class.java) - `when`(mockAnonymousUser.isAnonymous).thenReturn(true) - `when`(mockAnonymousUser.uid).thenReturn("anonymous-uid-123") - `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) - - val actionCodeSettings = ActionCodeSettings.newBuilder() - .setUrl("https://example.com/emailSignIn") - .setHandleCodeInApp(true) - .build() - - val emailProviderWithSettings = AuthProvider.Email( - actionCodeSettings = actionCodeSettings, - passwordValidationRules = emptyList() - ) - - val taskCompletionSource = TaskCompletionSource() - taskCompletionSource.setResult(null) - - `when`(mockFirebaseAuth.sendSignInLinkToEmail(any(), any())) - .thenReturn(taskCompletionSource.task) - - val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - val config = authUIConfiguration { - context = applicationContext - providers { provider(emailProviderWithSettings) } - isAnonymousUpgradeEnabled = true - } - - // Execute - instance.sendSignInLinkToEmail( - context = applicationContext, - config = config, - provider = emailProviderWithSettings, - email = "test@example.com" - ) - - // Verify - val sessionRecord = EmailLinkPersistenceManager.retrieveSessionRecord(applicationContext) - assertThat(sessionRecord?.anonymousUserId).isEqualTo("anonymous-uid-123") - } - - // ============================================================================================= - // signInWithEmailLink Tests - // ============================================================================================= - - @Test - fun `signInWithEmailLink completes normal sign-in flow`() = runTest { - mockStatic(EmailAuthProvider::class.java).use { mockedProvider -> - // Setup - val emailLink = "https://example.com/emailSignIn?oobCode=ABC123&ui_sid=session123" - - `when`(mockFirebaseAuth.isSignInWithEmailLink(emailLink)).thenReturn(true) - - // Save session to DataStore - EmailLinkPersistenceManager.saveEmail( - context = applicationContext, - email = "test@example.com", - sessionId = "session123", - anonymousUserId = null - ) - - val mockCredential = mock(AuthCredential::class.java) - mockedProvider.`when` { - EmailAuthProvider.getCredentialWithLink("test@example.com", emailLink) - }.thenReturn(mockCredential) - - val mockUser = mock(FirebaseUser::class.java) - val taskCompletionSource = TaskCompletionSource() - val mockAuthResult = mock(AuthResult::class.java) - `when`(mockAuthResult.user).thenReturn(mockUser) - taskCompletionSource.setResult(mockAuthResult) - - `when`(mockFirebaseAuth.currentUser).thenReturn(null) - `when`(mockFirebaseAuth.signInWithCredential(mockCredential)) - .thenReturn(taskCompletionSource.task) - - val actionCodeSettings = ActionCodeSettings.newBuilder() - .setUrl("https://example.com/emailSignIn") - .setHandleCodeInApp(true) - .build() - - val emailProviderWithSettings = AuthProvider.Email( - actionCodeSettings = actionCodeSettings, - passwordValidationRules = emptyList() - ) - - val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - val config = authUIConfiguration { - context = applicationContext - providers { provider(emailProviderWithSettings) } - } - - // Execute - instance.signInWithEmailLink( - context = applicationContext, - config = config, - provider = emailProviderWithSettings, - email = "test@example.com", - emailLink = emailLink, - existingUser = null - ) - - // Verify - verify(mockFirebaseAuth, atMost(1)) - .signInWithCredential(mockCredential) - - // Verify DataStore was cleared - val sessionRecord = - EmailLinkPersistenceManager.retrieveSessionRecord(applicationContext) - assertThat(sessionRecord).isNull() - } - } - - @Test - fun `signInWithEmailLink links social credential when stored`() = runTest { - mockStatic(EmailAuthProvider::class.java).use { mockedEmailProvider -> - mockStatic(GoogleAuthProvider::class.java).use { mockedGoogleProvider -> - // Setup - val emailLink = "https://example.com/emailSignIn?oobCode=ABC123&ui_sid=session123" - - `when`(mockFirebaseAuth.isSignInWithEmailLink(emailLink)).thenReturn(true) - - // Save session with Google credential to DataStore - EmailLinkPersistenceManager.saveEmail( - context = applicationContext, - email = "test@example.com", - sessionId = "session123", - anonymousUserId = null - ) - EmailLinkPersistenceManager.saveCredentialForLinking( - context = applicationContext, - providerType = "google.com", - idToken = "google-id-token", - accessToken = null - ) - - val mockEmailCredential = mock(AuthCredential::class.java) - mockedEmailProvider.`when` { - EmailAuthProvider.getCredentialWithLink("test@example.com", emailLink) - }.thenReturn(mockEmailCredential) - - val mockGoogleCredential = mock(AuthCredential::class.java) - mockedGoogleProvider.`when` { - GoogleAuthProvider.getCredential("google-id-token", null) - }.thenReturn(mockGoogleCredential) - - val mockUser = mock(FirebaseUser::class.java) - val taskCompletionSource = TaskCompletionSource() - val mockAuthResult = mock(AuthResult::class.java) - `when`(mockAuthResult.user).thenReturn(mockUser) - taskCompletionSource.setResult(mockAuthResult) - - `when`(mockFirebaseAuth.currentUser).thenReturn(null) - `when`(mockFirebaseAuth.signInWithCredential(mockEmailCredential)) - .thenReturn(taskCompletionSource.task) - - val linkTaskCompletionSource = TaskCompletionSource() - linkTaskCompletionSource.setResult(mockAuthResult) - `when`(mockUser.linkWithCredential(mockGoogleCredential)) - .thenReturn(linkTaskCompletionSource.task) - - val actionCodeSettings = ActionCodeSettings.newBuilder() - .setUrl("https://example.com/emailSignIn") - .setHandleCodeInApp(true) - .build() - - val emailProviderWithSettings = AuthProvider.Email( - actionCodeSettings = actionCodeSettings, - passwordValidationRules = emptyList() - ) - - val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - val config = authUIConfiguration { - context = applicationContext - providers { provider(emailProviderWithSettings) } - } - - // Execute - instance.signInWithEmailLink( - context = applicationContext, - config = config, - provider = emailProviderWithSettings, - email = "test@example.com", - emailLink = emailLink, - existingUser = null - ) - - // Verify - verify(mockFirebaseAuth, atMost(1)) - .signInWithCredential(mockEmailCredential) - verify(mockUser, atMost(1)) - .linkWithCredential(mockGoogleCredential) - - // Verify DataStore was cleared - val sessionRecord = - EmailLinkPersistenceManager.retrieveSessionRecord(applicationContext) - assertThat(sessionRecord).isNull() - } - } - } - - @Test - fun `signInWithEmailLink throws exception for invalid link`() = runTest { - // Setup - val emailLink = "https://example.com/invalid" - - `when`(mockFirebaseAuth.isSignInWithEmailLink(emailLink)).thenReturn(false) - - val actionCodeSettings = ActionCodeSettings.newBuilder() - .setUrl("https://example.com/emailSignIn") - .setHandleCodeInApp(true) - .build() - - val emailProviderWithSettings = AuthProvider.Email( - actionCodeSettings = actionCodeSettings, - passwordValidationRules = emptyList() - ) - - val instance = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - val config = authUIConfiguration { - context = applicationContext - providers { provider(emailProviderWithSettings) } - } - - // Execute - instance.signInWithEmailLink( - context = applicationContext, - config = config, - provider = emailProviderWithSettings, - email = "test@example.com", - emailLink = emailLink, - existingUser = null - ) - - // Verify - method returns early with error state, so we just verify it was called - verify(mockFirebaseAuth, atMost(1)) - .isSignInWithEmailLink(emailLink) - } -} From 751fcc98a7c3c9fafc483d7a68de8d4a36187499 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Mon, 6 Oct 2025 15:44:30 +0100 Subject: [PATCH 14/43] feat: Email provider integration --- .../auth_provider/AuthProvider.kt | 136 +++- .../EmailAuthProvider+FirebaseAuthUI.kt | 220 ++---- .../EmailAuthProviderFirebaseAuthUITest.kt | 714 ++++++++++++++++++ 3 files changed, 899 insertions(+), 171 deletions(-) create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt index c3b9b8e9d..1d4c875f5 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt @@ -20,6 +20,7 @@ import android.util.Log import androidx.compose.ui.graphics.Color import androidx.datastore.preferences.core.stringPreferencesKey import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.AuthException import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration import com.firebase.ui.auth.compose.configuration.AuthUIConfigurationDsl import com.firebase.ui.auth.compose.configuration.PasswordRule @@ -54,11 +55,11 @@ class AuthProvidersBuilder { /** * 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), +internal enum class Provider(val id: String, val isSocialProvider: Boolean = false) { + GOOGLE(GoogleAuthProvider.PROVIDER_ID, isSocialProvider = true), + FACEBOOK(FacebookAuthProvider.PROVIDER_ID, isSocialProvider = true), + TWITTER(TwitterAuthProvider.PROVIDER_ID, isSocialProvider = true), + GITHUB(GithubAuthProvider.PROVIDER_ID, isSocialProvider = true), EMAIL(EmailAuthProvider.PROVIDER_ID), PHONE(PhoneAuthProvider.PROVIDER_ID), ANONYMOUS("anonymous"), @@ -221,6 +222,131 @@ abstract class AuthProvider(open val providerId: String) { } } + /** + * Handles cross-device email link validation. + * + * This method validates email links that are opened on a different device + * from where they were sent. It performs security checks and throws appropriate + * exceptions if the link cannot be used. + * + * @param auth FirebaseAuth instance for validation + * @param sessionIdFromLink Session ID extracted from the email link + * @param anonymousUserIdFromLink Anonymous user ID from the link (if present) + * @param isEmailLinkForceSameDeviceEnabled Whether same-device is enforced + * @param oobCode The action code from the email link + * @param providerIdFromLink Provider ID from the link (for linking flows) + * + * @throws com.firebase.ui.auth.compose.AuthException.InvalidEmailLinkException if session ID is missing + * @throws com.firebase.ui.auth.compose.AuthException.EmailLinkWrongDeviceException if same-device is required + * @throws com.firebase.ui.auth.compose.AuthException.EmailLinkCrossDeviceLinkingException if provider linking is attempted + * @throws com.firebase.ui.auth.compose.AuthException.EmailLinkPromptForEmailException if email input is required + */ + internal suspend fun handleCrossDeviceEmailLink( + auth: FirebaseAuth, + sessionIdFromLink: String?, + anonymousUserIdFromLink: String?, + isEmailLinkForceSameDeviceEnabled: Boolean, + oobCode: String, + providerIdFromLink: String? + ) { + // Session ID must always be present in the link + if (sessionIdFromLink.isNullOrEmpty()) { + throw AuthException.InvalidEmailLinkException() + } + + // These scenarios require same-device flow + if (isEmailLinkForceSameDeviceEnabled || !anonymousUserIdFromLink.isNullOrEmpty()) { + throw AuthException.EmailLinkWrongDeviceException() + } + + // Validate the action code + auth.checkActionCode(oobCode).await() + + // If there's a provider ID, this is a linking flow which can't be done cross-device + if (!providerIdFromLink.isNullOrEmpty()) { + throw AuthException.EmailLinkCrossDeviceLinkingException() + } + + // Link is valid but we need the user to provide their email + throw AuthException.EmailLinkPromptForEmailException() + } + + /** + * Handles email link sign-in with social credential linking. + * + * This method signs in the user with an email link credential and then links + * a stored social provider credential (e.g., Google, Facebook). It handles both + * anonymous upgrade flows (with safe link) and normal linking flows. + * + * @param context Android context for creating scratch auth instance + * @param config Auth configuration + * @param auth FirebaseAuth instance + * @param emailLinkCredential The email link credential to sign in with + * @param storedCredentialForLink The social credential to link after sign-in + * @param updateAuthState Callback to update auth state + * + * @return AuthResult from the linking operation + */ + internal suspend fun handleEmailLinkWithSocialLinking( + context: Context, + config: AuthUIConfiguration, + auth: FirebaseAuth, + emailLinkCredential: com.google.firebase.auth.AuthCredential, + storedCredentialForLink: com.google.firebase.auth.AuthCredential, + updateAuthState: (com.firebase.ui.auth.compose.AuthState) -> Unit + ): com.google.firebase.auth.AuthResult { + return if (canUpgradeAnonymous(config, auth)) { + // Anonymous upgrade: Use safe link pattern with scratch auth + val appExplicitlyForValidation = com.google.firebase.FirebaseApp.initializeApp( + context, + auth.app.options, + "FUIAuthScratchApp_${System.currentTimeMillis()}" + ) + val authExplicitlyForValidation = FirebaseAuth + .getInstance(appExplicitlyForValidation) + + // Safe link: Validate that both credentials can be linked + val emailResult = authExplicitlyForValidation + .signInWithCredential(emailLinkCredential).await() + + val linkResult = emailResult.user + ?.linkWithCredential(storedCredentialForLink)?.await() + + // If safe link succeeds, emit merge conflict for UI to handle + if (linkResult?.user != null) { + updateAuthState(com.firebase.ui.auth.compose.AuthState.MergeConflict(storedCredentialForLink)) + } + + // Return the link result (will be non-null if successful) + linkResult!! + } else { + // Non-upgrade: Sign in with email link, then link social credential + val emailLinkResult = auth.signInWithCredential(emailLinkCredential).await() + + // Link the social credential + val linkResult = emailLinkResult.user + ?.linkWithCredential(storedCredentialForLink)?.await() + + // Merge profile from the linked social credential + linkResult?.user?.let { user -> + mergeProfile(auth, user.displayName, user.photoUrl) + } + + // Update to success state + if (linkResult?.user != null) { + updateAuthState( + com.firebase.ui.auth.compose.AuthState.Success( + result = linkResult, + user = linkResult.user!!, + isNewUser = linkResult.additionalUserInfo?.isNewUser ?: false + ) + ) + } + + linkResult!! + } + } + // For Send Email Link internal fun addSessionInfoToActionCodeSettings( sessionId: String, diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index 035b18381..07963a936 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -1,6 +1,7 @@ package com.firebase.ui.auth.compose.configuration.auth_provider import android.content.Context +import android.net.Uri import com.firebase.ui.auth.R import com.firebase.ui.auth.compose.AuthException import com.firebase.ui.auth.compose.AuthState @@ -30,7 +31,7 @@ import kotlinx.coroutines.tasks.await * @property idToken The ID token from the provider (required for Google, optional for Facebook) * @property accessToken The access token from the provider (required for Facebook, optional for Google) */ -data class CredentialForLinking( +internal class CredentialForLinking( val providerType: String, val idToken: String?, val accessToken: String? @@ -212,7 +213,6 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( * * @param context Android [Context] for creating scratch auth instance * @param config Auth UI configuration describing provider settings - * @param provider Email provider configuration (not used for provider detection) * @param email Email address for sign-in * @param password Password for sign-in * @param credentialForLinking Optional social provider credential to link after sign-in @@ -285,7 +285,6 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( context: Context, config: AuthUIConfiguration, - provider: AuthProvider.Email, email: String, password: String, credentialForLinking: AuthCredential? = null, @@ -298,12 +297,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( // Check if we're linking a social provider credential val isSocialProvider = credentialForLinking != null && - credentialForLinking.provider in listOf( - com.google.firebase.auth.GoogleAuthProvider.PROVIDER_ID, - com.google.firebase.auth.FacebookAuthProvider.PROVIDER_ID, - com.google.firebase.auth.TwitterAuthProvider.PROVIDER_ID, - com.google.firebase.auth.GithubAuthProvider.PROVIDER_ID - ) + (Provider.fromId(credentialForLinking.provider)?.isSocialProvider ?: false) // Create scratch auth instance to avoid losing anonymous user state val appExplicitlyForValidation = FirebaseApp.initializeApp( @@ -318,7 +312,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( // Safe link: sign in with email, then link social credential authExplicitlyForValidation .signInWithCredential(credentialToValidate).await() - .user?.linkWithCredential(credentialForLinking!!)?.await() + .user?.linkWithCredential(credentialForLinking)?.await() .also { // Emit merge conflict after successful validation updateAuthState(AuthState.MergeConflict(credentialToValidate)) @@ -467,7 +461,7 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( config: AuthUIConfiguration, credential: AuthCredential, displayName: String? = null, - photoUrl: android.net.Uri? = null + photoUrl: Uri? = null ): AuthResult? { try { updateAuthState(AuthState.Loading("Signing in user...")) @@ -763,22 +757,21 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( provider: AuthProvider.Email, email: String, emailLink: String, -): AuthResult? { +): AuthResult { try { updateAuthState(AuthState.Loading("Signing in with email link...")) // Validate link format if (!auth.isSignInWithEmailLink(emailLink)) { - updateAuthState( - AuthState.Error( - AuthException.UnknownException("Invalid email link") - ) - ) - return null + throw AuthException.InvalidEmailLinkException() } - // Parses email link for session data and returns sessionId, anonymousUserId, - // force same device flag etc. + // Validate email is not empty + if (email.isEmpty()) { + throw AuthException.EmailMismatchException() + } + + // Parse email link for session data val parser = EmailLinkParser(emailLink) val sessionIdFromLink = parser.sessionId val anonymousUserIdFromLink = parser.anonymousUserId @@ -790,161 +783,56 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( val sessionRecord = EmailLinkPersistenceManager.retrieveSessionRecord(context) val storedSessionId = sessionRecord?.sessionId - // Validate same-device - return when (provider.isDifferentDevice( + // Check if this is a different device flow + val isDifferentDevice = provider.isDifferentDevice( sessionIdFromLocal = storedSessionId, sessionIdFromLink = sessionIdFromLink - )) { - true -> { - if (sessionIdFromLink.isNullOrEmpty()) { - updateAuthState( - AuthState.Error( - AuthException.InvalidEmailLinkException() - ) - ) - return null - } - - if (isEmailLinkForceSameDeviceEnabled - || !anonymousUserIdFromLink.isNullOrEmpty() - ) { - updateAuthState( - AuthState.Error( - AuthException.EmailLinkWrongDeviceException() - ) - ) - return null - } - - val actionCodeResult = auth.checkActionCode(oobCode).await() - if (actionCodeResult != null) { - if (providerIdFromLink.isNullOrEmpty()) { - updateAuthState( - AuthState.Error( - AuthException.EmailLinkCrossDeviceLinkingException() - ) - ) - return null - } + ) - updateAuthState( - AuthState.Error( - AuthException.EmailLinkPromptForEmailException() - ) - ) - return null - } + if (isDifferentDevice) { + // Handle cross-device flow + provider.handleCrossDeviceEmailLink( + auth = auth, + sessionIdFromLink = sessionIdFromLink, + anonymousUserIdFromLink = anonymousUserIdFromLink, + isEmailLinkForceSameDeviceEnabled = isEmailLinkForceSameDeviceEnabled, + oobCode = oobCode, + providerIdFromLink = providerIdFromLink + ) + } - return null + // Validate anonymous user ID matches (same-device flow) + if (!anonymousUserIdFromLink.isNullOrEmpty()) { + val currentUser = auth.currentUser + if (currentUser == null || !currentUser.isAnonymous || currentUser.uid != anonymousUserIdFromLink) { + throw AuthException.EmailLinkDifferentAnonymousUserException() } + } - false -> { - // Validate anonymous user ID matches - if (!anonymousUserIdFromLink.isNullOrEmpty()) { - val currentUser = auth.currentUser - if (currentUser == null - || !currentUser.isAnonymous - || currentUser.uid != anonymousUserIdFromLink - ) { - updateAuthState( - AuthState.Error( - AuthException - .EmailLinkDifferentAnonymousUserException() - ) - ) - return null - } - } - - if (email.isEmpty()) { - updateAuthState( - AuthState.Error( - AuthException.EmailMismatchException() - ) - ) - return null - } - - // Get credential for linking from session record (already retrieved earlier) - val storedCredentialForLink = sessionRecord?.credentialForLinking - - if (storedCredentialForLink == null) { - // Normal Flow - // Create credential and sign in - val emailLinkCredential = - EmailAuthProvider.getCredentialWithLink(email, emailLink) - return signInAndLinkWithCredential(config, emailLinkCredential) - } else { - // Linking Flow - // Sign in with email link first, then link the social credential - val emailLinkCredential = - EmailAuthProvider.getCredentialWithLink(email, emailLink) - - if (AuthProvider.canUpgradeAnonymous(config, auth)) { - // Like scratch auth, this is used to avoid losing the anonymous user state in - // the main auth instance - val appExplicitlyForValidation = FirebaseApp.initializeApp( - context, - auth.app.options, - "FUIAuthScratchApp_${System.currentTimeMillis()}" - ) - val authExplicitlyForValidation = FirebaseAuth - .getInstance(appExplicitlyForValidation) - - // Safe Link - // Add the provider to the same account before triggering a merge failure. - authExplicitlyForValidation - .signInWithCredential(emailLinkCredential).await() - .also { result -> - if (result?.user != null) { - val linkResult = result - .user?.linkWithCredential(storedCredentialForLink)?.await() - if (linkResult?.user != null) { - // Update AuthState with a firebase auth merge failure - updateAuthState( - AuthState.MergeConflict( - storedCredentialForLink - ) - ) - } - } - } - } else { - // Sign in with email link - val emailLinkResult = auth.signInWithCredential(emailLinkCredential).await() - // Link the social credential - val linkResult = emailLinkResult.user?.linkWithCredential(storedCredentialForLink)?.await() + // Get credential for linking from session record + val storedCredentialForLink = sessionRecord?.credentialForLinking + val emailLinkCredential = EmailAuthProvider.getCredentialWithLink(email, emailLink) - // Merge profile from social credential result - linkResult?.user?.let { user -> - AuthProvider.mergeProfile( - auth, - user.displayName, - user.photoUrl - ) - } + val result = if (storedCredentialForLink == null) { + // Normal Flow: Just sign in with email link + signInAndLinkWithCredential(config, emailLinkCredential) + ?: throw AuthException.UnknownException("Sign in failed") + } else { + // Linking Flow: Sign in with email link, then link the social credential + provider.handleEmailLinkWithSocialLinking( + context = context, + config = config, + auth = auth, + emailLinkCredential = emailLinkCredential, + storedCredentialForLink = storedCredentialForLink, + updateAuthState = ::updateAuthState + ) + } - // Update to success state - if (linkResult?.user != null) { - updateAuthState( - AuthState.Success( - result = linkResult, - user = linkResult.user!!, - isNewUser = linkResult.additionalUserInfo?.isNewUser ?: false - ) - ) - } + // Clear DataStore after success + EmailLinkPersistenceManager.clear(context) - linkResult - } - }.also { - // Clear DataStore after success - EmailLinkPersistenceManager.clear(context) - } - } - }.also { - updateAuthState(AuthState.Idle) - } + return result } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in with email link was cancelled", diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt new file mode 100644 index 000000000..0fa5cf7e4 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt @@ -0,0 +1,714 @@ +/* + * 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.auth_provider + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.ActionCodeSettings +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import com.google.firebase.auth.FirebaseAuthInvalidUserException +import com.google.firebase.auth.FirebaseAuthUserCollisionException +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.GoogleAuthProvider +import com.google.android.gms.tasks.TaskCompletionSource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.verify +import org.mockito.Mockito.never +import org.mockito.Mockito.anyString +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Comprehensive unit tests for Email Authentication provider methods in FirebaseAuthUI. + * + * Tests cover all email auth methods: + * - createOrLinkUserWithEmailAndPassword + * - signInWithEmailAndPassword + * - signInAndLinkWithCredential + * - sendSignInLinkToEmail + * - signInWithEmailLink + * - sendPasswordResetEmail + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class EmailAuthProviderFirebaseAuthUITest { + + @Mock + private lateinit var mockFirebaseAuth: FirebaseAuth + + private lateinit var firebaseApp: FirebaseApp + private lateinit var applicationContext: Context + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + FirebaseAuthUI.clearInstanceCache() + + applicationContext = ApplicationProvider.getApplicationContext() + + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + firebaseApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + try { + firebaseApp.delete() + } catch (_: Exception) { + // Ignore if already deleted + } + } + + // ============================================================================================= + // createOrLinkUserWithEmailAndPassword Tests + // ============================================================================================= + + @Test + fun `Create user with email and password without anonymous upgrade should succeed`() = runTest { + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`(mockFirebaseAuth.createUserWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + + verify(mockFirebaseAuth) + .createUserWithEmailAndPassword("test@example.com", "Pass@123") + } + + @Test + fun `Link user with email and password with anonymous upgrade should succeed`() = runTest { + mockStatic(EmailAuthProvider::class.java).use { mockedProvider -> + val mockCredential = mock(AuthCredential::class.java) + mockedProvider.`when` { + EmailAuthProvider.getCredential("test@example.com", "Pass@123") + }.thenReturn(mockCredential) + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`( + mockFirebaseAuth.currentUser?.linkWithCredential( + ArgumentMatchers.any(AuthCredential::class.java) + ) + ).thenReturn(taskCompletionSource.task) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + + mockedProvider.verify { + EmailAuthProvider.getCredential("test@example.com", "Pass@123") + } + verify(mockAnonymousUser).linkWithCredential(mockCredential) + } + } + + @Test + fun `createOrLinkUserWithEmailAndPassword - rejects weak password`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "weak" + ) + } catch (e: Exception) { + assertThat(e.message).contains( + applicationContext + .getString(R.string.fui_error_password_too_short) + .format(emailProvider.minimumPasswordLength) + ) + } + + verify(mockFirebaseAuth, never()) + .createUserWithEmailAndPassword(anyString(), anyString()) + } + + @Test + fun `createOrLinkUserWithEmailAndPassword - validates custom password rules`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = listOf(PasswordRule.RequireUppercase) + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "pass@123" + ) + } catch (e: Exception) { + assertThat(e.message).isEqualTo(applicationContext.getString(R.string.fui_error_password_missing_uppercase)) + } + } + + @Test + fun `createOrLinkUserWithEmailAndPassword - respects isNewAccountsAllowed setting`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList(), + isNewAccountsAllowed = false + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + } catch (e: Exception) { + assertThat(e.message) + .isEqualTo(applicationContext.getString(R.string.fui_error_email_does_not_exist)) + } + } + + @Test + fun `createOrLinkUserWithEmailAndPassword - handles collision exception`() = runTest { + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockAnonymousUser.email).thenReturn("test@example.com") + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + + val collisionException = FirebaseAuthUserCollisionException( + "ERROR_EMAIL_ALREADY_IN_USE", + "Email already in use" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(collisionException) + `when`(mockAnonymousUser.linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java))) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + } catch (e: AuthException) { + assertThat(e.cause).isEqualTo(collisionException) + val currentState = instance.authStateFlow().first { it is AuthState.MergeConflict } + assertThat(currentState).isInstanceOf(AuthState.MergeConflict::class.java) + val mergeConflict = currentState as AuthState.MergeConflict + assertThat(mergeConflict.pendingCredential).isNotNull() + } + } + + // ============================================================================================= + // signInWithEmailAndPassword Tests + // ============================================================================================= + + @Test + fun `signInWithEmailAndPassword - successful sign in`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + val result = instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123" + ) + + assertThat(result).isNotNull() + assertThat(result?.user).isEqualTo(mockUser) + verify(mockFirebaseAuth).signInWithEmailAndPassword("test@example.com", "Pass@123") + } + + @Test + fun `signInWithEmailAndPassword - handles invalid credentials`() = runTest { + val invalidCredentialsException = FirebaseAuthInvalidCredentialsException( + "ERROR_WRONG_PASSWORD", + "Wrong password" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(invalidCredentialsException) + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + try { + instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123" + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.InvalidCredentialsException) { + assertThat(e.cause).isEqualTo(invalidCredentialsException) + } + } + + @Test + fun `signInWithEmailAndPassword - handles user not found`() = runTest { + val userNotFoundException = FirebaseAuthInvalidUserException( + "ERROR_USER_NOT_FOUND", + "User not found" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(userNotFoundException) + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + try { + instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123" + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.UserNotFoundException) { + assertThat(e.cause).isEqualTo(userNotFoundException) + } + } + + @Test + fun `signInWithEmailAndPassword - links credential after sign in`() = runTest { + val googleCredential = GoogleAuthProvider.getCredential("google-id-token", null) + val mockUser = mock(FirebaseUser::class.java) + val signInAuthResult = mock(AuthResult::class.java) + `when`(signInAuthResult.user).thenReturn(mockUser) + val signInTask = TaskCompletionSource() + signInTask.setResult(signInAuthResult) + + val linkAuthResult = mock(AuthResult::class.java) + `when`(linkAuthResult.user).thenReturn(mockUser) + val linkTask = TaskCompletionSource() + linkTask.setResult(linkAuthResult) + + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(signInTask.task) + `when`(mockUser.linkWithCredential(googleCredential)) + .thenReturn(linkTask.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123", + credentialForLinking = googleCredential + ) + + verify(mockUser).linkWithCredential(googleCredential) + } + + // ============================================================================================= + // signInAndLinkWithCredential Tests + // ============================================================================================= + + @Test + fun `signInAndLinkWithCredential - successful sign in with credential`() = runTest { + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithCredential(credential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + val result = instance.signInAndLinkWithCredential( + config = config, + credential = credential + ) + + assertThat(result).isNotNull() + assertThat(result?.user).isEqualTo(mockUser) + verify(mockFirebaseAuth).signInWithCredential(credential) + } + + @Test + fun `signInAndLinkWithCredential - handles anonymous upgrade`() = runTest { + val anonymousUser = mock(FirebaseUser::class.java) + `when`(anonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(anonymousUser) + + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(anonymousUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(anonymousUser.linkWithCredential(credential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + val result = instance.signInAndLinkWithCredential( + config = config, + credential = credential + ) + + assertThat(result).isNotNull() + verify(anonymousUser).linkWithCredential(credential) + verify(mockFirebaseAuth, never()).signInWithCredential(credential) + } + + @Test + fun `signInAndLinkWithCredential - handles collision and emits MergeConflict`() = runTest { + val anonymousUser = mock(FirebaseUser::class.java) + `when`(anonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(anonymousUser) + + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + val updatedCredential = EmailAuthProvider.getCredential("test@example.com", "Pass@123") + + val collisionException = FirebaseAuthUserCollisionException( + "ERROR_CREDENTIAL_ALREADY_IN_USE", + "Credential already in use" + ) + // Set updatedCredential using reflection + val field = FirebaseAuthUserCollisionException::class.java.getDeclaredField("zza") + field.isAccessible = true + field.set(collisionException, updatedCredential) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(collisionException) + `when`(anonymousUser.linkWithCredential(credential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + try { + instance.signInAndLinkWithCredential( + config = config, + credential = credential + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException) { + // Expected + } + + val currentState = instance.authStateFlow().first { it is AuthState.MergeConflict } + assertThat(currentState).isInstanceOf(AuthState.MergeConflict::class.java) + val mergeConflict = currentState as AuthState.MergeConflict + assertThat(mergeConflict.pendingCredential).isEqualTo(updatedCredential) + } + + // ============================================================================================= + // sendPasswordResetEmail Tests + // ============================================================================================= + + @Test + fun `sendPasswordResetEmail - successfully sends reset email`() = runTest { + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`(mockFirebaseAuth.sendPasswordResetEmail("test@example.com")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + val result = instance.sendPasswordResetEmail("test@example.com") + + assertThat(result).isEqualTo("test@example.com") + verify(mockFirebaseAuth).sendPasswordResetEmail("test@example.com") + + val finalState = instance.authStateFlow().first() + assertThat(finalState is AuthState.Idle).isTrue() + } + + @Test + fun `sendPasswordResetEmail - sends with ActionCodeSettings`() = runTest { + val actionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://myapp.com/resetPassword") + .setHandleCodeInApp(false) + .build() + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`(mockFirebaseAuth.sendPasswordResetEmail("test@example.com", actionCodeSettings)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + val result = instance.sendPasswordResetEmail("test@example.com", actionCodeSettings) + + assertThat(result).isEqualTo("test@example.com") + verify(mockFirebaseAuth).sendPasswordResetEmail("test@example.com", actionCodeSettings) + } + + @Test + fun `sendPasswordResetEmail - handles user not found`() = runTest { + val userNotFoundException = FirebaseAuthInvalidUserException( + "ERROR_USER_NOT_FOUND", + "User not found" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(userNotFoundException) + `when`(mockFirebaseAuth.sendPasswordResetEmail("test@example.com")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + try { + instance.sendPasswordResetEmail("test@example.com") + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.UserNotFoundException) { + assertThat(e.cause).isEqualTo(userNotFoundException) + } + } + + @Test + fun `sendPasswordResetEmail - handles invalid email`() = runTest { + val invalidEmailException = FirebaseAuthInvalidCredentialsException( + "ERROR_INVALID_EMAIL", + "Invalid email" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(invalidEmailException) + `when`(mockFirebaseAuth.sendPasswordResetEmail("test@example.com")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + try { + instance.sendPasswordResetEmail("test@example.com") + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.InvalidCredentialsException) { + assertThat(e.cause).isEqualTo(invalidEmailException) + } + } + + @Test + fun `sendPasswordResetEmail - handles cancellation`() = runTest { + val cancellationException = CancellationException("Operation cancelled") + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(cancellationException) + `when`(mockFirebaseAuth.sendPasswordResetEmail("test@example.com")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + try { + instance.sendPasswordResetEmail("test@example.com") + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.AuthCancelledException) { + assertThat(e.message).contains("cancelled") + assertThat(e.cause).isInstanceOf(CancellationException::class.java) + } + } +} From 32637b4f9f13f4453c21f7f818b1bb5c8c92b95b Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Tue, 7 Oct 2025 02:33:02 +0100 Subject: [PATCH 15/43] wip: SignIn, SignUp, ResetPassword flows --- .../compose/ui/components/AuthTextField.kt | 8 +- .../compose/ui/screens/EmailAuthScreen.kt | 222 +++++++++++++++ .../compose/ui/screens/ResetPasswordUI.kt | 25 ++ .../ui/auth/compose/ui/screens/SignUpUI.kt | 260 +++++++++++++++++ .../compose/ui/screens/sign_in/SignInUI.kt | 267 ++++++++++++++++++ 5 files changed, 780 insertions(+), 2 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/ResetPasswordUI.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignUpUI.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/sign_in/SignInUI.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt index 66f2b475e..4bfa8577a 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt @@ -4,7 +4,9 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -89,7 +91,8 @@ fun AuthTextField( var passwordVisible by remember { mutableStateOf(false) } TextField( - modifier = modifier, + modifier = modifier + .fillMaxWidth(), value = value, onValueChange = { newValue -> onValueChange(newValue) @@ -150,7 +153,8 @@ internal fun PreviewAuthTextField() { Column( modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .padding(horizontal = 16.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt new file mode 100644 index 000000000..943f1ca24 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt @@ -0,0 +1,222 @@ +package com.firebase.ui.auth.compose.ui.screens + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseAuth + +enum class EmailAuthMode { + SignIn, + SignUp, + ResetPassword, +} + +/** + * A class passed to the content slot, containing all the necessary information to render custom + * UIs for sign-in, sign-up, and password reset flows. + * + * @param mode An enum representing the current UI mode. Use a when expression on this to render + * the correct screen. + * @param isLoading true when an asynchronous operation (like signing in or sending an email) + * is in progress. + * @param error An optional error message to display to the user. + * @param email The current value of the email input field. + * @param onEmailChange (Modes: [EmailAuthMode.SignIn], [EmailAuthMode.SignUp], + * [EmailAuthMode.ResetPassword]) A callback to be invoked when the email input changes. + * @param password An optional custom layout composable for the provider buttons. + * @param onPasswordChange (Modes: [EmailAuthMode.SignIn], [EmailAuthMode.SignUp]) The current + * value of the password input field. + * @param confirmPassword (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when the password + * input changes. + * @param onConfirmPasswordChange (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when + * the password confirmation input changes. + * @param displayName (Mode: [EmailAuthMode.SignUp]) The current value of the display name field. + * @param onDisplayNameChange (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when the + * display name input changes. + * @param onSignInClick (Mode: [EmailAuthMode.SignIn]) A callback to be invoked to attempt a + * sign-in with the provided credentials. + * @param onSignUpClick (Mode: [EmailAuthMode.SignUp]) A callback to be invoked to attempt to + * create a new account. + * @param onSendResetLinkClick (Mode: [EmailAuthMode.ResetPassword]) A callback to be invoked to + * send a password reset email. + * @param resetLinkSent (Mode: [EmailAuthMode.ResetPassword]) true after the password reset link + * has been successfully sent. + * @param onGoToSignUp A callback to switch the UI to the SignUp mode. + * @param onGoToSignIn A callback to switch the UI to the SignIn mode. + * @param onGoToResetPassword A callback to switch the UI to the ResetPassword mode. + */ +class EmailAuthContentState( + val mode: EmailAuthMode, + val isLoading: Boolean = false, + val error: String? = null, + val email: String, + val onEmailChange: (String) -> Unit, + val password: String, + val onPasswordChange: (String) -> Unit, + val confirmPassword: String, + val onConfirmPasswordChange: (String) -> Unit, + val displayName: String, + val onDisplayNameChange: (String) -> Unit, + val onSignInClick: () -> Unit, + val onSignUpClick: () -> Unit, + val onSendResetLinkClick: () -> Unit, + val resetLinkSent: Boolean = false, + val onGoToSignUp: () -> Unit, + val onGoToSignIn: () -> Unit, + val onGoToResetPassword: () -> Unit, +) + +/** + * A stateful composable that manages the logic for all email-based authentication flows, + * including sign-in, sign-up, and password reset. It exposes the state for the current mode to + * a custom UI via a trailing lambda (slot), allowing for complete visual customization. + * + * @param provider The configuration object contains rules for email auth, such as whether a + * display name is required. + * @param onSuccess + * @param onError + * @param onCancel + * @param content + */ +@Composable +fun EmailAuthScreen( + modifier: Modifier = Modifier, + provider: AuthProvider.Email, + onSuccess: (AuthResult) -> Unit, + onError: (AuthException) -> Unit, + onCancel: () -> Unit, + content: @Composable ((EmailAuthContentState) -> Unit)? = null, +) { + val mode = remember { mutableStateOf(EmailAuthMode.SignIn) } + val emailTextValue = rememberSaveable { mutableStateOf("") } + val passwordTextValue = remember { mutableStateOf("") } + + val state = EmailAuthContentState( + mode = mode.value, + displayName = "", + email = emailTextValue.value, + password = passwordTextValue.value, + confirmPassword = "", + isLoading = false, + error = null, + resetLinkSent = false, + onEmailChange = { email -> + emailTextValue.value = email + }, + onPasswordChange = { password -> + passwordTextValue.value = password + }, + onConfirmPasswordChange = { + + }, + onDisplayNameChange = { + + }, + onSignInClick = { + mode.value = EmailAuthMode.SignIn + }, + onSignUpClick = { + mode.value = EmailAuthMode.SignUp + }, + onSendResetLinkClick = { + mode.value = EmailAuthMode.ResetPassword + }, + onGoToSignUp = { + + }, + onGoToSignIn = { + + }, + onGoToResetPassword = { + + } + ) + + content?.invoke(state) +} + +@Preview +@Composable +internal fun PreviewEmailAuthScreen() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = false, + isEmailLinkForceSameDeviceEnabled = true, + actionCodeSettings = null, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + + EmailAuthScreen( + provider = provider, + onSuccess = { + + }, + onError = { + + }, + onCancel = { + + }, + ) { state -> + when (state.mode) { + EmailAuthMode.SignIn -> { + SignInUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + provider = provider, + email = state.email, + isLoading = false, + password = state.password, + onEmailChange = state.onEmailChange, + onPasswordChange = state.onPasswordChange, + onSignInClick = state.onSignInClick, + onGoToSignUp = state.onGoToSignUp, + onGoToResetPassword = state.onGoToResetPassword, + ) + } + EmailAuthMode.SignUp -> { + SignUpUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + isLoading = state.isLoading, + displayName = state.displayName, + email = state.email, + password = state.password, + confirmPassword = state.confirmPassword, + onDisplayNameChange = state.onDisplayNameChange, + onEmailChange = state.onEmailChange, + onPasswordChange = state.onPasswordChange, + onConfirmPasswordChange = state.onConfirmPasswordChange, + onSignUpClick = state.onSignUpClick, + ) + } + EmailAuthMode.ResetPassword -> { + ResetPasswordUI( + email = state.email, + resetLinkSent = state.resetLinkSent, + onEmailChange = state.onEmailChange, + onSendResetLink = state.onSendResetLinkClick, + ) + } + } + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/ResetPasswordUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/ResetPasswordUI.kt new file mode 100644 index 000000000..5abaa5fd2 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/ResetPasswordUI.kt @@ -0,0 +1,25 @@ +package com.firebase.ui.auth.compose.ui.screens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview + +@Composable +fun ResetPasswordUI( + email: String, + resetLinkSent: Boolean, + onEmailChange: (String) -> Unit, + onSendResetLink: () -> Unit, +) { + +} + +@Preview +@Composable +fun PreviewResetPasswordUI() { + ResetPasswordUI( + email = "", + resetLinkSent = false, + onEmailChange = { email -> }, + onSendResetLink = {}, + ) +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignUpUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignUpUI.kt new file mode 100644 index 000000000..394a8f564 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignUpUI.kt @@ -0,0 +1,260 @@ +package com.firebase.ui.auth.compose.ui.screens + +import android.content.Intent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator +import com.firebase.ui.auth.compose.ui.components.AuthTextField + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignUpUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + displayName: String, + email: String, + password: String, + confirmPassword: String, + onDisplayNameChange: (String) -> Unit, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onConfirmPasswordChange: (String) -> Unit, + onSignUpClick: () -> Unit, +) { + val context = LocalContext.current + val emailValidator = remember { + EmailValidator(stringProvider = DefaultAuthUIStringProvider(context)) + } + val passwordValidator = remember { + PasswordValidator( + stringProvider = DefaultAuthUIStringProvider(context), + rules = emptyList() + ) + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text("Sign Up") + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AuthTextField( + value = email, + validator = emailValidator, + enabled = !isLoading, + label = { + Text("Email") + }, + onValueChange = { text -> + onEmailChange(text) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = displayName, + validator = emailValidator, + enabled = !isLoading, + label = { + Text("First & last Name") + }, + onValueChange = { text -> + onDisplayNameChange(text) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.AccountCircle, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = password, + validator = passwordValidator, + enabled = !isLoading, + label = { + Text("Password") + }, + onValueChange = { text -> + onPasswordChange(password) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = confirmPassword, + validator = passwordValidator, + enabled = !isLoading, + label = { + Text("Confirm Password") + }, + onValueChange = { text -> + onConfirmPasswordChange(password) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + modifier = Modifier + .align(Alignment.End), + onClick = { + onSignUpClick() + }, + enabled = !isLoading, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text("Sign Up") + } + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier + .align(Alignment.End), + ) { + TextButton( + onClick = { + val intent = Intent( + Intent.ACTION_VIEW, + configuration.tosUrl?.toUri() + ) + context.startActivity(intent) + }, + contentPadding = PaddingValues.Zero, + enabled = !isLoading, + ) { + Text( + modifier = modifier, + text = "Terms of Service", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + Spacer(modifier = Modifier.width(24.dp)) + TextButton( + onClick = { + val intent = Intent( + Intent.ACTION_VIEW, + configuration.privacyPolicyUrl?.toUri() + ) + context.startActivity(intent) + }, + contentPadding = PaddingValues.Zero, + enabled = !isLoading, + ) { + Text( + modifier = modifier, + text = "Privacy Policy", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + } + } + } +} + +@Preview +@Composable +fun PreviewSignUpUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = false, + isEmailLinkForceSameDeviceEnabled = true, + actionCodeSettings = null, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + + SignUpUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + isLoading = false, + displayName = "", + email = "", + password = "", + confirmPassword = "", + onDisplayNameChange = { name -> }, + onEmailChange = { email -> }, + onPasswordChange = { password -> }, + onConfirmPasswordChange = { confirmPassword -> }, + onSignUpClick = {}, + ) +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/sign_in/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/sign_in/SignInUI.kt new file mode 100644 index 000000000..abedf2bd2 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/sign_in/SignInUI.kt @@ -0,0 +1,267 @@ +package com.firebase.ui.auth.compose.ui.screens + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator +import com.firebase.ui.auth.compose.ui.components.AuthTextField +import com.firebase.ui.auth.compose.ui.method_picker.AnnotatedStringResource +import androidx.core.net.toUri + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignInUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + provider: AuthProvider.Email, + email: String, + password: String, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onSignInClick: () -> Unit, + onGoToSignUp: () -> Unit, + onGoToResetPassword: () -> Unit, +) { + val context = LocalContext.current + val emailValidator = remember { + EmailValidator(stringProvider = DefaultAuthUIStringProvider(context)) + } + val passwordValidator = remember { + PasswordValidator( + stringProvider = DefaultAuthUIStringProvider(context), + rules = emptyList() + ) + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text("Sign In") + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AuthTextField( + value = email, + validator = emailValidator, + enabled = !isLoading, + label = { + Text("Email") + }, + onValueChange = { text -> + onEmailChange(text) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = password, + validator = passwordValidator, + enabled = !isLoading, + label = { + Text("Password") + }, + onValueChange = { text -> + onPasswordChange(password) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton( + modifier = Modifier + .align(Alignment.Start), + onClick = { + onGoToResetPassword() + }, + enabled = !isLoading, + contentPadding = PaddingValues.Zero + ) { + Text( + modifier = modifier, + text = "Trouble signing in?", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .align(Alignment.End), + ) { + // Signup is hidden for email link sign in + if (!provider.isEmailLinkSignInEnabled) { + Button( + onClick = { + onGoToSignUp() + }, + enabled = !isLoading, + ) { + Text("Sign Up") + } + Spacer(modifier = Modifier.width(16.dp)) + } + Button( + onClick = { + // TODO(demolaf): When signIn is fired if Exception is UserNotFound + // then we check if provider.isNewAccountsAllowed then we show signUp + // else we show an error dialog stating signup is not allowed + onSignInClick() + }, + enabled = !isLoading, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text("Sign In") + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier + .align(Alignment.End), + ) { + TextButton( + onClick = { + val intent = Intent( + Intent.ACTION_VIEW, + configuration.tosUrl?.toUri() + ) + context.startActivity(intent) + }, + contentPadding = PaddingValues.Zero, + enabled = !isLoading, + ) { + Text( + modifier = modifier, + text = "Terms of Service", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + Spacer(modifier = Modifier.width(24.dp)) + TextButton( + onClick = { + val intent = Intent( + Intent.ACTION_VIEW, + configuration.privacyPolicyUrl?.toUri() + ) + context.startActivity(intent) + }, + contentPadding = PaddingValues.Zero, + enabled = !isLoading, + ) { + Text( + modifier = modifier, + text = "Privacy Policy", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + } + } + } +} + +@Preview +@Composable +fun PreviewSignInUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = false, + isEmailLinkForceSameDeviceEnabled = true, + actionCodeSettings = null, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + + AuthUITheme { + SignInUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + provider = provider, + email = "", + password = "", + isLoading = false, + onEmailChange = { email -> }, + onPasswordChange = { password -> }, + onSignInClick = {}, + onGoToSignUp = {}, + onGoToResetPassword = {}, + ) + } +} \ No newline at end of file From eb38a85efc65c3f7bf37b48022367d85b73fdf32 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Tue, 7 Oct 2025 10:02:50 +0100 Subject: [PATCH 16/43] refactor: remove libs.versions.toml catalog file --- auth/build.gradle.kts | 22 +++++++++++----------- build.gradle.kts | 2 +- buildSrc/src/main/kotlin/Config.kt | 15 ++++++++++++++- gradle/libs.versions.toml | 22 ---------------------- 4 files changed, 26 insertions(+), 35 deletions(-) delete mode 100644 gradle/libs.versions.toml diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index 57462420d..3175b0a9d 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") - alias(libs.plugins.compose.compiler) + id("org.jetbrains.kotlin.plugin.compose") version Config.kotlinVersion } android { @@ -74,17 +74,17 @@ android { } dependencies { - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.ui.graphics) - implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.foundation) - implementation(libs.androidx.compose.ui.tooling) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.activity.compose) + 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) - implementation(libs.androidx.compose.material.icons.extended) + implementation(Config.Libs.Androidx.Compose.materialIconsExtended) // The new activity result APIs force us to include Fragment 1.3.0 // See https://issuetracker.google.com/issues/152554847 implementation(Config.Libs.Androidx.fragment) @@ -116,7 +116,7 @@ dependencies { testImplementation(Config.Libs.Test.robolectric) testImplementation(Config.Libs.Test.kotlinReflect) testImplementation(Config.Libs.Provider.facebook) - testImplementation(libs.androidx.ui.test.junit4) + testImplementation(Config.Libs.Test.composeUiTestJunit4) debugImplementation(project(":internal:lintchecks")) } diff --git a/build.gradle.kts b/build.gradle.kts index 105624a8b..41393496e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,7 +19,7 @@ buildscript { plugins { id("com.github.ben-manes.versions") version "0.20.0" - alias(libs.plugins.compose.compiler) apply false + id("org.jetbrains.kotlin.plugin.compose") version Config.kotlinVersion apply false } allprojects { diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index b24d297ea..a21ca69bc 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.2.0" + const val kotlinVersion = "2.2.0" object SdkVersions { const val compile = 35 @@ -41,6 +41,18 @@ object Config { 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 materialIconsExtended = "androidx.compose.material:material-icons-extended" + const val activityCompose = "androidx.activity:activity-compose:1.9.0" + } } object Firebase { @@ -84,6 +96,7 @@ object Config { const val rules = "androidx.test:rules:1.5.0" const val kotlinReflect = "org.jetbrains.kotlin:kotlin-reflect" + const val composeUiTestJunit4 = "androidx.compose.ui:ui-test-junit4" } object Lint { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml deleted file mode 100644 index befb470ce..000000000 --- a/gradle/libs.versions.toml +++ /dev/null @@ -1,22 +0,0 @@ -[versions] -kotlin = "2.2.0" -androidxComposeBom = "2025.08.00" -androidxActivityCompose = "1.9.0" - -[libraries] -# Compose -androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } -androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } -androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } -androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } -androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } -androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } -androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" } -androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } - -# Testing -androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } - -[plugins] -compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } \ No newline at end of file From 3098775f2f4ba061dabd4a1df5a58d4211515122 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Tue, 7 Oct 2025 11:34:31 +0100 Subject: [PATCH 17/43] add sample app compose module --- .../compose/ui/screens/EmailAuthScreen.kt | 30 ++-- build.gradle | 10 +- composeapp/.gitignore | 1 + composeapp/build.gradle.kts | 60 +++++++ composeapp/proguard-rules.pro | 21 +++ .../composeapp/ExampleInstrumentedTest.kt | 24 +++ composeapp/src/main/AndroidManifest.xml | 24 +++ .../com/firebase/composeapp/MainActivity.kt | 106 +++++++++++ .../com/firebase/composeapp/ui/theme/Color.kt | 11 ++ .../com/firebase/composeapp/ui/theme/Theme.kt | 58 ++++++ .../com/firebase/composeapp/ui/theme/Type.kt | 34 ++++ .../drawable-v24/ic_launcher_foreground.xml | 30 ++++ .../res/drawable/ic_launcher_background.xml | 170 ++++++++++++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes composeapp/src/main/res/values/colors.xml | 10 ++ composeapp/src/main/res/values/strings.xml | 3 + composeapp/src/main/res/values/themes.xml | 5 + .../firebase/composeapp/ExampleUnitTest.kt | 17 ++ gradle/libs.versions.toml | 18 +- settings.gradle | 1 + 31 files changed, 629 insertions(+), 16 deletions(-) create mode 100644 composeapp/.gitignore create mode 100644 composeapp/build.gradle.kts create mode 100644 composeapp/proguard-rules.pro create mode 100644 composeapp/src/androidTest/java/com/firebase/composeapp/ExampleInstrumentedTest.kt create mode 100644 composeapp/src/main/AndroidManifest.xml create mode 100644 composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt create mode 100644 composeapp/src/main/java/com/firebase/composeapp/ui/theme/Color.kt create mode 100644 composeapp/src/main/java/com/firebase/composeapp/ui/theme/Theme.kt create mode 100644 composeapp/src/main/java/com/firebase/composeapp/ui/theme/Type.kt create mode 100644 composeapp/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 composeapp/src/main/res/drawable/ic_launcher_background.xml create mode 100644 composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 composeapp/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 composeapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 composeapp/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 composeapp/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 composeapp/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 composeapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 composeapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 composeapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 composeapp/src/main/res/values/colors.xml create mode 100644 composeapp/src/main/res/values/strings.xml create mode 100644 composeapp/src/main/res/values/themes.xml create mode 100644 composeapp/src/test/java/com/firebase/composeapp/ExampleUnitTest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt index 943f1ca24..502d8b1b8 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt @@ -95,16 +95,18 @@ fun EmailAuthScreen( onCancel: () -> Unit, content: @Composable ((EmailAuthContentState) -> Unit)? = null, ) { - val mode = remember { mutableStateOf(EmailAuthMode.SignIn) } + val mode = rememberSaveable { mutableStateOf(EmailAuthMode.SignIn) } + val displayNameValue = rememberSaveable { mutableStateOf("") } val emailTextValue = rememberSaveable { mutableStateOf("") } - val passwordTextValue = remember { mutableStateOf("") } + val passwordTextValue = rememberSaveable { mutableStateOf("") } + val confirmPasswordTextValue = rememberSaveable { mutableStateOf("") } val state = EmailAuthContentState( mode = mode.value, - displayName = "", + displayName = displayNameValue.value, email = emailTextValue.value, password = passwordTextValue.value, - confirmPassword = "", + confirmPassword = confirmPasswordTextValue.value, isLoading = false, error = null, resetLinkSent = false, @@ -114,29 +116,29 @@ fun EmailAuthScreen( onPasswordChange = { password -> passwordTextValue.value = password }, - onConfirmPasswordChange = { - + onConfirmPasswordChange = { confirmPassword -> + confirmPasswordTextValue.value = confirmPassword }, - onDisplayNameChange = { - + onDisplayNameChange = { displayName -> + displayNameValue.value = displayName }, onSignInClick = { - mode.value = EmailAuthMode.SignIn + }, onSignUpClick = { - mode.value = EmailAuthMode.SignUp + }, onSendResetLinkClick = { - mode.value = EmailAuthMode.ResetPassword + }, onGoToSignUp = { - + mode.value = EmailAuthMode.SignUp }, onGoToSignIn = { - + mode.value = EmailAuthMode.SignIn }, onGoToResetPassword = { - + mode.value = EmailAuthMode.ResetPassword } ) diff --git a/build.gradle b/build.gradle index e75ec13b3..b3a5a9caa 100644 --- a/build.gradle +++ b/build.gradle @@ -1,2 +1,10 @@ -// This empty file along with settings.gradle help Android Studio recognize the project +buildscript { + dependencies { + classpath libs.kotlin.gradle.plugin + } +} +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.compose.compiler) apply false +}// This empty file along with settings.gradle help Android Studio recognize the project // as a gradle project, despite the use of .gradle.kts scripts. \ No newline at end of file diff --git a/composeapp/.gitignore b/composeapp/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/composeapp/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/composeapp/build.gradle.kts b/composeapp/build.gradle.kts new file mode 100644 index 000000000..81fbc8d02 --- /dev/null +++ b/composeapp/build.gradle.kts @@ -0,0 +1,60 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "com.firebase.composeapp" + compileSdk = 35 + + defaultConfig { + applicationId = "com.firebase.composeapp" + minSdk = 23 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":auth")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} \ No newline at end of file diff --git a/composeapp/proguard-rules.pro b/composeapp/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/composeapp/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/composeapp/src/androidTest/java/com/firebase/composeapp/ExampleInstrumentedTest.kt b/composeapp/src/androidTest/java/com/firebase/composeapp/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..65b100682 --- /dev/null +++ b/composeapp/src/androidTest/java/com/firebase/composeapp/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.firebase.composeapp + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.firebase.composeapp", appContext.packageName) + } +} \ No newline at end of file diff --git a/composeapp/src/main/AndroidManifest.xml b/composeapp/src/main/AndroidManifest.xml new file mode 100644 index 000000000..c9ecd294e --- /dev/null +++ b/composeapp/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt new file mode 100644 index 000000000..9b7927e58 --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt @@ -0,0 +1,106 @@ +package com.firebase.composeapp + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.ui.screens.EmailAuthMode +import com.firebase.ui.auth.compose.ui.screens.EmailAuthScreen +import com.firebase.ui.auth.compose.ui.screens.ResetPasswordUI +import com.firebase.ui.auth.compose.ui.screens.SignInUI +import com.firebase.ui.auth.compose.ui.screens.SignUpUI + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + AuthUITheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = false, + isEmailLinkForceSameDeviceEnabled = true, + actionCodeSettings = null, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + + EmailAuthScreen( + provider = provider, + onSuccess = { + + }, + onError = { + + }, + onCancel = { + + }, + ) { state -> + when (state.mode) { + EmailAuthMode.SignIn -> { + SignInUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + provider = provider, + email = state.email, + isLoading = false, + password = state.password, + onEmailChange = state.onEmailChange, + onPasswordChange = state.onPasswordChange, + onSignInClick = state.onSignInClick, + onGoToSignUp = state.onGoToSignUp, + onGoToResetPassword = state.onGoToResetPassword, + ) + } + EmailAuthMode.SignUp -> { + SignUpUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + isLoading = state.isLoading, + displayName = state.displayName, + email = state.email, + password = state.password, + confirmPassword = state.confirmPassword, + onDisplayNameChange = state.onDisplayNameChange, + onEmailChange = state.onEmailChange, + onPasswordChange = state.onPasswordChange, + onConfirmPasswordChange = state.onConfirmPasswordChange, + onSignUpClick = state.onSignUpClick, + ) + } + EmailAuthMode.ResetPassword -> { + ResetPasswordUI( + email = state.email, + resetLinkSent = state.resetLinkSent, + onEmailChange = state.onEmailChange, + onSendResetLink = state.onSendResetLinkClick, + ) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Color.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Color.kt new file mode 100644 index 000000000..994915613 --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.firebase.composeapp.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Theme.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Theme.kt new file mode 100644 index 000000000..08bd9441a --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.firebase.composeapp.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun FirebaseUIAndroidTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Type.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Type.kt new file mode 100644 index 000000000..ea0657654 --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.firebase.composeapp.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/composeapp/src/main/res/drawable-v24/ic_launcher_foreground.xml b/composeapp/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..fde1368fc --- /dev/null +++ b/composeapp/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/composeapp/src/main/res/drawable/ic_launcher_background.xml b/composeapp/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..1e4408cae --- /dev/null +++ b/composeapp/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/composeapp/src/main/res/mipmap-hdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/composeapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/composeapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 zcmV-Y3$650Nk&FW3jhFDMM6+kP&il$0000G0000-002h-06|PpNWB9900E$G+qN-D z+81ABX7q?;bwx%xBg?kcwr$(C-Tex-ZCkHUw(Y9#+`E5-zuONG5fgw~E2WDng@Bc@ z24xy+R1n%~6xI#u9vJ8zREI)sb<&Il(016}Z~V1n^PU3-_H17A*Bf^o)&{_uBv}Py zulRfeE8g(g6HFhk_?o_;0@tz?1I+l+Y#Q*;RVC?(ud`_cU-~n|AX-b`JHrOIqn(-t&rOg-o`#C zh0LPxmbOAEb;zHTu!R3LDh1QO zZTf-|lJNUxi-PpcbRjw3n~n-pG;$+dIF6eqM5+L();B2O2tQ~|p{PlpNcvDbd1l%c zLtXn%lu(3!aNK!V#+HNn_D3lp z2%l+hK-nsj|Bi9;V*WIcQRTt5j90A<=am+cc`J zTYIN|PsYAhJ|=&h*4wI4ebv-C=Be#u>}%m;a{IGmJDU`0snWS&$9zdrT(z8#{OZ_Y zxwJx!ZClUi%YJjD6Xz@OP8{ieyJB=tn?>zaI-4JN;rr`JQbb%y5h2O-?_V@7pG_+y z(lqAsqYr!NyVb0C^|uclHaeecG)Sz;WV?rtoqOdAAN{j%?Uo%owya(F&qps@Id|Of zo@~Y-(YmfB+chv^%*3g4k3R0WqvuYUIA+8^SGJ{2Bl$X&X&v02>+0$4?di(34{pt* zG=f#yMs@Y|b&=HyH3k4yP&goF2LJ#tBLJNNDo6lG06r}ghC-pC4Q*=x3;|+W04zte zAl>l4kzUBQFYF(E`KJy?ZXd1tnfbH+Z~SMmA21KokJNs#eqcXWKUIC>{TuoKe^vhF z);H)o`t9j~`$h1D`#bxe@E`oE`cM9w(@)5Bp8BNukIwM>wZHfd0S;5bcXA*5KT3bj zc&_~`&{z7u{Et!Z_k78H75gXf4g8<_ul!H$eVspPeU3j&&Au=2R*Zp#M9$9s;fqwgzfiX=E_?BwVcfx3tG9Q-+<5fw z%Hs64z)@Q*%s3_Xd5>S4dg$s>@rN^ixeVj*tqu3ZV)biDcFf&l?lGwsa zWj3rvK}?43c{IruV2L`hUU0t^MemAn3U~x3$4mFDxj=Byowu^Q+#wKRPrWywLjIAp z9*n}eQ9-gZmnd9Y0WHtwi2sn6n~?i#n9VN1B*074_VbZZ=WrpkMYr{RsI ztM_8X1)J*DZejxkjOTRJ&a*lrvMKBQURNP#K)a5wIitfu(CFYV4FT?LUB$jVwJSZz zNBFTWg->Yk0j&h3e*a5>B=-xM7dE`IuOQna!u$OoxLlE;WdrNlN)1 z7**de7-hZ!(%_ZllHBLg`Ir#|t>2$*xVOZ-ADZKTN?{(NUeLU9GbuG-+Axf*AZ-P1 z0ZZ*fx+ck4{XtFsbcc%GRStht@q!m*ImssGwuK+P@%gEK!f5dHymg<9nSCXsB6 zQ*{<`%^bxB($Z@5286^-A(tR;r+p7B%^%$N5h%lb*Vlz-?DL9x;!j<5>~kmXP$E}m zQV|7uv4SwFs0jUervsxVUm>&9Y3DBIzc1XW|CUZrUdb<&{@D5yuLe%Xniw^x&{A2s z0q1+owDSfc3Gs?ht;3jw49c#mmrViUfX-yvc_B*wY|Lo7; zGh!t2R#BHx{1wFXReX*~`NS-LpSX z#TV*miO^~B9PF%O0huw!1Zv>^d0G3$^8dsC6VI!$oKDKiXdJt{mGkyA`+Gwd4D-^1qtNTUK)`N*=NTG-6}=5k6suNfdLt*dt8D| z%H#$k)z#ZRcf|zDWB|pn<3+7Nz>?WW9WdkO5(a^m+D4WRJ9{wc>Y}IN)2Kbgn;_O? zGqdr&9~|$Y0tP=N(k7^Eu;iO*w+f%W`20BNo)=Xa@M_)+o$4LXJyiw{F?a633SC{B zl~9FH%?^Rm*LVz`lkULs)%idDX^O)SxQol(3jDRyBVR!7d`;ar+D7do)jQ}m`g$TevUD5@?*P8)voa?kEe@_hl{_h8j&5eB-5FrYW&*FHVt$ z$kRF9Nstj%KRzpjdd_9wO=4zO8ritN*NPk_9avYrsF(!4))tm{Ga#OY z(r{0buexOzu7+rw8E08Gxd`LTOID{*AC1m*6Nw@osfB%0oBF5sf<~wH1kL;sd zo)k6^VyRFU`)dt*iX^9&QtWbo6yE8XXH?`ztvpiOLgI3R+=MOBQ9=rMVgi<*CU%+d1PQQ0a1U=&b0vkF207%xU0ssI2 literal 0 HcmV?d00001 diff --git a/composeapp/src/main/res/mipmap-mdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/composeapp/src/main/res/mipmap-xhdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/composeapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/composeapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/composeapp/src/main/res/values/colors.xml b/composeapp/src/main/res/values/colors.xml new file mode 100644 index 000000000..f8c6127d3 --- /dev/null +++ b/composeapp/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/composeapp/src/main/res/values/strings.xml b/composeapp/src/main/res/values/strings.xml new file mode 100644 index 000000000..c226d8f8f --- /dev/null +++ b/composeapp/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + ComposeApp + \ No newline at end of file diff --git a/composeapp/src/main/res/values/themes.xml b/composeapp/src/main/res/values/themes.xml new file mode 100644 index 000000000..1f225670b --- /dev/null +++ b/composeapp/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file From c7438a9354dcc59d8771f93d6fad1e9156cd4b10 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Fri, 10 Oct 2025 09:53:44 +0100 Subject: [PATCH 43/43] fix: remove opt out of edge to edge in app module --- app/src/main/res/values-v35/styles.xml | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 app/src/main/res/values-v35/styles.xml diff --git a/app/src/main/res/values-v35/styles.xml b/app/src/main/res/values-v35/styles.xml deleted file mode 100644 index f998c7f72..000000000 --- a/app/src/main/res/values-v35/styles.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - \ No newline at end of file