diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml
index 347c621c2..3446e5dbe 100644
--- a/auth/src/main/AndroidManifest.xml
+++ b/auth/src/main/AndroidManifest.xml
@@ -122,6 +122,7 @@
+
+ * // Auth flow completed
+ * }
+ *
+ * override fun onCreate(savedInstanceState: Bundle?) {
+ * super.onCreate(savedInstanceState)
+ *
+ * val authUI = FirebaseAuthUI.getInstance()
+ * val configuration = authUIConfiguration {
+ * providers = listOf(
+ * AuthProvider.Email(),
+ * AuthProvider.Google(...)
+ * )
+ * }
+ *
+ * authController = authUI.createAuthFlow(configuration)
+ *
+ * // Observe auth state
+ * lifecycleScope.launch {
+ * authController.authStateFlow.collect { state ->
+ * when (state) {
+ * is AuthState.Success -> {
+ * // User signed in successfully
+ * val user = state.user
+ * }
+ * is AuthState.Error -> {
+ * // Handle error
+ * }
+ * is AuthState.Cancelled -> {
+ * // User cancelled
+ * }
+ * else -> {}
+ * }
+ * }
+ * }
+ *
+ * // Start auth flow
+ * val intent = authController.createIntent(this)
+ * authLauncher.launch(intent)
+ * }
+ *
+ * override fun onDestroy() {
+ * super.onDestroy()
+ * authController.dispose()
+ * }
+ * }
+ * ```
+ *
+ * **Lifecycle Management:**
+ * - [createIntent] - Generate Intent to start the auth flow Activity
+ * - [start] - Alternative to launch the flow (for Activity context)
+ * - [cancel] - Cancel the ongoing auth flow, transitions to [AuthState.Cancelled]
+ * - [dispose] - Release all resources (coroutines, listeners). Call in onDestroy()
+ *
+ * @property authUI The [FirebaseAuthUI] instance managing authentication
+ * @property configuration The [AuthUIConfiguration] defining the auth flow behavior
+ *
+ * @since 10.0.0
+ */
+class AuthFlowController internal constructor(
+ private val authUI: FirebaseAuthUI,
+ private val configuration: AuthUIConfiguration
+) {
+
+ private val coroutineScope = CoroutineScope(Dispatchers.Main + Job())
+ private val isDisposed = AtomicBoolean(false)
+ private var stateCollectionJob: Job? = null
+
+ /**
+ * Flow of [AuthState] changes during the authentication flow.
+ *
+ * Subscribe to this flow to observe authentication state changes.
+ * The flow is backed by the [FirebaseAuthUI.authStateFlow] and will
+ * emit states like:
+ * - [AuthState.Idle] - No active authentication
+ * - [AuthState.Loading] - Authentication in progress
+ * - [AuthState.Success] - User signed in successfully
+ * - [AuthState.Error] - Authentication error occurred
+ * - [AuthState.Cancelled] - User cancelled the flow
+ * - [AuthState.RequiresMfa] - Multi-factor authentication required
+ * - [AuthState.RequiresEmailVerification] - Email verification required
+ */
+ val authStateFlow: Flow
+ get() {
+ checkNotDisposed()
+ return authUI.authStateFlow()
+ }
+
+ /**
+ * Creates an Intent to launch the Firebase authentication flow.
+ *
+ * Use this method with [ActivityResultLauncher] to start the auth flow
+ * and handle the result in a lifecycle-aware manner.
+ *
+ * **Example:**
+ * ```kotlin
+ * val authLauncher = registerForActivityResult(
+ * ActivityResultContracts.StartActivityForResult()
+ * ) { result ->
+ * if (result.resultCode == Activity.RESULT_OK) {
+ * // Auth flow completed successfully
+ * } else {
+ * // Auth flow cancelled or error
+ * }
+ * }
+ *
+ * val intent = authController.createIntent(this)
+ * authLauncher.launch(intent)
+ * ```
+ *
+ * @param context Android [Context] to create the Intent
+ * @return [Intent] configured to launch the auth flow Activity
+ * @throws IllegalStateException if the controller has been disposed
+ */
+ fun createIntent(context: Context): Intent {
+ checkNotDisposed()
+ return FirebaseAuthActivity.createIntent(context, configuration)
+ }
+
+ /**
+ * Starts the Firebase authentication flow.
+ *
+ * This method launches the auth flow Activity from the provided [Activity] context.
+ * For better lifecycle management, prefer using [createIntent] with
+ * [ActivityResultLauncher] instead.
+ *
+ * **Note:** This method uses [Activity.startActivityForResult] which is deprecated.
+ * Consider using [createIntent] with the Activity Result API instead.
+ *
+ * @param activity The [Activity] to launch from
+ * @param requestCode Request code for [Activity.onActivityResult]
+ * @throws IllegalStateException if the controller has been disposed
+ *
+ * @see createIntent
+ */
+ @Deprecated(
+ message = "Use createIntent() with ActivityResultLauncher instead",
+ replaceWith = ReplaceWith("createIntent(activity)"),
+ level = DeprecationLevel.WARNING
+ )
+ fun start(activity: Activity, requestCode: Int = RC_SIGN_IN) {
+ checkNotDisposed()
+ val intent = createIntent(activity)
+ activity.startActivityForResult(intent, requestCode)
+ }
+
+ /**
+ * Cancels the ongoing authentication flow.
+ *
+ * This method transitions the auth state to [AuthState.Cancelled] and
+ * signals the auth flow to terminate. The auth flow Activity will finish
+ * and return [Activity.RESULT_CANCELED].
+ *
+ * **Example:**
+ * ```kotlin
+ * // User clicked a "Cancel" button
+ * cancelButton.setOnClickListener {
+ * authController.cancel()
+ * }
+ * ```
+ *
+ * @throws IllegalStateException if the controller has been disposed
+ */
+ fun cancel() {
+ checkNotDisposed()
+ authUI.updateAuthState(AuthState.Cancelled)
+ }
+
+ /**
+ * Disposes the controller and releases all resources.
+ *
+ * This method:
+ * - Cancels all coroutines in the controller scope
+ * - Stops listening to auth state changes
+ * - Marks the controller as disposed
+ *
+ * Call this method in your Activity's `onDestroy()` to prevent memory leaks.
+ *
+ * **Important:** Once disposed, this controller cannot be reused. Create a new
+ * controller if you need to start another auth flow.
+ *
+ * **Example:**
+ * ```kotlin
+ * override fun onDestroy() {
+ * super.onDestroy()
+ * authController.dispose()
+ * }
+ * ```
+ *
+ * @throws IllegalStateException if already disposed (when called multiple times)
+ */
+ fun dispose() {
+ if (isDisposed.compareAndSet(false, true)) {
+ stateCollectionJob?.cancel()
+ coroutineScope.cancel()
+ }
+ }
+
+ /**
+ * Checks if the controller has been disposed.
+ *
+ * @return `true` if disposed, `false` otherwise
+ */
+ fun isDisposed(): Boolean = isDisposed.get()
+
+ private fun checkNotDisposed() {
+ check(!isDisposed.get()) {
+ "AuthFlowController has been disposed. Create a new controller to start another auth flow."
+ }
+ }
+
+ internal fun startStateCollection() {
+ if (stateCollectionJob == null || stateCollectionJob?.isActive == false) {
+ stateCollectionJob = authUI.authStateFlow()
+ .onEach { state ->
+ // Optional: Add logging or side effects here
+ }
+ .launchIn(coroutineScope)
+ }
+ }
+
+ companion object {
+ /**
+ * Request code for the sign-in activity result.
+ *
+ * Use this constant when calling [start] with `startActivityForResult`.
+ */
+ const val RC_SIGN_IN = 9001
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthActivity.kt b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthActivity.kt
new file mode 100644
index 000000000..fac4e0351
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthActivity.kt
@@ -0,0 +1,200 @@
+/*
+ * 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
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.lifecycleScope
+import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.compose.ui.screens.FirebaseAuthScreen
+import com.google.firebase.auth.FirebaseUser
+import kotlinx.coroutines.launch
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Activity that hosts the Firebase authentication flow UI.
+ *
+ * This activity displays the [FirebaseAuthScreen] composable and manages
+ * the authentication flow lifecycle. It automatically finishes when the user
+ * signs in successfully or cancels the flow.
+ *
+ * **Do not launch this Activity directly.**
+ * Use [AuthFlowController] to start the auth flow:
+ *
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ * val configuration = authUIConfiguration {
+ * providers = listOf(AuthProvider.Email(), AuthProvider.Google(...))
+ * }
+ * val controller = authUI.createAuthFlow(configuration)
+ * val intent = controller.createIntent(context)
+ * launcher.launch(intent)
+ * ```
+ *
+ * **Result Codes:**
+ * - [Activity.RESULT_OK] - User signed in successfully
+ * - [Activity.RESULT_CANCELED] - User cancelled or error occurred
+ *
+ * **Result Data:**
+ * - [EXTRA_USER_ID] - User ID string (when RESULT_OK)
+ * - [EXTRA_IS_NEW_USER] - Boolean indicating if user is new (when RESULT_OK)
+ * - [EXTRA_ERROR] - [AuthException] when an error occurs
+ *
+ * **Note:** To get the full user object after successful sign-in, use:
+ * ```kotlin
+ * FirebaseAuth.getInstance().currentUser
+ * ```
+ *
+ * @see AuthFlowController
+ * @see FirebaseAuthScreen
+ * @since 10.0.0
+ */
+class FirebaseAuthActivity : ComponentActivity() {
+
+ private lateinit var authUI: FirebaseAuthUI
+ private lateinit var configuration: AuthUIConfiguration
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Extract configuration from cache using UUID key
+ val configKey = intent.getStringExtra(EXTRA_CONFIGURATION_KEY)
+ configuration = if (configKey != null) {
+ configurationCache.remove(configKey)
+ } else {
+ null
+ } ?: run {
+ // Missing configuration, finish with error
+ setResult(RESULT_CANCELED)
+ finish()
+ return
+ }
+
+ authUI = FirebaseAuthUI.getInstance()
+
+ // Observe auth state to automatically finish when done
+ lifecycleScope.launch {
+ authUI.authStateFlow().collect { state ->
+ when (state) {
+ is AuthState.Success -> {
+ // User signed in successfully
+ val resultIntent = Intent().apply {
+ putExtra(EXTRA_USER_ID, state.user.uid)
+ putExtra(EXTRA_IS_NEW_USER, state.isNewUser)
+ }
+ setResult(RESULT_OK, resultIntent)
+ finish()
+ }
+ is AuthState.Cancelled -> {
+ // User cancelled the flow
+ setResult(RESULT_CANCELED)
+ finish()
+ }
+ is AuthState.Error -> {
+ // Error occurred, finish with error info
+ val resultIntent = Intent().apply {
+ putExtra(EXTRA_ERROR, state.exception)
+ }
+ setResult(RESULT_CANCELED, resultIntent)
+ // Don't finish on error, let user see error and retry
+ }
+ else -> {
+ // Other states, keep showing UI
+ }
+ }
+ }
+ }
+
+ // Set up Compose UI
+ setContent {
+ AuthUITheme(theme = configuration.theme) {
+ FirebaseAuthScreen(
+ authUI = authUI,
+ configuration = configuration,
+ onSignInSuccess = { authResult ->
+ // State flow will handle finishing
+ },
+ onSignInFailure = { exception ->
+ // State flow will handle error
+ },
+ onSignInCancelled = {
+ authUI.updateAuthState(AuthState.Cancelled)
+ }
+ )
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ // Reset auth state when activity is destroyed
+ if (!isFinishing) {
+ authUI.updateAuthState(AuthState.Idle)
+ }
+ }
+
+ companion object {
+ private const val EXTRA_CONFIGURATION_KEY = "com.firebase.ui.auth.compose.CONFIGURATION_KEY"
+
+ /**
+ * Intent extra key for user ID on successful sign-in.
+ * Use [com.google.firebase.auth.FirebaseAuth.getInstance().currentUser] to get the full user object.
+ */
+ const val EXTRA_USER_ID = "com.firebase.ui.auth.compose.USER_ID"
+
+ /**
+ * Intent extra key for isNewUser flag on successful sign-in.
+ */
+ const val EXTRA_IS_NEW_USER = "com.firebase.ui.auth.compose.IS_NEW_USER"
+
+ /**
+ * Intent extra key for [AuthException] on error.
+ */
+ const val EXTRA_ERROR = "com.firebase.ui.auth.compose.ERROR"
+
+ /**
+ * Cache for configurations passed through Intents.
+ * Uses UUID keys to avoid serialization issues with Context references.
+ */
+ private val configurationCache = ConcurrentHashMap()
+
+ /**
+ * Creates an Intent to launch the Firebase authentication flow.
+ *
+ * @param context Android [Context]
+ * @param configuration [AuthUIConfiguration] defining the auth flow
+ * @return Configured [Intent] to start [FirebaseAuthActivity]
+ */
+ internal fun createIntent(
+ context: Context,
+ configuration: AuthUIConfiguration
+ ): Intent {
+ val configKey = UUID.randomUUID().toString()
+ configurationCache[configKey] = configuration
+
+ return Intent(context, FirebaseAuthActivity::class.java).apply {
+ putExtra(EXTRA_CONFIGURATION_KEY, configKey)
+ }
+ }
+ }
+}
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 6146f10f7..4c1c2a1c2 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,6 +16,7 @@ package com.firebase.ui.auth.compose
import android.content.Context
import androidx.annotation.RestrictTo
+import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration
import com.google.firebase.FirebaseApp
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseAuth.AuthStateListener
@@ -112,6 +113,81 @@ class FirebaseAuthUI private constructor(
*/
fun getCurrentUser(): FirebaseUser? = auth.currentUser
+ /**
+ * Creates a new authentication flow controller with the specified configuration.
+ *
+ * This method returns an [AuthFlowController] that manages the authentication flow
+ * lifecycle. The controller provides methods to start the flow, monitor its state,
+ * and clean up resources when done.
+ *
+ * **Example with ActivityResultLauncher:**
+ * ```kotlin
+ * class MyActivity : ComponentActivity() {
+ * private lateinit var authController: AuthFlowController
+ *
+ * private val authLauncher = registerForActivityResult(
+ * ActivityResultContracts.StartActivityForResult()
+ * ) { result ->
+ * if (result.resultCode == Activity.RESULT_OK) {
+ * val userId = result.data?.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID)
+ * val isNewUser = result.data?.getBooleanExtra(
+ * FirebaseAuthActivity.EXTRA_IS_NEW_USER,
+ * false
+ * ) ?: false
+ * // Get the full user object
+ * val user = FirebaseAuth.getInstance().currentUser
+ * }
+ * }
+ *
+ * override fun onCreate(savedInstanceState: Bundle?) {
+ * super.onCreate(savedInstanceState)
+ *
+ * val authUI = FirebaseAuthUI.getInstance()
+ * val configuration = authUIConfiguration {
+ * providers = listOf(
+ * AuthProvider.Email(),
+ * AuthProvider.Google(...)
+ * )
+ * }
+ *
+ * authController = authUI.createAuthFlow(configuration)
+ *
+ * // Observe auth state
+ * lifecycleScope.launch {
+ * authController.authStateFlow.collect { state ->
+ * when (state) {
+ * is AuthState.Success -> {
+ * // User signed in successfully
+ * }
+ * is AuthState.Error -> {
+ * // Handle error
+ * }
+ * else -> {}
+ * }
+ * }
+ * }
+ *
+ * // Start auth flow
+ * val intent = authController.createIntent(this)
+ * authLauncher.launch(intent)
+ * }
+ *
+ * override fun onDestroy() {
+ * super.onDestroy()
+ * authController.dispose()
+ * }
+ * }
+ * ```
+ *
+ * @param configuration The [AuthUIConfiguration] defining the auth flow behavior
+ * @return A new [AuthFlowController] instance
+ * @see AuthFlowController
+ * @since 10.0.0
+ */
+ fun createAuthFlow(configuration: AuthUIConfiguration): AuthFlowController {
+ return AuthFlowController(this, configuration)
+ }
+
/**
* Returns a [Flow] that emits [AuthState] changes.
*
diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/AuthFlowControllerTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/AuthFlowControllerTest.kt
new file mode 100644
index 000000000..2c80a8e0e
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/compose/AuthFlowControllerTest.kt
@@ -0,0 +1,478 @@
+/*
+ * 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
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import androidx.test.core.app.ApplicationProvider
+import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider
+import com.google.common.truth.Truth.assertThat
+import com.google.firebase.FirebaseApp
+import com.google.firebase.FirebaseOptions
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+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.Mock
+import org.mockito.Mockito.mock
+import org.mockito.MockitoAnnotations
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Unit tests for [AuthFlowController] covering lifecycle management,
+ * intent creation, state observation, and resource disposal.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE, sdk = [34])
+class AuthFlowControllerTest {
+
+ private lateinit var applicationContext: Context
+ private lateinit var authUI: FirebaseAuthUI
+ private lateinit var configuration: AuthUIConfiguration
+
+ @Mock
+ private lateinit var mockActivity: Activity
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.openMocks(this)
+
+ applicationContext = ApplicationProvider.getApplicationContext()
+
+ // Clear any existing Firebase apps
+ FirebaseApp.getApps(applicationContext).forEach { app ->
+ app.delete()
+ }
+
+ // Initialize default FirebaseApp
+ FirebaseApp.initializeApp(
+ applicationContext,
+ FirebaseOptions.Builder()
+ .setApiKey("fake-api-key")
+ .setApplicationId("fake-app-id")
+ .setProjectId("fake-project-id")
+ .build()
+ )
+
+ authUI = FirebaseAuthUI.getInstance()
+ configuration = AuthUIConfiguration(
+ context = applicationContext,
+ providers = listOf(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ )
+ }
+
+ @After
+ fun tearDown() {
+ FirebaseAuthUI.clearInstanceCache()
+ FirebaseApp.getApps(applicationContext).forEach { app ->
+ try {
+ app.delete()
+ } catch (_: Exception) {
+ // Ignore if already deleted
+ }
+ }
+ }
+
+ // =============================================================================================
+ // Controller Creation Tests
+ // =============================================================================================
+
+ @Test
+ fun `createAuthFlow() returns new controller instance`() {
+ val controller = authUI.createAuthFlow(configuration)
+
+ assertThat(controller).isNotNull()
+ assertThat(controller.isDisposed()).isFalse()
+ }
+
+ @Test
+ fun `createAuthFlow() returns different instances each time`() {
+ val controller1 = authUI.createAuthFlow(configuration)
+ val controller2 = authUI.createAuthFlow(configuration)
+
+ // Each call should return a new controller instance
+ assertThat(controller1).isNotEqualTo(controller2)
+ }
+
+ // =============================================================================================
+ // Intent Creation Tests
+ // =============================================================================================
+
+ @Test
+ fun `createIntent() returns valid Intent`() {
+ val controller = authUI.createAuthFlow(configuration)
+ val intent = controller.createIntent(applicationContext)
+
+ assertThat(intent).isNotNull()
+ assertThat(intent.component?.className).contains("FirebaseAuthActivity")
+ }
+
+ @Test
+ fun `createIntent() contains configuration key`() {
+ val controller = authUI.createAuthFlow(configuration)
+ val intent = controller.createIntent(applicationContext)
+
+ // Intent should contain a configuration key extra
+ val configKey = intent.getStringExtra("com.firebase.ui.auth.compose.CONFIGURATION_KEY")
+ assertThat(configKey).isNotNull()
+ assertThat(configKey).isNotEmpty()
+ }
+
+ @Test
+ fun `createIntent() throws IllegalStateException when disposed`() {
+ val controller = authUI.createAuthFlow(configuration)
+ controller.dispose()
+
+ try {
+ controller.createIntent(applicationContext)
+ assertThat(false).isTrue() // Should not reach here
+ } catch (e: IllegalStateException) {
+ assertThat(e.message).contains("disposed")
+ }
+ }
+
+ // =============================================================================================
+ // Auth State Flow Tests
+ // =============================================================================================
+
+ @Test
+ fun `authStateFlow observes state changes from FirebaseAuthUI`() = runTest {
+ val controller = authUI.createAuthFlow(configuration)
+
+ // Collect initial state
+ val initialState = controller.authStateFlow.first()
+ assertThat(initialState).isInstanceOf(AuthState.Idle::class.java)
+ }
+
+ @Test
+ fun `authStateFlow emits Loading state when updated`() = runTest {
+ val controller = authUI.createAuthFlow(configuration)
+
+ // Update state
+ authUI.updateAuthState(AuthState.Loading("Testing"))
+
+ // Advance test scheduler to process all pending coroutines
+ testScheduler.advanceUntilIdle()
+
+ // Collect first state after update
+ val state = controller.authStateFlow.first()
+
+ // Should be Loading state
+ assertThat(state).isInstanceOf(AuthState.Loading::class.java)
+ assertThat((state as AuthState.Loading).message).isEqualTo("Testing")
+ }
+
+ @Test
+ fun `authStateFlow throws IllegalStateException when disposed`() = runTest {
+ val controller = authUI.createAuthFlow(configuration)
+ controller.dispose()
+
+ try {
+ controller.authStateFlow.first()
+ assertThat(false).isTrue() // Should not reach here
+ } catch (e: IllegalStateException) {
+ assertThat(e.message).contains("disposed")
+ }
+ }
+
+ // =============================================================================================
+ // Cancel Tests
+ // =============================================================================================
+
+ @Test
+ fun `cancel() updates state to Cancelled`() = runTest {
+ val controller = authUI.createAuthFlow(configuration)
+
+ // Cancel the flow
+ controller.cancel()
+
+ // Advance test scheduler to process all pending coroutines
+ testScheduler.advanceUntilIdle()
+
+ // Collect first state after cancel
+ val state = controller.authStateFlow.first()
+
+ // Should be Cancelled state
+ assertThat(state).isInstanceOf(AuthState.Cancelled::class.java)
+ }
+
+ @Test
+ fun `cancel() throws IllegalStateException when disposed`() {
+ val controller = authUI.createAuthFlow(configuration)
+ controller.dispose()
+
+ try {
+ controller.cancel()
+ assertThat(false).isTrue() // Should not reach here
+ } catch (e: IllegalStateException) {
+ assertThat(e.message).contains("disposed")
+ }
+ }
+
+ // =============================================================================================
+ // Dispose Tests
+ // =============================================================================================
+
+ @Test
+ fun `dispose() marks controller as disposed`() {
+ val controller = authUI.createAuthFlow(configuration)
+
+ assertThat(controller.isDisposed()).isFalse()
+
+ controller.dispose()
+
+ assertThat(controller.isDisposed()).isTrue()
+ }
+
+ @Test
+ fun `dispose() can be called multiple times safely`() {
+ val controller = authUI.createAuthFlow(configuration)
+
+ controller.dispose()
+ controller.dispose() // Should not throw
+
+ assertThat(controller.isDisposed()).isTrue()
+ }
+
+ @Test
+ fun `dispose() prevents further operations`() {
+ val controller = authUI.createAuthFlow(configuration)
+ controller.dispose()
+
+ // All operations should throw IllegalStateException
+ try {
+ controller.createIntent(applicationContext)
+ assertThat(false).isTrue()
+ } catch (e: IllegalStateException) {
+ assertThat(e.message).contains("disposed")
+ }
+
+ try {
+ controller.cancel()
+ assertThat(false).isTrue()
+ } catch (e: IllegalStateException) {
+ assertThat(e.message).contains("disposed")
+ }
+ }
+
+ @Test
+ fun `isDisposed() returns correct state`() {
+ val controller = authUI.createAuthFlow(configuration)
+
+ assertThat(controller.isDisposed()).isFalse()
+
+ controller.dispose()
+
+ assertThat(controller.isDisposed()).isTrue()
+ }
+
+ // =============================================================================================
+ // Deprecated Start Method Tests
+ // =============================================================================================
+
+ @Suppress("DEPRECATION")
+ @Test
+ fun `start() launches activity with correct intent`() {
+ val controller = authUI.createAuthFlow(configuration)
+ val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+
+ controller.start(activity, AuthFlowController.RC_SIGN_IN)
+
+ // Verify an activity was started
+ val shadowActivity = org.robolectric.Shadows.shadowOf(activity)
+ val startedIntent = shadowActivity.nextStartedActivity
+
+ assertThat(startedIntent).isNotNull()
+ assertThat(startedIntent.component?.className).contains("FirebaseAuthActivity")
+ }
+
+ @Suppress("DEPRECATION")
+ @Test
+ fun `start() throws IllegalStateException when disposed`() {
+ val controller = authUI.createAuthFlow(configuration)
+ val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+
+ controller.dispose()
+
+ try {
+ controller.start(activity, AuthFlowController.RC_SIGN_IN)
+ assertThat(false).isTrue() // Should not reach here
+ } catch (e: IllegalStateException) {
+ assertThat(e.message).contains("disposed")
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ @Test
+ fun `start() uses default request code when not specified`() {
+ val controller = authUI.createAuthFlow(configuration)
+ val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+
+ controller.start(activity)
+
+ // Verify an activity was started (request code verification is internal)
+ val shadowActivity = org.robolectric.Shadows.shadowOf(activity)
+ val startedIntent = shadowActivity.nextStartedActivity
+
+ assertThat(startedIntent).isNotNull()
+ }
+
+ // =============================================================================================
+ // Thread Safety Tests
+ // =============================================================================================
+
+ @Test
+ fun `dispose() is thread-safe`() {
+ val controller = authUI.createAuthFlow(configuration)
+
+ val threads = List(10) {
+ Thread {
+ controller.dispose()
+ }
+ }
+
+ // Start all threads concurrently
+ threads.forEach { it.start() }
+
+ // Wait for all threads to complete
+ threads.forEach { it.join() }
+
+ // Controller should be disposed exactly once
+ assertThat(controller.isDisposed()).isTrue()
+ }
+
+ @Test
+ fun `multiple controllers can be created and disposed independently`() {
+ val controller1 = authUI.createAuthFlow(configuration)
+ val controller2 = authUI.createAuthFlow(configuration)
+ val controller3 = authUI.createAuthFlow(configuration)
+
+ controller1.dispose()
+
+ // Other controllers should still be usable
+ assertThat(controller1.isDisposed()).isTrue()
+ assertThat(controller2.isDisposed()).isFalse()
+ assertThat(controller3.isDisposed()).isFalse()
+
+ // Can still create intents with non-disposed controllers
+ val intent2 = controller2.createIntent(applicationContext)
+ val intent3 = controller3.createIntent(applicationContext)
+
+ assertThat(intent2).isNotNull()
+ assertThat(intent3).isNotNull()
+
+ controller2.dispose()
+ controller3.dispose()
+
+ assertThat(controller2.isDisposed()).isTrue()
+ assertThat(controller3.isDisposed()).isTrue()
+ }
+
+ // =============================================================================================
+ // Configuration Tests
+ // =============================================================================================
+
+ @Test
+ fun `controller preserves configuration for intent creation`() {
+ val customConfig = AuthUIConfiguration(
+ context = applicationContext,
+ providers = listOf(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ ),
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null
+ )
+ ),
+ tosUrl = "https://example.com/tos",
+ privacyPolicyUrl = "https://example.com/privacy"
+ )
+
+ val controller = authUI.createAuthFlow(customConfig)
+ val intent = controller.createIntent(applicationContext)
+
+ // Intent should be created successfully with custom config
+ assertThat(intent).isNotNull()
+ assertThat(intent.component?.className).contains("FirebaseAuthActivity")
+ }
+
+ // =============================================================================================
+ // Lifecycle Tests
+ // =============================================================================================
+
+ @Test
+ fun `typical lifecycle - create, start, cancel, dispose`() = runTest {
+ val controller = authUI.createAuthFlow(configuration)
+
+ // Create intent
+ val intent = controller.createIntent(applicationContext)
+ assertThat(intent).isNotNull()
+
+ // Cancel flow
+ controller.cancel()
+
+ // Advance test scheduler to process all pending coroutines
+ testScheduler.advanceUntilIdle()
+
+ // Verify cancelled state
+ val state = controller.authStateFlow.first()
+ assertThat(state).isInstanceOf(AuthState.Cancelled::class.java)
+
+ // Dispose
+ controller.dispose()
+ assertThat(controller.isDisposed()).isTrue()
+ }
+
+ @Test
+ fun `typical lifecycle - create, start, observe, dispose`() = runTest {
+ val controller = authUI.createAuthFlow(configuration)
+
+ // Create intent
+ val intent = controller.createIntent(applicationContext)
+ assertThat(intent).isNotNull()
+
+ // Update state
+ authUI.updateAuthState(AuthState.Loading("Signing in..."))
+
+ // Advance test scheduler to process all pending coroutines
+ testScheduler.advanceUntilIdle()
+
+ // Observe state change
+ val state = controller.authStateFlow.first()
+ assertThat(state).isInstanceOf(AuthState.Loading::class.java)
+
+ // Dispose
+ controller.dispose()
+ assertThat(controller.isDisposed()).isTrue()
+ }
+}
diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthActivityTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthActivityTest.kt
new file mode 100644
index 000000000..8b21965fa
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthActivityTest.kt
@@ -0,0 +1,539 @@
+/*
+ * 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
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import androidx.test.core.app.ApplicationProvider
+import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider
+import com.google.common.truth.Truth.assertThat
+import com.google.firebase.FirebaseApp
+import com.google.firebase.FirebaseOptions
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.MultiFactorResolver
+import android.os.Looper
+import kotlinx.coroutines.delay
+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.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.MockitoAnnotations
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.android.controller.ActivityController
+import org.robolectric.annotation.Config
+
+/**
+ * Unit tests for [FirebaseAuthActivity] covering activity lifecycle,
+ * intent handling, state observation, and result handling.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE, sdk = [34])
+class FirebaseAuthActivityTest {
+
+ private lateinit var applicationContext: Context
+ private lateinit var authUI: FirebaseAuthUI
+ private lateinit var configuration: AuthUIConfiguration
+
+ @Mock
+ private lateinit var mockFirebaseUser: FirebaseUser
+
+ @Mock
+ private lateinit var mockMultiFactorResolver: MultiFactorResolver
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.openMocks(this)
+
+ applicationContext = ApplicationProvider.getApplicationContext()
+
+ // Clear any existing Firebase apps
+ FirebaseApp.getApps(applicationContext).forEach { app ->
+ app.delete()
+ }
+
+ // Initialize default 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)
+
+ configuration = AuthUIConfiguration(
+ context = applicationContext,
+ providers = listOf(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ )
+
+ // Reset auth state before each test
+ authUI.updateAuthState(AuthState.Idle)
+ }
+
+ @After
+ fun tearDown() {
+ FirebaseAuthUI.clearInstanceCache()
+ FirebaseApp.getApps(applicationContext).forEach { app ->
+ try {
+ app.delete()
+ } catch (_: Exception) {
+ // Ignore if already deleted
+ }
+ }
+ }
+
+ // =============================================================================================
+ // Activity Launch Tests
+ // =============================================================================================
+
+ @Test
+ fun `activity launches successfully with valid configuration`() {
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration)
+ val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+
+ val activity = controller.create().start().resume().get()
+
+ assertThat(activity).isNotNull()
+ assertThat(activity.isFinishing).isFalse()
+ }
+
+ @Test
+ fun `activity finishes immediately when configuration is missing`() {
+ // Create intent without configuration
+ val intent = Intent(applicationContext, FirebaseAuthActivity::class.java)
+
+ val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+ val activity = controller.create().get()
+
+ // Activity should finish immediately
+ assertThat(activity.isFinishing).isTrue()
+
+ // Result should be RESULT_CANCELED
+ val shadowActivity = shadowOf(activity)
+ assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_CANCELED)
+ }
+
+ // =============================================================================================
+ // Configuration Extraction Tests
+ // =============================================================================================
+
+ @Test
+ fun `createIntent() stores configuration in cache`() {
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration)
+
+ // Intent should contain configuration key
+ val configKey = intent.getStringExtra("com.firebase.ui.auth.compose.CONFIGURATION_KEY")
+ assertThat(configKey).isNotNull()
+ assertThat(configKey).isNotEmpty()
+ }
+
+ @Test
+ fun `activity extracts configuration from intent on onCreate`() {
+ val customConfig = AuthUIConfiguration(
+ context = applicationContext,
+ providers = listOf(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ ),
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null
+ )
+ ),
+ tosUrl = "https://example.com/tos"
+ )
+
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, customConfig)
+ val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+
+ val activity = controller.create().get()
+
+ // Activity should have been created successfully (not finished)
+ assertThat(activity.isFinishing).isFalse()
+ }
+
+ // =============================================================================================
+ // Auth State Success Tests
+ // =============================================================================================
+
+ @Test
+ fun `activity finishes with RESULT_OK on Success state`() = runTest {
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration)
+ val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+
+ val activity = controller.create().start().resume().get()
+
+ // Mock user
+ `when`(mockFirebaseUser.uid).thenReturn("test-user-id")
+
+ // Update to Success state
+ authUI.updateAuthState(AuthState.Success(
+ result = null,
+ user = mockFirebaseUser,
+ isNewUser = true
+ ))
+
+ // Process pending tasks on main looper
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Activity should finish
+ assertThat(activity.isFinishing).isTrue()
+
+ // Result should be RESULT_OK
+ val shadowActivity = shadowOf(activity)
+ assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_OK)
+
+ // Result intent should contain user data
+ val resultIntent = shadowActivity.resultIntent
+ assertThat(resultIntent).isNotNull()
+ assertThat(resultIntent.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID))
+ .isEqualTo("test-user-id")
+ assertThat(resultIntent.getBooleanExtra(FirebaseAuthActivity.EXTRA_IS_NEW_USER, false))
+ .isTrue()
+ }
+
+ @Test
+ fun `activity returns correct user data on success`() = runTest {
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration)
+ val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+
+ val activity = controller.create().start().resume().get()
+
+ // Mock user with specific data
+ `when`(mockFirebaseUser.uid).thenReturn("user-123")
+
+ // Update to Success state with isNewUser = false
+ authUI.updateAuthState(AuthState.Success(
+ result = null,
+ user = mockFirebaseUser,
+ isNewUser = false
+ ))
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ val shadowActivity = shadowOf(activity)
+ val resultIntent = shadowActivity.resultIntent
+
+ assertThat(resultIntent.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID))
+ .isEqualTo("user-123")
+ assertThat(resultIntent.getBooleanExtra(FirebaseAuthActivity.EXTRA_IS_NEW_USER, true))
+ .isFalse()
+ }
+
+ // =============================================================================================
+ // Auth State Cancelled Tests
+ // =============================================================================================
+
+ @Test
+ fun `activity finishes with RESULT_CANCELED on Cancelled state`() = runTest {
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration)
+ val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+
+ val activity = controller.create().start().resume().get()
+
+ // Update to Cancelled state
+ authUI.updateAuthState(AuthState.Cancelled)
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Activity should finish
+ assertThat(activity.isFinishing).isTrue()
+
+ // Result should be RESULT_CANCELED
+ val shadowActivity = shadowOf(activity)
+ assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_CANCELED)
+ }
+
+ // =============================================================================================
+ // Auth State Error Tests
+ // =============================================================================================
+
+ @Test
+ fun `activity sets RESULT_CANCELED on Error state but does not finish`() = runTest {
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration)
+ val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+
+ val activity = controller.create().start().resume().get()
+
+ // Update to Error state
+ val exception = AuthException.UnknownException("Test error")
+ authUI.updateAuthState(AuthState.Error(exception))
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Activity should NOT finish (to let user see error and retry)
+ assertThat(activity.isFinishing).isFalse()
+
+ // Result should be set to RESULT_CANCELED
+ val shadowActivity = shadowOf(activity)
+ assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_CANCELED)
+
+ // Result intent should contain error
+ val resultIntent = shadowActivity.resultIntent
+ assertThat(resultIntent).isNotNull()
+ assertThat(resultIntent.getSerializableExtra(FirebaseAuthActivity.EXTRA_ERROR))
+ .isInstanceOf(AuthException::class.java)
+ }
+
+ @Test
+ fun `activity includes error in result intent on Error state`() = runTest {
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration)
+ val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+
+ val activity = controller.create().start().resume().get()
+
+ // Update to Error state with specific exception
+ val exception = AuthException.InvalidCredentialsException(
+ message = "Invalid credentials"
+ )
+ authUI.updateAuthState(AuthState.Error(exception))
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ val shadowActivity = shadowOf(activity)
+ val resultIntent = shadowActivity.resultIntent
+ val resultError = resultIntent.getSerializableExtra(FirebaseAuthActivity.EXTRA_ERROR) as AuthException
+
+ assertThat(resultError).isInstanceOf(AuthException.InvalidCredentialsException::class.java)
+ assertThat(resultError.message).isEqualTo("Invalid credentials")
+ }
+
+ // =============================================================================================
+ // Activity Lifecycle Tests
+ // =============================================================================================
+
+ @Test
+ fun `activity resets auth state to Idle on destroy when not finishing`() {
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration)
+ val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+
+ val activity = controller.create().start().resume().get()
+
+ // Set some state
+ authUI.updateAuthState(AuthState.Loading("Testing"))
+
+ // Destroy without finishing
+ controller.pause().stop().destroy()
+
+ // Note: This test verifies the lifecycle hook is in place
+ // The actual state reset behavior depends on the isFinishing flag
+ }
+
+ @Test
+ fun `activity handles rapid state transitions`() = runTest {
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration)
+ val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+
+ val activity = controller.create().start().resume().get()
+
+ // Simulate rapid state changes
+ authUI.updateAuthState(AuthState.Loading("Loading..."))
+ shadowOf(Looper.getMainLooper()).idle()
+ authUI.updateAuthState(AuthState.Loading("Signing in..."))
+ shadowOf(Looper.getMainLooper()).idle()
+ `when`(mockFirebaseUser.uid).thenReturn("test-user")
+ authUI.updateAuthState(AuthState.Success(
+ result = null,
+ user = mockFirebaseUser,
+ isNewUser = false
+ ))
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Activity should finish on final Success state
+ assertThat(activity.isFinishing).isTrue()
+
+ val shadowActivity = shadowOf(activity)
+ assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_OK)
+ }
+
+ // =============================================================================================
+ // Intent Extras Constants Tests
+ // =============================================================================================
+
+ @Test
+ fun `activity exposes correct intent extra constants`() {
+ assertThat(FirebaseAuthActivity.EXTRA_USER_ID)
+ .isEqualTo("com.firebase.ui.auth.compose.USER_ID")
+ assertThat(FirebaseAuthActivity.EXTRA_IS_NEW_USER)
+ .isEqualTo("com.firebase.ui.auth.compose.IS_NEW_USER")
+ assertThat(FirebaseAuthActivity.EXTRA_ERROR)
+ .isEqualTo("com.firebase.ui.auth.compose.ERROR")
+ }
+
+ // =============================================================================================
+ // Configuration Cache Tests
+ // =============================================================================================
+
+ @Test
+ fun `configuration is removed from cache after onCreate`() {
+ val intent1 = FirebaseAuthActivity.createIntent(applicationContext, configuration)
+ val configKey1 = intent1.getStringExtra("com.firebase.ui.auth.compose.CONFIGURATION_KEY")
+
+ assertThat(configKey1).isNotNull()
+
+ // Create activity - this should consume the configuration from cache
+ val controller1 = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent1)
+ controller1.create().get()
+
+ // Create another intent
+ val intent2 = FirebaseAuthActivity.createIntent(applicationContext, configuration)
+ val configKey2 = intent2.getStringExtra("com.firebase.ui.auth.compose.CONFIGURATION_KEY")
+
+ // Should be a different key
+ assertThat(configKey2).isNotEqualTo(configKey1)
+ }
+
+ @Test
+ fun `multiple activities can be launched with different configurations`() {
+ val config1 = AuthUIConfiguration(
+ context = applicationContext,
+ providers = listOf(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ ),
+ tosUrl = "https://example.com/tos1"
+ )
+
+ val config2 = AuthUIConfiguration(
+ context = applicationContext,
+ providers = listOf(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null
+ )
+ ),
+ tosUrl = "https://example.com/tos2"
+ )
+
+ val intent1 = FirebaseAuthActivity.createIntent(applicationContext, config1)
+ val intent2 = FirebaseAuthActivity.createIntent(applicationContext, config2)
+
+ // Both activities should launch successfully
+ val controller1 = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent1)
+ val activity1 = controller1.create().get()
+ assertThat(activity1.isFinishing).isFalse()
+
+ val controller2 = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent2)
+ val activity2 = controller2.create().get()
+ assertThat(activity2.isFinishing).isFalse()
+ }
+
+ // =============================================================================================
+ // Other State Tests
+ // =============================================================================================
+
+ @Test
+ fun `activity continues showing UI on Loading state`() = runTest {
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration)
+ val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+
+ val activity = controller.create().start().resume().get()
+
+ // Update to Loading state
+ authUI.updateAuthState(AuthState.Loading("Signing in..."))
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Activity should NOT finish on Loading state
+ assertThat(activity.isFinishing).isFalse()
+ }
+
+ @Test
+ fun `activity continues showing UI on RequiresMfa state`() = runTest {
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration)
+ val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+
+ val activity = controller.create().start().resume().get()
+
+ // Update to RequiresMfa state (mocked resolver)
+ authUI.updateAuthState(AuthState.RequiresMfa(
+ resolver = mockMultiFactorResolver,
+ hint = "Enter verification code"
+ ))
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Activity should NOT finish on RequiresMfa state
+ assertThat(activity.isFinishing).isFalse()
+ }
+
+ @Test
+ fun `activity continues showing UI on RequiresEmailVerification state`() = runTest {
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration)
+ val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+
+ val activity = controller.create().start().resume().get()
+
+ // Update to RequiresEmailVerification state
+ `when`(mockFirebaseUser.email).thenReturn("test@example.com")
+ authUI.updateAuthState(AuthState.RequiresEmailVerification(
+ user = mockFirebaseUser,
+ email = "test@example.com"
+ ))
+
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // Activity should NOT finish on RequiresEmailVerification state
+ assertThat(activity.isFinishing).isFalse()
+ }
+
+ // =============================================================================================
+ // Theme Tests
+ // =============================================================================================
+
+ @Test
+ fun `activity applies theme from configuration`() {
+ val customConfig = AuthUIConfiguration(
+ context = applicationContext,
+ providers = listOf(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ ),
+ theme = com.firebase.ui.auth.compose.configuration.theme.AuthUITheme.Default
+ )
+
+ val intent = FirebaseAuthActivity.createIntent(applicationContext, customConfig)
+ val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent)
+
+ val activity = controller.create().get()
+
+ // Activity should launch successfully with custom theme
+ assertThat(activity.isFinishing).isFalse()
+ }
+}
diff --git a/composeapp/src/main/AndroidManifest.xml b/composeapp/src/main/AndroidManifest.xml
index 83e2dd1a5..3b452f820 100644
--- a/composeapp/src/main/AndroidManifest.xml
+++ b/composeapp/src/main/AndroidManifest.xml
@@ -38,6 +38,18 @@
tools:ignore="AppLinksAutoVerify,AppLinkUrlError" />
+
+
+
+
\ No newline at end of file
diff --git a/composeapp/src/main/java/com/firebase/composeapp/AuthFlowControllerDemoActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/AuthFlowControllerDemoActivity.kt
new file mode 100644
index 000000000..dfdcb0cab
--- /dev/null
+++ b/composeapp/src/main/java/com/firebase/composeapp/AuthFlowControllerDemoActivity.kt
@@ -0,0 +1,337 @@
+package com.firebase.composeapp
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.compose.AuthFlowController
+import com.firebase.ui.auth.compose.AuthState
+import com.firebase.ui.auth.compose.FirebaseAuthActivity
+import com.firebase.ui.auth.compose.FirebaseAuthUI
+import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.compose.configuration.PasswordRule
+import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.actionCodeSettings
+import kotlinx.coroutines.launch
+
+/**
+ * Demo activity showcasing the AuthFlowController API for managing
+ * Firebase authentication with lifecycle-safe control.
+ *
+ * This demonstrates:
+ * - Creating an AuthFlowController with configuration
+ * - Starting the auth flow using ActivityResultLauncher
+ * - Observing auth state changes
+ * - Handling results (success, cancelled, error)
+ * - Proper lifecycle management with dispose()
+ */
+class AuthFlowControllerDemoActivity : ComponentActivity() {
+
+ private lateinit var authController: AuthFlowController
+
+ // Modern ActivityResultLauncher for auth flow
+ private val authLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ when (result.resultCode) {
+ Activity.RESULT_OK -> {
+ // Get user data from result
+ val userId = result.data?.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID)
+ val isNewUser = result.data?.getBooleanExtra(
+ FirebaseAuthActivity.EXTRA_IS_NEW_USER,
+ false
+ ) ?: false
+
+ val user = FirebaseAuth.getInstance().currentUser
+ val message = if (isNewUser) {
+ "Welcome new user! ${user?.email ?: userId}"
+ } else {
+ "Welcome back! ${user?.email ?: userId}"
+ }
+ Toast.makeText(this, message, Toast.LENGTH_LONG).show()
+ }
+ Activity.RESULT_CANCELED -> {
+ Toast.makeText(this, "Auth cancelled", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Initialize FirebaseAuthUI
+ val authUI = FirebaseAuthUI.getInstance()
+
+ // Create auth configuration
+ val configuration = AuthUIConfiguration(
+ context = applicationContext,
+ providers = listOf(
+ 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,
+ )
+ ),
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = emptyList(),
+ smsCodeLength = 6,
+ timeout = 120L,
+ isInstantVerificationEnabled = true
+ ),
+ AuthProvider.Facebook(
+ applicationId = "792556260059222"
+ )
+ ),
+ tosUrl = "https://policies.google.com/terms?hl=en-NG&fg=1",
+ privacyPolicyUrl = "https://policies.google.com/privacy?hl=en-NG&fg=1"
+ )
+
+ // Create AuthFlowController
+ authController = authUI.createAuthFlow(configuration)
+
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ AuthFlowDemo(
+ authController = authController,
+ onStartAuth = { startAuthFlow() },
+ onCancelAuth = { cancelAuthFlow() }
+ )
+ }
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ // Clean up resources
+ authController.dispose()
+ }
+
+ private fun startAuthFlow() {
+ val intent = authController.createIntent(this)
+ authLauncher.launch(intent)
+ }
+
+ private fun cancelAuthFlow() {
+ authController.cancel()
+ Toast.makeText(this, "Auth flow cancelled", Toast.LENGTH_SHORT).show()
+ }
+
+ companion object {
+ fun createIntent(context: Context): Intent {
+ return Intent(context, AuthFlowControllerDemoActivity::class.java)
+ }
+ }
+}
+
+@Composable
+fun AuthFlowDemo(
+ authController: AuthFlowController,
+ onStartAuth: () -> Unit,
+ onCancelAuth: () -> Unit
+) {
+ val authState by authController.authStateFlow.collectAsState(AuthState.Idle)
+ var currentUser by remember { mutableStateOf(FirebaseAuth.getInstance().currentUser) }
+
+ // Observe Firebase auth state changes
+ DisposableEffect(Unit) {
+ val authStateListener = FirebaseAuth.AuthStateListener { auth ->
+ currentUser = auth.currentUser
+ }
+ FirebaseAuth.getInstance().addAuthStateListener(authStateListener)
+
+ onDispose {
+ FirebaseAuth.getInstance().removeAuthStateListener(authStateListener)
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
+ ) {
+ Text(
+ text = "⚙️ Low-Level API Demo",
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center
+ )
+
+ Text(
+ text = "AuthFlowController with ActivityResultLauncher",
+ style = MaterialTheme.typography.titleMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.primary
+ )
+
+ Text(
+ text = "This demonstrates manual control over the authentication flow with lifecycle-safe management.",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Current Auth State Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "Current State:",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = when (authState) {
+ is AuthState.Idle -> "Idle"
+ is AuthState.Loading -> "Loading: ${(authState as AuthState.Loading).message}"
+ is AuthState.Success -> "Success - User: ${(authState as AuthState.Success).user.email}"
+ is AuthState.Error -> "Error: ${(authState as AuthState.Error).exception.message}"
+ is AuthState.Cancelled -> "Cancelled"
+ is AuthState.RequiresMfa -> "MFA Required"
+ is AuthState.RequiresEmailVerification -> "Email Verification Required"
+ else -> "Unknown"
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = when (authState) {
+ is AuthState.Success -> MaterialTheme.colorScheme.primary
+ is AuthState.Error -> MaterialTheme.colorScheme.error
+ is AuthState.Loading -> MaterialTheme.colorScheme.tertiary
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ )
+ }
+ }
+
+ // Current User Card
+ currentUser?.let { user ->
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "Signed In User:",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = "Email: ${user.email ?: "N/A"}",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Text(
+ text = "UID: ${user.uid}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Action Buttons
+ if (currentUser == null) {
+ Button(
+ onClick = onStartAuth,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Start Auth Flow")
+ }
+
+ if (authState is AuthState.Loading) {
+ OutlinedButton(
+ onClick = onCancelAuth,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Cancel Auth Flow")
+ }
+ }
+ } else {
+ Button(
+ onClick = {
+ FirebaseAuth.getInstance().signOut()
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Sign Out")
+ }
+ }
+
+ // Info Card
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "Features:",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = "• Lifecycle-safe auth flow management",
+ style = MaterialTheme.typography.bodySmall
+ )
+ Text(
+ text = "• Observable auth state with Flow",
+ style = MaterialTheme.typography.bodySmall
+ )
+ Text(
+ text = "• Modern ActivityResultLauncher API",
+ style = MaterialTheme.typography.bodySmall
+ )
+ Text(
+ text = "• Automatic resource cleanup",
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+}
diff --git a/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt
new file mode 100644
index 000000000..0fd123c85
--- /dev/null
+++ b/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt
@@ -0,0 +1,210 @@
+package com.firebase.composeapp
+
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+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.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Text
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.runtime.Composable
+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.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.AuthUITheme
+import com.firebase.ui.auth.compose.ui.screens.EmailSignInLinkHandlerActivity
+import com.firebase.ui.auth.compose.ui.screens.FirebaseAuthScreen
+import com.firebase.ui.auth.compose.ui.screens.AuthSuccessUiContext
+import com.google.firebase.auth.actionCodeSettings
+
+class HighLevelApiDemoActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val authUI = FirebaseAuthUI.getInstance()
+ 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
+ )
+ },
+ 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.Facebook(
+ applicationId = "792556260059222"
+ )
+ )
+ }
+ 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("HighLevelApiDemoActivity", "Authentication success: ${result.user?.uid}")
+ },
+ onSignInFailure = { exception: AuthException ->
+ Log.e("HighLevelApiDemoActivity", "Authentication failed", exception)
+ },
+ onSignInCancelled = {
+ Log.d("HighLevelApiDemoActivity", "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()) {
+ Text(
+ text = stringProvider.signedInAs(identifier),
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(16.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.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))
+ 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()
+ }
+ }
+ }
+}
diff --git a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt
index 4c0bbf1ee..bc138fdb8 100644
--- a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt
+++ b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt
@@ -1,39 +1,24 @@
package com.firebase.composeapp
+import android.content.Intent
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
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Button
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.Text
-import androidx.compose.ui.Modifier
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
-import androidx.compose.runtime.Composable
-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.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
-import com.firebase.ui.auth.compose.ui.screens.AuthSuccessUiContext
import com.google.firebase.FirebaseApp
-import com.google.firebase.auth.actionCodeSettings
+/**
+ * Main launcher activity that allows users to choose between different
+ * authentication API demonstrations.
+ */
class MainActivity : ComponentActivity() {
companion object {
private const val USE_AUTH_EMULATOR = true
@@ -45,6 +30,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
+ // Initialize Firebase and configure emulator if needed
FirebaseApp.initializeApp(applicationContext)
val authUI = FirebaseAuthUI.getInstance()
@@ -52,198 +38,149 @@ class MainActivity : ComponentActivity() {
authUI.auth.useEmulator(AUTH_EMULATOR_HOST, AUTH_EMULATOR_PORT)
}
- val emailLink = intent.getStringExtra(EmailSignInLinkHandlerActivity.EXTRA_EMAIL_LINK)
-
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,
- )
- )
- )
- provider(
- AuthProvider.Phone(
- defaultNumber = null,
- defaultCountryCode = null,
- allowedCountries = emptyList(),
- smsCodeLength = 6,
- timeout = 120L,
- isInstantVerificationEnabled = true
- )
- )
- provider(
- AuthProvider.Facebook(
- applicationId = "792556260059222"
- )
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ ChooserScreen(
+ onHighLevelApiClick = {
+ startActivity(Intent(this, HighLevelApiDemoActivity::class.java))
+ },
+ onLowLevelApiClick = {
+ startActivity(Intent(this, AuthFlowControllerDemoActivity::class.java))
+ }
)
}
- 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"
- }
-
- 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
+@Composable
+fun ChooserScreen(
+ onHighLevelApiClick: () -> Unit,
+ onLowLevelApiClick: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .systemBarsPadding()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically)
) {
- 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(
- "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 = {
+ // Header
+ Text(
+ text = "Firebase Auth UI Compose",
+ style = MaterialTheme.typography.headlineLarge,
+ textAlign = TextAlign.Center
+ )
- }
- ) {
- 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)
- }
- }
- }
+ Text(
+ text = "Choose a demo to explore different authentication APIs",
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
- 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)
- }
- }
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // High-Level API Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onHighLevelApiClick
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "🎨 High-Level API",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "FirebaseAuthScreen Composable",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = "Best for: Pure Compose applications that want a complete, ready-to-use authentication UI with minimal setup.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Features:",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = "• Drop-in Composable\n• Automatic navigation\n• State management included\n• Customizable content",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
+ }
- 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))
- Text(
- text = stringProvider.profileMissingFieldsMessage(state.missingFields.joinToString()),
- textAlign = TextAlign.Center
- )
- }
- Spacer(modifier = Modifier.height(16.dp))
- Button(onClick = uiContext.onSignOut) {
- Text(stringProvider.signOutAction)
- }
- }
+ // Low-Level API Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onLowLevelApiClick
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "⚙️ Low-Level API",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "AuthFlowController",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = "Best for: Applications that need fine-grained control over the authentication flow with ActivityResultLauncher integration.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Features:",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = "• Lifecycle-safe controller\n• ActivityResultLauncher\n• Observable state with Flow\n• Manual flow control",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
+ }
- else -> {
- Column(
- modifier = Modifier.fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- CircularProgressIndicator()
- }
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Info card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "💡 Tip",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = "Both APIs provide the same authentication capabilities. Choose based on your app's architecture and control requirements.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
}
}
}