diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index b26051d1c..97267737b 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -74,22 +74,24 @@ android { } dependencies { - implementation(platform(Config.Libs.Androidx.Compose.bom)) - implementation(Config.Libs.Androidx.Compose.ui) - implementation(Config.Libs.Androidx.Compose.uiGraphics) - implementation(Config.Libs.Androidx.Compose.material3) - implementation(Config.Libs.Androidx.Compose.foundation) - implementation(Config.Libs.Androidx.Compose.tooling) - implementation(Config.Libs.Androidx.Compose.toolingPreview) - implementation(Config.Libs.Androidx.Compose.activityCompose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.activity.compose) implementation(Config.Libs.Androidx.materialDesign) implementation(Config.Libs.Androidx.activity) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.datastore.preferences) // The new activity result APIs force us to include Fragment 1.3.0 // See https://issuetracker.google.com/issues/152554847 implementation(Config.Libs.Androidx.fragment) implementation(Config.Libs.Androidx.customTabs) implementation(Config.Libs.Androidx.constraint) - implementation("androidx.credentials:credentials:1.3.0") + implementation(libs.androidx.credentials) implementation("androidx.credentials:credentials-play-services-auth:1.3.0") implementation(Config.Libs.Androidx.lifecycleExtensions) @@ -110,12 +112,27 @@ dependencies { testImplementation(Config.Libs.Test.junit) testImplementation(Config.Libs.Test.truth) - testImplementation(Config.Libs.Test.mockito) testImplementation(Config.Libs.Test.core) testImplementation(Config.Libs.Test.robolectric) testImplementation(Config.Libs.Test.kotlinReflect) testImplementation(Config.Libs.Provider.facebook) testImplementation(libs.androidx.ui.test.junit4) + testImplementation(libs.mockito) + testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.androidx.credentials) debugImplementation(project(":internal:lintchecks")) } + +val mockitoAgent by configurations.creating + +dependencies { + mockitoAgent(libs.mockito) { + isTransitive = false + } +} + +tasks.withType().configureEach { + jvmArgs("-javaagent:${mockitoAgent.asPath}") +} diff --git a/auth/src/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/components/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt new file mode 100644 index 000000000..66f2b475e --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt @@ -0,0 +1,201 @@ +package com.firebase.ui.auth.compose.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.configuration.validators.FieldValidator +import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator + +/** + * A customizable input field with built-in validation display. + * + * **Example usage:** + * ```kotlin + * val emailTextValue = remember { mutableStateOf("") } + * + * val emailValidator = remember { + * EmailValidator(stringProvider = DefaultAuthUIStringProvider(context)) + * } + * + * AuthTextField( + * value = emailTextValue, + * onValueChange = { emailTextValue.value = it }, + * label = { + * Text("Email") + * }, + * validator = emailValidator + * ) + * ``` + * + * @param modifier A modifier for the field. + * @param value The current value of the text field. + * @param onValueChange A callback when the value changes. + * @param label The label for the text field. + * @param enabled If the field is enabled. + * @param isError Manually set the error state. + * @param errorMessage A custom error message to display. + * @param validator A validator to automatically handle error state and messages. + * @param keyboardOptions Keyboard options for the field. + * @param keyboardActions Keyboard actions for the field. + * @param visualTransformation Visual transformation for the input (e.g., password). + * @param leadingIcon An optional icon to display at the start of the field. + * @param trailingIcon An optional icon to display at the start of the field. + */ +@Composable +fun AuthTextField( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit, + label: @Composable (() -> Unit)? = null, + enabled: Boolean = true, + isError: Boolean? = null, + errorMessage: String? = null, + validator: FieldValidator? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, +) { + val isSecureTextField = validator is PasswordValidator + var passwordVisible by remember { mutableStateOf(false) } + + TextField( + modifier = modifier, + value = value, + onValueChange = { newValue -> + onValueChange(newValue) + validator?.validate(newValue) + }, + label = label, + singleLine = true, + enabled = enabled, + isError = isError ?: validator?.hasError ?: false, + supportingText = { + if (validator?.hasError ?: false) { + Text(text = errorMessage ?: validator.errorMessage) + } + }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = if (isSecureTextField && !passwordVisible) + PasswordVisualTransformation() else visualTransformation, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon ?: { + if (isSecureTextField) { + IconButton( + onClick = { + passwordVisible = !passwordVisible + } + ) { + Icon( + imageVector = if (passwordVisible) + Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +internal fun PreviewAuthTextField() { + val context = LocalContext.current + val nameTextValue = remember { mutableStateOf("") } + val emailTextValue = remember { mutableStateOf("") } + val passwordTextValue = remember { mutableStateOf("") } + val emailValidator = remember { + EmailValidator(stringProvider = DefaultAuthUIStringProvider(context)) + } + val passwordValidator = remember { + PasswordValidator( + stringProvider = DefaultAuthUIStringProvider(context), + rules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireUppercase, + PasswordRule.RequireLowercase, + ) + ) + } + + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AuthTextField( + value = nameTextValue.value, + label = { + Text("Name") + }, + onValueChange = { text -> + nameTextValue.value = text + }, + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = emailTextValue.value, + validator = emailValidator, + label = { + Text("Email") + }, + onValueChange = { text -> + emailTextValue.value = text + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = passwordTextValue.value, + validator = passwordValidator, + label = { + Text("Password") + }, + onValueChange = { text -> + passwordTextValue.value = text + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "" + ) + } + ) + } +} \ No newline at end of file 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/components/AuthTextFieldTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt new file mode 100644 index 000000000..21629a1a2 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt @@ -0,0 +1,449 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.components + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [AuthTextField] covering UI interactions, validation, + * password visibility toggle, and error states. + * + * @suppress Internal test class + */ +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class AuthTextFieldTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var context: Context + private lateinit var stringProvider: AuthUIStringProvider + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + } + + // ============================================================================================= + // Basic UI Tests + // ============================================================================================= + + @Test + fun `AuthTextField displays correctly with basic configuration`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Name") } + ) + } + + composeTestRule + .onNodeWithText("Name") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField displays initial value`() { + composeTestRule.setContent { + AuthTextField( + value = "test@example.com", + onValueChange = { }, + label = { Text("Email") } + ) + } + + composeTestRule + .onNodeWithText("Email") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("test@example.com") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField updates value on text input`() { + composeTestRule.setContent { + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Email") } + ) + } + + composeTestRule + .onNodeWithText("Email") + .performTextInput("test@example.com") + + composeTestRule + .onNodeWithText("Email") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("test@example.com") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField respects enabled state`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") }, + enabled = false + ) + } + + composeTestRule + .onNodeWithText("Email") + .assertIsNotEnabled() + } + + @Test + fun `AuthTextField is enabled by default`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") } + ) + } + + composeTestRule + .onNodeWithText("Email") + .assertIsEnabled() + } + + @Test + fun `AuthTextField displays leading icon when provided`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Email Icon" + ) + } + ) + } + + composeTestRule + .onNodeWithContentDescription("Email Icon") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField displays custom trailing icon when provided`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") }, + trailingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Custom Trailing Icon" + ) + } + ) + } + + composeTestRule + .onNodeWithContentDescription("Custom Trailing Icon") + .assertIsDisplayed() + } + + // ============================================================================================= + // Validation Tests + // ============================================================================================= + + @Test + fun `AuthTextField validates email correctly with EmailValidator`() { + composeTestRule.setContent { + val emailValidator = remember { + EmailValidator(stringProvider = stringProvider) + } + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Email") }, + validator = emailValidator + ) + } + + composeTestRule + .onNodeWithText("Email") + .performTextInput("invalid-email") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.invalidEmailAddress) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Email") + .performTextClearance() + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.missingEmailAddress) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Email") + .performTextInput("valid@example.com") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.missingEmailAddress) + .assertIsNotDisplayed() + composeTestRule + .onNodeWithText(stringProvider.invalidEmailAddress) + .assertIsNotDisplayed() + } + + @Test + fun `AuthTextField displays custom error message when provided`() { + composeTestRule.setContent { + val emailValidator = remember { + EmailValidator(stringProvider = stringProvider) + } + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Email") }, + validator = emailValidator, + errorMessage = "Custom error message" + ) + } + + composeTestRule + .onNodeWithText("Email") + .performTextInput("invalid") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText("Custom error message") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField validates password with PasswordValidator`() { + composeTestRule.setContent { + val passwordValidator = remember { + PasswordValidator( + stringProvider = stringProvider, + rules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireUppercase, + PasswordRule.RequireLowercase + ) + ) + } + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Password") }, + validator = passwordValidator + ) + } + + composeTestRule + .onNodeWithText("Password") + .performTextInput("short") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.passwordTooShort.format(8)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Password") + .performTextClearance() + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.invalidPassword) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Password") + .performTextInput("pass@1234") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.passwordMissingUppercase) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Password") + .performTextInput("ValidPass123") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.passwordTooShort.format(8)) + .assertIsNotDisplayed() + composeTestRule + .onNodeWithText(stringProvider.passwordMissingLowercase) + .assertIsNotDisplayed() + composeTestRule + .onNodeWithText(stringProvider.passwordMissingUppercase) + .assertIsNotDisplayed() + } + + // ============================================================================================= + // Password Visibility Toggle Tests + // ============================================================================================= + + @Test + fun `AuthTextField shows password visibility toggle for PasswordValidator`() { + composeTestRule.setContent { + AuthTextField( + value = "password123", + onValueChange = { }, + label = { Text("Password") }, + validator = PasswordValidator( + stringProvider = stringProvider, + rules = emptyList() + ) + ) + } + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField toggles password visibility when icon is clicked`() { + composeTestRule.setContent { + AuthTextField( + value = "password123", + onValueChange = { }, + label = { Text("Password") }, + validator = PasswordValidator( + stringProvider = stringProvider, + rules = emptyList() + ) + ) + } + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertIsDisplayed() + .performClick() + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithContentDescription("Hide password") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField hides password visibility toggle for non-password fields`() { + composeTestRule.setContent { + AuthTextField( + value = "test@example.com", + onValueChange = { }, + label = { Text("Email") }, + ) + } + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertDoesNotExist() + + composeTestRule + .onNodeWithContentDescription("Hide password") + .assertDoesNotExist() + } + + @Test + fun `AuthTextField respects custom trailing icon over password toggle`() { + composeTestRule.setContent { + AuthTextField( + value = "password123", + onValueChange = { }, + label = { Text("Password") }, + validator = PasswordValidator( + stringProvider = stringProvider, + rules = emptyList() + ), + trailingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Custom Icon" + ) + } + ) + } + + composeTestRule + .onNodeWithContentDescription("Custom Icon") + .assertIsDisplayed() + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertDoesNotExist() + } +} 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..1a3265b6c 100644 --- a/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java +++ b/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java @@ -75,11 +75,17 @@ public static void initialize() { } private static void spyContextAndResources() { - CONTEXT = spy(CONTEXT); + // In Mockito 5.x, we need to avoid spying on objects that are already mocks/spies + if (!org.mockito.Mockito.mockingDetails(CONTEXT).isSpy()) { + CONTEXT = spy(CONTEXT); + } when(CONTEXT.getApplicationContext()) .thenReturn(CONTEXT); - Resources spiedResources = spy(CONTEXT.getResources()); - when(CONTEXT.getResources()).thenReturn(spiedResources); + Resources resources = CONTEXT.getResources(); + if (!org.mockito.Mockito.mockingDetails(resources).isSpy()) { + Resources spiedResources = spy(resources); + when(CONTEXT.getResources()).thenReturn(spiedResources); + } } private static void initializeApp(Context context) { diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 8b2317698..b24d297ea 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -41,17 +41,6 @@ object Config { const val pagingRxJava = "androidx.paging:paging-rxjava3:3.0.0" const val recyclerView = "androidx.recyclerview:recyclerview:1.2.1" const val materialDesign = "com.google.android.material:material:1.4.0" - - object Compose { - const val bom = "androidx.compose:compose-bom:2025.08.00" - const val ui = "androidx.compose.ui:ui" - const val uiGraphics = "androidx.compose.ui:ui-graphics" - const val toolingPreview = "androidx.compose.ui:ui-tooling-preview" - const val tooling = "androidx.compose.ui:ui-tooling" - const val foundation = "androidx.compose.foundation:foundation" - const val material3 = "androidx.compose.material3:material3" - const val activityCompose = "androidx.activity:activity-compose" - } } object Firebase { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 44eb15432..7b0eb3b69 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,44 @@ [versions] kotlin = "2.2.0" +# Compose +androidxComposeBom = "2025.08.00" +androidxActivityCompose = "1.9.0" + +# Authentication +credentials = "1.3.0" + +# Storage +datastorePreferences = "1.1.1" + +# Testing +mockito = "5.19.0" +mockitoInline = "5.2.0" +mockitoKotlin = "6.0.0" + [libraries] +# Compose +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } + +# Authentication +androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "credentials" } + +# Storage +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } + # Testing androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" } +mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoInline" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" } [plugins] compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } \ No newline at end of file