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