Skip to content

Commit 5a13fbc

Browse files
demolafLyokone
andauthored
feat: Facebook Sign-In (FacebookSignInHandler) (#2245)
* feat: MFA Screens * update gitignore * QR code generation * remove emulator * chore: upgrade facebook login to latest * feat: facebook client token for fb login sdk v13.0 and above * fix * wip: Facebook Sign-In * feat: Facebook Sign in integration * refactor: replace AuthState.MergeConflict with AccountLinkingRequiredException for account collision * pass context to rememberSignInWithFacebookLauncher * fix: linking credential hangs AuthState * facebook sign tests --------- Co-authored-by: Guillaume Bernos <guillaume@bernos.dev>
1 parent 8771332 commit 5a13fbc

File tree

32 files changed

+1186
-386
lines changed

32 files changed

+1186
-386
lines changed

.firebase/hosting.cHVibGlj.cache

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
index.html,1760725054923,96d3ff69603ba92f085431c7b56242a873ddcdd5a1c9691f7836b093f8114a5a
2+
.well-known/assetlinks.json,1760725039101,cbfe2437a47d2f4a2bca9bb7c1c789b4684d6a13694821e46e4177ccce023f4b

auth/src/main/AndroidManifest.xml

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
android:name="com.facebook.sdk.ApplicationId"
2828
android:value="@string/facebook_application_id" />
2929

30+
<meta-data
31+
android:name="com.facebook.sdk.ClientToken"
32+
android:value="@string/facebook_client_token"/>
33+
3034
<activity
3135
android:name=".KickoffActivity"
3236
android:label=""
@@ -120,14 +124,24 @@
120124
</activity>
121125

122126
<!-- Email Link Sign-In Handler Activity for Compose -->
123-
<!-- IMPORTANT: This activity is NOT exported by default -->
124-
<!-- Users must declare this activity with an intent filter in their app's AndroidManifest.xml -->
125-
<!-- See documentation for setup instructions -->
127+
<!-- This activity handles deep links for passwordless email authentication -->
128+
<!-- The host is automatically read from firebase_web_host in config.xml -->
129+
<!-- NOTE: firebase_web_host must be lowercase (e.g., project-id.firebaseapp.com) -->
126130
<activity
127131
android:name=".compose.ui.screens.EmailSignInLinkHandlerActivity"
128132
android:label=""
129-
android:exported="false"
130-
android:theme="@style/FirebaseUI.Transparent" />
133+
android:exported="true"
134+
android:theme="@style/FirebaseUI.Transparent">
135+
<intent-filter android:autoVerify="true">
136+
<action android:name="android.intent.action.VIEW" />
137+
<category android:name="android.intent.category.DEFAULT" />
138+
<category android:name="android.intent.category.BROWSABLE" />
139+
<data
140+
android:scheme="https"
141+
android:host="@string/firebase_web_host"
142+
tools:ignore="AppLinksAutoVerify,AppLinkUrlError" />
143+
</intent-filter>
144+
</activity>
131145

132146
<provider
133147
android:name=".data.client.AuthUiInitProvider"

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package com.firebase.ui.auth.compose
1616

1717
import com.firebase.ui.auth.compose.AuthException.Companion.from
1818
import com.google.firebase.FirebaseException
19+
import com.google.firebase.auth.AuthCredential
1920
import com.google.firebase.auth.FirebaseAuthException
2021
import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException
2122
import com.google.firebase.auth.FirebaseAuthInvalidUserException
@@ -167,13 +168,19 @@ abstract class AuthException(
167168
* Account linking is required to complete sign-in.
168169
*
169170
* This exception is thrown when a user tries to sign in with a provider
170-
* that needs to be linked to an existing account.
171+
* that needs to be linked to an existing account. For example, when a user
172+
* tries to sign in with Facebook but an account already exists with that
173+
* email using a different provider (like email/password).
171174
*
172175
* @property message The detailed error message
176+
* @property email The email address that already has an account (optional)
177+
* @property credential The credential that should be linked after signing in (optional)
173178
* @property cause The underlying [Throwable] that caused this exception
174179
*/
175180
class AccountLinkingRequiredException(
176181
message: String,
182+
val email: String? = null,
183+
val credential: AuthCredential? = null,
177184
cause: Throwable? = null
178185
) : AuthException(message, cause)
179186

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/FirebaseAuthUI.kt

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
2929
import kotlinx.coroutines.flow.callbackFlow
3030
import kotlinx.coroutines.flow.combine
3131
import kotlinx.coroutines.flow.distinctUntilChanged
32-
import kotlinx.coroutines.flow.drop
33-
import kotlinx.coroutines.flow.merge
34-
import kotlinx.coroutines.flow.onStart
3532
import kotlinx.coroutines.tasks.await
3633
import java.util.concurrent.ConcurrentHashMap
3734

@@ -221,7 +218,7 @@ class FirebaseAuthUI private constructor(
221218
) { firebaseState, internalState ->
222219
// Prefer non-idle internal states (like PasswordResetLinkSent, Error, etc.)
223220
if (internalState !is AuthState.Idle) internalState else firebaseState
224-
}
221+
}.distinctUntilChanged()
225222
}
226223

