diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt index d6ee60d12..f24a9f5ef 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt @@ -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 @@ -72,6 +73,9 @@ class FirebaseAuthUI private constructor( private val _authStateFlow = MutableStateFlow(AuthState.Idle) + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + var testCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null + /** * Checks whether a user is currently signed in. * diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt index f5fd12173..50108473b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt @@ -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 @@ -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? @@ -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 @@ -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) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index 33ded0d82..fef6e1a18 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -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 { diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt index b57daa427..2ed81f8b1 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt @@ -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, diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt index 52554cdf3..cd03a5b1d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt @@ -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 diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt index 4cfa2aa8a..fb86ee5d4 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt @@ -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 @@ -131,6 +132,7 @@ class GoogleAuthProviderFirebaseAuthUITest { `when`( mockCredentialManagerProvider.getGoogleCredential( context = eq(applicationContext), + credentialManager = any(), serverClientId = eq("test-client-id"), filterByAuthorizedAccounts = eq(true), autoSelectEnabled = eq(false) @@ -169,6 +171,7 @@ class GoogleAuthProviderFirebaseAuthUITest { // Verify credential manager was called verify(mockCredentialManagerProvider).getGoogleCredential( context = eq(applicationContext), + credentialManager = any(), serverClientId = eq("test-client-id"), filterByAuthorizedAccounts = eq(true), autoSelectEnabled = eq(false) @@ -198,6 +201,7 @@ class GoogleAuthProviderFirebaseAuthUITest { `when`( mockCredentialManagerProvider.getGoogleCredential( context = eq(applicationContext), + credentialManager = any(), serverClientId = eq("test-client-id"), filterByAuthorizedAccounts = eq(true), autoSelectEnabled = eq(false) @@ -242,6 +246,7 @@ class GoogleAuthProviderFirebaseAuthUITest { // Verify credential manager was called after authorization verify(mockCredentialManagerProvider).getGoogleCredential( context = eq(applicationContext), + credentialManager = any(), serverClientId = eq("test-client-id"), filterByAuthorizedAccounts = eq(true), autoSelectEnabled = eq(false) @@ -270,6 +275,7 @@ class GoogleAuthProviderFirebaseAuthUITest { `when`( mockCredentialManagerProvider.getGoogleCredential( context = eq(applicationContext), + credentialManager = any(), serverClientId = eq("test-client-id"), filterByAuthorizedAccounts = eq(true), autoSelectEnabled = eq(false) @@ -336,6 +342,7 @@ class GoogleAuthProviderFirebaseAuthUITest { `when`( mockCredentialManagerProvider.getGoogleCredential( context = eq(applicationContext), + credentialManager = any(), serverClientId = eq("test-client-id"), filterByAuthorizedAccounts = eq(true), autoSelectEnabled = eq(false) @@ -374,6 +381,7 @@ class GoogleAuthProviderFirebaseAuthUITest { // Verify sign-in continued despite authorization failure verify(mockCredentialManagerProvider).getGoogleCredential( context = eq(applicationContext), + credentialManager = any(), serverClientId = eq("test-client-id"), filterByAuthorizedAccounts = eq(true), autoSelectEnabled = eq(false) @@ -387,6 +395,7 @@ class GoogleAuthProviderFirebaseAuthUITest { `when`( mockCredentialManagerProvider.getGoogleCredential( context = eq(applicationContext), + credentialManager = any(), serverClientId = eq("test-client-id"), filterByAuthorizedAccounts = eq(true), autoSelectEnabled = eq(false) @@ -437,6 +446,7 @@ class GoogleAuthProviderFirebaseAuthUITest { `when`( mockCredentialManagerProvider.getGoogleCredential( context = eq(applicationContext), + credentialManager = any(), serverClientId = eq("test-client-id"), filterByAuthorizedAccounts = eq(true), autoSelectEnabled = eq(false) @@ -486,6 +496,7 @@ class GoogleAuthProviderFirebaseAuthUITest { `when`( mockCredentialManagerProvider.getGoogleCredential( context = eq(applicationContext), + credentialManager = any(), serverClientId = eq("test-client-id"), filterByAuthorizedAccounts = eq(true), autoSelectEnabled = eq(false) @@ -547,6 +558,7 @@ class GoogleAuthProviderFirebaseAuthUITest { `when`( mockCredentialManagerProvider.getGoogleCredential( context = eq(applicationContext), + credentialManager = any(), serverClientId = eq("test-client-id"), filterByAuthorizedAccounts = eq(true), autoSelectEnabled = eq(false) @@ -605,6 +617,7 @@ class GoogleAuthProviderFirebaseAuthUITest { `when`( mockCredentialManagerProvider.getGoogleCredential( context = eq(applicationContext), + credentialManager = any(), serverClientId = eq("test-client-id"), filterByAuthorizedAccounts = eq(true), autoSelectEnabled = eq(false) diff --git a/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt index fc71ead4d..fba6141b7 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt @@ -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 ) diff --git a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt index 45fb129f1..f04e8a796 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt @@ -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 } diff --git a/auth/src/main/res/drawable/fui_ic_line_24dp.xml b/composeapp/src/main/res/drawable/ic_line_logo_24dp.xml similarity index 100% rename from auth/src/main/res/drawable/fui_ic_line_24dp.xml rename to composeapp/src/main/res/drawable/ic_line_logo_24dp.xml diff --git a/e2eTest/build.gradle.kts b/e2eTest/build.gradle.kts index b54322dc2..7cd48b8c0 100644 --- a/e2eTest/build.gradle.kts +++ b/e2eTest/build.gradle.kts @@ -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) } diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/AnonymousAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/AnonymousAuthScreenTest.kt index 393391aae..883eaa57a 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/AnonymousAuthScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/AnonymousAuthScreenTest.kt @@ -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 @@ -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 = "existinguser@example.com" + 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() diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/GoogleAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/GoogleAuthScreenTest.kt new file mode 100644 index 000000000..8d92d1a8a --- /dev/null +++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/GoogleAuthScreenTest.kt @@ -0,0 +1,490 @@ +package com.firebase.ui.auth.compose.ui.screens + +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.os.Looper +import android.util.Base64 +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +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.string_provider.AuthUIStringProvider +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.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class GoogleAuthScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var applicationContext: Context + + private lateinit var stringProvider: AuthUIStringProvider + + private lateinit var authUI: FirebaseAuthUI + private lateinit var emulatorApi: EmulatorAuthApi + + @Mock + private lateinit var mockCredentialManager: CredentialManager + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + applicationContext = ApplicationProvider.getApplicationContext() + + stringProvider = DefaultAuthUIStringProvider(applicationContext) + + // Clear any existing Firebase apps + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + // Initialize default FirebaseApp + val firebaseApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + + authUI = FirebaseAuthUI.getInstance() + authUI.auth.useEmulator("127.0.0.1", 9099) + + val testCredentialManagerProvider = object : AuthProvider.Google.CredentialManagerProvider { + override suspend fun getGoogleCredential( + context: Context, + credentialManager: CredentialManager, + serverClientId: String, + filterByAuthorizedAccounts: Boolean, + autoSelectEnabled: Boolean + ): AuthProvider.Google.GoogleSignInResult { + return AuthProvider.Google.DefaultCredentialManagerProvider().getGoogleCredential( + context = context, + credentialManager = mockCredentialManager, + serverClientId = serverClientId, + filterByAuthorizedAccounts = filterByAuthorizedAccounts, + autoSelectEnabled = autoSelectEnabled + ) + } + } + authUI.testCredentialManagerProvider = testCredentialManagerProvider + + emulatorApi = EmulatorAuthApi( + projectId = firebaseApp.options.projectId + ?: throw IllegalStateException("Project ID is required for emulator interactions"), + emulatorHost = "127.0.0.1", + emulatorPort = 9099 + ) + + // Clear emulator data + emulatorApi.clearEmulatorData() + } + + @After + fun tearDown() { + // Clean up after each test to prevent test pollution + FirebaseAuthUI.clearInstanceCache() + + // Clear emulator data + emulatorApi.clearEmulatorData() + } + + @Test + fun `anonymous upgrade with google links anonymous user and emits Success auth state`() = runTest { + val email = "anonymousupgrade@example.com" + val name = "Anonymous Upgrade User" + val photoUrl = "https://example.com/avatar.jpg" + + // Generate a JWT token for the Google account + val mockIdToken = generateMockGoogleIdToken( + email = email, + name = name, + photoUrl = photoUrl + ) + val mockCredential = mock { + on { type } doReturn GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL + on { data } doReturn Bundle().apply { + putString( + "com.google.android.libraries.identity.googleid.BUNDLE_KEY_ID_TOKEN", + mockIdToken + ) + putString( + "com.google.android.libraries.identity.googleid.BUNDLE_KEY_ID", + email + ) + putString( + "com.google.android.libraries.identity.googleid.BUNDLE_KEY_DISPLAY_NAME", + name + ) + putParcelable( + "com.google.android.libraries.identity.googleid.BUNDLE_KEY_PROFILE_PICTURE_URI", + Uri.parse(photoUrl) + ) + } + on { displayName } doReturn name + on { profilePictureUri } doReturn Uri.parse(photoUrl) + } + val mockResult = mock { + on { credential } doReturn mockCredential + } + whenever(mockCredentialManager.getCredential(any(), any())) + .thenReturn(mockResult) + + // Track auth state changes + var currentAuthState: AuthState = AuthState.Idle + + composeTestRule.setContent { + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider(AuthProvider.Anonymous) + provider( + AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "test-server-client-id", + ) + ) + } + isAnonymousUpgradeEnabled = true + } + + TestAuthScreen(configuration = configuration) + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + currentAuthState = authState + } + + // Wait for UI to be ready + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Step 1: Sign in anonymously + 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 state change...") + composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + println("TEST: Auth state during anonymous sign-in: $currentAuthState") + currentAuthState is AuthState.Success + } + + // Verify anonymous user + composeTestRule.onNodeWithText("isAnonymous - true") + .assertIsDisplayed() + assertThat(authUI.auth.currentUser).isNotNull() + assertThat(authUI.auth.currentUser!!.isAnonymous).isTrue() + + val anonymousUserUID = authUI.auth.currentUser!!.uid + println("TEST: Anonymous user UID: $anonymousUserUID") + + // Step 2: Click "Upgrade with Google" button + println("TEST: Clicking 'Upgrade with Google' button...") + composeTestRule.onNodeWithText("Upgrade with Google") + .assertIsDisplayed() + .performClick() + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Step 3: Click the Google sign-in button on the method picker + println("TEST: Scrolling to Google sign-in button...") + composeTestRule + .onNodeWithTag("AuthMethodPicker LazyColumn") + .performScrollToNode(hasText(stringProvider.signInWithGoogle)) + + println("TEST: Clicking Google sign-in button...") + composeTestRule + .onNode(hasText(stringProvider.signInWithGoogle)) + .assertIsDisplayed() + .performClick() + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Wait for Google auth to complete and link + println("TEST: Waiting for Google auth and account linking...") + composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + println("TEST: Auth state during Google linking: $currentAuthState, isAnonymous: ${authUI.auth.currentUser?.isAnonymous}") + currentAuthState is AuthState.Success && authUI.auth.currentUser?.isAnonymous == false + } + + // Verify the linked account + println("TEST: Verifying linked account...") + assertThat(currentAuthState).isInstanceOf(AuthState.Success::class.java) + assertThat(authUI.auth.currentUser).isNotNull() + + // Verify UID is preserved (account was linked, not replaced) + assertThat(authUI.auth.currentUser!!.uid).isEqualTo(anonymousUserUID) + + // Verify user is no longer anonymous + assertThat(authUI.auth.currentUser!!.isAnonymous).isFalse() + + // Debug: Print user details + println("TEST: User email: ${authUI.auth.currentUser!!.email}") + println("TEST: User displayName: ${authUI.auth.currentUser!!.displayName}") + println("TEST: User photoUrl: ${authUI.auth.currentUser!!.photoUrl}") + println("TEST: User providerData: ${authUI.auth.currentUser!!.providerData.map { "${it.providerId}: ${it.displayName}, ${it.photoUrl}" }}") + + // Verify Google account details + assertThat(authUI.auth.currentUser!!.email).isEqualTo(email) + assertThat(authUI.auth.currentUser!!.displayName).isEqualTo(name) + assertThat(authUI.auth.currentUser!!.photoUrl.toString()).isEqualTo(photoUrl) + + // Verify Google provider is linked + val providerIds = authUI.auth.currentUser!!.providerData.map { it.providerId } + assertThat(providerIds).contains("google.com") + } + + @Test + fun `sign in with google emits Success auth state`() = runTest { + val email = "testuser@example.com" + val name = "Test Example User" + val photoUrl = "https://example.com/avatar.jpg" + // Generate a JWT token with the test email + val mockIdToken = generateMockGoogleIdToken( + email = email, + name = name, + photoUrl = photoUrl + ) + val mockCredential = mock { + on { type } doReturn GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL + on { data } doReturn Bundle().apply { + putString( + "com.google.android.libraries.identity.googleid.BUNDLE_KEY_ID_TOKEN", + mockIdToken + ) + putString( + "com.google.android.libraries.identity.googleid.BUNDLE_KEY_ID", + email + ) + putString( + "com.google.android.libraries.identity.googleid.BUNDLE_KEY_DISPLAY_NAME", + name + ) + putParcelable( + "com.google.android.libraries.identity.googleid.BUNDLE_KEY_PROFILE_PICTURE_URI", + Uri.parse(photoUrl) + ) + } + on { displayName } doReturn name + on { profilePictureUri } doReturn Uri.parse(photoUrl) + } + val mockResult = mock { + on { credential } doReturn mockCredential + } + whenever(mockCredentialManager.getCredential(any(), any())) + .thenReturn(mockResult) + + // Track auth state changes + var currentAuthState: AuthState = AuthState.Idle + + composeTestRule.setContent { + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider(AuthProvider.Anonymous) + provider( + AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "test-server-client-id", + ) + ) + } + } + + TestAuthScreen(configuration = configuration) + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + currentAuthState = authState + } + + // Scroll to the Google sign-in button + composeTestRule + .onNodeWithTag("AuthMethodPicker LazyColumn") + .performScrollToNode(hasText(stringProvider.signInWithGoogle)) + + // Click the actual Google sign-in button + composeTestRule + .onNode(hasText(stringProvider.signInWithGoogle)) + .assertIsDisplayed() + .performClick() + composeTestRule.waitForIdle() + + println("TEST: Pumping looper after click...") + shadowOf(Looper.getMainLooper()).idle() + + // Wait for auth state to transition to Success + println("TEST: Waiting for auth state change... Current state: $currentAuthState") + composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + println("TEST: Auth state during wait: $currentAuthState") + currentAuthState is AuthState.Success + } + + // Ensure final recomposition is complete before assertions + shadowOf(Looper.getMainLooper()).idle() + + // Verify the auth state and user properties + println("TEST: Verifying final auth state: $currentAuthState") + assertThat(currentAuthState) + .isInstanceOf(AuthState.Success::class.java) + assertThat(authUI.auth.currentUser).isNotNull() + assertThat(authUI.auth.currentUser!!.email).isEqualTo(email) + assertThat(authUI.auth.currentUser!!.displayName).isEqualTo(name) + assertThat(authUI.auth.currentUser!!.photoUrl.toString()) + .isEqualTo("https://example.com/avatar.jpg") + } + + @Composable + private fun TestAuthScreen(configuration: AuthUIConfiguration) { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = { result -> }, + onSignInFailure = { exception: AuthException -> }, + onSignInCancelled = {}, + authenticatedContent = { state, uiContext -> + when (state) { + is AuthState.Success -> { + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Authenticated User - (Success): ${state.user.email}", + textAlign = TextAlign.Center + ) + Text( + "UID - ${state.user.uid}", + textAlign = TextAlign.Center + ) + Text( + "isAnonymous - ${state.user.isAnonymous}", + textAlign = TextAlign.Center + ) + Text( + "Providers - " + + "${state.user.providerData.map { it.providerId }}", + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + if (state.user.isAnonymous) { + Button( + onClick = { + uiContext.onNavigate(AuthRoute.MethodPicker) + } + ) { + Text("Upgrade with Google") + } + } + } + } + } + } + ) + } + + /** + * Generates a mock Google ID token (JWT) with the specified email. + * This is useful for testing so that the token payload matches the test data. + */ + private fun generateMockGoogleIdToken( + email: String, + sub: String = "test-user-id", + name: String? = null, + photoUrl: String? = null + ): String { + // JWT Header + val header = """{"alg":"RS256","kid":"test"}""" + + // JWT Payload with dynamic email + val payload = buildString { + append("{") + append("\"iss\":\"https://accounts.google.com\",") + append("\"aud\":\"test-client-id\",") + append("\"sub\":\"$sub\",") + append("\"email\":\"$email\",") + append("\"email_verified\":true") + name?.let { append(",\"name\":\"$it\"") } + photoUrl?.let { append(",\"picture\":\"$it\"") } + append(",\"iat\":1689600000,\"exp\":1689603600") + append("}") + } + + // Base64 encode header and payload (URL-safe, no padding, no wrap) + val encodedHeader = Base64.encodeToString( + header.toByteArray(), + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + ) + val encodedPayload = Base64.encodeToString( + payload.toByteArray(), + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + ) + + // Return JWT format: header.payload.signature + // Signature doesn't need to be valid for testing + return "$encodedHeader.$encodedPayload.mock-signature" + } +} \ No newline at end of file