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}")
- }
-
}