227224
/**

auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt

Lines changed: 135 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@ import android.app.Activity
1818
import android.content.Context
1919
import android.net.Uri
2020
import android.util.Log
21+
import androidx.annotation.RestrictTo
22+
import androidx.annotation.VisibleForTesting
2123
import androidx.compose.ui.graphics.Color
24+
import androidx.core.net.toUri
2225
import androidx.datastore.preferences.core.stringPreferencesKey
26+
import com.facebook.AccessToken
2327
import com.firebase.ui.auth.R
2428
import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration
2529
import com.firebase.ui.auth.compose.configuration.AuthUIConfigurationDsl
@@ -44,8 +48,8 @@ import com.google.firebase.auth.PhoneAuthProvider
4448
import com.google.firebase.auth.TwitterAuthProvider
4549
import com.google.firebase.auth.UserProfileChangeRequest
4650
import com.google.firebase.auth.actionCodeSettings
51+
import kotlinx.coroutines.suspendCancellableCoroutine
4752
import kotlinx.coroutines.tasks.await
48-
import kotlinx.serialization.Serializable
4953
import java.util.concurrent.TimeUnit
5054
import kotlin.coroutines.resume
5155
import kotlin.coroutines.resumeWithException
@@ -89,14 +93,16 @@ internal enum class Provider(val id: String, val isSocialProvider: Boolean = fal
8993
*/
9094
abstract class OAuthProvider(
9195
override val providerId: String,
96+
97+
override val name: String,
9298
open val scopes: List<String> = emptyList(),
9399
open val customParameters: Map<String, String> = emptyMap(),
94-
) : AuthProvider(providerId)
100+
) : AuthProvider(providerId = providerId, name = name)
95101

