diff --git a/.firebase/hosting.cHVibGlj.cache b/.firebase/hosting.cHVibGlj.cache new file mode 100644 index 000000000..ef01935eb --- /dev/null +++ b/.firebase/hosting.cHVibGlj.cache @@ -0,0 +1,2 @@ +index.html,1760725054923,96d3ff69603ba92f085431c7b56242a873ddcdd5a1c9691f7836b093f8114a5a +.well-known/assetlinks.json,1760725039101,cbfe2437a47d2f4a2bca9bb7c1c789b4684d6a13694821e46e4177ccce023f4b diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml index a883affca..16055e162 100644 --- a/auth/src/main/AndroidManifest.xml +++ b/auth/src/main/AndroidManifest.xml @@ -27,6 +27,10 @@ android:name="com.facebook.sdk.ApplicationId" android:value="@string/facebook_application_id" /> + + - - - + + + + android:exported="true" + android:theme="@style/FirebaseUI.Transparent"> + + + + + + + // Prefer non-idle internal states (like PasswordResetLinkSent, Error, etc.) if (internalState !is AuthState.Idle) internalState else firebaseState - } + }.distinctUntilChanged() } /** diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt index 3a612e9bb..50101f7e0 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt @@ -18,8 +18,12 @@ import android.app.Activity import android.content.Context import android.net.Uri import android.util.Log +import androidx.annotation.RestrictTo +import androidx.annotation.VisibleForTesting import androidx.compose.ui.graphics.Color +import androidx.core.net.toUri import androidx.datastore.preferences.core.stringPreferencesKey +import com.facebook.AccessToken import com.firebase.ui.auth.R import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration import com.firebase.ui.auth.compose.configuration.AuthUIConfigurationDsl @@ -44,8 +48,8 @@ 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.suspendCancellableCoroutine import kotlinx.coroutines.tasks.await -import kotlinx.serialization.Serializable import java.util.concurrent.TimeUnit import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -89,14 +93,16 @@ internal enum class Provider(val id: String, val isSocialProvider: Boolean = fal */ abstract class OAuthProvider( override val providerId: String, + + override val name: String, open val scopes: List = emptyList(), open val customParameters: Map = emptyMap(), -) : AuthProvider(providerId) +) : AuthProvider(providerId = providerId, name = name) /** * Base abstract class for authentication providers. */ -abstract class AuthProvider(open val providerId: String) { +abstract class AuthProvider(open val providerId: String, open val name: String) { companion object { internal fun canUpgradeAnonymous(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean { @@ -201,7 +207,7 @@ abstract class AuthProvider(open val providerId: String) { * A list of custom password validation rules. */ val passwordValidationRules: List, - ) : AuthProvider(providerId = Provider.EMAIL.id) { + ) : AuthProvider(providerId = Provider.EMAIL.id, name = "Email") { companion object { const val SESSION_ID_LENGTH = 10 val KEY_EMAIL = stringPreferencesKey("com.firebase.ui.auth.data.client.email") @@ -327,7 +333,7 @@ abstract class AuthProvider(open val providerId: String) { * Enables instant verification of the phone number. Defaults to true. */ val isInstantVerificationEnabled: Boolean = true, - ) : AuthProvider(providerId = Provider.PHONE.id) { + ) : AuthProvider(providerId = Provider.PHONE.id, name = "Phone") { /** * Sealed class representing the result of phone number verification. * @@ -550,6 +556,7 @@ abstract class AuthProvider(open val providerId: String) { override val customParameters: Map = emptyMap(), ) : OAuthProvider( providerId = Provider.GOOGLE.id, + name = "Google", scopes = scopes, customParameters = customParameters ) { @@ -591,17 +598,13 @@ abstract class AuthProvider(open val providerId: String) { */ override val scopes: List = listOf("email", "public_profile"), - /** - * if true, enable limited login mode. Defaults to false. - */ - val limitedLogin: Boolean = false, - /** * A map of custom OAuth parameters. */ override val customParameters: Map = emptyMap(), ) : OAuthProvider( providerId = Provider.FACEBOOK.id, + name = "Facebook", scopes = scopes, customParameters = customParameters ) { @@ -627,6 +630,113 @@ abstract class AuthProvider(open val providerId: String) { } } } + + /** + * An interface to wrap the static `FacebookAuthProvider.getCredential` method to make it testable. + * @suppress + */ + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + interface CredentialProvider { + fun getCredential(token: String): AuthCredential + } + + /** + * The default implementation of [CredentialProvider] that calls the static method. + * @suppress + */ + internal class DefaultCredentialProvider : CredentialProvider { + override fun getCredential(token: String): AuthCredential { + return FacebookAuthProvider.getCredential(token) + } + } + + /** + * Internal data class to hold Facebook profile information. + */ + internal class FacebookProfileData( + val displayName: String?, + val email: String?, + val photoUrl: Uri?, + ) + + /** + * Fetches user profile data from Facebook Graph API. + * + * @param accessToken The Facebook access token + * @return FacebookProfileData containing user's display name, email, and photo URL + */ + internal suspend fun fetchFacebookProfile(accessToken: AccessToken): FacebookProfileData? { + return suspendCancellableCoroutine { continuation -> + val request = + com.facebook.GraphRequest.newMeRequest(accessToken) { jsonObject, response -> + try { + val error = response?.error + if (error != null) { + Log.e( + "FirebaseAuthUI.signInWithFacebook", + "Graph API error: ${error.errorMessage}" + ) + continuation.resume(null) + return@newMeRequest + } + + if (jsonObject == null) { + Log.e( + "FirebaseAuthUI.signInWithFacebook", + "Graph API returned null response" + ) + continuation.resume(null) + return@newMeRequest + } + + val name = jsonObject.optString("name") + val email = jsonObject.optString("email") + + // Extract photo URL from picture object + val photoUrl = try { + jsonObject.optJSONObject("picture") + ?.optJSONObject("data") + ?.optString("url") + ?.takeIf { it.isNotEmpty() }?.toUri() + } catch (e: Exception) { + Log.w( + "FirebaseAuthUI.signInWithFacebook", + "Error parsing photo URL", + e + ) + null + } + + Log.d( + "FirebaseAuthUI.signInWithFacebook", + "Profile fetched: name=$name, email=$email, hasPhoto=${photoUrl != null}" + ) + + continuation.resume( + FacebookProfileData( + displayName = name, + email = email, + photoUrl = photoUrl + ) + ) + } catch (e: Exception) { + Log.e( + "FirebaseAuthUI.signInWithFacebook", + "Error processing Graph API response", + e + ) + continuation.resume(null) + } + } + + // Request specific fields: id, name, email, and picture + val parameters = android.os.Bundle().apply { + putString("fields", "id,name,email,picture") + } + request.parameters = parameters + request.executeAsync() + } + } } /** @@ -639,6 +749,7 @@ abstract class AuthProvider(open val providerId: String) { override val customParameters: Map, ) : OAuthProvider( providerId = Provider.TWITTER.id, + name = "Twitter", customParameters = customParameters ) @@ -657,6 +768,7 @@ abstract class AuthProvider(open val providerId: String) { override val customParameters: Map, ) : OAuthProvider( providerId = Provider.GITHUB.id, + name = "Github", scopes = scopes, customParameters = customParameters ) @@ -681,6 +793,7 @@ abstract class AuthProvider(open val providerId: String) { override val customParameters: Map, ) : OAuthProvider( providerId = Provider.MICROSOFT.id, + name = "Microsoft", scopes = scopes, customParameters = customParameters ) @@ -700,6 +813,7 @@ abstract class AuthProvider(open val providerId: String) { override val customParameters: Map, ) : OAuthProvider( providerId = Provider.YAHOO.id, + name = "Yahoo", scopes = scopes, customParameters = customParameters ) @@ -724,6 +838,7 @@ abstract class AuthProvider(open val providerId: String) { override val customParameters: Map, ) : OAuthProvider( providerId = Provider.APPLE.id, + name = "Apple", scopes = scopes, customParameters = customParameters ) @@ -731,7 +846,10 @@ abstract class AuthProvider(open val providerId: String) { /** * Anonymous authentication provider. It has no configurable properties. */ - object Anonymous : AuthProvider(providerId = Provider.ANONYMOUS.id) { + object Anonymous : AuthProvider( + providerId = Provider.ANONYMOUS.id, + name = "Anonymous" + ) { internal fun validate(providers: List) { if (providers.size == 1 && providers.first() is Anonymous) { throw IllegalStateException( @@ -746,6 +864,11 @@ abstract class AuthProvider(open val providerId: String) { * A generic OAuth provider for any unsupported provider. */ class GenericOAuth( + /** + * The provider name. + */ + override val name: String, + /** * The provider ID as configured in the Firebase console. */ @@ -782,6 +905,7 @@ abstract class AuthProvider(open val providerId: String) { val contentColor: Color?, ) : OAuthProvider( providerId = providerId, + name = name, scopes = scopes, customParameters = customParameters ) { diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index 744dfd7c7..b85f4ef4b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -36,22 +36,6 @@ 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. @@ -59,8 +43,8 @@ internal class CredentialForLinking( * 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. + * into the Firebase profile, and throws [AuthException.AccountLinkingRequiredException] when + * anonymous upgrade encounters an existing account for the email. * * **Flow:** * 1. Check if new accounts are allowed (for non-upgrade flows) @@ -118,9 +102,9 @@ internal class CredentialForLinking( * 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 + * } catch (e: AuthException.AccountLinkingRequiredException) { + * // Email already exists - show account linking UI + * // User needs to sign in with existing account to link * } * ``` */ @@ -177,14 +161,20 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( 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 { - updateAuthState(AuthState.Error(authException)) - } - throw authException + // Account collision: email already exists + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with this email. " + + "Please sign in with your existing account.", + email = e.email, + credential = if (canUpgrade) { + e.updatedCredential ?: pendingCredential + } else { + null + }, + cause = e + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Create or link user with email and password was cancelled", @@ -206,15 +196,15 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( * 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. + * scenarios, it validates credentials in a scratch auth instance before throwing + * [AuthException.AccountLinkingRequiredException]. * * **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 + * - Throw [AuthException.AccountLinkingRequiredException] after successful validation * 2. If normal sign-in: * - Sign in with email/password * - If credential provided: link it and merge profile @@ -277,9 +267,9 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( * email = "existing@example.com", * password = "password123" * ) - * } catch (e: AuthException) { - * // AuthState.MergeConflict emitted - * // UI shows merge conflict resolution screen + * } catch (e: AuthException.AccountLinkingRequiredException) { + * // Account linking required - UI shows account linking screen + * // User needs to sign in with existing account to link anonymous account * } * ``` */ @@ -315,8 +305,16 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( .signInWithCredential(credentialToValidate).await() .user?.linkWithCredential(credentialForLinking)?.await() .also { - // Emit merge conflict after successful validation - updateAuthState(AuthState.MergeConflict(credentialToValidate)) + // Throw AccountLinkingRequiredException after successful validation + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with this email. " + + "Please sign in with your existing account to upgrade your anonymous account.", + email = email, + credential = credentialToValidate, + cause = null + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException } } else { // Just validate the email credential @@ -324,28 +322,41 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( 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)) + // Throw AccountLinkingRequiredException after successful validation + // Account exists and user is anonymous - needs to link accounts + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with this email. " + + "Please sign in with your existing account to upgrade your anonymous account.", + email = email, + credential = credentialToValidate, + cause = null + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException } } } else { // Normal sign-in auth.signInWithEmailAndPassword(email, password).await() - .also { result -> + .let { 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 -> - mergeProfile( - auth, - user.displayName, - user.photoUrl - ) - } - } + val linkResult = result.user + ?.linkWithCredential(credentialForLinking) + ?.await() + + // Merge profile from social provider + linkResult?.user?.let { user -> + mergeProfile( + auth, + user.displayName, + user.photoUrl + ) + } + + linkResult ?: result + } else { + result } } }.also { @@ -380,7 +391,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( * 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] + * 5. Handle collision exceptions by throwing [AuthException.AccountLinkingRequiredException] * * @param config The [AuthUIConfiguration] containing authentication settings * @param credential The [AuthCredential] to use for authentication. Can be from any provider. @@ -430,10 +441,10 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( * config = authUIConfig, * credential = phoneCredential * ) - * } catch (e: FirebaseAuthUserCollisionException) { + * } catch (e: AuthException.AccountLinkingRequiredException) { * // Phone number already exists on another account - * // AuthState.MergeConflict emitted with updatedCredential - * // UI can show merge conflict resolution screen + * // Account linking required - UI can show account linking screen + * // User needs to sign in with existing account to link * } * ``` * @@ -454,6 +465,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( config: AuthUIConfiguration, credential: AuthCredential, + provider: AuthProvider? = null, displayName: String? = null, photoUrl: Uri? = null, ): AuthResult? { @@ -471,21 +483,27 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( updateAuthState(AuthState.Idle) } } catch (e: FirebaseAuthUserCollisionException) { - // Special handling for collision exceptions - val authException = AuthException.from(e) - - if (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)) - } + // Account collision: account already exists with different sign-in method + // Create AccountLinkingRequiredException with credential for linking + val email = e.email + val credentialForException = if (canUpgradeAnonymous(config, auth)) { + // For anonymous upgrade, use the updated credential from the exception + e.updatedCredential ?: credential } else { - updateAuthState(AuthState.Error(authException)) + // For non-anonymous, use the original credential + credential } - throw authException + + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with the email ${email ?: ""}. " + + "Please sign in with your existing account to link " + + "your ${provider?.name ?: "this provider"} account.", + email = email, + credential = credentialForException, + cause = e + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in and link with credential was cancelled", @@ -508,8 +526,7 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( * * This method initiates the email-link (passwordless) authentication flow by sending * an email containing a magic link. The link includes session information for validation - * and security. Optionally supports account linking when a user tries to sign in with - * a social provider but an email link account exists. + * and security. * * **How it works:** * 1. Generates a unique session ID for same-device validation @@ -522,10 +539,11 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( * * **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 + * account already exists with that email, the social provider implementation should: + * 1. Catch the [FirebaseAuthUserCollisionException] from the sign-in attempt + * 2. Call [EmailLinkPersistenceManager.saveCredentialForLinking] with the provider tokens + * 3. Call this method to send the email link + * 4. When [signInWithEmailLink] completes, it automatically retrieves and links the saved credential * * **Session Security:** * - **Session ID**: Random 10-character string for same-device validation @@ -537,9 +555,6 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( * @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 @@ -570,55 +585,7 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( * // 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** + * **Example 2: Anonymous user upgrade** * ```kotlin * // User is currently signed in anonymously * // Send email link to upgrade anonymous account to permanent email account @@ -640,7 +607,6 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( config: AuthUIConfiguration, provider: AuthProvider.Email, email: String, - credentialForLinking: CredentialForLinking? = null, ) { try { updateAuthState(AuthState.Loading("Sending sign in email link...")) @@ -656,16 +622,6 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( val sessionId = SessionUtils.generateRandomAlphaNumericString(AuthProvider.Email.SESSION_ID_LENGTH) - // If credential provided, save it for linking after email link sign-in - if (credentialForLinking != null) { - EmailLinkPersistenceManager.saveCredentialForLinking( - context = context, - providerType = credentialForLinking.providerType, - idToken = credentialForLinking.idToken, - accessToken = credentialForLinking.accessToken - ) - } - // Modify actionCodeSettings Url to include sessionId, anonymousUserId, force same // device flag val updatedActionCodeSettings = @@ -822,21 +778,24 @@ suspend fun FirebaseAuthUI.signInWithEmailLink( .getInstance(appExplicitlyForValidation) // Safe link: Validate that both credentials can be linked - val result = authExplicitlyForValidation + authExplicitlyForValidation .signInWithCredential(emailLinkCredential).await() .user?.linkWithCredential(storedCredentialForLink)?.await() .also { result -> - // If safe link succeeds, emit merge conflict for UI to handle - updateAuthState( - AuthState.MergeConflict( - storedCredentialForLink - ) + // If safe link succeeds, throw AccountLinkingRequiredException for UI to handle + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with this email. " + + "Please sign in with your existing account to upgrade your anonymous account.", + email = email, + credential = storedCredentialForLink, + cause = null ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException } - return result } else { // Non-upgrade: Sign in with email link, then link social credential - val result = auth.signInWithCredential(emailLinkCredential).await() + auth.signInWithCredential(emailLinkCredential).await() // Link the social credential .user?.linkWithCredential(storedCredentialForLink)?.await() .also { result -> @@ -849,7 +808,6 @@ suspend fun FirebaseAuthUI.signInWithEmailLink( ) } } - return result } } // Clear DataStore after success diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 000000000..3ff865af0 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,198 @@ +/* + * 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 android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.facebook.AccessToken +import com.facebook.CallbackManager +import com.facebook.FacebookCallback +import com.facebook.FacebookException +import com.facebook.login.LoginManager +import com.facebook.login.LoginResult +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 kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +/** + * Creates a remembered launcher function for Facebook sign-in. + * + * Returns a launcher function that initiates the Facebook sign-in flow. Automatically handles + * profile data fetching, Firebase credential creation, anonymous account upgrades, and account + * linking when an email collision occurs. + * + * @param context Android context for DataStore access when saving credentials for linking + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider The [AuthProvider.Facebook] configuration with scopes and credential provider + * + * @return A launcher function that starts the Facebook sign-in flow when invoked + * + * @see signInWithFacebook + */ +@Composable +fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Facebook, +): () -> Unit { + val coroutineScope = rememberCoroutineScope() + val callbackManager = remember { CallbackManager.Factory.create() } + val loginManager = LoginManager.getInstance() + + val launcher = rememberLauncherForActivityResult( + loginManager.createLogInActivityResultContract( + callbackManager, + null + ), + onResult = {}, + ) + + DisposableEffect(Unit) { + loginManager.registerCallback( + callbackManager, + object : FacebookCallback { + override fun onSuccess(result: LoginResult) { + coroutineScope.launch { + try { + signInWithFacebook( + context = context, + config = config, + provider = provider, + accessToken = result.accessToken, + ) + } catch (e: AuthException) { + // Already an AuthException, don't re-wrap it + updateAuthState(AuthState.Error(e)) + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + } + } + } + + override fun onCancel() { + // val cancelledException = AuthException.AuthCancelledException( + // message = "Sign in with facebook was cancelled", + // ) + // updateAuthState(AuthState.Error(cancelledException)) + updateAuthState(AuthState.Idle) + } + + override fun onError(error: FacebookException) { + Log.e("FacebookAuthProvider", "Error during Facebook sign in", error) + val authException = AuthException.from(error) + updateAuthState( + AuthState.Error( + authException + ) + ) + } + }) + + onDispose { loginManager.unregisterCallback(callbackManager) } + } + + return { + updateAuthState( + AuthState.Loading("Signing in with facebook...") + ) + launcher.launch(provider.scopes) + } +} + +/** + * Signs in a user with Facebook by converting a Facebook access token to a Firebase credential. + * + * Fetches user profile data from Facebook Graph API, creates a Firebase credential, and signs in + * or upgrades an anonymous account. Handles account collisions by saving the Facebook credential + * for linking and throwing [AuthException.AccountLinkingRequiredException]. + * + * @param context Android context for DataStore access when saving credentials for linking + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider The [AuthProvider.Facebook] configuration + * @param accessToken The Facebook [AccessToken] from successful login + * @param credentialProvider Creates Firebase credentials from Facebook tokens + * + * @throws AuthException.AccountLinkingRequiredException if an account exists with the same email + * @throws AuthException.AuthCancelledException if the coroutine is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.InvalidCredentialsException if the Facebook token is invalid + * + * @see rememberSignInWithFacebookLauncher + * @see signInAndLinkWithCredential + */ +internal suspend fun FirebaseAuthUI.signInWithFacebook( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Facebook, + accessToken: AccessToken, + credentialProvider: AuthProvider.Facebook.CredentialProvider = AuthProvider.Facebook.DefaultCredentialProvider(), +) { + try { + updateAuthState( + AuthState.Loading("Signing in with facebook...") + ) + val profileData = provider.fetchFacebookProfile(accessToken) + val credential = credentialProvider.getCredential(accessToken.token) + signInAndLinkWithCredential( + config = config, + credential = credential, + provider = provider, + displayName = profileData?.displayName, + photoUrl = profileData?.photoUrl, + ) + } catch (e: AuthException.AccountLinkingRequiredException) { + // Account collision occurred - save Facebook credential for linking after email link sign-in + // This happens when a user tries to sign in with Facebook but an email link account exists + EmailLinkPersistenceManager.saveCredentialForLinking( + context = context, + providerType = provider.providerId, + idToken = null, + accessToken = accessToken.token + ) + + // Re-throw to let UI handle the account linking flow + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: FacebookException) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with facebook 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/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt index 3c17d113e..af381e78c 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt @@ -241,7 +241,7 @@ internal suspend fun FirebaseAuthUI.submitVerificationCode( * The method automatically handles: * - Normal sign-in for new or returning users * - Linking phone credential to anonymous accounts (if enabled in config) - * - Emitting [AuthState.MergeConflict] if phone number already exists on another account + * - Throwing [AuthException.AccountLinkingRequiredException] if phone number already exists on another account * * **Example: Sign in after instant verification** * ```kotlin @@ -270,10 +270,10 @@ internal suspend fun FirebaseAuthUI.submitVerificationCode( * config = authUIConfig, * credential = phoneCredential * ) - * } catch (e: FirebaseAuthUserCollisionException) { + * } catch (e: AuthException.AccountLinkingRequiredException) { * // Phone number already exists on another account - * // AuthState.MergeConflict will be emitted - * // Show merge conflict resolution screen + * // Account linking required - show account linking screen + * // User needs to sign in with existing account to link * } * ``` * 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 fb6b9098f..0a3e074c2 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 @@ -250,6 +250,7 @@ private fun PreviewAuthProviderButton() { ) AuthProviderButton( provider = AuthProvider.GenericOAuth( + name = "Generic Provider", providerId = "google.com", scopes = emptyList(), customParameters = emptyMap(), @@ -263,6 +264,7 @@ private fun PreviewAuthProviderButton() { ) AuthProviderButton( provider = AuthProvider.GenericOAuth( + name = "Generic Provider", providerId = "google.com", scopes = emptyList(), customParameters = emptyMap(), @@ -284,6 +286,7 @@ private fun PreviewAuthProviderButton() { ) AuthProviderButton( provider = AuthProvider.GenericOAuth( + name = "Generic Provider", providerId = "unknown_provider", scopes = emptyList(), customParameters = emptyMap(), diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt index 42f7339a9..ea2881e3a 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt @@ -146,7 +146,10 @@ private fun getRecoveryMessage( is AuthException.TooManyRequestsException -> stringProvider.tooManyRequestsRecoveryMessage is AuthException.MfaRequiredException -> stringProvider.mfaRequiredRecoveryMessage - is AuthException.AccountLinkingRequiredException -> stringProvider.accountLinkingRequiredRecoveryMessage + is AuthException.AccountLinkingRequiredException -> { + // Use the custom message which includes email and provider details + error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage + } is AuthException.AuthCancelledException -> stringProvider.authCancelledRecoveryMessage is AuthException.UnknownException -> stringProvider.unknownErrorRecoveryMessage else -> stringProvider.unknownErrorRecoveryMessage @@ -167,7 +170,7 @@ private fun getRecoveryActionText( return when (error) { is AuthException.AuthCancelledException -> stringProvider.continueText is AuthException.EmailAlreadyInUseException -> stringProvider.signInDefault // Use existing "Sign in" text - is AuthException.AccountLinkingRequiredException -> stringProvider.continueText // Use "Continue" for linking + is AuthException.AccountLinkingRequiredException -> stringProvider.signInDefault // User needs to sign in to link accounts is AuthException.MfaRequiredException -> stringProvider.continueText // Use "Continue" for MFA is AuthException.NetworkException, is AuthException.InvalidCredentialsException, diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt index ddfcc6a29..5a7977361 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt @@ -35,6 +35,7 @@ import com.firebase.ui.auth.compose.configuration.auth_provider.sendSignInLinkTo import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailAndPassword import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.ui.components.ErrorRecoveryDialog +import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import kotlinx.coroutines.launch @@ -118,6 +119,7 @@ fun EmailAuthScreen( context: Context, configuration: AuthUIConfiguration, authUI: FirebaseAuthUI, + credentialForLinking: AuthCredential? = null, onSuccess: (AuthResult) -> Unit, onError: (AuthException) -> Unit, onCancel: () -> Unit, @@ -143,6 +145,7 @@ fun EmailAuthScreen( val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) val isLoading = authState is AuthState.Loading + val authCredentialForLinking = remember { credentialForLinking } val errorMessage = if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null val resetLinkSent = authState is AuthState.PasswordResetLinkSent @@ -203,7 +206,6 @@ fun EmailAuthScreen( config = configuration, provider = provider, email = emailTextValue.value, - credentialForLinking = null, ) } else { authUI.signInWithEmailAndPassword( @@ -211,7 +213,7 @@ fun EmailAuthScreen( config = configuration, email = emailTextValue.value, password = passwordTextValue.value, - credentialForLinking = null, + credentialForLinking = authCredentialForLinking, ) } } catch (e: Exception) { diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInUI.kt index cb822e647..298ae0c46 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInUI.kt @@ -54,6 +54,7 @@ import com.firebase.ui.auth.compose.configuration.validators.EmailValidator import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator import com.firebase.ui.auth.compose.ui.components.AuthTextField import com.firebase.ui.auth.compose.ui.components.TermsAndPrivacyForm +import com.google.firebase.auth.AuthCredential @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/auth/src/main/res/values/config.xml b/auth/src/main/res/values/config.xml index ec03e4b3c..7aec5c460 100644 --- a/auth/src/main/res/values/config.xml +++ b/auth/src/main/res/values/config.xml @@ -21,6 +21,15 @@ --> fb_your_app_id + + CHANGE-ME - - - - - - - - - - \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt index b2d90a00e..363052b80 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt @@ -1,13 +1,10 @@ package com.firebase.composeapp import android.os.Bundle -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -19,6 +16,7 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay import com.firebase.composeapp.ui.screens.EmailAuthMain import com.firebase.composeapp.ui.screens.MfaEnrollmentMain +import com.firebase.composeapp.ui.screens.FirebaseAuthScreen import com.firebase.composeapp.ui.screens.PhoneAuthMain import com.firebase.ui.auth.compose.FirebaseAuthUI import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration @@ -26,13 +24,13 @@ import com.firebase.ui.auth.compose.configuration.PasswordRule import com.firebase.ui.auth.compose.configuration.authUIConfiguration import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailLink -import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme -import com.firebase.ui.auth.compose.ui.method_picker.AuthMethodPicker import com.firebase.ui.auth.compose.ui.screens.EmailSignInLinkHandlerActivity import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager import com.google.firebase.FirebaseApp +import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.actionCodeSettings +import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable @Serializable @@ -41,7 +39,7 @@ sealed class Route : NavKey { object MethodPicker : Route() @Serializable - object EmailAuth : Route() + class EmailAuth(@Contextual val credentialForLinking: AuthCredential? = null) : Route() @Serializable object PhoneAuth : Route() @@ -56,7 +54,10 @@ class MainActivity : ComponentActivity() { FirebaseApp.initializeApp(applicationContext) val authUI = FirebaseAuthUI.getInstance() - authUI.auth.useEmulator("10.0.2.2", 9099) + // authUI.auth.useEmulator("10.0.2.2", 9099) + + // Check if this activity was launched from an email link deep link + val emailLink = intent.getStringExtra(EmailSignInLinkHandlerActivity.EXTRA_EMAIL_LINK) val configuration = authUIConfiguration { context = applicationContext @@ -65,6 +66,7 @@ class MainActivity : ComponentActivity() { AuthProvider.Email( isDisplayNameRequired = true, isEmailLinkForceSameDeviceEnabled = true, + isEmailLinkSignInEnabled = false, emailLinkActionCodeSettings = actionCodeSettings { // The continue URL - where to redirect after email link is clicked url = "https://temp-test-aa342.firebaseapp.com" @@ -94,13 +96,25 @@ class MainActivity : ComponentActivity() { isInstantVerificationEnabled = true ) ) + provider( + AuthProvider.Facebook( + applicationId = "792556260059222" + ) + ) } tosUrl = "https://policies.google.com/terms?hl=en-NG&fg=1" privacyPolicyUrl = "https://policies.google.com/privacy?hl=en-NG&fg=1" } setContent { - val backStack = rememberNavBackStack(Route.MethodPicker) + // If there's an email link, navigate to EmailAuth screen + val initialRoute = if (emailLink != null) { + Route.EmailAuth(credentialForLinking = null) + } else { + Route.MethodPicker + } + + val backStack = rememberNavBackStack(initialRoute) AuthUITheme { Surface( @@ -118,39 +132,28 @@ class MainActivity : ComponentActivity() { val route = entry as Route when (route) { is Route.MethodPicker -> NavEntry(entry) { - Scaffold { innerPadding -> - AuthMethodPicker( - modifier = Modifier.padding(innerPadding), - providers = configuration.providers, - logo = AuthUIAsset.Resource(R.drawable.firebase_auth_120dp), - termsOfServiceUrl = configuration.tosUrl, - privacyPolicyUrl = configuration.privacyPolicyUrl, - onProviderSelected = { provider -> - Log.d( - "MainActivity", - "Selected Provider: $provider" - ) - when (provider) { - is AuthProvider.Email -> backStack.add(Route.EmailAuth) - is AuthProvider.Phone -> backStack.add(Route.PhoneAuth) - } - }, - ) - } + FirebaseAuthScreen( + authUI = authUI, + configuration = configuration, + backStack = backStack + ) } is Route.EmailAuth -> NavEntry(entry) { - val emailProvider = configuration.providers - .filterIsInstance() - .first() - LaunchEmailAuth(authUI, configuration, emailProvider, backStack) + LaunchEmailAuth( + authUI = authUI, + configuration = configuration, + backStack = backStack, + credentialForLinking = route.credentialForLinking, + emailLink = emailLink + ) } is Route.PhoneAuth -> NavEntry(entry) { - val phoneProvider = configuration.providers - .filterIsInstance() - .first() - LaunchPhoneAuth(authUI, configuration, phoneProvider) + LaunchPhoneAuth( + authUI = authUI, + configuration = configuration, + ) } is Route.MfaEnrollment -> NavEntry(entry) { @@ -168,14 +171,15 @@ class MainActivity : ComponentActivity() { private fun LaunchEmailAuth( authUI: FirebaseAuthUI, configuration: AuthUIConfiguration, - selectedProvider: AuthProvider.Email, - backStack: androidx.compose.runtime.snapshots.SnapshotStateList + credentialForLinking: AuthCredential? = null, + backStack: NavBackStack, + emailLink: String? = null ) { - // Check if this is an email link sign-in flow - val emailLink = intent.getStringExtra( - EmailSignInLinkHandlerActivity.EXTRA_EMAIL_LINK - ) + val provider = configuration.providers + .filterIsInstance() + .first() + // Handle email link sign-in if present if (emailLink != null) { LaunchedEffect(emailLink) { @@ -190,7 +194,7 @@ class MainActivity : ComponentActivity() { authUI.signInWithEmailLink( context = applicationContext, config = configuration, - provider = selectedProvider, + provider = provider, email = emailFromSession, emailLink = emailLink, ) @@ -205,6 +209,7 @@ class MainActivity : ComponentActivity() { context = applicationContext, configuration = configuration, authUI = authUI, + credentialForLinking = credentialForLinking, onSetupMfa = { backStack.add(Route.MfaEnrollment) } @@ -215,8 +220,11 @@ class MainActivity : ComponentActivity() { private fun LaunchPhoneAuth( authUI: FirebaseAuthUI, configuration: AuthUIConfiguration, - selectedProvider: AuthProvider.Phone, ) { + val provider = configuration.providers + .filterIsInstance() + .first() + PhoneAuthMain( context = applicationContext, configuration = configuration, diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/EmailAuthMain.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/EmailAuthMain.kt index f63505b00..b31e49d1e 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/EmailAuthMain.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/EmailAuthMain.kt @@ -19,12 +19,12 @@ import androidx.compose.ui.unit.dp import com.firebase.ui.auth.compose.AuthState import com.firebase.ui.auth.compose.FirebaseAuthUI import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration -import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.compose.ui.screens.EmailAuthMode import com.firebase.ui.auth.compose.ui.screens.EmailAuthScreen import com.firebase.ui.auth.compose.ui.screens.ResetPasswordUI import com.firebase.ui.auth.compose.ui.screens.SignInUI import com.firebase.ui.auth.compose.ui.screens.SignUpUI +import com.google.firebase.auth.AuthCredential import kotlinx.coroutines.launch @Composable @@ -32,6 +32,7 @@ fun EmailAuthMain( context: Context, configuration: AuthUIConfiguration, authUI: FirebaseAuthUI, + credentialForLinking: AuthCredential? = null, onSetupMfa: () -> Unit = {}, ) { val coroutineScope = rememberCoroutineScope() @@ -45,7 +46,8 @@ fun EmailAuthMain( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("Authenticated User - (Success): ${authUI.getCurrentUser()?.email}", + Text( + "Authenticated User - (Success): ${authUI.getCurrentUser()?.email}", textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(8.dp)) @@ -156,6 +158,7 @@ fun EmailAuthMain( context = context, configuration = configuration, authUI = authUI, + credentialForLinking = credentialForLinking, onSuccess = { result -> }, onError = { exception -> }, onCancel = { }, diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/FirebaseAuthScreen.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/FirebaseAuthScreen.kt new file mode 100644 index 000000000..3d24deda8 --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/FirebaseAuthScreen.kt @@ -0,0 +1,198 @@ +package com.firebase.composeapp.ui.screens + +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavBackStack +import com.firebase.composeapp.R +import com.firebase.composeapp.Route +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.rememberSignInWithFacebookLauncher +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.ErrorRecoveryDialog +import com.firebase.ui.auth.compose.ui.method_picker.AuthMethodPicker +import kotlinx.coroutines.launch + +@Composable +fun FirebaseAuthScreen( + authUI: FirebaseAuthUI, + configuration: AuthUIConfiguration, + backStack: NavBackStack, +) { + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + val stringProvider = DefaultAuthUIStringProvider(context) + + val isErrorDialogVisible = remember(authState) { mutableStateOf(authState is AuthState.Error) } + + Scaffold { innerPadding -> + Log.d("FirebaseAuthScreen", "Current state: $authState") + Box { + when (authState) { + is AuthState.Success -> { + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Authenticated User - (Success): ${authUI.getCurrentUser()?.email}", + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + coroutineScope.launch { + authUI.signOut(context) + } + } + ) { + Text("Sign Out") + } + } + } + + is AuthState.RequiresEmailVerification -> { + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Authenticated User - " + + "(RequiresEmailVerification): ${authUI.getCurrentUser()?.email}", + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + coroutineScope.launch { + authUI.signOut(context) + } + } + ) { + Text("Sign Out") + } + } + } + + else -> { + val onSignInWithFacebook = authUI.rememberSignInWithFacebookLauncher( + context = context, + config = configuration, + provider = configuration.providers.filterIsInstance() + .first() + ) + + AuthMethodPicker( + modifier = Modifier.padding(innerPadding), + providers = configuration.providers, + logo = AuthUIAsset.Resource(R.drawable.firebase_auth_120dp), + termsOfServiceUrl = configuration.tosUrl, + privacyPolicyUrl = configuration.privacyPolicyUrl, + onProviderSelected = { provider -> + Log.d( + "MainActivity", + "Selected Provider: $provider" + ) + when (provider) { + is AuthProvider.Email -> backStack.add(Route.EmailAuth()) + is AuthProvider.Phone -> backStack.add(Route.PhoneAuth) + is AuthProvider.Facebook -> onSignInWithFacebook() + } + }, + ) + } + } + + // Error dialog + if (isErrorDialogVisible.value && authState is AuthState.Error) { + ErrorRecoveryDialog( + error = when ((authState as AuthState.Error).exception) { + is AuthException -> (authState as AuthState.Error).exception as AuthException + else -> AuthException.from((authState as AuthState.Error).exception) + }, + stringProvider = stringProvider, + onRetry = { exception -> + isErrorDialogVisible.value = false + }, + onRecover = { exception -> + when (exception) { + is AuthException.EmailAlreadyInUseException -> { + // Navigate to email sign-in + backStack.add(Route.EmailAuth()) + } + + is AuthException.AccountLinkingRequiredException -> { + backStack.add(Route.EmailAuth(credentialForLinking = exception.credential)) + } + + else -> { + // For other errors, just dismiss and let user try again + } + } + isErrorDialogVisible.value = false + }, + onDismiss = { + isErrorDialogVisible.value = false + }, + ) + } + + // Loading modal + if (authState is AuthState.Loading) { + AlertDialog( + onDismissRequest = {}, + confirmButton = {}, + containerColor = Color.Transparent, + text = { + Column( + modifier = Modifier.padding(24.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = (authState as? AuthState.Loading)?.message + ?: "Loading...", + textAlign = TextAlign.Center + ) + } + } + ) + } + } + } +} \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/PhoneAuthMain.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/PhoneAuthMain.kt index 47e7bb8c7..d1ecc6f8b 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/PhoneAuthMain.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/PhoneAuthMain.kt @@ -60,32 +60,6 @@ fun PhoneAuthMain( } } - is AuthState.RequiresEmailVerification -> { - Column( - modifier = Modifier - .fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - "Authenticated User - " + - "(RequiresEmailVerification): " + - "${(authState as AuthState.RequiresEmailVerification).user.email}", - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(8.dp)) - Button( - onClick = { - coroutineScope.launch { - authUI.signOut(context) - } - } - ) { - Text("Sign Out") - } - } - } - else -> { PhoneAuthScreen( context = context, diff --git a/composeapp/src/main/res/values/strings.xml b/composeapp/src/main/res/values/strings.xml index c226d8f8f..318d7fb0f 100644 --- a/composeapp/src/main/res/values/strings.xml +++ b/composeapp/src/main/res/values/strings.xml @@ -1,3 +1,10 @@ ComposeApp + + temp-test-aa342.firebaseapp.com + + + 1131506989188007 + fb1131506989188007 + e3968638d7751ba83063e2a78bc27e4e \ No newline at end of file diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/testutil/EmulatorApi.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/testutil/EmulatorApi.kt index aff906c85..4b8f0497e 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/compose/testutil/EmulatorApi.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/testutil/EmulatorApi.kt @@ -4,7 +4,7 @@ import org.json.JSONArray import org.json.JSONObject import java.net.HttpURLConnection -internal class EmulatorAuthApi( +class EmulatorAuthApi( private val projectId: String, emulatorHost: String, emulatorPort: Int, diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/testutil/TestHelpers.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/testutil/TestHelpers.kt new file mode 100644 index 000000000..626d28059 --- /dev/null +++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/testutil/TestHelpers.kt @@ -0,0 +1,84 @@ +package com.firebase.ui.auth.compose.testutil + +import android.os.Looper +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.google.firebase.auth.FirebaseUser +import org.robolectric.Shadows.shadowOf + +/** + * Ensures a fresh user exists in the Firebase emulator with the given credentials. + * If a user already exists, they will be deleted first. + * The user will be signed out after creation, leaving an unverified account ready for testing. + * + * This function uses coroutines and automatically handles Robolectric's main looper. + * + * @param authUI The FirebaseAuthUI instance + * @param email The email address for the user + * @param password The password for the user + * @return The created FirebaseUser, or null if creation failed + */ +fun ensureFreshUser(authUI: FirebaseAuthUI, email: String, password: String): FirebaseUser? { + println("TEST: Ensuring fresh user for $email") + // Try to sign in - if successful, user exists and should be deleted + try { + authUI.auth.signInWithEmailAndPassword(email, password).awaitWithLooper() + .also { result -> + println("TEST: User exists (${result.user?.uid}), deleting...") + // User exists, delete them + result.user?.delete()?.awaitWithLooper() + println("TEST: User deleted") + } + } catch (_: Exception) { + // User doesn't exist - this is expected + } + + // Create fresh user + return authUI.auth.createUserWithEmailAndPassword(email, password).awaitWithLooper() + .user +} + +/** + * Verifies a user's email in the Firebase Auth Emulator by simulating the complete + * email verification flow. + * + * This function: + * 1. Sends a verification email using sendEmailVerification() + * 2. Retrieves the OOB (out-of-band) code from the emulator's OOB codes endpoint + * 3. Applies the action code to complete email verification + * + * This approach works with the Firebase Auth Emulator's documented API and simulates + * the real email verification flow that would occur in production. + * + * @param authUI The FirebaseAuthUI instance + * @param emulatorApi The EmulatorAuthApi instance for fetching OOB codes + * @param user The FirebaseUser whose email should be verified + * @throws Exception if the verification flow fails + */ +fun verifyEmailInEmulator(authUI: FirebaseAuthUI, emulatorApi: EmulatorAuthApi, user: FirebaseUser) { + println("TEST: Starting email verification for user ${user.uid}") + + // Step 1: Send verification email to generate an OOB code + user.sendEmailVerification().awaitWithLooper() + println("TEST: Sent email verification request") + + // Give the emulator time to process and store the OOB code + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(100) + + // Step 2: Retrieve the VERIFY_EMAIL OOB code for this user from the emulator + val email = requireNotNull(user.email) { "User email is required for OOB code lookup" } + val oobCode = emulatorApi.fetchVerifyEmailCode(email) + + println("TEST: Found OOB code: $oobCode") + + // Step 3: Apply the action code to verify the email + authUI.auth.applyActionCode(oobCode).awaitWithLooper() + println("TEST: Applied action code") + + // Step 4: Reload the user to refresh their email verification status + authUI.auth.currentUser?.reload()?.awaitWithLooper() + shadowOf(Looper.getMainLooper()).idle() + + println("TEST: Email verified successfully for user ${user.uid}") + println("TEST: User isEmailVerified: ${authUI.auth.currentUser?.isEmailVerified}") +} diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt index 602890412..3e975ec51 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt @@ -25,11 +25,12 @@ import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIS import com.firebase.ui.auth.compose.testutil.AUTH_STATE_WAIT_TIMEOUT_MS import com.firebase.ui.auth.compose.testutil.EmulatorAuthApi import com.firebase.ui.auth.compose.testutil.awaitWithLooper +import com.firebase.ui.auth.compose.testutil.ensureFreshUser +import com.firebase.ui.auth.compose.testutil.verifyEmailInEmulator import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions import com.google.firebase.auth.AuthResult -import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.actionCodeSettings import org.junit.After import org.junit.Before @@ -128,7 +129,7 @@ class EmailAuthScreenTest { val password = "test123" // Setup: Create a fresh unverified user - ensureFreshUser(email, password) + ensureFreshUser(authUI, email, password) // Sign out authUI.auth.signOut() @@ -197,12 +198,12 @@ class EmailAuthScreenTest { val password = "test123" // Setup: Create a fresh unverified user - val user = ensureFreshUser(email, password) + val user = ensureFreshUser(authUI, email, password) requireNotNull(user) { "Failed to create user" } // Verify email using Firebase Auth Emulator OOB codes flow - verifyEmailInEmulator(user = user) + verifyEmailInEmulator(authUI, emulatorApi, user) // Sign out authUI.auth.signOut() @@ -351,7 +352,7 @@ class EmailAuthScreenTest { val password = "test123" // Setup: Create a fresh user - ensureFreshUser(email, password) + ensureFreshUser(authUI, email, password) // Sign out authUI.auth.signOut() @@ -433,7 +434,7 @@ class EmailAuthScreenTest { val password = "test123" // Setup: Create a fresh user - ensureFreshUser(email, password) + ensureFreshUser(authUI, email, password) // Sign out authUI.auth.signOut() @@ -577,76 +578,4 @@ class EmailAuthScreenTest { } } } - - /** - * Ensures a fresh user exists in the Firebase emulator with the given credentials. - * If a user already exists, they will be deleted first. - * The user will be signed out after creation, leaving an unverified account ready for testing. - * - * This function uses coroutines and automatically handles Robolectric's main looper. - */ - private fun ensureFreshUser(email: String, password: String): FirebaseUser? { - println("TEST: Ensuring fresh user for $email") - // Try to sign in - if successful, user exists and should be deleted - try { - authUI.auth.signInWithEmailAndPassword(email, password).awaitWithLooper() - .also { result -> - println("TEST: User exists (${result.user?.uid}), deleting...") - // User exists, delete them - result.user?.delete()?.awaitWithLooper() - println("TEST: User deleted") - } - } catch (_: Exception) { - // User doesn't exist - this is expected - } - - // Create fresh user - return authUI.auth.createUserWithEmailAndPassword(email, password).awaitWithLooper() - .user - } - - /** - * Verifies a user's email in the Firebase Auth Emulator by simulating the complete - * email verification flow. - * - * This function: - * 1. Sends a verification email using sendEmailVerification() - * 2. Retrieves the OOB (out-of-band) code from the emulator's OOB codes endpoint - * 3. Applies the action code to complete email verification - * - * This approach works with the Firebase Auth Emulator's documented API and simulates - * the real email verification flow that would occur in production. - * - * @param user The FirebaseUser whose email should be verified - * @throws Exception if the verification flow fails - */ - private fun verifyEmailInEmulator(user: FirebaseUser) { - println("TEST: Starting email verification for user ${user.uid}") - - // Step 1: Send verification email to generate an OOB code - user.sendEmailVerification().awaitWithLooper() - println("TEST: Sent email verification request") - - // Give the emulator time to process and store the OOB code - shadowOf(Looper.getMainLooper()).idle() - Thread.sleep(100) - - // Step 2: Retrieve the VERIFY_EMAIL OOB code for this user from the emulator - val email = requireNotNull(user.email) { "User email is required for OOB code lookup" } - val oobCode = emulatorApi.fetchVerifyEmailCode(email) - - println("TEST: Found OOB code: $oobCode") - - // Step 3: Apply the action code to verify the email - authUI.auth.applyActionCode(oobCode).awaitWithLooper() - println("TEST: Applied action code") - - // Step 4: Reload the user to refresh their email verification status - authUI.auth.currentUser?.reload()?.awaitWithLooper() - shadowOf(Looper.getMainLooper()).idle() - - println("TEST: Email verified successfully for user ${user.uid}") - println("TEST: User isEmailVerified: ${authUI.auth.currentUser?.isEmailVerified}") - } - }