Skip to content

Commit ac541ec

Browse files
demolafthatfiredev
andauthored
feat: add Facebook sign-out functionality (#2287)
* feat: add Facebook sign-out functionality * fix: update placeholders in strings.xml * fix: make Facebook sign-out testable and add provider-specific sign-out tests * fix: show specific Firebase error message for InvalidCredentialsException in recovery dialog --------- Co-authored-by: Rosário P. Fernandes <[email protected]>
1 parent 7503edb commit ac541ec

File tree

10 files changed

+271
-21
lines changed

10 files changed

+271
-21
lines changed
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<resources>
22
<string name="app_name">FirebaseUI Demo</string>
33

4-
<string name="firebase_web_host" translatable="false">flutterfire-e2e-tests.firebaseapp.com</string>
4+
<string name="firebase_web_host" translatable="false">CHANGE-HERE</string>
55

66
<!-- Facebook SDK Configuration -->
7-
<string name="facebook_application_id" translatable="false">128693022464535</string>
8-
<string name="facebook_login_protocol_scheme" translatable="false">fb128693022464535</string>
9-
<string name="facebook_client_token" translatable="false">16dbbdf0cfb309034a6ad98ac2a21688</string>
7+
<string name="facebook_application_id" translatable="false">APP-ID</string>
8+
<string name="facebook_login_protocol_scheme" translatable="false">fbAPP-ID</string>
9+
<string name="facebook_client_token" translatable="false">CHANGE-HERE</string>
1010
</resources>

auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import android.content.Intent
1919
import androidx.annotation.RestrictTo
2020
import com.firebase.ui.auth.configuration.AuthUIConfiguration
2121
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
22+
import com.firebase.ui.auth.configuration.auth_provider.signOutFromFacebook
2223
import com.firebase.ui.auth.configuration.auth_provider.signOutFromGoogle
2324
import com.google.firebase.FirebaseApp
2425
import com.google.firebase.auth.FirebaseAuth
@@ -77,6 +78,9 @@ class FirebaseAuthUI private constructor(
7778
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
7879
var testCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null
7980

81+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
82+
var testLoginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null
83+
8084
/**
8185
* Checks whether a user is currently signed in.
8286
*
@@ -367,6 +371,7 @@ class FirebaseAuthUI private constructor(
367371
auth.signOut()
368372
.also {
369373
signOutFromGoogle(context)
374+
signOutFromFacebook()
370375
}
371376

372377
// Update state to idle (user signed out)

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import android.util.Log
2121
import androidx.annotation.RestrictTo
2222
import androidx.compose.ui.graphics.Color
2323
import androidx.core.net.toUri
24+
import androidx.credentials.ClearCredentialStateRequest
2425
import androidx.credentials.CredentialManager
2526
import androidx.credentials.GetCredentialRequest
2627
import androidx.datastore.preferences.core.stringPreferencesKey
@@ -568,6 +569,11 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
568569
filterByAuthorizedAccounts: Boolean,
569570
autoSelectEnabled: Boolean
570571
): GoogleSignInResult
572+
573+
suspend fun clearCredentialState(
574+
context: Context,
575+
credentialManager: CredentialManager,
576+
)
571577
}
572578

573579
/**
@@ -604,6 +610,13 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
604610
photoUrl = googleIdTokenCredential.profilePictureUri,
605611
)
606612
}
613+
614+
override suspend fun clearCredentialState(
615+
context: Context,
616+
credentialManager: CredentialManager,
617+
) {
618+
credentialManager.clearCredentialState(ClearCredentialStateRequest())
619+
}
607620
}
608621
}
609622

@@ -655,21 +668,28 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
655668
}
656669

657670
/**
658-
* An interface to wrap the static `FacebookAuthProvider.getCredential` method to make it testable.
671+
* An interface to wrap Facebook LoginManager and credential operations to make them testable.
659672
* @suppress
660673
*/
661-
internal interface CredentialProvider {
674+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
675+
interface LoginManagerProvider {
662676
fun getCredential(token: String): AuthCredential
677+
fun logOut()
663678
}
664679

665680
/**
666-
* The default implementation of [CredentialProvider] that calls the static method.
681+
* The default implementation of [LoginManagerProvider].
667682
* @suppress
668683
*/
669-
internal class DefaultCredentialProvider : CredentialProvider {
684+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
685+
class DefaultLoginManagerProvider : LoginManagerProvider {
670686
override fun getCredential(token: String): AuthCredential {
671687
return FacebookAuthProvider.getCredential(token)
672688
}
689+
690+
override fun logOut() {
691+
com.facebook.login.LoginManager.getInstance().logOut()
692+
}
673693
}
674694

675695
/**

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ internal suspend fun FirebaseAuthUI.signInWithFacebook(
144144
config: AuthUIConfiguration,
145145
provider: AuthProvider.Facebook,
146146
accessToken: AccessToken,
147-
credentialProvider: AuthProvider.Facebook.CredentialProvider = AuthProvider.Facebook.DefaultCredentialProvider(),
147+
credentialProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(),
148148
) {
149149
try {
150150
updateAuthState(
@@ -210,3 +210,23 @@ internal suspend fun FirebaseAuthUI.signInWithFacebook(
210210
}
211211
}
212212

213+
/**
214+
* Signs out the current user from Facebook.
215+
*
216+
* Invokes Facebook's LoginManager to log out the user from their Facebook session.
217+
* This method silently catches and ignores any exceptions that may occur during the
218+
* logout process to ensure the sign-out flow continues even if Facebook logout fails.
219+
*
220+
* This is typically called as part of the overall sign-out flow when a user signs out
221+
* from Firebase Authentication.
222+
*/
223+
internal fun FirebaseAuthUI.signOutFromFacebook(
224+
loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(),
225+
) {
226+
try {
227+
if (Provider.fromId(getCurrentUser()?.providerId) != Provider.FACEBOOK) return
228+
(testLoginManagerProvider ?: loginManagerProvider).logOut()
229+
} catch (e: Exception) {
230+
Log.e("FacebookAuthProvider", "Error during Facebook sign out", e)
231+
}
232+
}

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

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
package com.firebase.ui.auth.configuration.auth_provider
22

33
import android.content.Context
4+
import android.util.Log
45
import androidx.compose.runtime.Composable
56
import androidx.compose.runtime.remember
67
import androidx.compose.runtime.rememberCoroutineScope
7-
import androidx.credentials.ClearCredentialStateRequest
88
import androidx.credentials.CredentialManager
99
import androidx.credentials.exceptions.GetCredentialException
1010
import androidx.credentials.exceptions.NoCredentialException
@@ -161,7 +161,7 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle(
161161
providerId = provider.providerId,
162162
identifier = identifier
163163
)
164-
android.util.Log.d("GoogleAuthProvider", "Sign-in preference saved for: $identifier")
164+
Log.d("GoogleAuthProvider", "Sign-in preference saved for: $identifier")
165165
}
166166
} catch (e: Exception) {
167167
// Failed to save preference - log but don't break auth flow
@@ -216,13 +216,17 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle(
216216
*
217217
* @param context Android context for Credential Manager
218218
*/
219-
internal suspend fun signOutFromGoogle(context: Context) {
219+
internal suspend fun FirebaseAuthUI.signOutFromGoogle(
220+
context: Context,
221+
credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider = AuthProvider.Google.DefaultCredentialManagerProvider(),
222+
) {
220223
try {
221-
val credentialManager = CredentialManager.create(context)
222-
credentialManager.clearCredentialState(
223-
ClearCredentialStateRequest()
224+
if (Provider.fromId(getCurrentUser()?.providerId) != Provider.GOOGLE) return
225+
(testCredentialManagerProvider ?: credentialManagerProvider).clearCredentialState(
226+
context = context,
227+
credentialManager = CredentialManager.create(context)
224228
)
225-
} catch (_: Exception) {
226-
229+
} catch (e: Exception) {
230+
Log.e("GoogleAuthProvider", "Error during Google sign out", e)
227231
}
228232
}

auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,11 @@ private fun getRecoveryMessage(
126126
): String {
127127
return when (error) {
128128
is AuthException.NetworkException -> stringProvider.networkErrorRecoveryMessage
129-
is AuthException.InvalidCredentialsException -> stringProvider.invalidCredentialsRecoveryMessage
129+
is AuthException.InvalidCredentialsException -> {
130+
// Use the actual error message from Firebase if available, otherwise fallback to generic message
131+
error.message?.takeIf { it.isNotBlank() && it != "Invalid credentials provided" }
132+
?: stringProvider.invalidCredentialsRecoveryMessage
133+
}
130134
is AuthException.UserNotFoundException -> stringProvider.userNotFoundRecoveryMessage
131135
is AuthException.WeakPasswordException -> {
132136
// Include specific reason if available

auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import android.content.Context
1818
import android.content.Intent
1919
import android.net.Uri
2020
import androidx.test.core.app.ApplicationProvider
21+
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
2122
import com.google.android.gms.tasks.TaskCompletionSource
2223
import com.google.common.truth.Truth.assertThat
2324
import com.google.firebase.FirebaseApp
@@ -26,6 +27,7 @@ import com.google.firebase.FirebaseOptions
2627
import com.google.firebase.auth.FirebaseAuth
2728
import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException
2829
import com.google.firebase.auth.FirebaseUser
30+
import com.google.firebase.auth.UserInfo
2931
import kotlinx.coroutines.CancellationException
3032
import kotlinx.coroutines.test.runTest
3133
import org.junit.After
@@ -401,6 +403,156 @@ class FirebaseAuthUITest {
401403
}
402404
}
403405

406+
@Test
407+
fun `signOut() calls Google sign out when user provider is Google`() = runTest {
408+
// Setup mock user with Google provider
409+
val mockUser = mock(FirebaseUser::class.java)
410+
val mockUserInfo = mock(UserInfo::class.java)
411+
`when`(mockUserInfo.providerId).thenReturn("google.com")
412+
`when`(mockUser.providerId).thenReturn("google.com")
413+
`when`(mockUser.providerData).thenReturn(listOf(mockUserInfo))
414+
415+
// Setup mock auth
416+
val mockAuth = mock(FirebaseAuth::class.java)
417+
`when`(mockAuth.currentUser).thenReturn(mockUser)
418+
doNothing().`when`(mockAuth).signOut()
419+
420+
// Create mock credential manager provider
421+
var googleSignOutCalled = false
422+
val mockCredentialManagerProvider = object : AuthProvider.Google.CredentialManagerProvider {
423+
override suspend fun getGoogleCredential(
424+
context: Context,
425+
credentialManager: androidx.credentials.CredentialManager,
426+
serverClientId: String,
427+
filterByAuthorizedAccounts: Boolean,
428+
autoSelectEnabled: Boolean,
429+
): AuthProvider.Google.GoogleSignInResult {
430+
throw UnsupportedOperationException("Not used in this test")
431+
}
432+
433+
override suspend fun clearCredentialState(
434+
context: Context,
435+
credentialManager: androidx.credentials.CredentialManager,
436+
) {
437+
googleSignOutCalled = true
438+
}
439+
}
440+
441+
// Create instance with mock auth and inject test provider
442+
val instance = FirebaseAuthUI.create(defaultApp, mockAuth)
443+
instance.testCredentialManagerProvider = mockCredentialManagerProvider
444+
val context = ApplicationProvider.getApplicationContext<Context>()
445+
446+
// Perform sign out
447+
instance.signOut(context)
448+
449+
// Verify Google sign out was called
450+
assertThat(googleSignOutCalled).isTrue()
451+
verify(mockAuth).signOut()
452+
}
453+
454+
@Test
455+
fun `signOut() calls Facebook sign out when user provider is Facebook`() = runTest {
456+
// Setup mock user with Facebook provider
457+
val mockUser = mock(FirebaseUser::class.java)
458+
val mockUserInfo = mock(UserInfo::class.java)
459+
`when`(mockUserInfo.providerId).thenReturn("facebook.com")
460+
`when`(mockUser.providerId).thenReturn("facebook.com")
461+
`when`(mockUser.providerData).thenReturn(listOf(mockUserInfo))
462+
463+
// Setup mock auth
464+
val mockAuth = mock(FirebaseAuth::class.java)
465+
`when`(mockAuth.currentUser).thenReturn(mockUser)
466+
doNothing().`when`(mockAuth).signOut()
467+
468+
// Create mock login manager provider
469+
var facebookSignOutCalled = false
470+
val mockLoginManagerProvider = object : AuthProvider.Facebook.LoginManagerProvider {
471+
override fun getCredential(token: String): com.google.firebase.auth.AuthCredential {
472+
throw UnsupportedOperationException("Not used in this test")
473+
}
474+
475+
override fun logOut() {
476+
facebookSignOutCalled = true
477+
}
478+
}
479+
480+
// Create instance with mock auth and inject test provider
481+
val instance = FirebaseAuthUI.create(defaultApp, mockAuth)
482+
instance.testLoginManagerProvider = mockLoginManagerProvider
483+
val context = ApplicationProvider.getApplicationContext<Context>()
484+
485+
// Perform sign out
486+
instance.signOut(context)
487+
488+
// Verify Facebook sign out was called
489+
assertThat(facebookSignOutCalled).isTrue()
490+
verify(mockAuth).signOut()
491+
}
492+
493+
@Test
494+
fun `signOut() does not call Google or Facebook sign out when user provider is Email`() =
495+
runTest {
496+
// Setup mock user with Email provider
497+
val mockUser = mock(FirebaseUser::class.java)
498+
val mockUserInfo = mock(UserInfo::class.java)
499+
`when`(mockUserInfo.providerId).thenReturn("password")
500+
`when`(mockUser.providerId).thenReturn("password")
501+
`when`(mockUser.providerData).thenReturn(listOf(mockUserInfo))
502+
503+
// Setup mock auth
504+
val mockAuth = mock(FirebaseAuth::class.java)
505+
`when`(mockAuth.currentUser).thenReturn(mockUser)
506+
doNothing().`when`(mockAuth).signOut()
507+
508+
// Create mock providers
509+
var googleSignOutCalled = false
510+
val mockCredentialManagerProvider =
511+
object : AuthProvider.Google.CredentialManagerProvider {
512+
override suspend fun getGoogleCredential(
513+
context: Context,
514+
credentialManager: androidx.credentials.CredentialManager,
515+
serverClientId: String,
516+
filterByAuthorizedAccounts: Boolean,
517+
autoSelectEnabled: Boolean,
518+
): AuthProvider.Google.GoogleSignInResult {
519+
throw UnsupportedOperationException("Not used in this test")
520+
}
521+
522+
override suspend fun clearCredentialState(
523+
context: Context,
524+
credentialManager: androidx.credentials.CredentialManager,
525+
) {
526+
googleSignOutCalled = true
527+
}
528+
}
529+
530+
var facebookSignOutCalled = false
531+
val mockLoginManagerProvider = object : AuthProvider.Facebook.LoginManagerProvider {
532+
override fun getCredential(token: String): com.google.firebase.auth.AuthCredential {
533+
throw UnsupportedOperationException("Not used in this test")
534+
}
535+
536+
override fun logOut() {
537+
facebookSignOutCalled = true
538+
}
539+
}
540+
541+
// Create instance with mock auth and inject test providers
542+
val instance = FirebaseAuthUI.create(defaultApp, mockAuth)
543+
instance.testCredentialManagerProvider = mockCredentialManagerProvider
544+
instance.testLoginManagerProvider = mockLoginManagerProvider
545+
val context = ApplicationProvider.getApplicationContext<Context>()
546+
547+
// Perform sign out
548+
instance.signOut(context)
549+
550+
// Verify neither Google nor Facebook sign out was called
551+
assertThat(googleSignOutCalled).isFalse()
552+
assertThat(facebookSignOutCalled).isFalse()
553+
verify(mockAuth).signOut()
554+
}
555+
404556
// =============================================================================================
405557
// Delete Account Tests
406558
// =============================================================================================

auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class FacebookAuthProviderFirebaseAuthUITest {
6565
private lateinit var mockFirebaseAuth: FirebaseAuth
6666

6767
@Mock
68-
private lateinit var mockFBAuthCredentialProvider: AuthProvider.Facebook.CredentialProvider
68+
private lateinit var mockFBAuthCredentialProvider: AuthProvider.Facebook.LoginManagerProvider
6969

7070
private lateinit var firebaseApp: FirebaseApp
7171
private lateinit var applicationContext: Context

0 commit comments

Comments
 (0)