96102
/**
97103
* Base abstract class for authentication providers.
98104
*/
99-
abstract class AuthProvider(open val providerId: String) {
105+
abstract class AuthProvider(open val providerId: String, open val name: String) {
100106

101107
companion object {
102108
internal fun canUpgradeAnonymous(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean {
@@ -201,7 +207,7 @@ abstract class AuthProvider(open val providerId: String) {
201207
* A list of custom password validation rules.
202208
*/
203209
val passwordValidationRules: List<PasswordRule>,
204-
) : AuthProvider(providerId = Provider.EMAIL.id) {
210+
) : AuthProvider(providerId = Provider.EMAIL.id, name = "Email") {
205211
companion object {
206212
const val SESSION_ID_LENGTH = 10
207213
val KEY_EMAIL = stringPreferencesKey("com.firebase.ui.auth.data.client.email")
@@ -327,7 +333,7 @@ abstract class AuthProvider(open val providerId: String) {
327333
* Enables instant verification of the phone number. Defaults to true.
328334
*/
329335
val isInstantVerificationEnabled: Boolean = true,
330-
) : AuthProvider(providerId = Provider.PHONE.id) {
336+
) : AuthProvider(providerId = Provider.PHONE.id, name = "Phone") {
331337
/**
332338
* Sealed class representing the result of phone number verification.
333339
*
@@ -550,6 +556,7 @@ abstract class AuthProvider(open val providerId: String) {
550556
override val customParameters: Map<String, String> = emptyMap(),
551557
) : OAuthProvider(
552558
providerId = Provider.GOOGLE.id,
559+
name = "Google",
553560
scopes = scopes,
554561
customParameters = customParameters
555562
) {
@@ -591,17 +598,13 @@ abstract class AuthProvider(open val providerId: String) {
591598
*/
592599
override val scopes: List<String> = listOf("email", "public_profile"),
593600

594-
/**
595-
* if true, enable limited login mode. Defaults to false.
596-
*/
597-
val limitedLogin: Boolean = false,
598-
599601
/**
600602
* A map of custom OAuth parameters.
601603
*/
602604
override val customParameters: Map<String, String> = emptyMap(),
603605
) : OAuthProvider(
604606
providerId = Provider.FACEBOOK.id,
607+
name = "Facebook",
605608
scopes = scopes,
606609
customParameters = customParameters
607610
) {
@@ -627,6 +630,113 @@ abstract class AuthProvider(open val providerId: String) {
627630
}
628631
}
629632
}
633+
634+
/**
635+
* An interface to wrap the static `FacebookAuthProvider.getCredential` method to make it testable.
636+
* @suppress
637+
*/
638+
@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
639+
interface CredentialProvider {
640+
fun getCredential(token: String): AuthCredential
641+
}
642+
643+
/**
644+
* The default implementation of [CredentialProvider] that calls the static method.
645+
* @suppress
646+
*/
647+
internal class DefaultCredentialProvider : CredentialProvider {
648+
override fun getCredential(token: String): AuthCredential {
649+
return FacebookAuthProvider.getCredential(token)
650+
}
651+
}
652+
653+
/**
654+
* Internal data class to hold Facebook profile information.
655+
*/
656+
internal class FacebookProfileData(
657+
val displayName: String?,
658+
val email: String?,
659+
val photoUrl: Uri?,
660+
)
661+
662+
/**
663+
* Fetches user profile data from Facebook Graph API.
664+
*
665+
* @param accessToken The Facebook access token
666+
* @return FacebookProfileData containing user's display name, email, and photo URL
667+
*/
668+
internal suspend fun fetchFacebookProfile(accessToken: AccessToken): FacebookProfileData? {
669+
return suspendCancellableCoroutine { continuation ->
670+
val request =
671+
com.facebook.GraphRequest.newMeRequest(accessToken) { jsonObject, response ->
672+
try {
673+
val error = response?.error
674+
if (error != null) {
675+
Log.e(
676+
"FirebaseAuthUI.signInWithFacebook",
677+
"Graph API error: ${error.errorMessage}"
678+
)
679+
continuation.resume(null)
680+
return@newMeRequest
681+
}
682+
683+
if (jsonObject == null) {
684+
Log.e(
685+
"FirebaseAuthUI.signInWithFacebook",
686+
"Graph API returned null response"
687+
)
688+
continuation.resume(null)
689+
return@newMeRequest
690+
}
691+
692+
val name = jsonObject.optString("name")
693+
val email = jsonObject.optString("email")
694+
695+
// Extract photo URL from picture object
696+
val photoUrl = try {
697+
jsonObject.optJSONObject("picture")
698+
?.optJSONObject("data")
699+
?.optString("url")
700+
?.takeIf { it.isNotEmpty() }?.toUri()
701+
} catch (e: Exception) {
702+
Log.w(
703+
"FirebaseAuthUI.signInWithFacebook",
704+
"Error parsing photo URL",
705+
e
706+
)
707+
null
708+
}
709+
710+
Log.d(
711+
"FirebaseAuthUI.signInWithFacebook",
712+
"Profile fetched: name=$name, email=$email, hasPhoto=${photoUrl != null}"
713+
)
714+
715+
continuation.resume(
716+
FacebookProfileData(
717+
displayName = name,
718+
email = email,
719+
photoUrl = photoUrl
720+
)
721+
)
722+
} catch (e: Exception) {
723+
Log.e(
724+
"FirebaseAuthUI.signInWithFacebook",
725+
"Error processing Graph API response",
726+
e
727+
)
728+
continuation.resume(null)
729+
}
730+
}
731+
732+
// Request specific fields: id, name, email, and picture
733+
val parameters = android.os.Bundle().apply {
734+
putString("fields", "id,name,email,picture")
735+
}
736+
request.parameters = parameters
737+
request.executeAsync()
738+
}
739+
}
630740
}
631741

632742
/**
@@ -639,6 +749,7 @@ abstract class AuthProvider(open val providerId: String) {
639749
override val customParameters: Map<String, String>,
640750
) : OAuthProvider(
641751
providerId = Provider.TWITTER.id,
752+
name = "Twitter",
642753
customParameters = customParameters
643754
)
644755

@@ -657,6 +768,7 @@ abstract class AuthProvider(open val providerId: String) {
657768
override val customParameters: Map<String, String>,
658769
) : OAuthProvider(
659770
providerId = Provider.GITHUB.id,
771+
name = "Github",
660772
scopes = scopes,
661773
customParameters = customParameters
662774
)
@@ -681,6 +793,7 @@ abstract class AuthProvider(open val providerId: String) {
681793
override val customParameters: Map<String, String>,
682794
) : OAuthProvider(
683795
providerId = Provider.MICROSOFT.id,
796+
name = "Microsoft",
684797
scopes = scopes,
685798
customParameters = customParameters
686799
)
@@ -700,6 +813,7 @@ abstract class AuthProvider(open val providerId: String) {
700813
override val customParameters: Map<String, String>,
701814
) : OAuthProvider(
702815
providerId = Provider.YAHOO.id,
816+
name = "Yahoo",
703817
scopes = scopes,
704818
customParameters = customParameters
705819
)
@@ -724,14 +838,18 @@ abstract class AuthProvider(open val providerId: String) {
724838
override val customParameters: Map<String, String>,
725839
) : OAuthProvider(
726840
providerId = Provider.APPLE.id,
841+
name = "Apple",
727842
scopes = scopes,
728843
customParameters = customParameters
729844
)
730845

731846
/**
732847
* Anonymous authentication provider. It has no configurable properties.
733848
*/
734-
object Anonymous : AuthProvider(providerId = Provider.ANONYMOUS.id) {
849+
object Anonymous : AuthProvider(
850+
providerId = Provider.ANONYMOUS.id,
851+
name = "Anonymous"
852+
) {
735853
internal fun validate(providers: List<AuthProvider>) {
736854
if (providers.size == 1 && providers.first() is Anonymous) {
737855
throw IllegalStateException(
@@ -746,6 +864,11 @@ abstract class AuthProvider(open val providerId: String) {
746864
* A generic OAuth provider for any unsupported provider.
747865
*/
748866
class GenericOAuth(
867+
/**
868+
* The provider name.
869+
*/
870+
override val name: String,
871+
749872
/**
750873
* The provider ID as configured in the Firebase console.
751874
*/
@@ -782,6 +905,7 @@ abstract class AuthProvider(open val providerId: String) {
782905
val contentColor: Color?,
783906
) : OAuthProvider(
784907
providerId = providerId,
908+
name = name,
785909
scopes = scopes,
786910
customParameters = customParameters
787911
) {

0 commit comments

Comments
 (0)