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
new file mode 100644
index 000000000..79e8b2cfe
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt
@@ -0,0 +1,175 @@
+/*
+ * 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 androidx.annotation.RestrictTo
+import com.google.firebase.FirebaseApp
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.ktx.auth
+import com.google.firebase.ktx.Firebase
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * The central class that coordinates all authentication operations for Firebase Auth UI Compose.
+ * This class manages UI state and provides methods for signing in, signing up, and managing
+ * user accounts.
+ *
+ *
Usage
+ *
+ * **Default app instance:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ * ```
+ *
+ * **Custom app instance:**
+ * ```kotlin
+ * val customApp = Firebase.app("secondary")
+ * val authUI = FirebaseAuthUI.getInstance(customApp)
+ * ```
+ *
+ * **Multi-tenancy with custom auth:**
+ * ```kotlin
+ * val customAuth = Firebase.auth(customApp).apply {
+ * tenantId = "my-tenant-id"
+ * }
+ * val authUI = FirebaseAuthUI.create(customApp, customAuth)
+ * ```
+ *
+ * @property app The [FirebaseApp] instance used for authentication
+ * @property auth The [FirebaseAuth] instance used for authentication operations
+ *
+ * @since 10.0.0
+ */
+class FirebaseAuthUI private constructor(
+ val app: FirebaseApp,
+ val auth: FirebaseAuth
+) {
+ companion object {
+ /** Cache for singleton instances per FirebaseApp. Thread-safe via ConcurrentHashMap. */
+ private val instanceCache = ConcurrentHashMap()
+
+ /** Special key for the default app instance to distinguish from named instances. */
+ private const val DEFAULT_APP_KEY = "__FIREBASE_UI_DEFAULT__"
+
+ /**
+ * Returns a cached singleton instance for the default Firebase app.
+ *
+ * This method ensures that the same instance is returned for the default app across the
+ * entire application lifecycle. The instance is lazily created on first access and cached
+ * for subsequent calls.
+ *
+ * **Example:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ * val user = authUI.auth.currentUser
+ * ```
+ *
+ * @return The cached [FirebaseAuthUI] instance for the default app
+ * @throws IllegalStateException if Firebase has not been initialized. Call
+ * `FirebaseApp.initializeApp(Context)` before using this method.
+ */
+ @JvmStatic
+ fun getInstance(): FirebaseAuthUI {
+ val defaultApp = try {
+ FirebaseApp.getInstance()
+ } catch (e: IllegalStateException) {
+ throw IllegalStateException(
+ "Default FirebaseApp is not initialized. " +
+ "Make sure to call FirebaseApp.initializeApp(Context) first.",
+ e
+ )
+ }
+
+ return instanceCache.getOrPut(DEFAULT_APP_KEY) {
+ FirebaseAuthUI(defaultApp, Firebase.auth)
+ }
+ }
+
+ /**
+ * Returns a cached instance for a specific Firebase app.
+ *
+ * Each [FirebaseApp] gets its own distinct instance that is cached for subsequent calls
+ * with the same app. This allows for multiple Firebase projects to be used within the
+ * same application.
+ *
+ * **Example:**
+ * ```kotlin
+ * val secondaryApp = Firebase.app("secondary")
+ * val authUI = FirebaseAuthUI.getInstance(secondaryApp)
+ * ```
+ *
+ * @param app The [FirebaseApp] instance to use
+ * @return The cached [FirebaseAuthUI] instance for the specified app
+ */
+ @JvmStatic
+ fun getInstance(app: FirebaseApp): FirebaseAuthUI {
+ val cacheKey = app.name
+ return instanceCache.getOrPut(cacheKey) {
+ FirebaseAuthUI(app, Firebase.auth(app))
+ }
+ }
+
+ /**
+ * Creates a new instance with custom configuration, useful for multi-tenancy.
+ *
+ * This method always returns a new instance and does **not** use caching, allowing for
+ * custom [FirebaseAuth] configurations such as tenant IDs or custom authentication states.
+ * Use this when you need fine-grained control over the authentication instance.
+ *
+ * **Example - Multi-tenancy:**
+ * ```kotlin
+ * val app = Firebase.app("tenant-app")
+ * val auth = Firebase.auth(app).apply {
+ * tenantId = "customer-tenant-123"
+ * }
+ * val authUI = FirebaseAuthUI.create(app, auth)
+ * ```
+ *
+ * @param app The [FirebaseApp] instance to use
+ * @param auth The [FirebaseAuth] instance with custom configuration
+ * @return A new [FirebaseAuthUI] instance with the provided dependencies
+ */
+ @JvmStatic
+ fun create(app: FirebaseApp, auth: FirebaseAuth): FirebaseAuthUI {
+ return FirebaseAuthUI(app, auth)
+ }
+
+ /**
+ * Clears all cached instances. This method is intended for testing purposes only.
+ *
+ * @suppress This is an internal API and should not be used in production code.
+ * @RestrictTo RestrictTo.Scope.TESTS
+ */
+ @JvmStatic
+ @RestrictTo(RestrictTo.Scope.TESTS)
+ internal fun clearInstanceCache() {
+ instanceCache.clear()
+ }
+
+ /**
+ * Returns the current number of cached instances. This method is intended for testing
+ * purposes only.
+ *
+ * @return The number of cached [FirebaseAuthUI] instances
+ * @suppress This is an internal API and should not be used in production code.
+ * @RestrictTo RestrictTo.Scope.TESTS
+ */
+ @JvmStatic
+ @RestrictTo(RestrictTo.Scope.TESTS)
+ internal fun getCacheSize(): Int {
+ return instanceCache.size
+ }
+ }
+}
\ No newline at end of file
diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt
new file mode 100644
index 000000000..277d10a95
--- /dev/null
+++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt
@@ -0,0 +1,326 @@
+/*
+ * 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 androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import com.google.firebase.FirebaseApp
+import com.google.firebase.FirebaseOptions
+import com.google.firebase.auth.FirebaseAuth
+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.Mockito.mock
+import org.mockito.MockitoAnnotations
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Unit tests for [FirebaseAuthUI] covering singleton behavior, multi-app support,
+ * and custom authentication injection for multi-tenancy scenarios.
+ *
+ * @suppress Internal test class
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE)
+class FirebaseAuthUITest {
+
+ @Mock
+ private lateinit var mockFirebaseAuth: FirebaseAuth
+
+ private lateinit var defaultApp: FirebaseApp
+ private lateinit var secondaryApp: FirebaseApp
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.initMocks(this)
+
+ // Clear the instance cache before each test to ensure test isolation
+ FirebaseAuthUI.clearInstanceCache()
+
+ // Clear any existing Firebase apps
+ val context = ApplicationProvider.getApplicationContext()
+ FirebaseApp.getApps(context).forEach { app ->
+ app.delete()
+ }
+
+ // Initialize default FirebaseApp
+ defaultApp = FirebaseApp.initializeApp(
+ context,
+ FirebaseOptions.Builder()
+ .setApiKey("fake-api-key")
+ .setApplicationId("fake-app-id")
+ .setProjectId("fake-project-id")
+ .build()
+ )
+
+ // Initialize secondary FirebaseApp
+ secondaryApp = FirebaseApp.initializeApp(
+ context,
+ FirebaseOptions.Builder()
+ .setApiKey("fake-api-key-2")
+ .setApplicationId("fake-app-id-2")
+ .setProjectId("fake-project-id-2")
+ .build(),
+ "secondary"
+ )
+ }
+
+ @After
+ fun tearDown() {
+ // Clean up after each test to prevent test pollution
+ FirebaseAuthUI.clearInstanceCache()
+
+ // Clean up Firebase apps
+ try {
+ defaultApp.delete()
+ } catch (_: Exception) {
+ // Ignore if already deleted
+ }
+ try {
+ secondaryApp.delete()
+ } catch (_: Exception) {
+ // Ignore if already deleted
+ }
+ }
+
+ // =============================================================================================
+ // Singleton Behavior Tests
+ // =============================================================================================
+
+ @Test
+ fun `getInstance() returns same instance for default app`() {
+ // Get instance twice
+ val instance1 = FirebaseAuthUI.getInstance()
+ val instance2 = FirebaseAuthUI.getInstance()
+
+ // Verify they are the same instance (singleton pattern)
+ assertThat(instance1).isEqualTo(instance2)
+ assertThat(instance1.app.name).isEqualTo(FirebaseApp.DEFAULT_APP_NAME)
+
+ // Verify only one instance is cached
+ assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1)
+ }
+
+ @Test
+ fun `getInstance() works with initialized Firebase app`() {
+ // Ensure we can get an instance when Firebase is properly initialized
+ val instance = FirebaseAuthUI.getInstance()
+
+ // Verify the instance uses the default app
+ assertThat(instance.app).isEqualTo(defaultApp)
+ assertThat(instance.auth).isNotNull()
+ }
+
+ // =============================================================================================
+ // Multi-App Support Tests
+ // =============================================================================================
+
+ @Test
+ fun `getInstance(app) returns distinct instances per FirebaseApp`() {
+ // Get instances for different apps
+ val defaultInstance = FirebaseAuthUI.getInstance(defaultApp)
+ val secondaryInstance = FirebaseAuthUI.getInstance(secondaryApp)
+
+ // Verify they are different instances
+ assertThat(defaultInstance).isNotEqualTo(secondaryInstance)
+
+ // Verify correct apps are used
+ assertThat(defaultInstance.app).isEqualTo(defaultApp)
+ assertThat(secondaryInstance.app).isEqualTo(secondaryApp)
+
+ // Verify both instances are cached
+ assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(2)
+ }
+
+ @Test
+ fun `getInstance(app) returns same instance for same app`() {
+ // Get instance twice for the same app
+ val instance1 = FirebaseAuthUI.getInstance(defaultApp)
+ val instance2 = FirebaseAuthUI.getInstance(defaultApp)
+
+ // Verify they are the same instance (caching works)
+ assertThat(instance1).isEqualTo(instance2)
+ assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1)
+ }
+
+ @Test
+ fun `getInstance(app) with secondary app returns correct instance`() {
+ // Get instance for secondary app
+ val instance = FirebaseAuthUI.getInstance(secondaryApp)
+
+ // Verify correct app is used
+ assertThat(instance.app).isEqualTo(secondaryApp)
+ assertThat(instance.app.name).isEqualTo("secondary")
+ }
+
+ // =============================================================================================
+ // Custom Auth Injection Tests
+ // =============================================================================================
+
+ @Test
+ fun `create() returns new instance with provided dependencies`() {
+ // Create instances with custom auth
+ val instance1 = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth)
+ val instance2 = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth)
+
+ // Verify they are different instances (no caching)
+ assertThat(instance1).isNotEqualTo(instance2)
+
+ // Verify correct dependencies are used
+ assertThat(instance1.app).isEqualTo(defaultApp)
+ assertThat(instance1.auth).isEqualTo(mockFirebaseAuth)
+ assertThat(instance2.app).isEqualTo(defaultApp)
+ assertThat(instance2.auth).isEqualTo(mockFirebaseAuth)
+
+ // Verify cache is not used for create()
+ assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(0)
+ }
+
+ @Test
+ fun `create() allows custom auth injection for multi-tenancy`() {
+ // Create mock custom auth with tenant
+ val customAuth = mock(FirebaseAuth::class.java)
+ `when`(customAuth.tenantId).thenReturn("customer-tenant-123")
+
+ // Create instance with custom auth
+ val instance = FirebaseAuthUI.create(defaultApp, customAuth)
+
+ // Verify custom auth is used
+ assertThat(instance.auth).isEqualTo(customAuth)
+ assertThat(instance.auth.tenantId).isEqualTo("customer-tenant-123")
+ }
+
+ @Test
+ fun `create() with different auth instances returns different FirebaseAuthUI instances`() {
+ // Create two different mock auth instances
+ val auth1 = mock(FirebaseAuth::class.java)
+ val auth2 = mock(FirebaseAuth::class.java)
+
+ // Create instances with different auth
+ val instance1 = FirebaseAuthUI.create(defaultApp, auth1)
+ val instance2 = FirebaseAuthUI.create(defaultApp, auth2)
+
+ // Verify they are different instances
+ assertThat(instance1).isNotEqualTo(instance2)
+ assertThat(instance1.auth).isEqualTo(auth1)
+ assertThat(instance2.auth).isEqualTo(auth2)
+ }
+
+ // =============================================================================================
+ // Cache Isolation Tests
+ // =============================================================================================
+
+ @Test
+ fun `getInstance() and getInstance(app) use separate cache entries for default app`() {
+ // Get default instance via getInstance()
+ val defaultInstance1 = FirebaseAuthUI.getInstance()
+
+ // Get instance for default app via getInstance(app)
+ val defaultInstance2 = FirebaseAuthUI.getInstance(defaultApp)
+
+ // They should be different cached instances even though they're for the same app
+ // because getInstance() uses a special cache key "[DEFAULT]"
+ assertThat(defaultInstance1).isNotEqualTo(defaultInstance2)
+ assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(2)
+
+ // But they should use the same underlying FirebaseApp
+ assertThat(defaultInstance1.app).isEqualTo(defaultInstance2.app)
+ }
+
+ @Test
+ fun `cache is properly isolated between different apps`() {
+ // Create instances for different apps
+ val instance1 = FirebaseAuthUI.getInstance()
+ val instance2 = FirebaseAuthUI.getInstance(defaultApp)
+ val instance3 = FirebaseAuthUI.getInstance(secondaryApp)
+
+ // Verify all three instances are different
+ assertThat(instance1).isNotEqualTo(instance2)
+ assertThat(instance2).isNotEqualTo(instance3)
+ assertThat(instance1).isNotEqualTo(instance3)
+
+ // Verify cache size
+ assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(3)
+
+ // Clear cache
+ FirebaseAuthUI.clearInstanceCache()
+ assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(0)
+
+ // Create new instances - should be different objects than before
+ val newInstance1 = FirebaseAuthUI.getInstance()
+ val newInstance2 = FirebaseAuthUI.getInstance(defaultApp)
+
+ assertThat(newInstance1).isNotEqualTo(instance1)
+ assertThat(newInstance2).isNotEqualTo(instance2)
+ }
+
+ // =============================================================================================
+ // Thread Safety Tests
+ // =============================================================================================
+
+ @Test
+ fun `getInstance() is thread-safe`() {
+ val instances = mutableListOf()
+ val threads = List(10) {
+ Thread {
+ instances.add(FirebaseAuthUI.getInstance())
+ }
+ }
+
+ // Start all threads concurrently
+ threads.forEach { it.start() }
+
+ // Wait for all threads to complete
+ threads.forEach { it.join() }
+
+ // All instances should be the same (thread-safe singleton)
+ val firstInstance = instances.first()
+ instances.forEach { instance ->
+ assertThat(instance).isEqualTo(firstInstance)
+ }
+
+ // Only one instance should be cached
+ assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1)
+ }
+
+ @Test
+ fun `getInstance(app) is thread-safe`() {
+ val instances = mutableListOf()
+ val threads = List(10) {
+ Thread {
+ instances.add(FirebaseAuthUI.getInstance(secondaryApp))
+ }
+ }
+
+ // Start all threads concurrently
+ threads.forEach { it.start() }
+
+ // Wait for all threads to complete
+ threads.forEach { it.join() }
+
+ // All instances should be the same (thread-safe singleton)
+ val firstInstance = instances.first()
+ instances.forEach { instance ->
+ assertThat(instance).isEqualTo(firstInstance)
+ }
+
+ // Only one instance should be cached
+ assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1)
+ }
+}
\ No newline at end of file