@@ -36,31 +36,15 @@ import com.google.firebase.auth.FirebaseAuthUserCollisionException
3636import kotlinx.coroutines.CancellationException
3737import kotlinx.coroutines.tasks.await
3838
39- /* *
40- * Holds credential information for account linking with email link sign-in.
41- *
42- * When a user tries to sign in with a social provider (Google, Facebook, etc.) but an
43- * email link account exists with that email, this data is used to link the accounts
44- * after email link authentication completes.
45- *
46- * @property providerType The provider ID (e.g., "google.com", "facebook.com")
47- * @property idToken The ID token from the provider (required for Google, optional for Facebook)
48- * @property accessToken The access token from the provider (required for Facebook, optional for Google)
49- */
50- internal class CredentialForLinking (
51- val providerType : String ,
52- val idToken : String? ,
53- val accessToken : String? ,
54- )
5539
5640/* *
5741 * Creates an email/password account or links the credential to an anonymous user.
5842 *
5943 * Mirrors the legacy email sign-up handler: validates password strength, validates custom
6044 * password rules, checks if new accounts are allowed, chooses between
6145 * `createUserWithEmailAndPassword` and `linkWithCredential`, merges the supplied display name
62- * into the Firebase profile, and emits [AuthState.MergeConflict ] when anonymous upgrade
63- * encounters an existing account for the email.
46+ * into the Firebase profile, and throws [AuthException.AccountLinkingRequiredException ] when
47+ * anonymous upgrade encounters an existing account for the email.
6448 *
6549 * **Flow:**
6650 * 1. Check if new accounts are allowed (for non-upgrade flows)
@@ -118,9 +102,9 @@ internal class CredentialForLinking(
118102 * password = "MyPassword456"
119103 * )
120104 * // Anonymous account upgraded to permanent email/password account
121- * } catch (e: AuthException) {
122- * // Check if AuthState.MergeConflict was emitted
123- * // This means email already exists - show merge conflict UI
105+ * } catch (e: AuthException.AccountLinkingRequiredException ) {
106+ * // Email already exists - show account linking UI
107+ * // User needs to sign in with existing account to link
124108 * }
125109 * ```
126110 */
@@ -177,14 +161,20 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
177161 updateAuthState(AuthState .Idle )
178162 return result
179163 } catch (e: FirebaseAuthUserCollisionException ) {
180- val authException = AuthException .from(e)
181- if (canUpgrade && pendingCredential != null ) {
182- // Anonymous upgrade collision: emit merge conflict state
183- updateAuthState(AuthState .MergeConflict (pendingCredential))
184- } else {
185- updateAuthState(AuthState .Error (authException))
186- }
187- throw authException
164+ // Account collision: email already exists
165+ val accountLinkingException = AuthException .AccountLinkingRequiredException (
166+ message = " An account already exists with this email. " +
167+ " Please sign in with your existing account." ,
168+ email = e.email,
169+ credential = if (canUpgrade) {
170+ e.updatedCredential ? : pendingCredential
171+ } else {
172+ null
173+ },
174+ cause = e
175+ )
176+ updateAuthState(AuthState .Error (accountLinkingException))
177+ throw accountLinkingException
188178 } catch (e: CancellationException ) {
189179 val cancelledException = AuthException .AuthCancelledException (
190180 message = " Create or link user with email and password was cancelled" ,
@@ -206,15 +196,15 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
206196 * Signs in a user with email and password, optionally linking a social credential.
207197 *
208198 * This method handles both normal sign-in and anonymous upgrade flows. In anonymous upgrade
209- * scenarios, it validates credentials in a scratch auth instance before emitting a merge
210- * conflict state .
199+ * scenarios, it validates credentials in a scratch auth instance before throwing
200+ * [AuthException.AccountLinkingRequiredException] .
211201 *
212202 * **Flow:**
213203 * 1. If anonymous upgrade:
214204 * - Create scratch auth instance to validate credential
215205 * - If linking social provider: sign in with email, then link social credential (safe link)
216206 * - Otherwise: just validate email credential
217- * - Emit [AuthState.MergeConflict ] after successful validation
207+ * - Throw [AuthException.AccountLinkingRequiredException ] after successful validation
218208 * 2. If normal sign-in:
219209 * - Sign in with email/password
220210 * - If credential provided: link it and merge profile
@@ -277,9 +267,9 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
277267278268 * password = "password123"
279269 * )
280- * } catch (e: AuthException) {
281- * // AuthState.MergeConflict emitted
282- * // UI shows merge conflict resolution screen
270+ * } catch (e: AuthException.AccountLinkingRequiredException ) {
271+ * // Account linking required - UI shows account linking screen
272+ * // User needs to sign in with existing account to link anonymous account
283273 * }
284274 * ```
285275 */
@@ -315,18 +305,34 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
315305 .signInWithCredential(credentialToValidate).await()
316306 .user?.linkWithCredential(credentialForLinking)?.await()
317307 .also {
318- // Emit merge conflict after successful validation
319- updateAuthState(AuthState .MergeConflict (credentialToValidate))
308+ // Throw AccountLinkingRequiredException after successful validation
309+ val accountLinkingException = AuthException .AccountLinkingRequiredException (
310+ message = " An account already exists with this email. " +
311+ " Please sign in with your existing account to upgrade your anonymous account." ,
312+ email = email,
313+ credential = credentialToValidate,
314+ cause = null
315+ )
316+ updateAuthState(AuthState .Error (accountLinkingException))
317+ throw accountLinkingException
320318 }
321319 } else {
322320 // Just validate the email credential
323321 // No linking for non-federated IDPs
324322 authExplicitlyForValidation
325323 .signInWithCredential(credentialToValidate).await()
326324 .also {
327- // Emit merge conflict after successful validation
328- // Merge failure occurs because account exists and user is anonymous
329- updateAuthState(AuthState .MergeConflict (credentialToValidate))
325+ // Throw AccountLinkingRequiredException after successful validation
326+ // Account exists and user is anonymous - needs to link accounts
327+ val accountLinkingException = AuthException .AccountLinkingRequiredException (
328+ message = " An account already exists with this email. " +
329+ " Please sign in with your existing account to upgrade your anonymous account." ,
330+ email = email,
331+ credential = credentialToValidate,
332+ cause = null
333+ )
334+ updateAuthState(AuthState .Error (accountLinkingException))
335+ throw accountLinkingException
330336 }
331337 }
332338 } else {
@@ -380,7 +386,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
380386 * 2. If yes: Link credential to anonymous user
381387 * 3. If no: Sign in with credential
382388 * 4. Merge profile information (name, photo) into Firebase user
383- * 5. Handle collision exceptions by emitting [AuthState.MergeConflict ]
389+ * 5. Handle collision exceptions by throwing [AuthException.AccountLinkingRequiredException ]
384390 *
385391 * @param config The [AuthUIConfiguration] containing authentication settings
386392 * @param credential The [AuthCredential] to use for authentication. Can be from any provider.
@@ -430,10 +436,10 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
430436 * config = authUIConfig,
431437 * credential = phoneCredential
432438 * )
433- * } catch (e: FirebaseAuthUserCollisionException ) {
439+ * } catch (e: AuthException.AccountLinkingRequiredException ) {
434440 * // Phone number already exists on another account
435- * // AuthState.MergeConflict emitted with updatedCredential
436- * // UI can show merge conflict resolution screen
441+ * // Account linking required - UI can show account linking screen
442+ * // User needs to sign in with existing account to link
437443 * }
438444 * ```
439445 *
@@ -472,32 +478,27 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential(
472478 updateAuthState(AuthState .Idle )
473479 }
474480 } catch (e: FirebaseAuthUserCollisionException ) {
475- // Special handling for collision exceptions
476- if (canUpgradeAnonymous(config, auth)) {
477- // Anonymous upgrade collision: emit merge conflict with updated credential
478- val updatedCredential = e.updatedCredential
479- val authException = AuthException .from(e)
480- if (updatedCredential != null ) {
481- updateAuthState(AuthState .MergeConflict (updatedCredential))
482- } else {
483- updateAuthState(AuthState .Error (authException))
484- }
485- throw authException
481+ // Account collision: account already exists with different sign-in method
482+ // Create AccountLinkingRequiredException with credential for linking
483+ val email = e.email
484+ val credentialForException = if (canUpgradeAnonymous(config, auth)) {
485+ // For anonymous upgrade, use the updated credential from the exception
486+ e.updatedCredential ? : credential
486487 } else {
487- // Non-anonymous collision: account already exists with different sign-in method
488- // Create AccountLinkingRequiredException with credential for linking
489- val email = e.email
490- val accountLinkingException = AuthException .AccountLinkingRequiredException (
491- message = " An account already exists with the email ${email ? : " " } . " +
492- " Please sign in with your existing account to link " +
493- " your ${provider?.name ? : " this provider" } account." ,
494- email = email,
495- credential = credential,
496- cause = e
497- )
498- updateAuthState(AuthState .Error (accountLinkingException))
499- throw accountLinkingException
488+ // For non-anonymous, use the original credential
489+ credential
500490 }
491+
492+ val accountLinkingException = AuthException .AccountLinkingRequiredException (
493+ message = " An account already exists with the email ${email ? : " " } . " +
494+ " Please sign in with your existing account to link " +
495+ " your ${provider?.name ? : " this provider" } account." ,
496+ email = email,
497+ credential = credentialForException,
498+ cause = e
499+ )
500+ updateAuthState(AuthState .Error (accountLinkingException))
501+ throw accountLinkingException
501502 } catch (e: CancellationException ) {
502503 val cancelledException = AuthException .AuthCancelledException (
503504 message = " Sign in and link with credential was cancelled" ,
@@ -520,8 +521,7 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential(
520521 *
521522 * This method initiates the email-link (passwordless) authentication flow by sending
522523 * an email containing a magic link. The link includes session information for validation
523- * and security. Optionally supports account linking when a user tries to sign in with
524- * a social provider but an email link account exists.
524+ * and security.
525525 *
526526 * **How it works:**
527527 * 1. Generates a unique session ID for same-device validation
@@ -534,10 +534,11 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential(
534534 *
535535 * **Account Linking Support:**
536536 * If a user tries to sign in with a social provider (Google, Facebook) but an email link
537- * account already exists with that email, you can link the accounts by:
538- * 1. Catching the [FirebaseAuthUserCollisionException] from the social sign-in attempt
539- * 2. Calling this method with [credentialForLinking] containing the social provider tokens
540- * 3. When [signInWithEmailLink] completes, it automatically retrieves and links the saved credential
537+ * account already exists with that email, the social provider implementation should:
538+ * 1. Catch the [FirebaseAuthUserCollisionException] from the sign-in attempt
539+ * 2. Call [EmailLinkPersistenceManager.saveCredentialForLinking] with the provider tokens
540+ * 3. Call this method to send the email link
541+ * 4. When [signInWithEmailLink] completes, it automatically retrieves and links the saved credential
541542 *
542543 * **Session Security:**
543544 * - **Session ID**: Random 10-character string for same-device validation
@@ -549,9 +550,6 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential(
549550 * @param config The [AuthUIConfiguration] containing authentication settings
550551 * @param provider The [AuthProvider.Email] configuration with [ActionCodeSettings]
551552 * @param email The email address to send the sign-in link to
552- * @param credentialForLinking Optional credential linking data. If provided, this credential
553- * will be automatically linked after email link sign-in completes. Pass null for basic
554- * email link sign-in without account linking.
555553 *
556554 * @throws AuthException.InvalidCredentialsException if email is invalid
557555 * @throws AuthException.AuthCancelledException if the operation is cancelled
@@ -582,55 +580,7 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential(
582580 * // User is now signed in
583581 * ```
584582 *
585- * **Example 2: Complete account linking flow (Google → Email Link)**
586- * ```kotlin
587- * // Step 1: User tries to sign in with Google
588- * try {
589- * val googleAccount = GoogleSignIn.getLastSignedInAccount(context)
590- * val googleIdToken = googleAccount?.idToken
591- * val googleCredential = GoogleAuthProvider.getCredential(googleIdToken, null)
592- *
593- * firebaseAuthUI.signInAndLinkWithCredential(
594- * config = authUIConfig,
595- * credential = googleCredential
596- * )
597- * } catch (e: FirebaseAuthUserCollisionException) {
598- * // Email already exists with Email Link provider
599- *
600- * // Step 2: Send email link with credential for linking
601- * firebaseAuthUI.sendSignInLinkToEmail(
602- * context = context,
603- * config = authUIConfig,
604- * provider = emailProvider,
605- * email = email,
606- * credentialForLinking = CredentialForLinking(
607- * providerType = "google.com",
608- * idToken = googleIdToken, // From GoogleSignInAccount
609- * accessToken = null
610- * )
611- * )
612- *
613- * // Step 3: Show "Check your email" UI
614- * }
615- *
616- * // Step 4: User clicks email link → App opens
617- * // (In your deep link handling Activity)
618- * val emailLink = intent.data.toString()
619- * firebaseAuthUI.signInWithEmailLink(
620- * context = context,
621- * config = authUIConfig,
622- * provider = emailProvider,
623- * email = email,
624- * emailLink = emailLink
625- * )
626- * // signInWithEmailLink automatically:
627- * // 1. Signs in with email link
628- * // 2. Retrieves the saved Google credential from DataStore
629- * // 3. Links the Google credential to the email link account
630- * // 4. User is now signed in with both Email Link AND Google linked
631- * ```
632- *
633- * **Example 3: Anonymous user upgrade**
583+ * **Example 2: Anonymous user upgrade**
634584 * ```kotlin
635585 * // User is currently signed in anonymously
636586 * // Send email link to upgrade anonymous account to permanent email account
@@ -652,7 +602,6 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail(
652602 config : AuthUIConfiguration ,
653603 provider : AuthProvider .Email ,
654604 email : String ,
655- credentialForLinking : CredentialForLinking ? = null,
656605) {
657606 try {
658607 updateAuthState(AuthState .Loading (" Sending sign in email link..." ))
@@ -668,16 +617,6 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail(
668617 val sessionId =
669618 SessionUtils .generateRandomAlphaNumericString(AuthProvider .Email .SESSION_ID_LENGTH )
670619
671- // If credential provided, save it for linking after email link sign-in
672- if (credentialForLinking != null ) {
673- EmailLinkPersistenceManager .saveCredentialForLinking(
674- context = context,
675- providerType = credentialForLinking.providerType,
676- idToken = credentialForLinking.idToken,
677- accessToken = credentialForLinking.accessToken
678- )
679- }
680-
681620 // Modify actionCodeSettings Url to include sessionId, anonymousUserId, force same
682621 // device flag
683622 val updatedActionCodeSettings =
@@ -838,12 +777,16 @@ suspend fun FirebaseAuthUI.signInWithEmailLink(
838777 .signInWithCredential(emailLinkCredential).await()
839778 .user?.linkWithCredential(storedCredentialForLink)?.await()
840779 .also { result ->
841- // If safe link succeeds, emit merge conflict for UI to handle
842- updateAuthState(
843- AuthState .MergeConflict (
844- storedCredentialForLink
845- )
780+ // If safe link succeeds, throw AccountLinkingRequiredException for UI to handle
781+ val accountLinkingException = AuthException .AccountLinkingRequiredException (
782+ message = " An account already exists with this email. " +
783+ " Please sign in with your existing account to upgrade your anonymous account." ,
784+ email = email,
785+ credential = storedCredentialForLink,
786+ cause = null
846787 )
788+ updateAuthState(AuthState .Error (accountLinkingException))
789+ throw accountLinkingException
847790 }
848791 return result
849792 } else {
0 commit comments