From ad1392d0860194bd1ef72ca609754bd8833c9355 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Tue, 7 Oct 2025 17:28:50 +0100 Subject: [PATCH 1/9] feat: Email provider integration - added: sign in, sign up, reset password, email link and anonymous auto upgrade - upgrade mockito - fixed spying mocked objects in new library test error --- auth/build.gradle.kts | 20 +- .../firebase/ui/auth/compose/AuthException.kt | 76 +- .../com/firebase/ui/auth/compose/AuthState.kt | 61 +- .../ui/auth/compose/FirebaseAuthUI.kt | 5 +- .../configuration/AuthUIConfiguration.kt | 6 +- .../compose/configuration/PasswordRule.kt | 2 +- .../{ => auth_provider}/AuthProvider.kt | 282 +++++- .../EmailAuthProvider+FirebaseAuthUI.kt | 954 ++++++++++++++++++ .../AuthUIStringProviderSample.kt | 2 +- .../theme/ProviderStyleDefaults.kt | 2 +- .../ui/components/AuthProviderButton.kt | 5 +- .../ui/method_picker/AuthMethodPicker.kt | 2 +- .../util/EmailLinkPersistenceManager.kt | 178 ++++ .../ui/auth/compose/FirebaseAuthUITest.kt | 32 +- .../configuration/AuthUIConfigurationTest.kt | 1 + .../{ => auth_provider}/AuthProviderTest.kt | 16 +- .../EmailAuthProviderFirebaseAuthUITest.kt | 714 +++++++++++++ .../ui/components/AuthProviderButtonTest.kt | 4 +- .../ui/method_picker/AuthMethodPickerTest.kt | 2 +- .../ui/auth/testhelpers/TestHelper.java | 23 +- buildSrc/src/main/kotlin/Config.kt | 5 + 21 files changed, 2319 insertions(+), 73 deletions(-) rename auth/src/main/java/com/firebase/ui/auth/compose/configuration/{ => auth_provider}/AuthProvider.kt (52%) 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/build.gradle.kts b/auth/build.gradle.kts index 3175b0a9d..0fd30ade8 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -85,12 +85,13 @@ dependencies { implementation(Config.Libs.Androidx.materialDesign) implementation(Config.Libs.Androidx.activity) implementation(Config.Libs.Androidx.Compose.materialIconsExtended) + implementation(Config.Libs.Androidx.datastorePreferences) // 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) implementation(Config.Libs.Androidx.customTabs) implementation(Config.Libs.Androidx.constraint) - implementation("androidx.credentials:credentials:1.3.0") + implementation(Config.Libs.Androidx.credentials) implementation("androidx.credentials:credentials-play-services-auth:1.3.0") implementation(Config.Libs.Androidx.lifecycleExtensions) @@ -111,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(Config.Libs.Test.mockitoCore) + testImplementation(Config.Libs.Test.mockitoInline) + testImplementation(Config.Libs.Test.mockitoKotlin) + testImplementation(Config.Libs.Androidx.credentials) testImplementation(Config.Libs.Test.composeUiTestJunit4) debugImplementation(project(":internal:lintchecks")) } + +val mockitoAgent by configurations.creating + +dependencies { + mockitoAgent(Config.Libs.Test.mockitoCore) { + isTransitive = false + } +} + +tasks.withType().configureEach { + jvmArgs("-javaagent:${mockitoAgent.asPath}") +} 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..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,8 @@ 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 import com.google.firebase.auth.MultiFactorResolver @@ -204,6 +206,63 @@ 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() { + 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. @@ -219,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/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt index fe1f6cf80..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 @@ -168,7 +168,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!! @@ -374,7 +375,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/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt index 6fb0202de..71762628b 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 @@ -20,11 +20,11 @@ import com.google.firebase.auth.ActionCodeSettings import androidx.compose.ui.graphics.vector.ImageVector 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.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.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/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 52% 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 87008cd28..1d4c875f5 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,23 +12,34 @@ * 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.compose.ui.graphics.Color +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.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 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.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.UserProfileChangeRequest +import com.google.firebase.auth.actionCodeSettings +import kotlinx.coroutines.tasks.await @AuthUIConfigurationDsl class AuthProvidersBuilder { @@ -44,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"), @@ -76,6 +87,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. */ @@ -118,7 +197,18 @@ abstract class AuthProvider(open val providerId: String) { */ val passwordValidationRules: List ) : AuthProvider(providerId = Provider.EMAIL.id) { - fun validate() { + companion object { + 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") + } + + internal fun validate() { if (isEmailLinkSignInEnabled) { val actionCodeSettings = requireNotNull(actionCodeSettings) { "ActionCodeSettings cannot be null when using " + @@ -131,6 +221,172 @@ 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, + 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 + ) + } + } + + // For Sign In With Email Link + internal 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() } /** @@ -172,7 +428,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" @@ -235,7 +491,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, @@ -287,7 +543,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 " + @@ -414,7 +670,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. " + @@ -467,7 +723,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..07963a936 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,954 @@ +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 +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.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 + +/** + * Holds credential information for account linking with email link sign-in. + * + * 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) + */ +internal 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 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 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 + * + * @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: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, + 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...")) + 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) + } + } + 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", + 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 credential. + * + * 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. 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 context Android [Context] for creating scratch auth instance + * @param config Auth UI configuration describing provider settings + * @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 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 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: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, + 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) + + // Check if we're linking a social provider credential + val isSocialProvider = credentialForLinking != null && + (Provider.fromId(credentialForLinking.provider)?.isSocialProvider ?: false) + + // 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) + + 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 { + // 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 + 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) + } + } 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. + * + * 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) + * - 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, + displayName: String? = null, + photoUrl: Uri? = null +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Signing in user...")) + 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) + } + } 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", + 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. Optionally supports account linking when a user tries to sign in with + * a social provider but an email link account exists. + * + * **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 + * + * **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 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...")) + + // 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) + + // 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 = + 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, +): AuthResult { + try { + updateAuthState(AuthState.Loading("Signing in with email link...")) + + // Validate link format + if (!auth.isSignInWithEmailLink(emailLink)) { + throw AuthException.InvalidEmailLinkException() + } + + // 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 + 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 + + // Check if this is a different device flow + val isDifferentDevice = provider.isDifferentDevice( + sessionIdFromLocal = storedSessionId, + sessionIdFromLink = sessionIdFromLink + ) + + if (isDifferentDevice) { + // Handle cross-device flow + provider.handleCrossDeviceEmailLink( + auth = auth, + sessionIdFromLink = sessionIdFromLink, + anonymousUserIdFromLink = anonymousUserIdFromLink, + isEmailLinkForceSameDeviceEnabled = isEmailLinkForceSameDeviceEnabled, + oobCode = oobCode, + providerIdFromLink = providerIdFromLink + ) + } + + // 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() + } + } + + // Get credential for linking from session record + val storedCredentialForLink = sessionRecord?.credentialForLinking + val emailLinkCredential = EmailAuthProvider.getCredentialWithLink(email, emailLink) + + 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 + ) + } + + // Clear DataStore after success + EmailLinkPersistenceManager.clear(context) + + return result + } 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 + } +} + +/** + * 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/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 255d6c59e..d67339eb7 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 @@ -8,7 +8,6 @@ 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.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -25,8 +24,8 @@ 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.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 855e3d6b3..58466f37e 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 @@ -19,7 +19,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 5fd0d201c..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 @@ -14,7 +14,11 @@ package com.firebase.ui.auth.compose +import android.content.Context import androidx.test.core.app.ApplicationProvider +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 @@ -22,21 +26,29 @@ 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.EmailAuthProvider 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.verify 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 @@ -64,7 +76,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() } @@ -346,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) @@ -364,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 { @@ -385,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 { @@ -414,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) @@ -431,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 { @@ -459,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 { @@ -485,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 { @@ -511,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 { 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..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) + } + } +} 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..c921651f6 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,6 @@ 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.string_provider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset @@ -41,6 +39,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.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.Provider /** * Unit tests for [AuthProviderButton] covering UI interactions, styling, 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 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..2641d98aa 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 @@ -45,6 +45,7 @@ import static com.firebase.ui.auth.AuthUI.EMAIL_LINK_PROVIDER; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; @@ -75,11 +76,17 @@ public static void initialize() { } private static void spyContextAndResources() { - CONTEXT = spy(CONTEXT); - when(CONTEXT.getApplicationContext()) - .thenReturn(CONTEXT); - Resources spiedResources = spy(CONTEXT.getResources()); - when(CONTEXT.getResources()).thenReturn(spiedResources); + // 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); + } + doReturn(CONTEXT).when(CONTEXT).getApplicationContext(); + + Resources resources = CONTEXT.getResources(); + if (!org.mockito.Mockito.mockingDetails(resources).isSpy()) { + resources = spy(resources); + } + doReturn(resources).when(CONTEXT).getResources(); } private static void initializeApp(Context context) { @@ -94,9 +101,9 @@ private static void initializeApp(Context context) { } private static void initializeProviders() { - when(CONTEXT.getString(R.string.firebase_web_host)).thenReturn("abc"); - when(CONTEXT.getString(R.string.default_web_client_id)).thenReturn("abc"); - when(CONTEXT.getString(R.string.facebook_application_id)).thenReturn("abc"); + doReturn("abc").when(CONTEXT).getString(R.string.firebase_web_host); + doReturn("abc").when(CONTEXT).getString(R.string.default_web_client_id); + doReturn("abc").when(CONTEXT).getString(R.string.facebook_application_id); } public static FirebaseUser getMockFirebaseUser() { diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index a21ca69bc..481ee19cd 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -42,6 +42,8 @@ object Config { const val recyclerView = "androidx.recyclerview:recyclerview:1.2.1" const val materialDesign = "com.google.android.material:material:1.4.0" + const val datastorePreferences = "androidx.datastore:datastore-preferences:1.1.1" + const val credentials = "androidx.credentials:credentials:1.3.0" object Compose { const val bom = "androidx.compose:compose-bom:2025.08.00" const val ui = "androidx.compose.ui:ui" @@ -88,6 +90,9 @@ object Config { const val junitExt = "androidx.test.ext:junit:1.1.5" const val truth = "com.google.truth:truth:0.42" const val mockito = "org.mockito:mockito-android:2.21.0" + const val mockitoCore = "org.mockito:mockito-core:5.19.0" + const val mockitoInline = "org.mockito:mockito-inline:5.2.0" + const val mockitoKotlin = "org.mockito.kotlin:mockito-kotlin:6.0.0" const val robolectric = "org.robolectric:robolectric:4.14" const val core = "androidx.test:core:1.5.0" From 84b914af0a832a2329935d8095bc8522976d6550 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Wed, 8 Oct 2025 00:18:50 +0100 Subject: [PATCH 2/9] feat: add PasswordResetLinkSent state --- .../com/firebase/ui/auth/compose/AuthState.kt | 9 ++++ .../EmailAuthProvider+FirebaseAuthUI.kt | 24 ++++------- .../EmailAuthProviderFirebaseAuthUITest.kt | 42 ++++++++++++------- 3 files changed, 44 insertions(+), 31 deletions(-) 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 c2f9d8a99..676327eeb 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 @@ -263,6 +263,15 @@ abstract class AuthState private constructor() { "AuthState.MergeConflict(pendingCredential=$pendingCredential)" } + /** + * Password reset link has been sent to the user's email. + */ + class PasswordResetLinkSent : AuthState() { + override fun equals(other: Any?): Boolean = other is PasswordResetLinkSent + override fun hashCode(): Int = javaClass.hashCode() + override fun toString(): String = "AuthState.PasswordResetLinkSent" + } + companion object { /** * Creates an Idle state instance. 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 07963a936..9855a3193 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 @@ -860,8 +860,9 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( * **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) + * 3. Emit [AuthState.PasswordResetLinkSent] state + * 4. User clicks link in email to reset password + * 5. 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] @@ -872,8 +873,6 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( * @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 @@ -883,7 +882,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( * **Example 1: Basic password reset** * ```kotlin * try { - * val email = firebaseAuthUI.sendPasswordResetEmail( + * firebaseAuthUI.sendPasswordResetEmail( * email = "user@example.com" * ) * // Show success message: "Password reset email sent to $email" @@ -906,7 +905,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( * ) * .build() * - * val email = firebaseAuthUI.sendPasswordResetEmail( + * firebaseAuthUI.sendPasswordResetEmail( * email = "user@example.com", * actionCodeSettings = actionCodeSettings * ) @@ -924,18 +923,11 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( 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 + auth.sendPasswordResetEmail(email, actionCodeSettings).await() + updateAuthState(AuthState.PasswordResetLinkSent()) } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Send password reset email 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 index 0fa5cf7e4..6ffb55ca0 100644 --- 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 @@ -618,18 +618,22 @@ class EmailAuthProviderFirebaseAuthUITest { fun `sendPasswordResetEmail - successfully sends reset email`() = runTest { val taskCompletionSource = TaskCompletionSource() taskCompletionSource.setResult(null) - `when`(mockFirebaseAuth.sendPasswordResetEmail("test@example.com")) - .thenReturn(taskCompletionSource.task) + `when`(mockFirebaseAuth.sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + )).thenReturn(taskCompletionSource.task) val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) - val result = instance.sendPasswordResetEmail("test@example.com") + instance.sendPasswordResetEmail("test@example.com") - assertThat(result).isEqualTo("test@example.com") - verify(mockFirebaseAuth).sendPasswordResetEmail("test@example.com") + verify(mockFirebaseAuth).sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + ) - val finalState = instance.authStateFlow().first() - assertThat(finalState is AuthState.Idle).isTrue() + val finalState = instance.authStateFlow().first { it is AuthState.PasswordResetLinkSent } + assertThat(finalState).isInstanceOf(AuthState.PasswordResetLinkSent::class.java) } @Test @@ -645,10 +649,12 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) - val result = instance.sendPasswordResetEmail("test@example.com", actionCodeSettings) + instance.sendPasswordResetEmail("test@example.com", actionCodeSettings) - assertThat(result).isEqualTo("test@example.com") verify(mockFirebaseAuth).sendPasswordResetEmail("test@example.com", actionCodeSettings) + + val finalState = instance.authStateFlow().first { it is AuthState.PasswordResetLinkSent } + assertThat(finalState).isInstanceOf(AuthState.PasswordResetLinkSent::class.java) } @Test @@ -659,8 +665,10 @@ class EmailAuthProviderFirebaseAuthUITest { ) val taskCompletionSource = TaskCompletionSource() taskCompletionSource.setException(userNotFoundException) - `when`(mockFirebaseAuth.sendPasswordResetEmail("test@example.com")) - .thenReturn(taskCompletionSource.task) + `when`(mockFirebaseAuth.sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + )).thenReturn(taskCompletionSource.task) val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) @@ -680,8 +688,10 @@ class EmailAuthProviderFirebaseAuthUITest { ) val taskCompletionSource = TaskCompletionSource() taskCompletionSource.setException(invalidEmailException) - `when`(mockFirebaseAuth.sendPasswordResetEmail("test@example.com")) - .thenReturn(taskCompletionSource.task) + `when`(mockFirebaseAuth.sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + )).thenReturn(taskCompletionSource.task) val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) @@ -698,8 +708,10 @@ class EmailAuthProviderFirebaseAuthUITest { val cancellationException = CancellationException("Operation cancelled") val taskCompletionSource = TaskCompletionSource() taskCompletionSource.setException(cancellationException) - `when`(mockFirebaseAuth.sendPasswordResetEmail("test@example.com")) - .thenReturn(taskCompletionSource.task) + `when`(mockFirebaseAuth.sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + )).thenReturn(taskCompletionSource.task) val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) From 9c665cad8bc2e19e1d2d66d7ffe42c4ef0c463c1 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Wed, 8 Oct 2025 04:30:28 +0100 Subject: [PATCH 3/9] chore: remove unused methods --- .../auth_provider/AuthProvider.kt | 125 ------------------ .../EmailAuthProvider+FirebaseAuthUI.kt | 109 +++++++++++---- 2 files changed, 84 insertions(+), 150 deletions(-) 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 1d4c875f5..6ad27d4a1 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 @@ -222,131 +222,6 @@ 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 9855a3193..ebd2207dc 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,6 +7,8 @@ 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.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider.Companion.mergeProfile import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager import com.firebase.ui.auth.util.data.EmailLinkParser import com.firebase.ui.auth.util.data.SessionUtils @@ -122,7 +124,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( email: String, password: String ): AuthResult? { - val canUpgrade = AuthProvider.canUpgradeAnonymous(config, auth) + val canUpgrade = canUpgradeAnonymous(config, auth) val pendingCredential = if (canUpgrade) EmailAuthProvider.getCredential(email, password) else null @@ -160,7 +162,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( }.also { authResult -> authResult?.user?.let { // Merge display name into profile (photoUri is always null for email/password) - AuthProvider.mergeProfile(auth, name, null) + mergeProfile(auth, name, null) } } updateAuthState(AuthState.Idle) @@ -221,7 +223,6 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( * * @throws AuthException.InvalidCredentialsException if email or password is incorrect * @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 for network-related failures * @@ -291,7 +292,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( ): AuthResult? { try { updateAuthState(AuthState.Loading("Signing in...")) - return if (AuthProvider.canUpgradeAnonymous(config, auth)) { + return if (canUpgradeAnonymous(config, auth)) { // Anonymous upgrade flow: validate credential in scratch auth val credentialToValidate = EmailAuthProvider.getCredential(email, password) @@ -338,7 +339,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( .also { linkResult -> // Merge profile from social provider linkResult?.user?.let { user -> - AuthProvider.mergeProfile( + mergeProfile( auth, user.displayName, user.photoUrl @@ -465,14 +466,14 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( ): AuthResult? { try { updateAuthState(AuthState.Loading("Signing in user...")) - return if (AuthProvider.canUpgradeAnonymous(config, auth)) { + return if (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) + mergeProfile(auth, displayName, photoUrl) } updateAuthState(AuthState.Idle) } @@ -480,7 +481,7 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( // Special handling for collision exceptions val authException = AuthException.from(e) - if (AuthProvider.canUpgradeAnonymous(config, auth)) { + if (canUpgradeAnonymous(config, auth)) { // Anonymous upgrade collision: emit merge conflict with updated credential val updatedCredential = e.updatedCredential if (updatedCredential != null) { @@ -664,7 +665,7 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( // 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 + if (canUpgradeAnonymous(config, auth)) (auth.currentUser?.uid ?: "") else "" // Generate sessionId @@ -791,14 +792,26 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( if (isDifferentDevice) { // Handle cross-device flow - provider.handleCrossDeviceEmailLink( - auth = auth, - sessionIdFromLink = sessionIdFromLink, - anonymousUserIdFromLink = anonymousUserIdFromLink, - isEmailLinkForceSameDeviceEnabled = isEmailLinkForceSameDeviceEnabled, - oobCode = oobCode, - providerIdFromLink = providerIdFromLink - ) + // 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() } // Validate anonymous user ID matches (same-device flow) @@ -819,14 +832,60 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( ?: 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 - ) + if (canUpgradeAnonymous(config, auth)) { + // Anonymous upgrade: Use safe link pattern with scratch auth + val appExplicitlyForValidation = 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( + 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( + AuthState.Success( + result = linkResult, + user = linkResult.user!!, + isNewUser = linkResult.additionalUserInfo?.isNewUser ?: false + ) + ) + } + + linkResult!! + } } // Clear DataStore after success From e31ffec07204e3d33a06e79f91de0488bf2c6384 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Wed, 8 Oct 2025 11:27:21 +0100 Subject: [PATCH 4/9] chore: remove unused comments and code --- .../com/firebase/ui/auth/compose/AuthState.kt | 31 ----------- .../EmailAuthProvider+FirebaseAuthUI.kt | 55 +------------------ 2 files changed, 3 insertions(+), 83 deletions(-) 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 676327eeb..481e1cfa1 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,7 +14,6 @@ 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 @@ -206,36 +205,6 @@ 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. * 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 ebd2207dc..e3a33911e 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 @@ -109,12 +109,6 @@ internal class CredentialForLinking( * // This means email already exists - show merge conflict UI * } * ``` - * - * **Old library reference:** - * - 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, @@ -172,11 +166,6 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( 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) { @@ -276,12 +265,6 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( * // UI shows merge conflict resolution screen * } * ``` - * - * **Old library reference:** - * - 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, @@ -450,13 +433,6 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( * ) * // User signed in with email link (passwordless) * ``` - * - * **Old library reference:** - * - AuthOperationManager.java:76-84 (signInAndLinkWithCredential implementation) - * - 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, @@ -489,10 +465,6 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( } 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) { @@ -640,13 +612,6 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( * // 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 signInWithEmailLink * @see EmailLinkPersistenceManager.saveCredentialForLinking * @see com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail @@ -742,14 +707,6 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( * @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( @@ -758,7 +715,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( provider: AuthProvider.Email, email: String, emailLink: String, -): AuthResult { +): AuthResult? { try { updateAuthState(AuthState.Loading("Signing in with email link...")) @@ -859,7 +816,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( } // Return the link result (will be non-null if successful) - linkResult!! + linkResult } else { // Non-upgrade: Sign in with email link, then link social credential val emailLinkResult = auth.signInWithCredential(emailLinkCredential).await() @@ -884,7 +841,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( ) } - linkResult!! + linkResult } } @@ -971,13 +928,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( * // 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, From 01e873f9b3bb085a38dd8837b55d7d7f10797b83 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Wed, 8 Oct 2025 11:34:03 +0100 Subject: [PATCH 5/9] chore: remove unused imports, reformat --- .../firebase/ui/auth/compose/AuthException.kt | 3 ++- .../com/firebase/ui/auth/compose/AuthState.kt | 14 +++++++------ .../ui/auth/compose/FirebaseAuthUI.kt | 2 +- .../configuration/AuthUIConfiguration.kt | 8 ++++---- .../auth_provider/AuthProvider.kt | 4 ++-- .../AuthUIStringProviderSample.kt | 2 +- .../DefaultAuthUIStringProvider.kt | 6 +++++- .../validators/PasswordValidator.kt | 2 +- .../ui/components/AuthProviderButton.kt | 3 +-- .../ui/components/ErrorRecoveryDialog.kt | 3 +++ .../compose/FirebaseAuthUIAuthStateTest.kt | 4 ++-- .../ui/auth/compose/FirebaseAuthUITest.kt | 20 ++++--------------- .../EmailAuthProviderFirebaseAuthUITest.kt | 8 ++++---- .../validators/PasswordValidatorTest.kt | 2 +- .../ui/components/AuthProviderButtonTest.kt | 6 +++--- 15 files changed, 42 insertions(+), 45 deletions(-) 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 a111ae867..a1206b10b 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 @@ -235,7 +235,8 @@ abstract class AuthException( cause: Throwable? = null ) : AuthException( "You are are attempting to sign in a different email than previously " + - "provided", cause) + "provided", cause + ) companion object { /** 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 481e1cfa1..db11dc4f2 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,8 @@ package com.firebase.ui.auth.compose +import com.firebase.ui.auth.compose.AuthState.Companion.Cancelled +import com.firebase.ui.auth.compose.AuthState.Companion.Idle import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseUser @@ -73,8 +75,8 @@ abstract class AuthState private constructor() { if (this === other) return true if (other !is Success) return false return result == other.result && - user == other.user && - isNewUser == other.isNewUser + user == other.user && + isNewUser == other.isNewUser } override fun hashCode(): Int { @@ -102,7 +104,7 @@ abstract class AuthState private constructor() { if (this === other) return true if (other !is Error) return false return exception == other.exception && - isRecoverable == other.isRecoverable + isRecoverable == other.isRecoverable } override fun hashCode(): Int { @@ -138,7 +140,7 @@ abstract class AuthState private constructor() { if (this === other) return true if (other !is RequiresMfa) return false return resolver == other.resolver && - hint == other.hint + hint == other.hint } override fun hashCode(): Int { @@ -165,7 +167,7 @@ abstract class AuthState private constructor() { if (this === other) return true if (other !is RequiresEmailVerification) return false return user == other.user && - email == other.email + email == other.email } override fun hashCode(): Int { @@ -192,7 +194,7 @@ abstract class AuthState private constructor() { if (this === other) return true if (other !is RequiresProfileCompletion) return false return user == other.user && - missingFields == other.missingFields + missingFields == other.missingFields } override fun hashCode(): Int { 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 12032abc6..21617b1b7 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 @@ -14,6 +14,7 @@ package com.firebase.ui.auth.compose +import android.content.Context import androidx.annotation.RestrictTo import com.google.firebase.FirebaseApp import com.google.firebase.auth.FirebaseAuth @@ -21,7 +22,6 @@ import com.google.firebase.auth.FirebaseAuth.AuthStateListener import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.ktx.auth import com.google.firebase.ktx.Firebase -import android.content.Context import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow 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 71762628b..1267aea84 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 @@ -15,15 +15,15 @@ package com.firebase.ui.auth.compose.configuration 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.string_provider.AuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider 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 +import com.google.firebase.auth.ActionCodeSettings +import java.util.Locale fun authUIConfiguration(block: AuthUIConfigurationBuilder.() -> Unit) = AuthUIConfigurationBuilder().apply(block).build() 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 6ad27d4a1..0f2a2a891 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,7 +20,6 @@ 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 @@ -136,7 +135,8 @@ abstract class AuthProvider(open val providerId: String) { } // Build profile update with provided values - val nameToSet = if (currentDisplayName.isNullOrEmpty()) displayName else currentDisplayName + val nameToSet = + if (currentDisplayName.isNullOrEmpty()) displayName else currentDisplayName val photoToSet = currentPhotoUrl ?: photoUri if (nameToSet != null || photoToSet != null) { 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 c0beeaec9..453f28cda 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,9 +15,9 @@ package com.firebase.ui.auth.compose.configuration.string_provider import android.content.Context -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 +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider class AuthUIStringProviderSample { /** diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt index 5eba036af..5c5e867e5 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -196,7 +196,11 @@ class DefaultAuthUIStringProvider( override val userNotFoundRecoveryMessage: String get() = localizedContext.getString(R.string.fui_error_email_does_not_exist) override val weakPasswordRecoveryMessage: String - get() = localizedContext.resources.getQuantityString(R.plurals.fui_error_weak_password, 6, 6) + get() = localizedContext.resources.getQuantityString( + R.plurals.fui_error_weak_password, + 6, + 6 + ) override val emailAlreadyInUseRecoveryMessage: String get() = localizedContext.getString(R.string.fui_email_account_creation_error) override val tooManyRequestsRecoveryMessage: String 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 2d9efafc1..65f163fea 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,8 +14,8 @@ package com.firebase.ui.auth.compose.configuration.validators -import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider internal class PasswordValidator( override val stringProvider: AuthUIStringProvider, 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 d67339eb7..8bed40873 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 @@ -1,7 +1,6 @@ package com.firebase.ui.auth.compose.ui.components 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 @@ -14,13 +13,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon 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.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt index 732a48662..6698adc67 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt @@ -135,6 +135,7 @@ private fun getRecoveryMessage( "$baseMessage\n\nReason: $reason" } ?: baseMessage } + is AuthException.EmailAlreadyInUseException -> { // Include email if available val baseMessage = stringProvider.emailAlreadyInUseRecoveryMessage @@ -142,6 +143,7 @@ private fun getRecoveryMessage( "$baseMessage ($email)" } ?: baseMessage } + is AuthException.TooManyRequestsException -> stringProvider.tooManyRequestsRecoveryMessage is AuthException.MfaRequiredException -> stringProvider.mfaRequiredRecoveryMessage is AuthException.AccountLinkingRequiredException -> stringProvider.accountLinkingRequiredRecoveryMessage @@ -173,6 +175,7 @@ private fun getRecoveryActionText( is AuthException.WeakPasswordException, is AuthException.TooManyRequestsException, is AuthException.UnknownException -> stringProvider.retryAction + else -> stringProvider.retryAction } } diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt index 333f00c7a..98e72d6fd 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt @@ -24,11 +24,11 @@ import com.google.firebase.auth.FirebaseAuth.AuthStateListener import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactorResolver import com.google.firebase.auth.UserInfo +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch -import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before @@ -36,9 +36,9 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentCaptor import org.mockito.Mock -import org.mockito.Mockito.`when` import org.mockito.Mockito.mock import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config 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 3ace65f8a..6b61afaae 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,9 +16,7 @@ package com.firebase.ui.auth.compose import android.content.Context import androidx.test.core.app.ApplicationProvider -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.android.gms.tasks.TaskCompletionSource import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseException @@ -26,29 +24,19 @@ 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.TaskCompletionSource -import com.google.firebase.auth.AuthCredential -import com.google.firebase.auth.AuthResult -import com.google.firebase.auth.EmailAuthProvider 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.verify import org.mockito.Mockito.doNothing import org.mockito.Mockito.doThrow -import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` 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 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 index 6ffb55ca0..79bbbcfef 100644 --- 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 @@ -22,6 +22,7 @@ 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.android.gms.tasks.TaskCompletionSource import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions @@ -35,7 +36,6 @@ 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 @@ -45,12 +45,12 @@ 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.anyString 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.Mockito.verify +import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config 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 27d34b6a6..af36c1e18 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,9 +17,9 @@ 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.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.PasswordRule 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/ui/components/AuthProviderButtonTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt index c921651f6..3baa351e0 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,6 +27,9 @@ 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.R +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 @@ -38,9 +41,6 @@ import org.junit.Test 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.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.compose.configuration.auth_provider.Provider /** * Unit tests for [AuthProviderButton] covering UI interactions, styling, From f8a3014d03e2205ddcf2e2c13619aac0dc67214c Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Wed, 8 Oct 2025 11:42:48 +0100 Subject: [PATCH 6/9] chore: remove comments --- .../compose/configuration/auth_provider/AuthProvider.kt | 6 ------ 1 file changed, 6 deletions(-) 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 0f2a2a891..1c9b88048 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 @@ -109,11 +109,6 @@ abstract class AuthProvider(open val providerId: String) { * @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. */ @@ -149,7 +144,6 @@ abstract class AuthProvider(open val providerId: String) { } } 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) } } From edbf2416fcffa10a89fef9b46db55e8aecf335ae Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Wed, 8 Oct 2025 11:44:08 +0100 Subject: [PATCH 7/9] chore: remove comments --- .../util/EmailLinkPersistenceManager.kt | 20 ------------------- 1 file changed, 20 deletions(-) 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 index 47b5e9e1e..3764151c0 100644 --- 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 @@ -36,9 +36,6 @@ private val Context.dataStore: DataStore by preferencesDataStore(na * - 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 { @@ -46,9 +43,6 @@ 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 @@ -74,11 +68,6 @@ object EmailLinkPersistenceManager { * 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 @@ -100,9 +89,6 @@ object EmailLinkPersistenceManager { /** * 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 */ @@ -142,9 +128,6 @@ object EmailLinkPersistenceManager { /** * 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) { @@ -161,9 +144,6 @@ object EmailLinkPersistenceManager { /** * 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 From aeec7dccd92e9c8b2577313b2499c4f191ad01f4 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Wed, 8 Oct 2025 13:10:44 +0100 Subject: [PATCH 8/9] handle authState exceptions --- .../auth_provider/EmailAuthProvider+FirebaseAuthUI.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 e3a33911e..d917e80d6 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 @@ -166,6 +166,8 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( if (canUpgrade && pendingCredential != null) { // Anonymous upgrade collision: emit merge conflict state updateAuthState(AuthState.MergeConflict(pendingCredential)) + } else { + updateAuthState(AuthState.Error(authException)) } throw authException } catch (e: CancellationException) { @@ -465,6 +467,8 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( } else { updateAuthState(AuthState.Error(authException)) } + } else { + updateAuthState(AuthState.Error(authException)) } throw authException } catch (e: CancellationException) { From 16c6126b053943b17bb5365f9241bdf8af5ffbe9 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Wed, 8 Oct 2025 13:18:17 +0100 Subject: [PATCH 9/9] fix: mockito 5 upgrade stubbing issues --- .../com/firebase/ui/auth/testhelpers/TestHelper.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 2641d98aa..2311a45b8 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 @@ -76,17 +76,19 @@ public static void initialize() { } private static void spyContextAndResources() { - // In Mockito 5.x, we need to avoid spying on objects that are already mocks/spies + // In Mockito 5.x with inline mock maker, we can spy on final classes + // Use doReturn().when() pattern to avoid calling real methods during stubbing if (!org.mockito.Mockito.mockingDetails(CONTEXT).isSpy()) { CONTEXT = spy(CONTEXT); } doReturn(CONTEXT).when(CONTEXT).getApplicationContext(); - Resources resources = CONTEXT.getResources(); - if (!org.mockito.Mockito.mockingDetails(resources).isSpy()) { - resources = spy(resources); + // Get and spy on Resources, ensuring the spy is properly returned + Resources originalResources = CONTEXT.getResources(); + if (!org.mockito.Mockito.mockingDetails(originalResources).isSpy()) { + Resources spiedResources = spy(originalResources); + doReturn(spiedResources).when(CONTEXT).getResources(); } - doReturn(resources).when(CONTEXT).getResources(); } private static void initializeApp(Context context) {