diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml
index 16055e162..347c621c2 100644
--- a/auth/src/main/AndroidManifest.xml
+++ b/auth/src/main/AndroidManifest.xml
@@ -122,27 +122,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Unit {
+ val coroutineScope = rememberCoroutineScope()
+ return remember(this) {
+ {
+ coroutineScope.launch {
+ try {
+ signInAnonymously()
+ } catch (e: AuthException) {
+ // Already an AuthException, don't re-wrap it
+ updateAuthState(AuthState.Error(e))
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Signs in a user anonymously with Firebase Authentication.
+ *
+ * This method creates a temporary anonymous user account that can be used for testing
+ * or as a starting point for users who want to try the app before creating a permanent
+ * account. Anonymous users can later be upgraded to permanent accounts by linking
+ * credentials (email/password, social providers, phone, etc.).
+ *
+ * **Flow:**
+ * 1. Updates auth state to loading with "Signing in anonymously..." message
+ * 2. Calls Firebase Auth's `signInAnonymously()` method
+ * 3. Updates auth state to idle on success
+ * 4. Handles cancellation and converts exceptions to [AuthException] types
+ *
+ * **Anonymous Account Benefits:**
+ * - No user data collection required
+ * - Immediate access to app features
+ * - Can be upgraded to permanent account later
+ * - Useful for guest users and app trials
+ *
+ * **Account Upgrade:**
+ * Anonymous accounts can be upgraded to permanent accounts by calling methods like:
+ * - [signInAndLinkWithCredential] with email/password or social credentials
+ * - [createOrLinkUserWithEmailAndPassword] for email/password accounts
+ * - [signInWithPhoneAuthCredential] for phone authentication
+ *
+ * **Example: Basic anonymous sign-in**
+ * ```kotlin
+ * try {
+ * firebaseAuthUI.signInAnonymously()
+ * // User is now signed in anonymously
+ * // Show app content or prompt for account creation
+ * } catch (e: AuthException.AuthCancelledException) {
+ * // User cancelled the sign-in process
+ * } catch (e: AuthException.NetworkException) {
+ * // Network error occurred
+ * }
+ * ```
+ *
+ * **Example: Anonymous sign-in with upgrade flow**
+ * ```kotlin
+ * // Step 1: Sign in anonymously
+ * firebaseAuthUI.signInAnonymously()
+ *
+ * // Step 2: Later, upgrade to permanent account
+ * try {
+ * firebaseAuthUI.createOrLinkUserWithEmailAndPassword(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * name = "John Doe",
+ * email = "john@example.com",
+ * password = "SecurePass123!"
+ * )
+ * // Anonymous account upgraded to permanent email/password account
+ * } catch (e: AuthException.AccountLinkingRequiredException) {
+ * // Email already exists - show account linking UI
+ * }
+ * ```
+ *
+ * @throws AuthException.AuthCancelledException if the coroutine is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ * @throws AuthException.UnknownException for other authentication errors
+ *
+ * @see signInAndLinkWithCredential for upgrading anonymous accounts
+ * @see createOrLinkUserWithEmailAndPassword for email/password upgrade
+ * @see signInWithPhoneAuthCredential for phone authentication upgrade
+ */
+internal suspend fun FirebaseAuthUI.signInAnonymously() {
+ try {
+ updateAuthState(AuthState.Loading("Signing in anonymously..."))
+ auth.signInAnonymously().await()
+ updateAuthState(AuthState.Idle)
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign in anonymously was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
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 50101f7e0..13d4728a4 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
@@ -635,8 +635,7 @@ abstract class AuthProvider(open val providerId: String, open val name: String)
* An interface to wrap the static `FacebookAuthProvider.getCredential` method to make it testable.
* @suppress
*/
- @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
- interface CredentialProvider {
+ internal interface CredentialProvider {
fun getCredential(token: String): AuthCredential
}
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 f010ab06c..5e9784e9a 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
@@ -697,8 +697,7 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail(
*
* @see sendSignInLinkToEmail for sending the initial email link
*/
-// TODO(demolaf: make this internal when done testing email link sign in with composeapp
-suspend fun FirebaseAuthUI.signInWithEmailLink(
+internal suspend fun FirebaseAuthUI.signInWithEmailLink(
context: Context,
config: AuthUIConfiguration,
provider: AuthProvider.Email,
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt
index 3ff865af0..ff8548579 100644
--- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt
@@ -51,7 +51,7 @@ import kotlinx.coroutines.launch
* @see signInWithFacebook
*/
@Composable
-fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
+internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
context: Context,
config: AuthUIConfiguration,
provider: AuthProvider.Facebook,
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt
index 6082e9f1b..5bb2cfd05 100644
--- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt
@@ -122,10 +122,10 @@ fun AuthMethodPicker(
}
}
AnnotatedStringResource(
+ modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp),
context = context,
inPreview = inPreview,
previewText = "By continuing, you accept our Terms of Service and Privacy Policy.",
- modifier = Modifier.padding(vertical = 16.dp),
id = R.string.fui_tos_and_pp,
links = arrayOf(
"Terms of Service" to (termsOfServiceUrl ?: ""),
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt
index 076f5de87..1d021f41b 100644
--- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt
@@ -28,7 +28,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -50,11 +49,11 @@ import com.firebase.ui.auth.compose.FirebaseAuthUI
import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration
import com.firebase.ui.auth.compose.configuration.MfaConfiguration
import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.compose.configuration.auth_provider.rememberAnonymousSignInHandler
import com.firebase.ui.auth.compose.configuration.auth_provider.rememberSignInWithFacebookLauncher
import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailLink
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.configuration.theme.AuthUIAsset
import com.firebase.ui.auth.compose.ui.components.ErrorRecoveryDialog
import com.firebase.ui.auth.compose.ui.method_picker.AuthMethodPicker
import com.firebase.ui.auth.compose.ui.screens.phone.PhoneAuthScreen
@@ -98,9 +97,14 @@ fun FirebaseAuthScreen(
val pendingLinkingCredential = remember { mutableStateOf(null) }
val pendingResolver = remember { mutableStateOf(null) }
+ val anonymousProvider = configuration.providers.filterIsInstance().firstOrNull()
val emailProvider = configuration.providers.filterIsInstance().firstOrNull()
val facebookProvider = configuration.providers.filterIsInstance().firstOrNull()
- val logoAsset = configuration.logo?.let { AuthUIAsset.Vector(it) }
+ val logoAsset = configuration.logo
+
+ val onSignInAnonymously = anonymousProvider?.let {
+ authUI.rememberAnonymousSignInHandler()
+ }
val onSignInWithFacebook = facebookProvider?.let {
authUI.rememberSignInWithFacebookLauncher(
@@ -128,6 +132,8 @@ fun FirebaseAuthScreen(
privacyPolicyUrl = configuration.privacyPolicyUrl,
onProviderSelected = { provider ->
when (provider) {
+ is AuthProvider.Anonymous -> onSignInAnonymously?.invoke()
+
is AuthProvider.Email -> {
navController.navigate(AuthRoute.Email.route)
}
@@ -226,6 +232,9 @@ fun FirebaseAuthScreen(
Log.e("FirebaseAuthScreen", "Failed to refresh user", e)
}
}
+ },
+ onNavigate = { route ->
+ navController.navigate(route.route)
}
)
}
@@ -431,7 +440,7 @@ fun FirebaseAuthScreen(
}
}
-private sealed class AuthRoute(val route: String) {
+sealed class AuthRoute(val route: String) {
object MethodPicker : AuthRoute("auth_method_picker")
object Email : AuthRoute("auth_email")
object Phone : AuthRoute("auth_phone")
@@ -445,7 +454,8 @@ data class AuthSuccessUiContext(
val stringProvider: AuthUIStringProvider,
val onSignOut: () -> Unit,
val onManageMfa: () -> Unit,
- val onReloadUser: () -> Unit
+ val onReloadUser: () -> Unit,
+ val onNavigate: (AuthRoute) -> Unit,
)
@Composable
diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt
index 4190a7bf0..b5e306b40 100644
--- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt
@@ -23,6 +23,7 @@ import com.firebase.ui.auth.R
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.configuration.theme.AuthUIAsset
import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme
import com.google.common.truth.Truth.assertThat
import com.google.firebase.auth.actionCodeSettings
@@ -98,6 +99,7 @@ class AuthUIConfigurationTest {
url = "https://example.com/verify"
handleCodeInApp = true
}
+ val logoAsset = AuthUIAsset.Vector(Icons.Default.AccountCircle)
val config = authUIConfiguration {
context = applicationContext
@@ -122,7 +124,7 @@ class AuthUIConfigurationTest {
isAnonymousUpgradeEnabled = true
tosUrl = "https://example.com/tos"
privacyPolicyUrl = "https://example.com/privacy"
- logo = Icons.Default.AccountCircle
+ logo = logoAsset
passwordResetActionCodeSettings = customPasswordResetActionCodeSettings
isNewEmailAccountsAllowed = false
isDisplayNameRequired = false
@@ -139,7 +141,7 @@ class AuthUIConfigurationTest {
assertThat(config.isAnonymousUpgradeEnabled).isTrue()
assertThat(config.tosUrl).isEqualTo("https://example.com/tos")
assertThat(config.privacyPolicyUrl).isEqualTo("https://example.com/privacy")
- assertThat(config.logo).isEqualTo(Icons.Default.AccountCircle)
+ assertThat(config.logo).isEqualTo(logoAsset)
assertThat(config.passwordResetActionCodeSettings)
.isEqualTo(customPasswordResetActionCodeSettings)
assertThat(config.isNewEmailAccountsAllowed).isFalse()
diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt
new file mode 100644
index 000000000..c36899a63
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt
@@ -0,0 +1,316 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.compose.configuration.auth_provider
+
+import android.content.Context
+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.google.android.gms.tasks.TaskCompletionSource
+import com.google.common.truth.Truth.assertThat
+import com.google.firebase.FirebaseApp
+import com.google.firebase.FirebaseNetworkException
+import com.google.firebase.FirebaseOptions
+import com.google.firebase.auth.AuthCredential
+import com.google.firebase.auth.AuthResult
+import com.google.firebase.auth.EmailAuthProvider
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.FirebaseAuthUserCollisionException
+import com.google.firebase.auth.FirebaseUser
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers
+import org.mockito.Mock
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+import org.mockito.MockitoAnnotations
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE)
+class AnonymousAuthProviderFirebaseAuthUITest {
+
+ @Mock
+ private lateinit var mockFirebaseAuth: FirebaseAuth
+
+ private lateinit var firebaseApp: FirebaseApp
+ private lateinit var applicationContext: Context
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.openMocks(this)
+
+ FirebaseAuthUI.clearInstanceCache()
+
+ applicationContext = ApplicationProvider.getApplicationContext()
+
+ FirebaseApp.getApps(applicationContext).forEach { app ->
+ app.delete()
+ }
+
+ firebaseApp = FirebaseApp.initializeApp(
+ applicationContext,
+ FirebaseOptions.Builder()
+ .setApiKey("fake-api-key")
+ .setApplicationId("fake-app-id")
+ .setProjectId("fake-project-id")
+ .build()
+ )
+ }
+
+ @After
+ fun tearDown() {
+ FirebaseAuthUI.clearInstanceCache()
+ try {
+ firebaseApp.delete()
+ } catch (_: Exception) {
+ // Ignore if already deleted
+ }
+ }
+
+ // =============================================================================================
+ // signInAnonymously Tests
+ // =============================================================================================
+
+ @Test
+ fun `signInAnonymously - successful anonymous sign in`() = runTest {
+ val mockUser = mock(FirebaseUser::class.java)
+ `when`(mockUser.isAnonymous).thenReturn(true)
+ val mockAuthResult = mock(AuthResult::class.java)
+ `when`(mockAuthResult.user).thenReturn(mockUser)
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setResult(mockAuthResult)
+ `when`(mockFirebaseAuth.signInAnonymously())
+ .thenReturn(taskCompletionSource.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+
+ instance.signInAnonymously()
+
+ verify(mockFirebaseAuth).signInAnonymously()
+
+ val finalState = instance.authStateFlow().first { it is AuthState.Idle }
+ assertThat(finalState).isInstanceOf(AuthState.Idle::class.java)
+ }
+
+ @Test
+ fun `signInAnonymously - handles network error`() = runTest {
+ val networkException = FirebaseNetworkException("Network error")
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setException(networkException)
+ `when`(mockFirebaseAuth.signInAnonymously())
+ .thenReturn(taskCompletionSource.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+
+ try {
+ instance.signInAnonymously()
+ assertThat(false).isTrue() // Should not reach here
+ } catch (e: AuthException.NetworkException) {
+ assertThat(e.cause).isEqualTo(networkException)
+ }
+
+ val currentState = instance.authStateFlow().first { it is AuthState.Error }
+ assertThat(currentState).isInstanceOf(AuthState.Error::class.java)
+ val errorState = currentState as AuthState.Error
+ assertThat(errorState.exception).isInstanceOf(AuthException.NetworkException::class.java)
+ }
+
+ @Test
+ fun `signInAnonymously - handles cancellation`() = runTest {
+ val cancellationException = CancellationException("Operation cancelled")
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setException(cancellationException)
+ `when`(mockFirebaseAuth.signInAnonymously())
+ .thenReturn(taskCompletionSource.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+
+ try {
+ instance.signInAnonymously()
+ assertThat(false).isTrue() // Should not reach here
+ } catch (e: AuthException.AuthCancelledException) {
+ assertThat(e.message).contains("cancelled")
+ assertThat(e.cause).isInstanceOf(CancellationException::class.java)
+ }
+
+ val currentState = instance.authStateFlow().first { it is AuthState.Error }
+ assertThat(currentState).isInstanceOf(AuthState.Error::class.java)
+ val errorState = currentState as AuthState.Error
+ assertThat(errorState.exception).isInstanceOf(AuthException.AuthCancelledException::class.java)
+ }
+
+ @Test
+ fun `signInAnonymously - handles generic exception`() = runTest {
+ val genericException = RuntimeException("Something went wrong")
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setException(genericException)
+ `when`(mockFirebaseAuth.signInAnonymously())
+ .thenReturn(taskCompletionSource.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+
+ try {
+ instance.signInAnonymously()
+ assertThat(false).isTrue() // Should not reach here
+ } catch (e: AuthException.UnknownException) {
+ assertThat(e.cause).isEqualTo(genericException)
+ }
+
+ val currentState = instance.authStateFlow().first { it is AuthState.Error }
+ assertThat(currentState).isInstanceOf(AuthState.Error::class.java)
+ val errorState = currentState as AuthState.Error
+ assertThat(errorState.exception).isInstanceOf(AuthException.UnknownException::class.java)
+ }
+
+ // =============================================================================================
+ // Anonymous Account Upgrade Tests
+ // =============================================================================================
+
+ @Test
+ fun `Upgrade anonymous account with email and password when isAnonymousUpgradeEnabled`() = runTest {
+ val mockAnonymousUser = mock(FirebaseUser::class.java)
+ `when`(mockAnonymousUser.isAnonymous).thenReturn(true)
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser)
+
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setResult(mock(AuthResult::class.java))
+ `when`(mockAnonymousUser.linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java)))
+ .thenReturn(taskCompletionSource.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val emailProvider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(AuthProvider.Anonymous)
+ provider(emailProvider)
+ }
+ isAnonymousUpgradeEnabled = true
+ }
+
+ instance.createOrLinkUserWithEmailAndPassword(
+ context = applicationContext,
+ config = config,
+ provider = emailProvider,
+ name = null,
+ email = "test@example.com",
+ password = "Pass@123"
+ )
+
+ verify(mockAnonymousUser).linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java))
+ }
+
+ @Test
+ fun `Upgrade anonymous account throws AccountLinkingRequiredException on collision`() = runTest {
+ val mockAnonymousUser = mock(FirebaseUser::class.java)
+ `when`(mockAnonymousUser.isAnonymous).thenReturn(true)
+ `when`(mockAnonymousUser.email).thenReturn(null)
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser)
+
+ val collisionException = mock(FirebaseAuthUserCollisionException::class.java)
+ `when`(collisionException.errorCode).thenReturn("ERROR_EMAIL_ALREADY_IN_USE")
+ `when`(collisionException.email).thenReturn("test@example.com")
+
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setException(collisionException)
+ `when`(mockAnonymousUser.linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java)))
+ .thenReturn(taskCompletionSource.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val emailProvider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(AuthProvider.Anonymous)
+ provider(emailProvider)
+ }
+ isAnonymousUpgradeEnabled = true
+ }
+
+ try {
+ instance.createOrLinkUserWithEmailAndPassword(
+ context = applicationContext,
+ config = config,
+ provider = emailProvider,
+ name = null,
+ email = "test@example.com",
+ password = "Pass@123"
+ )
+ assertThat(false).isTrue() // Should not reach here
+ } catch (e: AuthException.AccountLinkingRequiredException) {
+ assertThat(e.cause).isEqualTo(collisionException)
+ assertThat(e.email).isEqualTo("test@example.com")
+ assertThat(e.credential).isNotNull()
+ }
+
+ val currentState = instance.authStateFlow().first { it is AuthState.Error }
+ assertThat(currentState).isInstanceOf(AuthState.Error::class.java)
+ val errorState = currentState as AuthState.Error
+ assertThat(errorState.exception).isInstanceOf(AuthException.AccountLinkingRequiredException::class.java)
+ }
+
+ @Test
+ fun `Upgrade anonymous account with credential when isAnonymousUpgradeEnabled`() = runTest {
+ val mockAnonymousUser = mock(FirebaseUser::class.java)
+ `when`(mockAnonymousUser.isAnonymous).thenReturn(true)
+ `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser)
+
+ val credential = EmailAuthProvider.getCredential("test@example.com", "Pass@123")
+ val mockAuthResult = mock(AuthResult::class.java)
+ `when`(mockAuthResult.user).thenReturn(mockAnonymousUser)
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setResult(mockAuthResult)
+ `when`(mockAnonymousUser.linkWithCredential(credential))
+ .thenReturn(taskCompletionSource.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val emailProvider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(AuthProvider.Anonymous)
+ provider(emailProvider)
+ }
+ isAnonymousUpgradeEnabled = true
+ }
+
+ val result = instance.signInAndLinkWithCredential(
+ config = config,
+ credential = credential
+ )
+
+ assertThat(result).isNotNull()
+ verify(mockAnonymousUser).linkWithCredential(credential)
+ }
+}
diff --git a/composeapp/src/main/AndroidManifest.xml b/composeapp/src/main/AndroidManifest.xml
index fccb57d77..83e2dd1a5 100644
--- a/composeapp/src/main/AndroidManifest.xml
+++ b/composeapp/src/main/AndroidManifest.xml
@@ -22,6 +22,21 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt
index 28d9bf068..4c0bbf1ee 100644
--- a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt
+++ b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt
@@ -4,6 +4,7 @@ import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -25,6 +26,7 @@ import com.firebase.ui.auth.compose.FirebaseAuthUI
import com.firebase.ui.auth.compose.configuration.PasswordRule
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.theme.AuthUIAsset
import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme
import com.firebase.ui.auth.compose.ui.screens.EmailSignInLinkHandlerActivity
import com.firebase.ui.auth.compose.ui.screens.FirebaseAuthScreen
@@ -41,6 +43,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
FirebaseApp.initializeApp(applicationContext)
val authUI = FirebaseAuthUI.getInstance()
@@ -51,172 +54,196 @@ class MainActivity : ComponentActivity() {
val emailLink = intent.getStringExtra(EmailSignInLinkHandlerActivity.EXTRA_EMAIL_LINK)
- val configuration = authUIConfiguration {
- context = applicationContext
- providers {
- provider(
- AuthProvider.Email(
- isDisplayNameRequired = true,
- isEmailLinkForceSameDeviceEnabled = true,
- isEmailLinkSignInEnabled = false,
- emailLinkActionCodeSettings = actionCodeSettings {
- url = "https://temp-test-aa342.firebaseapp.com"
- handleCodeInApp = true
- setAndroidPackageName(
- "com.firebase.composeapp",
- true,
- null
+ setContent {
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(AuthProvider.Anonymous)
+ provider(
+ AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isEmailLinkForceSameDeviceEnabled = true,
+ isEmailLinkSignInEnabled = false,
+ emailLinkActionCodeSettings = actionCodeSettings {
+ url = "https://temp-test-aa342.firebaseapp.com"
+ handleCodeInApp = true
+ setAndroidPackageName(
+ "com.firebase.composeapp",
+ true,
+ null
+ )
+ },
+ isNewAccountsAllowed = true,
+ minimumPasswordLength = 8,
+ passwordValidationRules = listOf(
+ PasswordRule.MinimumLength(8),
+ PasswordRule.RequireLowercase,
+ PasswordRule.RequireUppercase,
)
- },
- isNewAccountsAllowed = true,
- minimumPasswordLength = 8,
- passwordValidationRules = listOf(
- PasswordRule.MinimumLength(8),
- PasswordRule.RequireLowercase,
- PasswordRule.RequireUppercase,
)
)
- )
- provider(
- AuthProvider.Phone(
- defaultNumber = null,
- defaultCountryCode = null,
- allowedCountries = emptyList(),
- smsCodeLength = 6,
- timeout = 120L,
- isInstantVerificationEnabled = true
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = emptyList(),
+ smsCodeLength = 6,
+ timeout = 120L,
+ isInstantVerificationEnabled = true
+ )
)
- )
- provider(
- AuthProvider.Facebook(
- applicationId = "792556260059222"
+ provider(
+ AuthProvider.Facebook(
+ applicationId = "792556260059222"
+ )
)
- )
+ }
+ logo = AuthUIAsset.Resource(R.drawable.firebase_auth_120dp)
+ tosUrl = "https://policies.google.com/terms?hl=en-NG&fg=1"
+ privacyPolicyUrl = "https://policies.google.com/privacy?hl=en-NG&fg=1"
}
- tosUrl = "https://policies.google.com/terms?hl=en-NG&fg=1"
- privacyPolicyUrl = "https://policies.google.com/privacy?hl=en-NG&fg=1"
- }
- setContent {
- AuthUITheme {
- Surface(
- modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
- ) {
- FirebaseAuthScreen(
- configuration = configuration,
- authUI = authUI,
- emailLink = emailLink,
- onSignInSuccess = { result ->
- Log.d("MainActivity", "Authentication success: ${result.user?.uid}")
- },
- onSignInFailure = { exception: AuthException ->
- Log.e("MainActivity", "Authentication failed", exception)
- },
- onSignInCancelled = {
- Log.d("MainActivity", "Authentication cancelled")
- },
- authenticatedContent = { state, uiContext ->
- AppAuthenticatedContent(state, uiContext)
- }
- )
+ setContent {
+ AuthUITheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ emailLink = emailLink,
+ onSignInSuccess = { result ->
+ Log.d("MainActivity", "Authentication success: ${result.user?.uid}")
+ },
+ onSignInFailure = { exception: AuthException ->
+ Log.e("MainActivity", "Authentication failed", exception)
+ },
+ onSignInCancelled = {
+ Log.d("MainActivity", "Authentication cancelled")
+ },
+ authenticatedContent = { state, uiContext ->
+ AppAuthenticatedContent(state, uiContext)
+ }
+ )
+ }
}
}
}
}
-}
-@Composable
-private fun AppAuthenticatedContent(
- state: AuthState,
- uiContext: AuthSuccessUiContext
-) {
- val stringProvider = uiContext.stringProvider
- when (state) {
- is AuthState.Success -> {
- val user = uiContext.authUI.getCurrentUser()
- val identifier = user?.email ?: user?.phoneNumber ?: user?.uid.orEmpty()
- Column(
- modifier = Modifier.fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- if (identifier.isNotBlank()) {
+ @Composable
+ private fun AppAuthenticatedContent(
+ state: AuthState,
+ uiContext: AuthSuccessUiContext
+ ) {
+ val stringProvider = uiContext.stringProvider
+ when (state) {
+ is AuthState.Success -> {
+ val user = uiContext.authUI.getCurrentUser()
+ val identifier = user?.email ?: user?.phoneNumber ?: user?.uid.orEmpty()
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ if (identifier.isNotBlank()) {
+ Text(
+ text = stringProvider.signedInAs(identifier),
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
Text(
- text = stringProvider.signedInAs(identifier),
+ "isAnonymous - ${state.user.isAnonymous}",
textAlign = TextAlign.Center
)
- Spacer(modifier = Modifier.height(16.dp))
- }
+ Text(
+ "Providers - ${state.user.providerData.map { it.providerId }}",
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ if (state.user.isAnonymous) {
+ Button(
+ onClick = {
- Button(onClick = uiContext.onManageMfa) {
- Text(stringProvider.manageMfaAction)
- }
- Spacer(modifier = Modifier.height(8.dp))
- Button(onClick = uiContext.onSignOut) {
- Text(stringProvider.signOutAction)
+ }
+ ) {
+ Text("Upgrade with Email")
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = uiContext.onManageMfa) {
+ Text(stringProvider.manageMfaAction)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = uiContext.onSignOut) {
+ Text(stringProvider.signOutAction)
+ }
}
}
- }
- is AuthState.RequiresEmailVerification -> {
- val email = uiContext.authUI.getCurrentUser()?.email ?: stringProvider.emailProvider
- Column(
- modifier = Modifier.fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- Text(
- text = stringProvider.verifyEmailInstruction(email),
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.bodyMedium
- )
- Spacer(modifier = Modifier.height(16.dp))
- Button(onClick = { uiContext.authUI.getCurrentUser()?.sendEmailVerification() }) {
- Text(stringProvider.resendVerificationEmailAction)
- }
- Spacer(modifier = Modifier.height(8.dp))
- Button(onClick = uiContext.onReloadUser) {
- Text(stringProvider.verifiedEmailAction)
- }
- Spacer(modifier = Modifier.height(8.dp))
- Button(onClick = uiContext.onSignOut) {
- Text(stringProvider.signOutAction)
+ is AuthState.RequiresEmailVerification -> {
+ val email = uiContext.authUI.getCurrentUser()?.email ?: stringProvider.emailProvider
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = stringProvider.verifyEmailInstruction(email),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = {
+ uiContext.authUI.getCurrentUser()?.sendEmailVerification()
+ }) {
+ Text(stringProvider.resendVerificationEmailAction)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = uiContext.onReloadUser) {
+ Text(stringProvider.verifiedEmailAction)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = uiContext.onSignOut) {
+ Text(stringProvider.signOutAction)
+ }
}
}
- }
- is AuthState.RequiresProfileCompletion -> {
- Column(
- modifier = Modifier.fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- Text(
- text = stringProvider.profileCompletionMessage,
- textAlign = TextAlign.Center
- )
- if (state.missingFields.isNotEmpty()) {
- Spacer(modifier = Modifier.height(12.dp))
+ is AuthState.RequiresProfileCompletion -> {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
Text(
- text = stringProvider.profileMissingFieldsMessage(state.missingFields.joinToString()),
+ text = stringProvider.profileCompletionMessage,
textAlign = TextAlign.Center
)
- }
- Spacer(modifier = Modifier.height(16.dp))
- Button(onClick = uiContext.onSignOut) {
- Text(stringProvider.signOutAction)
+ if (state.missingFields.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = stringProvider.profileMissingFieldsMessage(state.missingFields.joinToString()),
+ textAlign = TextAlign.Center
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = uiContext.onSignOut) {
+ Text(stringProvider.signOutAction)
+ }
}
}
- }
- else -> {
- Column(
- modifier = Modifier.fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- CircularProgressIndicator()
+ else -> {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ CircularProgressIndicator()
+ }
}
}
}
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
new file mode 100644
index 000000000..db2d6971d
--- /dev/null
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/AnonymousAuthScreenTest.kt
@@ -0,0 +1,342 @@
+package com.firebase.ui.auth.compose.ui.screens
+
+import android.content.Context
+import android.os.Looper
+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.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
+import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+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.google.common.truth.Truth.assertThat
+import com.google.firebase.FirebaseApp
+import com.google.firebase.FirebaseOptions
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.MockitoAnnotations
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+@Config(sdk = [34])
+@RunWith(RobolectricTestRunner::class)
+class AnonymousAuthScreenTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private lateinit var applicationContext: Context
+
+ private lateinit var stringProvider: AuthUIStringProvider
+
+ lateinit var authUI: FirebaseAuthUI
+ private lateinit var emulatorApi: EmulatorAuthApi
+
+ @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)
+
+ 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 sign-in emits Success auth state`() {
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(AuthProvider.Anonymous)
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ }
+
+ // Track auth state changes
+ var currentAuthState: AuthState = AuthState.Idle
+
+ composeTestRule.setContent {
+ TestAuthScreen(configuration = configuration)
+ val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
+ currentAuthState = authState
+ }
+
+ // Wait for the navigation to settle and UI to be ready
+ composeTestRule.waitForIdle()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ composeTestRule.onNodeWithText(stringProvider.signInAnonymously)
+ .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, isAnonymous" +
+ " - ${authUI.auth.currentUser?.isAnonymous}"
+ )
+ currentAuthState is AuthState.Success
+ }
+
+ composeTestRule.onNodeWithText("isAnonymous - true")
+ .assertIsDisplayed()
+
+ // 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!!.isAnonymous).isEqualTo(true)
+ }
+
+ @Test
+ fun `anonymous upgrade enabled links new user sign-up and emits RequiresEmailVerification auth state`() {
+ val name = "Anonymous Upgrade User"
+ val email = "anonymousupgrade@example.com"
+ val password = "Test@123"
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(AuthProvider.Anonymous)
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ }
+ isAnonymousUpgradeEnabled = true
+ }
+
+ // Track auth state changes
+ var currentAuthState: AuthState = AuthState.Idle
+
+ composeTestRule.setContent {
+ TestAuthScreen(
+ configuration = configuration,
+ name = name,
+ email = email,
+ password = password,
+ )
+ val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
+ currentAuthState = authState
+ }
+
+ // Wait for the navigation to settle and UI to be ready
+ composeTestRule.waitForIdle()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ composeTestRule.onNodeWithText(stringProvider.signInAnonymously)
+ .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, isAnonymous" +
+ " - ${authUI.auth.currentUser?.isAnonymous}"
+ )
+ currentAuthState is AuthState.Success
+ }
+
+ composeTestRule.onNodeWithText("isAnonymous - true")
+ .assertIsDisplayed()
+
+ assertThat(authUI.auth.currentUser!!.isAnonymous).isEqualTo(true)
+
+ val anonymousUserUID = authUI.auth.currentUser!!.uid
+
+ composeTestRule.onNodeWithText("Upgrade with Email")
+ .performClick()
+
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText(stringProvider.signupPageTitle.uppercase())
+ .assertIsDisplayed()
+ .performClick()
+ 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)
+ composeTestRule.onNodeWithText(stringProvider.signupPageTitle.uppercase())
+ .performScrollTo()
+ .assertIsDisplayed()
+ .performClick()
+
+ composeTestRule.waitForIdle()
+ println("TEST: Pumping looper after click...")
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Wait for auth state to transition to RequiresEmailVerification
+ 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.RequiresEmailVerification
+ }
+
+ // Verify the auth state and user properties
+ println(
+ "TEST: Verifying final auth state: $currentAuthState, " +
+ "anonymous user uid - $anonymousUserUID, linked user uid - " +
+ authUI.auth.currentUser!!.uid
+ )
+ assertThat(currentAuthState)
+ .isInstanceOf(AuthState.RequiresEmailVerification::class.java)
+ assertThat(authUI.auth.currentUser).isNotNull()
+ assertThat(authUI.auth.currentUser!!.uid).isEqualTo(anonymousUserUID)
+ assertThat(authUI.auth.currentUser!!.isAnonymous).isEqualTo(false)
+ assertThat(authUI.auth.currentUser!!.email)
+ .isEqualTo(email)
+ }
+
+ @Composable
+ private fun TestAuthScreen(
+ configuration: AuthUIConfiguration,
+ name: String = "",
+ email: String = "",
+ password: String = "",
+ ) {
+ authUI.signOut(applicationContext)
+ composeTestRule.waitForIdle()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ 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.Email)
+ }
+ ) {
+ Text("Upgrade with Email")
+ }
+ }
+ }
+ }
+ }
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt
index f6b4cf7a1..77cbc8893 100644
--- a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt
@@ -121,7 +121,7 @@ class EmailAuthScreenTest {
}
composeTestRule.setContent {
- FirebaseAuthScreen(configuration = configuration)
+ TestAuthScreen(configuration = configuration)
}
composeTestRule.onNodeWithText(stringProvider.signInDefault)
@@ -156,7 +156,7 @@ class EmailAuthScreenTest {
var currentAuthState: AuthState = AuthState.Idle
composeTestRule.setContent {
- FirebaseAuthScreen(configuration = configuration)
+ TestAuthScreen(configuration = configuration)
val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
currentAuthState = authState
}
@@ -242,7 +242,7 @@ class EmailAuthScreenTest {
var currentAuthState: AuthState = AuthState.Idle
composeTestRule.setContent {
- FirebaseAuthScreen(configuration = configuration)
+ TestAuthScreen(configuration = configuration)
val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
currentAuthState = authState
}
@@ -309,7 +309,7 @@ class EmailAuthScreenTest {
var currentAuthState: AuthState = AuthState.Idle
composeTestRule.setContent {
- FirebaseAuthScreen(configuration = configuration)
+ TestAuthScreen(configuration = configuration)
val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
currentAuthState = authState
}
@@ -391,7 +391,7 @@ class EmailAuthScreenTest {
var currentAuthState: AuthState = AuthState.Idle
composeTestRule.setContent {
- FirebaseAuthScreen(configuration = configuration)
+ TestAuthScreen(configuration = configuration)
val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
currentAuthState = authState
}
@@ -484,7 +484,7 @@ class EmailAuthScreenTest {
var currentAuthState: AuthState = AuthState.Idle
composeTestRule.setContent {
- FirebaseAuthScreen(configuration = configuration)
+ TestAuthScreen(configuration = configuration)
val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
currentAuthState = authState
}
@@ -534,7 +534,7 @@ class EmailAuthScreenTest {
}
@Composable
- private fun FirebaseAuthScreen(
+ private fun TestAuthScreen(
configuration: AuthUIConfiguration,
onSuccess: ((AuthResult) -> Unit) = {},
onError: ((AuthException) -> Unit) = {},
diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/PhoneAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/PhoneAuthScreenTest.kt
index c9490dbb2..6eac57e8b 100644
--- a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/PhoneAuthScreenTest.kt
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/PhoneAuthScreenTest.kt
@@ -122,7 +122,7 @@ class PhoneAuthScreenTest {
}
composeTestRule.setContent {
- FirebaseAuthScreen(configuration = configuration)
+ TestAuthScreen(configuration = configuration)
}
composeTestRule.onNodeWithText(stringProvider.enterPhoneNumberTitle)
@@ -151,7 +151,7 @@ class PhoneAuthScreenTest {
var currentAuthState: AuthState = AuthState.Idle
composeTestRule.setContent {
- FirebaseAuthScreen(configuration = configuration)
+ TestAuthScreen(configuration = configuration)
val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
currentAuthState = authState
}
@@ -279,7 +279,7 @@ class PhoneAuthScreenTest {
}
composeTestRule.setContent {
- FirebaseAuthScreen(configuration = configuration)
+ TestAuthScreen(configuration = configuration)
}
// Send verification code to get to verification screen
@@ -326,7 +326,7 @@ class PhoneAuthScreenTest {
}
composeTestRule.setContent {
- FirebaseAuthScreen(configuration = configuration)
+ TestAuthScreen(configuration = configuration)
}
// The country selector should show the default country's dial code (GB = +44)
@@ -355,7 +355,7 @@ class PhoneAuthScreenTest {
}
composeTestRule.setContent {
- FirebaseAuthScreen(configuration = configuration)
+ TestAuthScreen(configuration = configuration)
}
// Send verification code
@@ -394,7 +394,7 @@ class PhoneAuthScreenTest {
}
composeTestRule.setContent {
- FirebaseAuthScreen(configuration = configuration)
+ TestAuthScreen(configuration = configuration)
}
// The send verification code button should be enabled since phone number is pre-filled
@@ -404,7 +404,7 @@ class PhoneAuthScreenTest {
}
@Composable
- private fun FirebaseAuthScreen(
+ private fun TestAuthScreen(
configuration: AuthUIConfiguration,
onSuccess: ((AuthResult) -> Unit) = {},
onError: ((AuthException) -> Unit) = {},