Skip to content

Commit 32d7d99

Browse files
committed
refactor: replace AuthState.MergeConflict with AccountLinkingRequiredException for account collision
1 parent 40c3c71 commit 32d7d99

File tree

6 files changed

+133
-190
lines changed

6 files changed

+133
-190
lines changed

auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -209,33 +209,6 @@ abstract class AuthState private constructor() {
209209
"AuthState.RequiresProfileCompletion(user=$user, missingFields=$missingFields)"
210210
}
211211

212-
/**
213-
* Pending credential for an anonymous upgrade merge conflict.
214-
*
215-
* Emitted when an anonymous user attempts to convert to a permanent account but
216-
* Firebase detects that the target email already belongs to another user. The UI can
217-
* prompt the user to resolve the conflict by signing in with the existing account and
218-
* later linking the stored [pendingCredential].
219-
*/
220-
class MergeConflict(
221-
val pendingCredential: AuthCredential
222-
) : AuthState() {
223-
override fun equals(other: Any?): Boolean {
224-
if (this === other) return true
225-
if (other !is MergeConflict) return false
226-
return pendingCredential == other.pendingCredential
227-
}
228-
229-
override fun hashCode(): Int {
230-
var result = pendingCredential.hashCode()
231-
result = 31 * result + pendingCredential.hashCode()
232-
return result
233-
}
234-
235-
override fun toString(): String =
236-
"AuthState.MergeConflict(pendingCredential=$pendingCredential)"
237-
}
238-
239212
/**
240213
* Password reset link has been sent to the user's email.
241214
*/

auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt

Lines changed: 85 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -36,31 +36,15 @@ import com.google.firebase.auth.FirebaseAuthUserCollisionException
3636
import kotlinx.coroutines.CancellationException
3737
import 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(
277267
* email = "[email protected]",
278268
* 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

Comments
 (0)