Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ package com.firebase.ui.auth.compose

import android.content.Context
import androidx.annotation.RestrictTo
import com.firebase.ui.auth.compose.configuration.auth_provider.signOutFromGoogle
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.signOutFromGoogle
import com.google.firebase.FirebaseApp
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseAuth.AuthStateListener
Expand Down Expand Up @@ -72,6 +73,9 @@ class FirebaseAuthUI private constructor(

private val _authStateFlow = MutableStateFlow<AuthState>(AuthState.Idle)

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
var testCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null

/**
* Checks whether a user is currently signed in.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import android.app.Activity
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.annotation.RestrictTo
import androidx.compose.ui.graphics.Color
import androidx.core.net.toUri
import androidx.credentials.CredentialManager
Expand Down Expand Up @@ -536,7 +537,8 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
* Result container for Google Sign-In credential flow.
* @suppress
*/
internal data class GoogleSignInResult(
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
data class GoogleSignInResult(
val credential: AuthCredential,
val displayName: String?,
val photoUrl: Uri?
Expand Down Expand Up @@ -570,9 +572,11 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
* An interface to wrap the Credential Manager flow for Google Sign-In.
* @suppress
*/
internal interface CredentialManagerProvider {
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
interface CredentialManagerProvider {
suspend fun getGoogleCredential(
context: Context,
credentialManager: CredentialManager,
serverClientId: String,
filterByAuthorizedAccounts: Boolean,
autoSelectEnabled: Boolean
Expand All @@ -583,14 +587,15 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
* The default implementation of [CredentialManagerProvider].
* @suppress
*/
internal class DefaultCredentialManagerProvider : CredentialManagerProvider {
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
class DefaultCredentialManagerProvider : CredentialManagerProvider {
override suspend fun getGoogleCredential(
context: Context,
credentialManager: CredentialManager,
serverClientId: String,
filterByAuthorizedAccounts: Boolean,
autoSelectEnabled: Boolean
autoSelectEnabled: Boolean,
): GoogleSignInResult {
val credentialManager = CredentialManager.create(context)
val googleIdOption = GetGoogleIdOption.Builder()
.setServerClientId(serverClientId)
.setFilterByAuthorizedAccounts(filterByAuthorizedAccounts)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
val accountLinkingException = AuthException.AccountLinkingRequiredException(
message = "An account already exists with this email. " +
"Please sign in with your existing account.",
email = e.email,
email = e.email ?: email,
credential = if (canUpgrade) {
e.updatedCredential ?: pendingCredential
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,14 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle(
}
}

val result = credentialManagerProvider.getGoogleCredential(
context = context,
serverClientId = provider.serverClientId!!,
filterByAuthorizedAccounts = true,
autoSelectEnabled = false
)
val result =
(testCredentialManagerProvider ?: credentialManagerProvider).getGoogleCredential(
context = context,
credentialManager = CredentialManager.create(context),
serverClientId = provider.serverClientId!!,
filterByAuthorizedAccounts = true,
autoSelectEnabled = false
)

signInAndLinkWithCredential(
config = config,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,9 @@ fun EmailAuthScreen(
}
)

if (isErrorDialogVisible.value) {
if (isErrorDialogVisible.value &&
(authState as AuthState.Error).exception !is AuthException.AccountLinkingRequiredException
) {
ErrorRecoveryDialog(
error = when ((authState as AuthState.Error).exception) {
is AuthException -> (authState as AuthState.Error).exception as AuthException
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package com.firebase.ui.auth.compose.configuration.auth_provider
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import androidx.credentials.CredentialManager
import androidx.test.core.app.ApplicationProvider
import com.firebase.ui.auth.compose.AuthException
import com.firebase.ui.auth.compose.AuthState
Expand Down Expand Up @@ -131,6 +132,7 @@ class GoogleAuthProviderFirebaseAuthUITest {
`when`(
mockCredentialManagerProvider.getGoogleCredential(
context = eq(applicationContext),
credentialManager = any<CredentialManager>(),
serverClientId = eq("test-client-id"),
filterByAuthorizedAccounts = eq(true),
autoSelectEnabled = eq(false)
Expand Down Expand Up @@ -169,6 +171,7 @@ class GoogleAuthProviderFirebaseAuthUITest {
// Verify credential manager was called
verify(mockCredentialManagerProvider).getGoogleCredential(
context = eq(applicationContext),
credentialManager = any<CredentialManager>(),
serverClientId = eq("test-client-id"),
filterByAuthorizedAccounts = eq(true),
autoSelectEnabled = eq(false)
Expand Down Expand Up @@ -198,6 +201,7 @@ class GoogleAuthProviderFirebaseAuthUITest {
`when`(
mockCredentialManagerProvider.getGoogleCredential(
context = eq(applicationContext),
credentialManager = any<CredentialManager>(),
serverClientId = eq("test-client-id"),
filterByAuthorizedAccounts = eq(true),
autoSelectEnabled = eq(false)
Expand Down Expand Up @@ -242,6 +246,7 @@ class GoogleAuthProviderFirebaseAuthUITest {
// Verify credential manager was called after authorization
verify(mockCredentialManagerProvider).getGoogleCredential(
context = eq(applicationContext),
credentialManager = any<CredentialManager>(),
serverClientId = eq("test-client-id"),
filterByAuthorizedAccounts = eq(true),
autoSelectEnabled = eq(false)
Expand Down Expand Up @@ -270,6 +275,7 @@ class GoogleAuthProviderFirebaseAuthUITest {
`when`(
mockCredentialManagerProvider.getGoogleCredential(
context = eq(applicationContext),
credentialManager = any<CredentialManager>(),
serverClientId = eq("test-client-id"),
filterByAuthorizedAccounts = eq(true),
autoSelectEnabled = eq(false)
Expand Down Expand Up @@ -336,6 +342,7 @@ class GoogleAuthProviderFirebaseAuthUITest {
`when`(
mockCredentialManagerProvider.getGoogleCredential(
context = eq(applicationContext),
credentialManager = any<CredentialManager>(),
serverClientId = eq("test-client-id"),
filterByAuthorizedAccounts = eq(true),
autoSelectEnabled = eq(false)
Expand Down Expand Up @@ -374,6 +381,7 @@ class GoogleAuthProviderFirebaseAuthUITest {
// Verify sign-in continued despite authorization failure
verify(mockCredentialManagerProvider).getGoogleCredential(
context = eq(applicationContext),
credentialManager = any<CredentialManager>(),
serverClientId = eq("test-client-id"),
filterByAuthorizedAccounts = eq(true),
autoSelectEnabled = eq(false)
Expand All @@ -387,6 +395,7 @@ class GoogleAuthProviderFirebaseAuthUITest {
`when`(
mockCredentialManagerProvider.getGoogleCredential(
context = eq(applicationContext),
credentialManager = any<CredentialManager>(),
serverClientId = eq("test-client-id"),
filterByAuthorizedAccounts = eq(true),
autoSelectEnabled = eq(false)
Expand Down Expand Up @@ -437,6 +446,7 @@ class GoogleAuthProviderFirebaseAuthUITest {
`when`(
mockCredentialManagerProvider.getGoogleCredential(
context = eq(applicationContext),
credentialManager = any<CredentialManager>(),
serverClientId = eq("test-client-id"),
filterByAuthorizedAccounts = eq(true),
autoSelectEnabled = eq(false)
Expand Down Expand Up @@ -486,6 +496,7 @@ class GoogleAuthProviderFirebaseAuthUITest {
`when`(
mockCredentialManagerProvider.getGoogleCredential(
context = eq(applicationContext),
credentialManager = any<CredentialManager>(),
serverClientId = eq("test-client-id"),
filterByAuthorizedAccounts = eq(true),
autoSelectEnabled = eq(false)
Expand Down Expand Up @@ -547,6 +558,7 @@ class GoogleAuthProviderFirebaseAuthUITest {
`when`(
mockCredentialManagerProvider.getGoogleCredential(
context = eq(applicationContext),
credentialManager = any<CredentialManager>(),
serverClientId = eq("test-client-id"),
filterByAuthorizedAccounts = eq(true),
autoSelectEnabled = eq(false)
Expand Down Expand Up @@ -605,6 +617,7 @@ class GoogleAuthProviderFirebaseAuthUITest {
`when`(
mockCredentialManagerProvider.getGoogleCredential(
context = eq(applicationContext),
credentialManager = any<CredentialManager>(),
serverClientId = eq("test-client-id"),
filterByAuthorizedAccounts = eq(true),
autoSelectEnabled = eq(false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ class HighLevelApiDemoActivity : ComponentActivity() {
scopes = emptyList(),
customParameters = emptyMap(),
buttonLabel = "Sign in with LINE",
buttonIcon = AuthUIAsset.Resource(com.firebase.ui.auth.R.drawable.fui_ic_line_24dp),
buttonIcon = AuthUIAsset.Resource(R.drawable.ic_line_logo_24dp),
buttonColor = Color(0xFF06C755),
contentColor = Color.White
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import com.google.firebase.FirebaseApp
*/
class MainActivity : ComponentActivity() {
companion object {
private const val USE_AUTH_EMULATOR = false
private const val USE_AUTH_EMULATOR = true
private const val AUTH_EMULATOR_HOST = "10.0.2.2"
private const val AUTH_EMULATOR_PORT = 9099
}
Expand Down
1 change: 1 addition & 0 deletions e2eTest/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ dependencies {
testImplementation(Config.Libs.Test.mockitoInline)
testImplementation(Config.Libs.Test.mockitoKotlin)
testImplementation(Config.Libs.Androidx.credentials)
testImplementation(Config.Libs.Misc.googleid)
testImplementation(Config.Libs.Test.composeUiTestJunit4)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringPr
import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider
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.ensureFreshUser
import com.google.common.truth.Truth.assertThat
import com.google.firebase.FirebaseApp
import com.google.firebase.FirebaseOptions
Expand Down Expand Up @@ -275,6 +276,133 @@ class AnonymousAuthScreenTest {
.isEqualTo(email)
}

@Test
fun `anonymous upgrade with existing email shows dialog with AccountLinking message`() {
val name = "Existing User"
val email = "[email protected]"
val password = "Password123!"

// Step 1: Create an email/password account first
println("TEST: Creating email/password account...")
ensureFreshUser(authUI, email, password)
println("TEST: Email/password account created")

// Step 2: Sign out
authUI.auth.signOut()
shadowOf(Looper.getMainLooper()).idle()
assertThat(authUI.auth.currentUser).isNull()
println("TEST: Signed out, but email account still exists in emulator")

// Step 3: Sign in anonymously
val configuration = authUIConfiguration {
context = applicationContext
providers {
provider(AuthProvider.Anonymous)
provider(
AuthProvider.Email(
emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList()
)
)
}
isAnonymousUpgradeEnabled = true
}

var currentAuthState: AuthState = AuthState.Idle

composeTestRule.setContent {
TestAuthScreen(configuration = configuration)
val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
currentAuthState = authState
}

composeTestRule.waitForIdle()
shadowOf(Looper.getMainLooper()).idle()

println("TEST: Clicking anonymous sign-in button...")
composeTestRule.onNodeWithText(stringProvider.signInAnonymously)
.assertIsDisplayed()
.performClick()

composeTestRule.waitForIdle()
shadowOf(Looper.getMainLooper()).idle()

// Wait for anonymous auth to complete
println("TEST: Waiting for anonymous auth...")
composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
shadowOf(Looper.getMainLooper()).idle()
println("TEST: Auth state: $currentAuthState")
currentAuthState is AuthState.Success
}

assertThat(authUI.auth.currentUser!!.isAnonymous).isTrue()
val anonymousUserUID = authUI.auth.currentUser!!.uid
println("TEST: Anonymous user UID: $anonymousUserUID")

// Step 4: Try to upgrade with existing email
println("TEST: Clicking 'Upgrade with Email' button...")
composeTestRule.onNodeWithText("Upgrade with Email")
.assertIsDisplayed()
.performClick()

composeTestRule.waitForIdle()
shadowOf(Looper.getMainLooper()).idle()

// Navigate to sign-up
println("TEST: Navigating to sign-up...")
composeTestRule.onNodeWithText(stringProvider.signupPageTitle.uppercase())
.assertIsDisplayed()
.performClick()

// Enter the existing email
println("TEST: Entering existing email...")
composeTestRule.onNodeWithText(stringProvider.emailHint)
.assertIsDisplayed()
.performTextInput(email)
composeTestRule.onNodeWithText(stringProvider.nameHint)
.assertIsDisplayed()
.performTextInput(name)
composeTestRule.onNodeWithText(stringProvider.passwordHint)
.performScrollTo()
.assertIsDisplayed()
.performTextInput(password)
composeTestRule.onNodeWithText(stringProvider.confirmPasswordHint)
.performScrollTo()
.assertIsDisplayed()
.performTextInput(password)

// Click sign-up button
println("TEST: Clicking sign-up button...")
composeTestRule.onNodeWithText(stringProvider.signupPageTitle.uppercase())
.performScrollTo()
.assertIsDisplayed()
.performClick()

composeTestRule.waitForIdle()
shadowOf(Looper.getMainLooper()).idle()

// Step 5: Wait for error state (AccountLinkingRequiredException)
println("TEST: Waiting for AccountLinkingRequiredException...")
composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) {
shadowOf(Looper.getMainLooper()).idle()
println("TEST: Auth state: $currentAuthState")
currentAuthState is AuthState.Error
}

// Step 6: Verify ErrorRecoveryDialog is displayed
println("TEST: Verifying ErrorRecoveryDialog is displayed...")
composeTestRule.onNodeWithText(stringProvider.errorDialogTitle)
.assertIsDisplayed()

// Verify exception
assertThat(currentAuthState).isInstanceOf(AuthState.Error::class.java)
val errorState = currentAuthState as AuthState.Error
assertThat(errorState.exception).isInstanceOf(AuthException.AccountLinkingRequiredException::class.java)

val linkingException = errorState.exception as AuthException.AccountLinkingRequiredException
assertThat(linkingException.email).isEqualTo(email)
}

@Composable
private fun TestAuthScreen(configuration: AuthUIConfiguration) {
composeTestRule.waitForIdle()
Expand Down
Loading