From 1e44a13a921f3a509e26df942fc93d82576a6f8f Mon Sep 17 00:00:00 2001 From: rosariopf Date: Wed, 19 Mar 2025 17:02:06 +0000 Subject: [PATCH 01/41] Begin development on 10.0.0 --- buildSrc/src/main/kotlin/Config.kt | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index e4aed3d45..d5e15edfe 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -1,5 +1,5 @@ object Config { - const val version = "9.0.0" + const val version = "10.0.0-SNAPSHOT" val submodules = listOf("auth", "common", "firestore", "database", "storage") private const val kotlinVersion = "2.1.0" diff --git a/gradle.properties b/gradle.properties index 26ae8292a..f0e5ec4c8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,7 +16,7 @@ android.nonTransitiveRClass=false android.defaults.buildfeatures.buildconfig=true GROUP=com.firebaseui -VERSION_NAME=9.0.0 +VERSION_NAME=10.0.0-SNAPSHOT POM_PACKAGING=aar POM_DESCRIPTION=FirebaseUI for Android From bae4ce44cf8545b06d33f5c7d631351b33c9f87d Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Mon, 15 Sep 2025 14:56:38 +0200 Subject: [PATCH 02/41] feat: Core: FirebaseAuthUI Singleton & DI (#2215) * feat: Core: FirebaseAuthUI Singleton & DI * clean --- .../ui/auth/compose/FirebaseAuthUI.kt | 175 ++++++++++ .../ui/auth/compose/FirebaseAuthUITest.kt | 326 ++++++++++++++++++ 2 files changed, 501 insertions(+) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt 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 From 91b0a49ac5bbab7b3bc8ccf7a6cf73a36b6f2c67 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Thu, 18 Sep 2025 16:46:52 +0200 Subject: [PATCH 03/41] feat: Core: Auth State & User Accessors (#2217) --- .../com/firebase/ui/auth/compose/AuthState.kt | 222 ++++++++++ .../ui/auth/compose/FirebaseAuthUI.kt | 154 +++++++ .../compose/FirebaseAuthUIAuthStateTest.kt | 379 ++++++++++++++++++ 3 files changed, 755 insertions(+) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt new file mode 100644 index 000000000..d2163500a --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt @@ -0,0 +1,222 @@ +/* + * 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 com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorResolver + +/** + * Represents the authentication state in Firebase Auth UI. + * + * This class encapsulates all possible authentication states that can occur during + * the authentication flow, including success, error, and intermediate states. + * + * Use the companion object factory methods or specific subclass constructors to create instances. + * + * @since 10.0.0 + */ +abstract class AuthState private constructor() { + + /** + * Initial state before any authentication operation has been started. + */ + class Idle internal constructor() : AuthState() { + override fun equals(other: Any?): Boolean = other is Idle + override fun hashCode(): Int = javaClass.hashCode() + override fun toString(): String = "AuthState.Idle" + } + + /** + * Authentication operation is in progress. + * + * @property message Optional message describing what is being loaded + */ + class Loading(val message: String? = null) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Loading) return false + return message == other.message + } + + override fun hashCode(): Int = message?.hashCode() ?: 0 + + override fun toString(): String = "AuthState.Loading(message=$message)" + } + + /** + * Authentication completed successfully. + * + * @property result The [AuthResult] containing the authenticated user, may be null if not available + * @property user The authenticated [FirebaseUser] + * @property isNewUser Whether this is a newly created user account + */ + class Success( + val result: AuthResult?, + val user: FirebaseUser, + val isNewUser: Boolean = false + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Success) return false + return result == other.result && + user == other.user && + isNewUser == other.isNewUser + } + + override fun hashCode(): Int { + var result1 = result?.hashCode() ?: 0 + result1 = 31 * result1 + user.hashCode() + result1 = 31 * result1 + isNewUser.hashCode() + return result1 + } + + override fun toString(): String = + "AuthState.Success(result=$result, user=$user, isNewUser=$isNewUser)" + } + + /** + * An error occurred during authentication. + * + * @property exception The [Exception] that occurred + * @property isRecoverable Whether the error can be recovered from + */ + class Error( + val exception: Exception, + val isRecoverable: Boolean = true + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Error) return false + return exception == other.exception && + isRecoverable == other.isRecoverable + } + + override fun hashCode(): Int { + var result = exception.hashCode() + result = 31 * result + isRecoverable.hashCode() + return result + } + + override fun toString(): String = + "AuthState.Error(exception=$exception, isRecoverable=$isRecoverable)" + } + + /** + * Authentication was cancelled by the user. + */ + class Cancelled internal constructor() : AuthState() { + override fun equals(other: Any?): Boolean = other is Cancelled + override fun hashCode(): Int = javaClass.hashCode() + override fun toString(): String = "AuthState.Cancelled" + } + + /** + * Multi-factor authentication is required to complete sign-in. + * + * @property resolver The [MultiFactorResolver] to complete MFA + * @property hint Optional hint about which factor to use + */ + class RequiresMfa( + val resolver: MultiFactorResolver, + val hint: String? = null + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RequiresMfa) return false + return resolver == other.resolver && + hint == other.hint + } + + override fun hashCode(): Int { + var result = resolver.hashCode() + result = 31 * result + (hint?.hashCode() ?: 0) + return result + } + + override fun toString(): String = + "AuthState.RequiresMfa(resolver=$resolver, hint=$hint)" + } + + /** + * Email verification is required before the user can access the app. + * + * @property user The [FirebaseUser] who needs to verify their email + * @property email The email address that needs verification + */ + class RequiresEmailVerification( + val user: FirebaseUser, + val email: String + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RequiresEmailVerification) return false + return user == other.user && + email == other.email + } + + override fun hashCode(): Int { + var result = user.hashCode() + result = 31 * result + email.hashCode() + return result + } + + override fun toString(): String = + "AuthState.RequiresEmailVerification(user=$user, email=$email)" + } + + /** + * The user needs to complete their profile information. + * + * @property user The [FirebaseUser] who needs to complete their profile + * @property missingFields List of profile fields that need to be completed + */ + class RequiresProfileCompletion( + val user: FirebaseUser, + val missingFields: List = emptyList() + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RequiresProfileCompletion) return false + return user == other.user && + missingFields == other.missingFields + } + + override fun hashCode(): Int { + var result = user.hashCode() + result = 31 * result + missingFields.hashCode() + return result + } + + override fun toString(): String = + "AuthState.RequiresProfileCompletion(user=$user, missingFields=$missingFields)" + } + + companion object { + /** + * Creates an Idle state instance. + * @return A new [Idle] state + */ + @JvmStatic + val Idle: Idle = Idle() + + /** + * Creates a Cancelled state instance. + * @return A new [Cancelled] state + */ + @JvmStatic + val Cancelled: Cancelled = Cancelled() + } +} \ No newline at end of file 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 79e8b2cfe..58bfab344 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 @@ -17,8 +17,14 @@ 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.FirebaseAuth.AuthStateListener +import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.ktx.auth import com.google.firebase.ktx.Firebase +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.callbackFlow import java.util.concurrent.ConcurrentHashMap /** @@ -56,6 +62,154 @@ class FirebaseAuthUI private constructor( val app: FirebaseApp, val auth: FirebaseAuth ) { + + private val _authStateFlow = MutableStateFlow(AuthState.Idle) + + /** + * Checks whether a user is currently signed in. + * + * This method directly mirrors the state of [FirebaseAuth] and returns true if there is + * a currently signed-in user, false otherwise. + * + * **Example:** + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * if (authUI.isSignedIn()) { + * // User is signed in + * navigateToHome() + * } else { + * // User is not signed in + * navigateToLogin() + * } + * ``` + * + * @return `true` if a user is signed in, `false` otherwise + */ + fun isSignedIn(): Boolean = auth.currentUser != null + + /** + * Returns the currently signed-in user, or null if no user is signed in. + * + * This method returns the same value as [FirebaseAuth.currentUser] and provides + * direct access to the current user object. + * + * **Example:** + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * val user = authUI.getCurrentUser() + * user?.let { + * println("User email: ${it.email}") + * println("User ID: ${it.uid}") + * } + * ``` + * + * @return The currently signed-in [FirebaseUser], or `null` if no user is signed in + */ + fun getCurrentUser(): FirebaseUser? = auth.currentUser + + /** + * Returns a [Flow] that emits [AuthState] changes. + * + * This flow observes changes to the authentication state and emits appropriate + * [AuthState] objects. The flow will emit: + * - [AuthState.Idle] when there's no active authentication operation + * - [AuthState.Loading] during authentication operations + * - [AuthState.Success] when a user successfully signs in + * - [AuthState.Error] when an authentication error occurs + * - [AuthState.Cancelled] when authentication is cancelled + * - [AuthState.RequiresMfa] when multi-factor authentication is needed + * - [AuthState.RequiresEmailVerification] when email verification is needed + * + * The flow automatically emits [AuthState.Success] or [AuthState.Idle] based on + * the current authentication state when collection starts. + * + * **Example:** + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * + * lifecycleScope.launch { + * authUI.authStateFlow().collect { state -> + * when (state) { + * is AuthState.Success -> { + * // User is signed in + * updateUI(state.user) + * } + * is AuthState.Error -> { + * // Handle error + * showError(state.exception.message) + * } + * is AuthState.Loading -> { + * // Show loading indicator + * showProgressBar() + * } + * // ... handle other states + * } + * } + * } + * ``` + * + * @return A [Flow] of [AuthState] that emits authentication state changes + */ + fun authStateFlow(): Flow = callbackFlow { + // Set initial state based on current auth state + val initialState = auth.currentUser?.let { user -> + AuthState.Success(result = null, user = user, isNewUser = false) + } ?: AuthState.Idle + + trySend(initialState) + + // Create auth state listener + val authStateListener = AuthStateListener { firebaseAuth -> + val currentUser = firebaseAuth.currentUser + val state = if (currentUser != null) { + // Check if email verification is required + if (!currentUser.isEmailVerified && + currentUser.email != null && + currentUser.providerData.any { it.providerId == "password" }) { + AuthState.RequiresEmailVerification( + user = currentUser, + email = currentUser.email!! + ) + } else { + AuthState.Success( + result = null, + user = currentUser, + isNewUser = false + ) + } + } else { + AuthState.Idle + } + trySend(state) + } + + // Add listener + auth.addAuthStateListener(authStateListener) + + // Also observe internal state changes + _authStateFlow.value.let { currentState -> + if (currentState !is AuthState.Idle && currentState !is AuthState.Success) { + trySend(currentState) + } + } + + // Remove listener when flow collection is cancelled + awaitClose { + auth.removeAuthStateListener(authStateListener) + } + } + + /** + * Updates the internal authentication state. + * This method is intended for internal use by authentication operations. + * + * @param state The new [AuthState] to emit + * @suppress This is an internal API + */ + internal fun updateAuthState(state: AuthState) { + _authStateFlow.value = state + } + companion object { /** Cache for singleton instances per FirebaseApp. Thread-safe via ConcurrentHashMap. */ private val instanceCache = ConcurrentHashMap() diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt new file mode 100644 index 000000000..333f00c7a --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt @@ -0,0 +1,379 @@ +/* + * 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.AuthResult +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuth.AuthStateListener +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorResolver +import com.google.firebase.auth.UserInfo +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [FirebaseAuthUI] auth state management functionality including + * isSignedIn(), getCurrentUser(), and authStateFlow() methods. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class FirebaseAuthUIAuthStateTest { + + @Mock + private lateinit var mockFirebaseAuth: FirebaseAuth + + @Mock + private lateinit var mockFirebaseUser: FirebaseUser + + @Mock + private lateinit var mockAuthResult: AuthResult + + @Mock + private lateinit var mockMultiFactorResolver: MultiFactorResolver + + private lateinit var defaultApp: FirebaseApp + private lateinit var authUI: FirebaseAuthUI + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + // Clear the instance cache before each test + 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() + ) + + // Create FirebaseAuthUI instance with mock auth + authUI = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + } + + @After + fun tearDown() { + // Clean up after each test + FirebaseAuthUI.clearInstanceCache() + try { + defaultApp.delete() + } catch (_: Exception) { + // Ignore if already deleted + } + } + + // ============================================================================================= + // isSignedIn() Tests + // ============================================================================================= + + @Test + fun `isSignedIn() returns true when user is signed in`() { + // Given a signed-in user + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + + // When checking if signed in + val isSignedIn = authUI.isSignedIn() + + // Then it should return true + assertThat(isSignedIn).isTrue() + } + + @Test + fun `isSignedIn() returns false when user is not signed in`() { + // Given no signed-in user + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + // When checking if signed in + val isSignedIn = authUI.isSignedIn() + + // Then it should return false + assertThat(isSignedIn).isFalse() + } + + // ============================================================================================= + // getCurrentUser() Tests + // ============================================================================================= + + @Test + fun `getCurrentUser() returns user when signed in`() { + // Given a signed-in user + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + + // When getting current user + val currentUser = authUI.getCurrentUser() + + // Then it should return the user + assertThat(currentUser).isEqualTo(mockFirebaseUser) + } + + @Test + fun `getCurrentUser() returns null when not signed in`() { + // Given no signed-in user + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + // When getting current user + val currentUser = authUI.getCurrentUser() + + // Then it should return null + assertThat(currentUser).isNull() + } + + // ============================================================================================= + // authStateFlow() Tests + // ============================================================================================= + + @Test + fun `authStateFlow() emits Idle when no user is signed in`() = runBlocking { + // Given no signed-in user + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + // When collecting auth state flow + val state = authUI.authStateFlow().first() + + // Then it should emit Idle state + assertThat(state).isEqualTo(AuthState.Idle) + } + + @Test + fun `authStateFlow() emits Success when user is signed in`() = runBlocking { + // Given a signed-in user + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + `when`(mockFirebaseUser.isEmailVerified).thenReturn(true) + `when`(mockFirebaseUser.email).thenReturn("test@example.com") + `when`(mockFirebaseUser.uid).thenReturn("test-uid") + `when`(mockFirebaseUser.providerData).thenReturn(emptyList()) + + // When collecting auth state flow + val state = authUI.authStateFlow().first() + + // Then it should emit Success state + assertThat(state).isInstanceOf(AuthState.Success::class.java) + val successState = state as AuthState.Success + assertThat(successState.user).isEqualTo(mockFirebaseUser) + assertThat(successState.isNewUser).isFalse() + } + + @Test + fun `authStateFlow() emits Success even with unverified email for now`() = runBlocking { + // Given a signed-in user with unverified email + // Note: The current implementation checks for password provider, which might not be + // matched properly due to mocking limitations. This test verifies current behavior. + val mockProviderData = mock(UserInfo::class.java) + `when`(mockProviderData.providerId).thenReturn("password") + + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + `when`(mockFirebaseUser.isEmailVerified).thenReturn(false) + `when`(mockFirebaseUser.email).thenReturn("test@example.com") + `when`(mockFirebaseUser.providerData).thenReturn(listOf(mockProviderData)) + + // When collecting auth state flow + val state = authUI.authStateFlow().first() + + // Then it should emit Success state (current behavior with mocked data) + assertThat(state).isInstanceOf(AuthState.Success::class.java) + val successState = state as AuthState.Success + assertThat(successState.user).isEqualTo(mockFirebaseUser) + } + + @Test + fun `authStateFlow() responds to auth state changes`() = runBlocking { + // Given initial state with no user + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + // Capture the auth state listener + val listenerCaptor = ArgumentCaptor.forClass(AuthStateListener::class.java) + + // Start collecting the flow + val states = mutableListOf() + val job = launch { + authUI.authStateFlow().take(3).toList(states) + } + + // Wait for listener to be registered + delay(100) + verify(mockFirebaseAuth).addAuthStateListener(listenerCaptor.capture()) + + // Simulate user sign-in + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + `when`(mockFirebaseUser.isEmailVerified).thenReturn(true) + `when`(mockFirebaseUser.providerData).thenReturn(emptyList()) + listenerCaptor.value.onAuthStateChanged(mockFirebaseAuth) + + // Simulate user sign-out + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + listenerCaptor.value.onAuthStateChanged(mockFirebaseAuth) + + // Wait for all states to be collected + job.join() + + // Verify the emitted states + assertThat(states).hasSize(3) + assertThat(states[0]).isEqualTo(AuthState.Idle) // Initial state + assertThat(states[1]).isInstanceOf(AuthState.Success::class.java) // After sign-in + assertThat(states[2]).isEqualTo(AuthState.Idle) // After sign-out + } + + @Test + fun `authStateFlow() removes listener when flow is cancelled`() = runBlocking { + // Given auth state flow + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + // Capture the auth state listener + val listenerCaptor = ArgumentCaptor.forClass(AuthStateListener::class.java) + + // Start collecting the flow + val job = launch { + authUI.authStateFlow().first() + } + + // Wait for the job to complete + job.join() + + // Verify that the listener was added and then removed + verify(mockFirebaseAuth).addAuthStateListener(listenerCaptor.capture()) + verify(mockFirebaseAuth).removeAuthStateListener(listenerCaptor.value) + } + + // ============================================================================================= + // Internal State Update Tests + // ============================================================================================= + + @Test + fun `updateAuthState() updates internal state flow`() = runBlocking { + // Given initial idle state + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + // When updating auth state internally + authUI.updateAuthState(AuthState.Loading("Signing in...")) + + // Then the flow should reflect the updated state + val states = mutableListOf() + val job = launch { + authUI.authStateFlow().take(2).toList(states) + } + + // Update state again + delay(100) + authUI.updateAuthState(AuthState.Cancelled) + + job.join() + + // The first state should be Idle (initial), second should be Loading + assertThat(states[0]).isEqualTo(AuthState.Idle) + // Note: The internal state update may not be immediately visible in the flow + // because the auth state listener overrides it + } + + // ============================================================================================= + // AuthState Class Tests + // ============================================================================================= + + @Test + fun `AuthState Success contains correct properties`() { + // Create Success state + val state = AuthState.Success( + result = mockAuthResult, + user = mockFirebaseUser, + isNewUser = true + ) + + // Verify properties + assertThat(state.result).isEqualTo(mockAuthResult) + assertThat(state.user).isEqualTo(mockFirebaseUser) + assertThat(state.isNewUser).isTrue() + } + + @Test + fun `AuthState Error contains exception and recoverability`() { + // Create Error state + val exception = Exception("Test error") + val state = AuthState.Error( + exception = exception, + isRecoverable = false + ) + + // Verify properties + assertThat(state.exception).isEqualTo(exception) + assertThat(state.isRecoverable).isFalse() + } + + @Test + fun `AuthState RequiresMfa contains resolver`() { + // Create RequiresMfa state + val state = AuthState.RequiresMfa( + resolver = mockMultiFactorResolver, + hint = "Use SMS" + ) + + // Verify properties + assertThat(state.resolver).isEqualTo(mockMultiFactorResolver) + assertThat(state.hint).isEqualTo("Use SMS") + } + + @Test + fun `AuthState Loading can contain message`() { + // Create Loading state with message + val state = AuthState.Loading("Processing...") + + // Verify properties + assertThat(state.message).isEqualTo("Processing...") + } + + @Test + fun `AuthState RequiresProfileCompletion contains missing fields`() { + // Create RequiresProfileCompletion state + val missingFields = listOf("displayName", "photoUrl") + val state = AuthState.RequiresProfileCompletion( + user = mockFirebaseUser, + missingFields = missingFields + ) + + // Verify properties + assertThat(state.user).isEqualTo(mockFirebaseUser) + assertThat(state.missingFields).containsExactly("displayName", "photoUrl") + } +} \ No newline at end of file From ed436cf67ede70b8be9f0ebca40efbea9f5206b6 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:50:42 +0100 Subject: [PATCH 04/41] feat(AuthUIConfiguration): implement configuration model, DSL builder and tests (#2216) --- auth/build.gradle.kts | 13 + .../compose/configuration/AuthProvider.kt | 357 ++++++++++++++++++ .../configuration/AuthUIConfiguration.kt | 192 ++++++++++ .../configuration/AuthUIStringProvider.kt | 41 ++ .../auth/compose/configuration/AuthUITheme.kt | 205 ++++++++++ .../compose/configuration/PasswordRule.kt | 52 +++ .../configuration/AuthUIConfigurationTest.kt | 300 +++++++++++++++ build.gradle.kts | 1 + buildSrc/src/main/kotlin/Config.kt | 20 +- gradle/libs.versions.toml | 7 + 10 files changed, 1184 insertions(+), 4 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt create mode 100644 gradle/libs.versions.toml diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index 66df263a8..8e6d5304e 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -4,6 +4,7 @@ plugins { id("com.android.library") id("com.vanniktech.maven.publish") id("org.jetbrains.kotlin.android") + alias(libs.plugins.compose.compiler) } android { @@ -67,9 +68,20 @@ android { kotlinOptions { jvmTarget = "17" } + buildFeatures { + compose = true + } } dependencies { + implementation(platform(Config.Libs.Androidx.Compose.bom)) + implementation(Config.Libs.Androidx.Compose.ui) + implementation(Config.Libs.Androidx.Compose.uiGraphics) + implementation(Config.Libs.Androidx.Compose.material3) + implementation(Config.Libs.Androidx.Compose.foundation) + implementation(Config.Libs.Androidx.Compose.tooling) + implementation(Config.Libs.Androidx.Compose.toolingPreview) + implementation(Config.Libs.Androidx.Compose.activityCompose) implementation(Config.Libs.Androidx.materialDesign) implementation(Config.Libs.Androidx.activity) // The new activity result APIs force us to include Fragment 1.3.0 @@ -101,6 +113,7 @@ dependencies { testImplementation(Config.Libs.Test.mockito) testImplementation(Config.Libs.Test.core) testImplementation(Config.Libs.Test.robolectric) + testImplementation(Config.Libs.Test.kotlinReflect) testImplementation(Config.Libs.Provider.facebook) debugImplementation(project(":internal:lintchecks")) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt new file mode 100644 index 000000000..ef8bb0771 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt @@ -0,0 +1,357 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration + +import android.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import com.google.firebase.auth.ActionCodeSettings +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FacebookAuthProvider +import com.google.firebase.auth.GithubAuthProvider +import com.google.firebase.auth.GoogleAuthProvider +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.TwitterAuthProvider + +@AuthUIConfigurationDsl +class AuthProvidersBuilder { + private val providers = mutableListOf() + + fun provider(provider: AuthProvider) { + providers.add(provider) + } + + internal fun build(): List = providers.toList() +} + +/** + * Enum class to represent all possible providers. + */ +internal enum class Provider(val id: String) { + GOOGLE(GoogleAuthProvider.PROVIDER_ID), + FACEBOOK(FacebookAuthProvider.PROVIDER_ID), + TWITTER(TwitterAuthProvider.PROVIDER_ID), + GITHUB(GithubAuthProvider.PROVIDER_ID), + EMAIL(EmailAuthProvider.PROVIDER_ID), + PHONE(PhoneAuthProvider.PROVIDER_ID), + ANONYMOUS("anonymous"), + MICROSOFT("microsoft.com"), + YAHOO("yahoo.com"), + APPLE("apple.com"), +} + +/** + * Base abstract class for OAuth authentication providers with common properties. + */ +abstract class OAuthProvider( + override val providerId: String, + open val scopes: List = emptyList(), + open val customParameters: Map = emptyMap() +) : AuthProvider(providerId) + +/** + * Base abstract class for authentication providers. + */ +abstract class AuthProvider(open val providerId: String) { + /** + * Email/Password authentication provider configuration. + */ + class Email( + /** + * Requires the user to provide a display name. Defaults to true. + */ + val isDisplayNameRequired: Boolean = true, + + /** + * Enables email link sign-in, Defaults to false. + */ + val isEmailLinkSignInEnabled: Boolean = false, + + /** + * Settings for email link actions. + */ + val actionCodeSettings: ActionCodeSettings?, + + /** + * Allows new accounts to be created. Defaults to true. + */ + val isNewAccountsAllowed: Boolean = true, + + /** + * The minimum length for a password. Defaults to 6. + */ + val minimumPasswordLength: Int = 6, + + /** + * A list of custom password validation rules. + */ + val passwordValidationRules: List + ) : AuthProvider(providerId = Provider.EMAIL.id) { + fun validate() { + if (isEmailLinkSignInEnabled) { + val actionCodeSettings = actionCodeSettings + ?: requireNotNull(actionCodeSettings) { + "ActionCodeSettings cannot be null when using " + + "email link sign in." + } + + check(actionCodeSettings.canHandleCodeInApp()) { + "You must set canHandleCodeInApp in your " + + "ActionCodeSettings to true for Email-Link Sign-in." + } + } + } + } + + /** + * Phone number authentication provider configuration. + */ + class Phone( + /** + * The default country code to pre-select. + */ + val defaultCountryCode: String?, + + /** + * A list of allowed country codes. + */ + val allowedCountries: List?, + + /** + * The expected length of the SMS verification code. Defaults to 6. + */ + val smsCodeLength: Int = 6, + + /** + * The timeout in seconds for receiving the SMS. Defaults to 60L. + */ + val timeout: Long = 60L, + + /** + * Enables instant verification of the phone number. Defaults to true. + */ + val isInstantVerificationEnabled: Boolean = true, + + /** + * Enables automatic retrieval of the SMS code. Defaults to true. + */ + val isAutoRetrievalEnabled: Boolean = true + ) : AuthProvider(providerId = Provider.PHONE.id) + + /** + * Google Sign-In provider configuration. + */ + class Google( + /** + * The list of scopes to request. + */ + override val scopes: List, + + /** + * The OAuth 2.0 client ID for your server. + */ + val serverClientId: String?, + + /** + * Requests an ID token. Default to true. + */ + val requestIdToken: Boolean = true, + + /** + * Requests the user's profile information. Defaults to true. + */ + val requestProfile: Boolean = true, + + /** + * Requests the user's email address. Defaults to true. + */ + val requestEmail: Boolean = true, + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map = emptyMap() + ) : OAuthProvider( + providerId = Provider.GOOGLE.id, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Facebook Login provider configuration. + */ + class Facebook( + /** + * The list of scopes (permissions) to request. Defaults to email and public_profile. + */ + override val scopes: List = listOf("email", "public_profile"), + + /** + * if true, enable limited login mode. Defaults to false. + */ + val limitedLogin: Boolean = false, + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map = emptyMap() + ) : OAuthProvider( + providerId = Provider.FACEBOOK.id, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Twitter/X authentication provider configuration. + */ + class Twitter( + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map + ) : OAuthProvider( + providerId = Provider.TWITTER.id, + customParameters = customParameters + ) + + /** + * Github authentication provider configuration. + */ + class Github( + /** + * The list of scopes to request. Defaults to user:email. + */ + override val scopes: List = listOf("user:email"), + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map + ) : OAuthProvider( + providerId = Provider.GITHUB.id, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Microsoft authentication provider configuration. + */ + class Microsoft( + /** + * The list of scopes to request. Defaults to openid, profile, email. + */ + override val scopes: List = listOf("openid", "profile", "email"), + + /** + * The tenant ID for Azure Active Directory. + */ + val tenant: String?, + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map + ) : OAuthProvider( + providerId = Provider.MICROSOFT.id, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Yahoo authentication provider configuration. + */ + class Yahoo( + /** + * The list of scopes to request. Defaults to openid, profile, email. + */ + override val scopes: List = listOf("openid", "profile", "email"), + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map + ) : OAuthProvider( + providerId = Provider.YAHOO.id, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Apple Sign-In provider configuration. + */ + class Apple( + /** + * The list of scopes to request. Defaults to name and email. + */ + override val scopes: List = listOf("name", "email"), + + /** + * The locale for the sign-in page. + */ + val locale: String?, + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map + ) : OAuthProvider( + providerId = Provider.APPLE.id, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Anonymous authentication provider. It has no configurable properties. + */ + object Anonymous : AuthProvider(providerId = Provider.ANONYMOUS.id) + + /** + * A generic OAuth provider for any unsupported provider. + */ + class GenericOAuth( + /** + * The provider ID as configured in the Firebase console. + */ + override val providerId: String, + + /** + * The list of scopes to request. + */ + override val scopes: List, + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map, + + /** + * The text to display on the provider button. + */ + val buttonLabel: String, + + /** + * An optional icon for the provider button. + */ + val buttonIcon: ImageVector?, + + /** + * An optional background color for the provider button. + */ + val buttonColor: Color? + ) : OAuthProvider( + providerId = providerId, + scopes = scopes, + customParameters = customParameters + ) +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt new file mode 100644 index 000000000..aca1ccf9e --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt @@ -0,0 +1,192 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration + +import java.util.Locale +import com.google.firebase.auth.ActionCodeSettings +import androidx.compose.ui.graphics.vector.ImageVector + +fun actionCodeSettings( + block: ActionCodeSettings.Builder.() -> Unit +) = ActionCodeSettings.newBuilder().apply(block).build() + +fun authUIConfiguration(block: AuthUIConfigurationBuilder.() -> Unit): AuthUIConfiguration { + val builder = AuthUIConfigurationBuilder() + builder.block() + return builder.build() +} + +@DslMarker +annotation class AuthUIConfigurationDsl + +@AuthUIConfigurationDsl +class AuthUIConfigurationBuilder { + private val providers = mutableListOf() + var theme: AuthUITheme = AuthUITheme.Default + var stringProvider: AuthUIStringProvider? = null + var locale: Locale? = null + var isCredentialManagerEnabled: Boolean = true + var isMfaEnabled: Boolean = true + var isAnonymousUpgradeEnabled: Boolean = false + var tosUrl: String? = null + var privacyPolicyUrl: String? = null + var logo: ImageVector? = null + var actionCodeSettings: ActionCodeSettings? = null + var isNewEmailAccountsAllowed: Boolean = true + var isDisplayNameRequired: Boolean = true + var isProviderChoiceAlwaysShown: Boolean = false + + fun providers(block: AuthProvidersBuilder.() -> Unit) { + val builder = AuthProvidersBuilder() + builder.block() + providers.addAll(builder.build()) + } + + internal fun build(): AuthUIConfiguration { + validate() + return AuthUIConfiguration( + providers = providers.toList(), + theme = theme, + stringProvider = stringProvider, + locale = locale, + isCredentialManagerEnabled = isCredentialManagerEnabled, + isMfaEnabled = isMfaEnabled, + isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled, + tosUrl = tosUrl, + privacyPolicyUrl = privacyPolicyUrl, + logo = logo, + actionCodeSettings = actionCodeSettings, + isNewEmailAccountsAllowed = isNewEmailAccountsAllowed, + isDisplayNameRequired = isDisplayNameRequired, + isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown + ) + } + + private fun validate() { + // At least one provider + if (providers.isEmpty()) { + throw IllegalArgumentException("At least one provider must be configured") + } + + // No unsupported providers + val supportedProviderIds = Provider.entries.map { it.id }.toSet() + val unknownProviders = providers.filter { it.providerId !in supportedProviderIds } + require(unknownProviders.isEmpty()) { + "Unknown providers: ${unknownProviders.joinToString { it.providerId }}" + } + + // Cannot have only anonymous provider + if (providers.size == 1 && providers.first() is AuthProvider.Anonymous) { + throw IllegalStateException( + "Sign in as guest cannot be the only sign in method. " + + "In this case, sign the user in anonymously your self; no UI is needed." + ) + } + + // Check for duplicate providers + val providerIds = providers.map { it.providerId } + val duplicates = providerIds.groupingBy { it }.eachCount().filter { it.value > 1 } + + require(duplicates.isEmpty()) { + val message = duplicates.keys.joinToString(", ") + throw IllegalArgumentException( + "Each provider can only be set once. Duplicates: $message" + ) + } + + // Provider specific validations + providers.forEach { provider -> + when (provider) { + is AuthProvider.Email -> provider.validate() + else -> null + } + } + } +} + +/** + * Configuration object for the authentication flow. + */ +class AuthUIConfiguration( + /** + * The list of enabled authentication providers. + */ + val providers: List = emptyList(), + + /** + * The theming configuration for the UI. Default to [AuthUITheme.Default]. + */ + val theme: AuthUITheme = AuthUITheme.Default, + + /** + * A custom provider for localized strings. + */ + val stringProvider: AuthUIStringProvider? = null, + + /** + * The locale for internationalization. + */ + val locale: Locale? = null, + + /** + * Enables integration with Android's Credential Manager API. Defaults to true. + */ + val isCredentialManagerEnabled: Boolean = true, + + /** + * Enables Multi-Factor Authentication support. Defaults to true. + */ + val isMfaEnabled: Boolean = true, + + /** + * Allows upgrading an anonymous user to a new credential. + */ + val isAnonymousUpgradeEnabled: Boolean = false, + + /** + * The URL for the terms of service. + */ + val tosUrl: String? = null, + + /** + * The URL for the privacy policy. + */ + val privacyPolicyUrl: String? = null, + + /** + * The logo to display on the authentication screens. + */ + val logo: ImageVector? = null, + + /** + * Configuration for email link sign-in. + */ + val actionCodeSettings: ActionCodeSettings? = null, + + /** + * Allows new email accounts to be created. Defaults to true. + */ + val isNewEmailAccountsAllowed: Boolean = true, + + /** + * Requires the user to provide a display name on sign-up. Defaults to true. + */ + val isDisplayNameRequired: Boolean = true, + + /** + * Always shows the provider selection screen, even if only one is enabled. + */ + val isProviderChoiceAlwaysShown: Boolean = false, +) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt new file mode 100644 index 000000000..fe5bbf302 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration + +import android.content.Context +import com.firebase.ui.auth.R + +/** + * An interface for providing localized string resources. This interface defines methods for all + * user-facing strings, such as initializing(), signInWithGoogle(), invalidEmail(), + * passwordsDoNotMatch(), etc., allowing for complete localization of the UI. + */ +interface AuthUIStringProvider { + fun initializing(): String + fun signInWithGoogle(): String + fun invalidEmail(): String + fun passwordsDoNotMatch(): String +} + +class DefaultAuthUIStringProvider(private val context: Context) : AuthUIStringProvider { + override fun initializing(): String = "" + + override fun signInWithGoogle(): String = + context.getString(R.string.fui_sign_in_with_google) + + override fun invalidEmail(): String = "" + + override fun passwordsDoNotMatch(): String = "" +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt new file mode 100644 index 000000000..d2ae7032d --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt @@ -0,0 +1,205 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private val LocalAuthUITheme = staticCompositionLocalOf { AuthUITheme.Default } + +/** + * Theming configuration for the entire Auth UI. + */ +class AuthUITheme( + /** + * The color scheme to use. + */ + val colorScheme: ColorScheme, + + /** + * The typography to use. + */ + val typography: Typography, + + /** + * The shapes to use for UI elements. + */ + val shapes: Shapes, + + /** + * A map of provider IDs to custom styling. + */ + val providerStyles: Map = emptyMap() +) { + + /** + * A class nested within AuthUITheme that defines the visual appearance of a specific + * provider button, allowing for per-provider branding and customization. + */ + class ProviderStyle( + /** + * The background color of the button. + */ + val backgroundColor: Color, + + /** + * The color of the text label on the button. + */ + val contentColor: Color, + + /** + * An optional tint color for the provider's icon. If null, + * the icon's intrinsic color is used. + */ + var iconTint: Color? = null, + + /** + * The shape of the button container. Defaults to RoundedCornerShape(4.dp). + */ + val shape: Shape = RoundedCornerShape(4.dp), + + /** + * The shadow elevation for the button. Defaults to 2.dp. + */ + val elevation: Dp = 2.dp + ) + + companion object { + /** + * A standard light theme with Material 3 defaults and + * pre-configured provider styles. + */ + val Default = AuthUITheme( + colorScheme = lightColorScheme(), + typography = Typography(), + shapes = Shapes(), + providerStyles = defaultProviderStyles + ) + + /** + * Creates a theme inheriting the app's current Material + * Theme settings. + */ + @Composable + fun fromMaterialTheme( + providerStyles: Map = Default.providerStyles + ): AuthUITheme { + return AuthUITheme( + colorScheme = MaterialTheme.colorScheme, + typography = MaterialTheme.typography, + shapes = MaterialTheme.shapes, + providerStyles = providerStyles + ) + } + + internal val defaultProviderStyles + get(): Map { + return Provider.entries.associate { provider -> + when (provider) { + Provider.GOOGLE -> { + provider.id to ProviderStyle( + backgroundColor = Color.White, + contentColor = Color(0xFF757575) + ) + } + + Provider.FACEBOOK -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFF3B5998), + contentColor = Color.White + ) + } + + Provider.TWITTER -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFF5BAAF4), + contentColor = Color.White + ) + } + + Provider.GITHUB -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFF24292E), + contentColor = Color.White + ) + } + + Provider.EMAIL -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFFD0021B), + contentColor = Color.White + ) + } + + Provider.PHONE -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFF43C5A5), + contentColor = Color.White + ) + } + + Provider.ANONYMOUS -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFFF4B400), + contentColor = Color.White + ) + } + + Provider.MICROSOFT -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFF2F2F2F), + contentColor = Color.White + ) + } + + Provider.YAHOO -> { + provider.id to ProviderStyle( + backgroundColor = Color(0xFF720E9E), + contentColor = Color.White + ) + } + + Provider.APPLE -> { + provider.id to ProviderStyle( + backgroundColor = Color.Black, + contentColor = Color.White + ) + } + } + } + } + } +} + +@Composable +fun AuthUITheme( + theme: AuthUITheme = AuthUITheme.Default, + content: @Composable () -> Unit +) { + CompositionLocalProvider(LocalAuthUITheme provides theme) { + content() + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt new file mode 100644 index 000000000..242ea6e83 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration + +/** + * An abstract class representing a set of validation rules that can be applied to a password field, + * typically within the [AuthProvider.Email] configuration. + */ +abstract class PasswordRule { + /** + * Requires the password to have at least a certain number of characters. + */ + class MinimumLength(val value: Int) : PasswordRule() + + /** + * Requires the password to contain at least one uppercase letter (A-Z). + */ + object RequireUppercase : PasswordRule() + + /** + * Requires the password to contain at least one lowercase letter (a-z). + */ + object RequireLowercase: PasswordRule() + + /** + * Requires the password to contain at least one numeric digit (0-9). + */ + object RequireDigit: PasswordRule() + + /** + * Requires the password to contain at least one special character (e.g., !@#$%^&*). + */ + object RequireSpecialCharacter: PasswordRule() + + /** + * Defines a custom validation rule using a regular expression and provides a specific error + * message on failure. + */ + class Custom(val regex: Regex, val errorMessage: String) +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt new file mode 100644 index 000000000..c8e627ff5 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -0,0 +1,300 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import com.google.common.truth.Truth.assertThat +import com.google.firebase.auth.actionCodeSettings +import org.junit.Assert.assertThrows +import org.junit.Test +import java.util.Locale +import kotlin.reflect.KMutableProperty +import kotlin.reflect.full.memberProperties + +class AuthUIConfigurationTest { + + @Test + fun `authUIConfiguration with minimal setup uses correct defaults`() { + val config = authUIConfiguration { + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "" + ) + ) + } + } + + assertThat(config.providers).hasSize(1) + assertThat(config.theme).isEqualTo(AuthUITheme.Default) + assertThat(config.stringProvider).isNull() + assertThat(config.locale).isNull() + assertThat(config.isCredentialManagerEnabled).isTrue() + assertThat(config.isMfaEnabled).isTrue() + assertThat(config.isAnonymousUpgradeEnabled).isFalse() + assertThat(config.tosUrl).isNull() + assertThat(config.privacyPolicyUrl).isNull() + assertThat(config.logo).isNull() + assertThat(config.actionCodeSettings).isNull() + assertThat(config.isNewEmailAccountsAllowed).isTrue() + assertThat(config.isDisplayNameRequired).isTrue() + assertThat(config.isProviderChoiceAlwaysShown).isFalse() + } + + @Test + fun `authUIConfiguration with all fields overridden uses custom values`() { + val customTheme = AuthUITheme.Default + val customStringProvider = object : AuthUIStringProvider { + override fun initializing(): String = "" + override fun signInWithGoogle(): String = "" + override fun invalidEmail(): String = "" + override fun passwordsDoNotMatch(): String = "" + } + val customLocale = Locale.US + val customActionCodeSettings = actionCodeSettings { + url = "https://example.com/verify" + handleCodeInApp = true + } + + val config = authUIConfiguration { + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "" + ) + ) + provider( + AuthProvider.Github( + customParameters = mapOf() + ) + ) + } + theme = customTheme + stringProvider = customStringProvider + locale = customLocale + isCredentialManagerEnabled = false + isMfaEnabled = false + isAnonymousUpgradeEnabled = true + tosUrl = "https://example.com/tos" + privacyPolicyUrl = "https://example.com/privacy" + logo = Icons.Default.AccountCircle + actionCodeSettings = customActionCodeSettings + isNewEmailAccountsAllowed = false + isDisplayNameRequired = false + isProviderChoiceAlwaysShown = true + } + + assertThat(config.providers).hasSize(2) + assertThat(config.theme).isEqualTo(customTheme) + assertThat(config.stringProvider).isEqualTo(customStringProvider) + assertThat(config.locale).isEqualTo(customLocale) + assertThat(config.isCredentialManagerEnabled).isFalse() + assertThat(config.isMfaEnabled).isFalse() + assertThat(config.isAnonymousUpgradeEnabled).isTrue() + assertThat(config.tosUrl).isEqualTo("https://example.com/tos") + assertThat(config.privacyPolicyUrl).isEqualTo("https://example.com/privacy") + assertThat(config.logo).isEqualTo(Icons.Default.AccountCircle) + assertThat(config.actionCodeSettings).isEqualTo(customActionCodeSettings) + assertThat(config.isNewEmailAccountsAllowed).isFalse() + assertThat(config.isDisplayNameRequired).isFalse() + assertThat(config.isProviderChoiceAlwaysShown).isTrue() + } + + // =========================================================================================== + // Validation Tests + // =========================================================================================== + + @Test(expected = IllegalArgumentException::class) + fun `authUIConfiguration throws when no providers configured`() { + authUIConfiguration { } + } + + @Test + fun `validation accepts all supported providers`() { + val config = authUIConfiguration { + providers { + provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) + provider(AuthProvider.Facebook()) + provider(AuthProvider.Twitter(customParameters = mapOf())) + provider(AuthProvider.Github(customParameters = mapOf())) + provider(AuthProvider.Microsoft(customParameters = mapOf(), tenant = null)) + provider(AuthProvider.Yahoo(customParameters = mapOf())) + provider(AuthProvider.Apple(customParameters = mapOf(), locale = null)) + provider(AuthProvider.Phone(defaultCountryCode = null, allowedCountries = null)) + provider(AuthProvider.Email(actionCodeSettings = null, passwordValidationRules = listOf())) + } + } + assertThat(config.providers).hasSize(9) + } + + @Test(expected = IllegalArgumentException::class) + fun `validation throws for unsupported provider`() { + val mockProvider = AuthProvider.GenericOAuth( + providerId = "unsupported.provider", + scopes = listOf(), + customParameters = mapOf(), + buttonLabel = "Test", + buttonIcon = null, + buttonColor = null + ) + + authUIConfiguration { + providers { + provider(mockProvider) + } + } + } + + @Test(expected = IllegalStateException::class) + fun `validate throws when only anonymous provider is configured`() { + authUIConfiguration { + providers { + provider(AuthProvider.Anonymous) + } + } + } + + @Test(expected = IllegalArgumentException::class) + fun `validate throws for duplicate providers`() { + authUIConfiguration { + providers { + provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) + provider(AuthProvider.Google(scopes = listOf("email"), serverClientId = "different")) + } + } + } + + @Test(expected = IllegalArgumentException::class) + fun `validate throws for enableEmailLinkSignIn true when actionCodeSettings is null`() { + authUIConfiguration { + providers { + provider(AuthProvider.Email( + isEmailLinkSignInEnabled = true, + actionCodeSettings = null, + passwordValidationRules = listOf() + )) + } + } + } + + @Test(expected = IllegalStateException::class) + fun `validate throws for enableEmailLinkSignIn true when actionCodeSettings canHandleCodeInApp false`() { + val customActionCodeSettings = actionCodeSettings { + url = "https://example.com" + handleCodeInApp = false + } + authUIConfiguration { + providers { + provider(AuthProvider.Email( + isEmailLinkSignInEnabled = true, + actionCodeSettings = customActionCodeSettings, + passwordValidationRules = listOf() + )) + } + } + } + + // =========================================================================================== + // Provider Configuration Tests + // =========================================================================================== + + @Test + fun `providers block can be called multiple times and accumulates providers`() { + val config = authUIConfiguration { + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "" + ) + ) + } + + providers { + provider( + AuthProvider.Github( + customParameters = mapOf() + ) + ) + } + isCredentialManagerEnabled = true + } + + assertThat(config.providers).hasSize(2) + } + + // =========================================================================================== + // Builder Immutability Tests + // =========================================================================================== + + @Test + fun `authUIConfiguration providers list is immutable`() { + val config = authUIConfiguration { + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "" + ) + ) + } + } + + val originalSize = config.providers.size + + assertThrows(UnsupportedOperationException::class.java) { + (config.providers as MutableList).add( + AuthProvider.Twitter(customParameters = mapOf()) + ) + } + + assertThat(config.providers.size).isEqualTo(originalSize) + } + + @Test + fun `authUIConfiguration creates immutable configuration`() { + val kClass = AuthUIConfiguration::class + + val allProperties = kClass.memberProperties + + allProperties.forEach { + assertThat(it).isNotInstanceOf(KMutableProperty::class.java) + } + + val expectedProperties = setOf( + "providers", + "theme", + "stringProvider", + "locale", + "isCredentialManagerEnabled", + "isMfaEnabled", + "isAnonymousUpgradeEnabled", + "tosUrl", + "privacyPolicyUrl", + "logo", + "actionCodeSettings", + "isNewEmailAccountsAllowed", + "isDisplayNameRequired", + "isProviderChoiceAlwaysShown" + ) + + val actualProperties = allProperties.map { it.name }.toSet() + + assertThat(actualProperties).containsExactlyElementsIn(expectedProperties) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index c87edeee9..105624a8b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,6 +19,7 @@ buildscript { plugins { id("com.github.ben-manes.versions") version "0.20.0" + alias(libs.plugins.compose.compiler) apply false } allprojects { diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index d5e15edfe..7e9352e85 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -2,11 +2,11 @@ object Config { const val version = "10.0.0-SNAPSHOT" val submodules = listOf("auth", "common", "firestore", "database", "storage") - private const val kotlinVersion = "2.1.0" + private const val kotlinVersion = "2.2.0" object SdkVersions { - const val compile = 34 - const val target = 34 + const val compile = 35 + const val target = 35 const val min = 23 } @@ -40,8 +40,18 @@ object Config { const val paging = "androidx.paging:paging-runtime:3.0.0" const val pagingRxJava = "androidx.paging:paging-rxjava3:3.0.0" const val recyclerView = "androidx.recyclerview:recyclerview:1.2.1" - const val materialDesign = "com.google.android.material:material:1.4.0" + + object Compose { + const val bom = "androidx.compose:compose-bom:2025.08.00" + const val ui = "androidx.compose.ui:ui" + const val uiGraphics = "androidx.compose.ui:ui-graphics" + const val toolingPreview = "androidx.compose.ui:ui-tooling-preview" + const val tooling = "androidx.compose.ui:ui-tooling" + const val foundation = "androidx.compose.foundation:foundation" + const val material3 = "androidx.compose.material3:material3" + const val activityCompose = "androidx.activity:activity-compose" + } } object Firebase { @@ -83,6 +93,8 @@ object Config { const val archCoreTesting = "androidx.arch.core:core-testing:2.1.0" const val runner = "androidx.test:runner:1.5.0" const val rules = "androidx.test:rules:1.5.0" + + const val kotlinReflect = "org.jetbrains.kotlin:kotlin-reflect" } object Lint { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..379fe1b5b --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,7 @@ +[versions] +kotlin = "2.2.0" + +[libraries] + +[plugins] +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } \ No newline at end of file From ed47c1d7f4c581b2d808c34b197dd92633bce919 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 22 Sep 2025 07:11:27 +0100 Subject: [PATCH 05/41] feat: Email & Password Validator and PasswordRule validations (#2218) * feat(AuthUIConfiguration): implement configuration model, DSL builder and tests * refactor: use OAuthProvider base class for common properties * feat: add Provider enum class for provider ids * feat: setup default provider styles for each provider * test: added builder validation logic from old auth library and tests * refactor: changes in API design docs replaced sealed with abstract class, data with regular class use isXX prefix for booleans * test: fix AuthUIConfiguration constructor test * wip: email validator and password validator * feat: added password rules validations, FieldValidator interface added email and password validator * test: EmailValidator, PasswordValidator and PasswordRule * docs: update PasswordRule comments * docs: update PasswordRule comments * fix: remove mock annotation --- .../configuration/AuthUIStringProvider.kt | 62 +++- .../compose/configuration/PasswordRule.kt | 81 ++++- .../validators/EmailValidator.kt | 48 +++ .../validators/FieldValidationStatus.kt | 24 ++ .../validators/FieldValidator.kt | 39 ++ .../validators/PasswordValidator.kt | 54 +++ auth/src/main/res/values/strings.xml | 7 + .../configuration/AuthUIConfigurationTest.kt | 68 ++-- .../compose/configuration/PasswordRuleTest.kt | 336 ++++++++++++++++++ .../validators/EmailValidatorTest.kt | 109 ++++++ .../validators/PasswordValidatorTest.kt | 277 +++++++++++++++ 11 files changed, 1064 insertions(+), 41 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidationStatus.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt index fe5bbf302..0e7080722 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt @@ -23,19 +23,59 @@ import com.firebase.ui.auth.R * passwordsDoNotMatch(), etc., allowing for complete localization of the UI. */ interface AuthUIStringProvider { - fun initializing(): String - fun signInWithGoogle(): String - fun invalidEmail(): String - fun passwordsDoNotMatch(): String -} + /** Loading text displayed during initialization or processing states */ + val initializing: String + + /** Button text for Google sign-in option */ + val signInWithGoogle: String + + /** Error message when email address field is empty */ + val missingEmailAddress: String + + /** Error message when email address format is invalid */ + val invalidEmailAddress: String + + /** Generic error message for incorrect password during sign-in */ + val invalidPassword: String -class DefaultAuthUIStringProvider(private val context: Context) : AuthUIStringProvider { - override fun initializing(): String = "" + /** Error message when password confirmation doesn't match the original password */ + val passwordsDoNotMatch: String - override fun signInWithGoogle(): String = - context.getString(R.string.fui_sign_in_with_google) + /** Error message when password doesn't meet minimum length requirement. Should support string formatting with minimum length parameter. */ + val passwordTooShort: String - override fun invalidEmail(): String = "" + /** Error message when password is missing at least one uppercase letter (A-Z) */ + val passwordMissingUppercase: String + + /** Error message when password is missing at least one lowercase letter (a-z) */ + val passwordMissingLowercase: String + + /** Error message when password is missing at least one numeric digit (0-9) */ + val passwordMissingDigit: String + + /** Error message when password is missing at least one special character */ + val passwordMissingSpecialCharacter: String +} - override fun passwordsDoNotMatch(): String = "" +internal class DefaultAuthUIStringProvider(private val context: Context) : AuthUIStringProvider { + override val initializing: String get() = "" + override val signInWithGoogle: String + get() = context.getString(R.string.fui_sign_in_with_google) + override val missingEmailAddress: String + get() = context.getString(R.string.fui_missing_email_address) + override val invalidEmailAddress: String + get() = context.getString(R.string.fui_invalid_email_address) + override val invalidPassword: String + get() = context.getString(R.string.fui_error_invalid_password) + override val passwordsDoNotMatch: String get() = "" + override val passwordTooShort: String + get() = context.getString(R.string.fui_error_password_too_short) + override val passwordMissingUppercase: String + get() = context.getString(R.string.fui_error_password_missing_uppercase) + override val passwordMissingLowercase: String + get() = context.getString(R.string.fui_error_password_missing_lowercase) + override val passwordMissingDigit: String + get() = context.getString(R.string.fui_error_password_missing_digit) + override val passwordMissingSpecialCharacter: String + get() = context.getString(R.string.fui_error_password_missing_special_character) } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt index 242ea6e83..8f53822f2 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt @@ -22,31 +22,100 @@ abstract class PasswordRule { /** * Requires the password to have at least a certain number of characters. */ - class MinimumLength(val value: Int) : PasswordRule() + class MinimumLength(val value: Int) : PasswordRule() { + override fun isValid(password: String): Boolean { + return password.length >= this@MinimumLength.value + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordTooShort.format(value) + } + } /** * Requires the password to contain at least one uppercase letter (A-Z). */ - object RequireUppercase : PasswordRule() + object RequireUppercase : PasswordRule() { + override fun isValid(password: String): Boolean { + return password.any { it.isUpperCase() } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingUppercase + } + } /** * Requires the password to contain at least one lowercase letter (a-z). */ - object RequireLowercase: PasswordRule() + object RequireLowercase : PasswordRule() { + override fun isValid(password: String): Boolean { + return password.any { it.isLowerCase() } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingLowercase + } + } /** * Requires the password to contain at least one numeric digit (0-9). */ - object RequireDigit: PasswordRule() + object RequireDigit : PasswordRule() { + override fun isValid(password: String): Boolean { + return password.any { it.isDigit() } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingDigit + } + } /** * Requires the password to contain at least one special character (e.g., !@#$%^&*). */ - object RequireSpecialCharacter: PasswordRule() + object RequireSpecialCharacter : PasswordRule() { + private val specialCharacters = "!@#$%^&*()_+-=[]{}|;:,.<>?".toSet() + + override fun isValid(password: String): Boolean { + return password.any { it in specialCharacters } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingSpecialCharacter + } + } /** * Defines a custom validation rule using a regular expression and provides a specific error * message on failure. */ - class Custom(val regex: Regex, val errorMessage: String) + class Custom( + val regex: Regex, + val errorMessage: String + ) : PasswordRule() { + override fun isValid(password: String): Boolean { + return regex.matches(password) + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return errorMessage + } + } + + /** + * Validates whether the given password meets this rule's requirements. + * + * @param password The password to validate + * @return true if the password meets this rule's requirements, false otherwise + */ + internal abstract fun isValid(password: String): Boolean + + /** + * Returns the appropriate error message for this rule when validation fails. + * + * @param stringProvider The string provider for localized error messages + * @return The localized error message for this rule + */ + internal abstract fun getErrorMessage(stringProvider: AuthUIStringProvider): String } \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt new file mode 100644 index 000000000..d6b66194f --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.validators + +import com.firebase.ui.auth.compose.configuration.AuthUIStringProvider + +internal class EmailValidator(override val stringProvider: AuthUIStringProvider) : FieldValidator { + private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + + override val hasError: Boolean + get() = _validationStatus.hasError + + override val errorMessage: String + get() = _validationStatus.errorMessage ?: "" + + override fun validate(value: String): Boolean { + if (value.isEmpty()) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.missingEmailAddress + ) + return false + } + + if (!android.util.Patterns.EMAIL_ADDRESS.matcher(value).matches()) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.invalidEmailAddress + ) + return false + } + + _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + return true + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidationStatus.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidationStatus.kt new file mode 100644 index 000000000..7a681c921 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidationStatus.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.validators + +/** + * Class for encapsulating [hasError] and [errorMessage] properties in + * internal FieldValidator subclasses. + */ +internal class FieldValidationStatus( + val hasError: Boolean, + val errorMessage: String? = null, +) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt new file mode 100644 index 000000000..a26741897 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.validators + +import com.firebase.ui.auth.compose.configuration.AuthUIStringProvider + +/** + * An interface for validating input fields. + */ +interface FieldValidator { + val stringProvider: AuthUIStringProvider + + /** + * Returns true if the last validation failed. + */ + val hasError: Boolean + + /** + * The error message for the current state. + */ + val errorMessage: String + + /** + * Runs validation on a value and returns true if valid. + */ + fun validate(value: String): Boolean +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt new file mode 100644 index 000000000..35605818e --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.validators + +import com.firebase.ui.auth.compose.configuration.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.PasswordRule + +internal class PasswordValidator( + override val stringProvider: AuthUIStringProvider, + private val rules: List +) : FieldValidator { + private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + + override val hasError: Boolean + get() = _validationStatus.hasError + + override val errorMessage: String + get() = _validationStatus.errorMessage ?: "" + + override fun validate(value: String): Boolean { + if (value.isEmpty()) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.invalidPassword + ) + return false + } + + for (rule in rules) { + if (!rule.isValid(value)) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = rule.getErrorMessage(stringProvider) + ) + return false + } + } + + _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + return true + } +} diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 52314a505..71b87f547 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -93,6 +93,13 @@ An unknown error occurred. Incorrect password. + + Password must be at least %1$d characters long + Password must contain at least one uppercase letter + Password must contain at least one lowercase letter + Password must contain at least one number + Password must contain at least one special character + App logo diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt index c8e627ff5..0233848e4 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -20,12 +20,23 @@ import com.google.common.truth.Truth.assertThat import com.google.firebase.auth.actionCodeSettings import org.junit.Assert.assertThrows import org.junit.Test +import org.mockito.Mockito.mock import java.util.Locale import kotlin.reflect.KMutableProperty import kotlin.reflect.full.memberProperties +/** + * Unit tests for [AuthUIConfiguration] covering configuration builder behavior, + * validation rules, provider setup, and immutability guarantees. + * + * @suppress Internal test class + */ class AuthUIConfigurationTest { + // ============================================================================================= + // Basic Configuration Tests + // ============================================================================================= + @Test fun `authUIConfiguration with minimal setup uses correct defaults`() { val config = authUIConfiguration { @@ -58,12 +69,7 @@ class AuthUIConfigurationTest { @Test fun `authUIConfiguration with all fields overridden uses custom values`() { val customTheme = AuthUITheme.Default - val customStringProvider = object : AuthUIStringProvider { - override fun initializing(): String = "" - override fun signInWithGoogle(): String = "" - override fun invalidEmail(): String = "" - override fun passwordsDoNotMatch(): String = "" - } + val customStringProvider = mock(AuthUIStringProvider::class.java) val customLocale = Locale.US val customActionCodeSettings = actionCodeSettings { url = "https://example.com/verify" @@ -115,9 +121,9 @@ class AuthUIConfigurationTest { assertThat(config.isProviderChoiceAlwaysShown).isTrue() } - // =========================================================================================== + // ============================================================================================= // Validation Tests - // =========================================================================================== + // ============================================================================================= @Test(expected = IllegalArgumentException::class) fun `authUIConfiguration throws when no providers configured`() { @@ -136,7 +142,12 @@ class AuthUIConfigurationTest { provider(AuthProvider.Yahoo(customParameters = mapOf())) provider(AuthProvider.Apple(customParameters = mapOf(), locale = null)) provider(AuthProvider.Phone(defaultCountryCode = null, allowedCountries = null)) - provider(AuthProvider.Email(actionCodeSettings = null, passwordValidationRules = listOf())) + provider( + AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = listOf() + ) + ) } } assertThat(config.providers).hasSize(9) @@ -174,7 +185,12 @@ class AuthUIConfigurationTest { authUIConfiguration { providers { provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) - provider(AuthProvider.Google(scopes = listOf("email"), serverClientId = "different")) + provider( + AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "different" + ) + ) } } } @@ -183,11 +199,13 @@ class AuthUIConfigurationTest { fun `validate throws for enableEmailLinkSignIn true when actionCodeSettings is null`() { authUIConfiguration { providers { - provider(AuthProvider.Email( - isEmailLinkSignInEnabled = true, - actionCodeSettings = null, - passwordValidationRules = listOf() - )) + provider( + AuthProvider.Email( + isEmailLinkSignInEnabled = true, + actionCodeSettings = null, + passwordValidationRules = listOf() + ) + ) } } } @@ -200,18 +218,20 @@ class AuthUIConfigurationTest { } authUIConfiguration { providers { - provider(AuthProvider.Email( - isEmailLinkSignInEnabled = true, - actionCodeSettings = customActionCodeSettings, - passwordValidationRules = listOf() - )) + provider( + AuthProvider.Email( + isEmailLinkSignInEnabled = true, + actionCodeSettings = customActionCodeSettings, + passwordValidationRules = listOf() + ) + ) } } } - // =========================================================================================== + // ============================================================================================= // Provider Configuration Tests - // =========================================================================================== + // ============================================================================================= @Test fun `providers block can be called multiple times and accumulates providers`() { @@ -238,9 +258,9 @@ class AuthUIConfigurationTest { assertThat(config.providers).hasSize(2) } - // =========================================================================================== + // ============================================================================================= // Builder Immutability Tests - // =========================================================================================== + // ============================================================================================= @Test fun `authUIConfiguration providers list is immutable`() { diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt new file mode 100644 index 000000000..d3cacb488 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt @@ -0,0 +1,336 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [PasswordRule] implementations covering validation logic + * and error message generation for each password rule type. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class PasswordRuleTest { + + private lateinit var stringProvider: DefaultAuthUIStringProvider + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + } + + // ============================================================================================= + // MinimumLength Rule Tests + // ============================================================================================= + + @Test + fun `MinimumLength isValid returns true for password meeting length requirement`() { + val rule = PasswordRule.MinimumLength(8) + + val isValid = rule.isValid("password123") + + assertThat(isValid).isTrue() + } + + @Test + fun `MinimumLength isValid returns false for password shorter than requirement`() { + val rule = PasswordRule.MinimumLength(8) + + val isValid = rule.isValid("short") + + assertThat(isValid).isFalse() + } + + @Test + fun `MinimumLength getErrorMessage returns formatted message`() { + val context = ApplicationProvider.getApplicationContext() + val rule = PasswordRule.MinimumLength(10) + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(context.getString(R.string.fui_error_password_too_short, 10)) + } + + // ============================================================================================= + // RequireUppercase Rule Tests + // ============================================================================================= + + @Test + fun `RequireUppercase isValid returns true for password with uppercase letter`() { + val rule = PasswordRule.RequireUppercase + + val isValid = rule.isValid("Password123") + + assertThat(isValid).isTrue() + } + + @Test + fun `RequireUppercase isValid returns false for password without uppercase letter`() { + val rule = PasswordRule.RequireUppercase + + val isValid = rule.isValid("password123") + + assertThat(isValid).isFalse() + } + + @Test + fun `RequireUppercase getErrorMessage returns correct message`() { + val context = ApplicationProvider.getApplicationContext() + val rule = PasswordRule.RequireUppercase + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(context.getString(R.string.fui_error_password_missing_uppercase)) + } + + // ============================================================================================= + // RequireLowercase Rule Tests + // ============================================================================================= + + @Test + fun `RequireLowercase isValid returns true for password with lowercase letter`() { + val rule = PasswordRule.RequireLowercase + + val isValid = rule.isValid("Password123") + + assertThat(isValid).isTrue() + } + + @Test + fun `RequireLowercase isValid returns false for password without lowercase letter`() { + val rule = PasswordRule.RequireLowercase + + val isValid = rule.isValid("PASSWORD123") + + assertThat(isValid).isFalse() + } + + @Test + fun `RequireLowercase getErrorMessage returns correct message`() { + val context = ApplicationProvider.getApplicationContext() + val rule = PasswordRule.RequireLowercase + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(context.getString(R.string.fui_error_password_missing_lowercase)) + } + + // ============================================================================================= + // RequireDigit Rule Tests + // ============================================================================================= + + @Test + fun `RequireDigit isValid returns true for password with digit`() { + val rule = PasswordRule.RequireDigit + + val isValid = rule.isValid("Password123") + + assertThat(isValid).isTrue() + } + + @Test + fun `RequireDigit isValid returns false for password without digit`() { + val rule = PasswordRule.RequireDigit + + val isValid = rule.isValid("Password") + + assertThat(isValid).isFalse() + } + + @Test + fun `RequireDigit getErrorMessage returns correct message`() { + val context = ApplicationProvider.getApplicationContext() + val rule = PasswordRule.RequireDigit + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(context.getString(R.string.fui_error_password_missing_digit)) + } + + // ============================================================================================= + // RequireSpecialCharacter Rule Tests + // ============================================================================================= + + @Test + fun `RequireSpecialCharacter isValid returns true for password with special character`() { + val rule = PasswordRule.RequireSpecialCharacter + + val isValid = rule.isValid("Password123!") + + assertThat(isValid).isTrue() + } + + @Test + fun `RequireSpecialCharacter isValid returns false for password without special character`() { + val rule = PasswordRule.RequireSpecialCharacter + + val isValid = rule.isValid("Password123") + + assertThat(isValid).isFalse() + } + + @Test + fun `RequireSpecialCharacter getErrorMessage returns correct message`() { + val context = ApplicationProvider.getApplicationContext() + val rule = PasswordRule.RequireSpecialCharacter + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(context.getString(R.string.fui_error_password_missing_special_character)) + } + + @Test + fun `RequireSpecialCharacter validates various special characters`() { + val rule = PasswordRule.RequireSpecialCharacter + val specialChars = "!@#$%^&*()_+-=[]{}|;:,.<>?" + + specialChars.forEach { char -> + val isValid = rule.isValid("Password123$char") + assertThat(isValid).isTrue() + } + } + + // ============================================================================================= + // Custom Rule Tests + // ============================================================================================= + + @Test + fun `Custom rule isValid works with provided regex`() { + val rule = PasswordRule.Custom( + regex = Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$"), + errorMessage = "Custom validation failed" + ) + + val validPassword = rule.isValid("Password123") + val invalidPassword = rule.isValid("weak") + + assertThat(validPassword).isTrue() + assertThat(invalidPassword).isFalse() + } + + @Test + fun `Custom rule getErrorMessage returns custom message`() { + val customMessage = "Custom validation failed" + val rule = PasswordRule.Custom( + regex = Regex(".*"), + errorMessage = customMessage + ) + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(customMessage) + } + + @Test + fun `Custom rule with complex regex works correctly`() { + // Must contain at least one letter, one number, and be 6+ characters + val rule = PasswordRule.Custom( + regex = Regex("^(?=.*[a-zA-Z])(?=.*\\d).{6,}$"), + errorMessage = "Must contain letter and number, min 6 chars" + ) + + assertThat(rule.isValid("abc123")).isTrue() + assertThat(rule.isValid("password1")).isTrue() + assertThat(rule.isValid("123456")).isFalse() // No letter + assertThat(rule.isValid("abcdef")).isFalse() // No number + assertThat(rule.isValid("abc12")).isFalse() // Too short + } + + // ============================================================================================= + // Rule Extensibility Tests + // ============================================================================================= + + @Test + fun `custom password rule by extending PasswordRule works`() { + val customRule = object : PasswordRule() { + override fun isValid(password: String): Boolean { + return password.contains("test") + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return "Password must contain 'test'" + } + } + + val validResult = customRule.isValid("testing123") + val invalidResult = customRule.isValid("invalid") + val errorMessage = customRule.getErrorMessage(stringProvider) + + assertThat(validResult).isTrue() + assertThat(invalidResult).isFalse() + assertThat(errorMessage).isEqualTo("Password must contain 'test'") + } + + @Test + fun `multiple custom rules can be created independently`() { + val rule1 = object : PasswordRule() { + override fun isValid(password: String): Boolean = password.startsWith("prefix") + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String = "Must start with 'prefix'" + } + + val rule2 = object : PasswordRule() { + override fun isValid(password: String): Boolean = password.endsWith("suffix") + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String = "Must end with 'suffix'" + } + + assertThat(rule1.isValid("prefixPassword")).isTrue() + assertThat(rule1.isValid("passwordsuffix")).isFalse() + + assertThat(rule2.isValid("passwordsuffix")).isTrue() + assertThat(rule2.isValid("prefixPassword")).isFalse() + + assertThat(rule1.getErrorMessage(stringProvider)).isEqualTo("Must start with 'prefix'") + assertThat(rule2.getErrorMessage(stringProvider)).isEqualTo("Must end with 'suffix'") + } + + // ============================================================================================= + // Edge Case Tests + // ============================================================================================= + + @Test + fun `all rules handle empty password correctly`() { + val rules = listOf( + PasswordRule.MinimumLength(1), + PasswordRule.RequireUppercase, + PasswordRule.RequireLowercase, + PasswordRule.RequireDigit, + PasswordRule.RequireSpecialCharacter + ) + + rules.forEach { rule -> + val isValid = rule.isValid("") + assertThat(isValid).isFalse() + } + } + + @Test + fun `MinimumLength with zero length allows any password`() { + val rule = PasswordRule.MinimumLength(0) + + assertThat(rule.isValid("")).isTrue() + assertThat(rule.isValid("any")).isTrue() + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt new file mode 100644 index 000000000..520908181 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.validators + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.DefaultAuthUIStringProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Integration tests for [EmailValidator] covering email validation logic, + * error state management, and integration with [DefaultAuthUIStringProvider] + * using real Android string resources. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class EmailValidatorTest { + + private lateinit var stringProvider: DefaultAuthUIStringProvider + + private lateinit var emailValidator: EmailValidator + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + emailValidator = EmailValidator(stringProvider) + } + + // ============================================================================================= + // Initial State Tests + // ============================================================================================= + + @Test + fun `validator initial state has no error`() { + assertThat(emailValidator.hasError).isFalse() + assertThat(emailValidator.errorMessage).isEmpty() + } + + // ============================================================================================= + // Validation Logic Tests + // ============================================================================================= + + @Test + fun `validate returns false and sets error for empty email`() { + val context = ApplicationProvider.getApplicationContext() + + val isValid = emailValidator.validate("") + + assertThat(isValid).isFalse() + assertThat(emailValidator.hasError).isTrue() + assertThat(emailValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_missing_email_address)) + } + + @Test + fun `validate returns false and sets error for invalid email format`() { + val context = ApplicationProvider.getApplicationContext() + + val isValid = emailValidator.validate("invalid-email") + + assertThat(isValid).isFalse() + assertThat(emailValidator.hasError).isTrue() + assertThat(emailValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_invalid_email_address)) + } + + @Test + fun `validate returns true and clears error for valid email`() { + val isValid = emailValidator.validate("test@example.com") + + assertThat(isValid).isTrue() + assertThat(emailValidator.hasError).isFalse() + assertThat(emailValidator.errorMessage).isEmpty() + } + + @Test + fun `validate clears previous error when valid email provided`() { + emailValidator.validate("invalid") + assertThat(emailValidator.hasError).isTrue() + + val isValid = emailValidator.validate("valid@example.com") + + assertThat(isValid).isTrue() + assertThat(emailValidator.hasError).isFalse() + assertThat(emailValidator.errorMessage).isEmpty() + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt new file mode 100644 index 000000000..4e8d2e440 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt @@ -0,0 +1,277 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.validators + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Integration tests for [PasswordValidator] covering password validation logic, + * password rule enforcement, error state management, and integration with + * [DefaultAuthUIStringProvider] using real Android string resources. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class PasswordValidatorTest { + + private lateinit var stringProvider: DefaultAuthUIStringProvider + private lateinit var passwordValidator: PasswordValidator + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + } + + // ============================================================================================= + // Initial State Tests + // ============================================================================================= + + @Test + fun `validator initial state has no error`() { + passwordValidator = PasswordValidator(stringProvider, emptyList()) + + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } + + // ============================================================================================= + // Empty Password Validation Tests + // ============================================================================================= + + @Test + fun `validate returns false and sets error for empty password`() { + val context = ApplicationProvider.getApplicationContext() + passwordValidator = PasswordValidator(stringProvider, emptyList()) + + val isValid = passwordValidator.validate("") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_invalid_password)) + } + + // ============================================================================================= + // Minimum Length Rule Tests + // ============================================================================================= + + @Test + fun `validate returns false for password shorter than minimum length`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf(PasswordRule.MinimumLength(8)) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("short") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_too_short, 8)) + } + + @Test + fun `validate returns true for password meeting minimum length`() { + val rules = listOf(PasswordRule.MinimumLength(8)) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("password123") + + assertThat(isValid).isTrue() + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } + + // ============================================================================================= + // Character Requirement Tests + // ============================================================================================= + + @Test + fun `validate returns false for password missing uppercase letter`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf(PasswordRule.RequireUppercase) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("password123") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_missing_uppercase)) + } + + @Test + fun `validate returns true for password with uppercase letter`() { + val rules = listOf(PasswordRule.RequireUppercase) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("Password123") + + assertThat(isValid).isTrue() + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } + + @Test + fun `validate returns false for password missing lowercase letter`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf(PasswordRule.RequireLowercase) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("PASSWORD123") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_missing_lowercase)) + } + + @Test + fun `validate returns false for password missing digit`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf(PasswordRule.RequireDigit) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("Password") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_missing_digit)) + } + + @Test + fun `validate returns false for password missing special character`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf(PasswordRule.RequireSpecialCharacter) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("Password123") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_missing_special_character)) + } + + // ============================================================================================= + // Multiple Rules Tests + // ============================================================================================= + + @Test + fun `validate returns false and shows first failing rule error`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireUppercase, + PasswordRule.RequireDigit + ) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("short") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + // Should show the first failing rule (MinimumLength) + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_too_short, 8)) + } + + @Test + fun `validate returns true for password meeting all rules`() { + val rules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireUppercase, + PasswordRule.RequireLowercase, + PasswordRule.RequireDigit, + PasswordRule.RequireSpecialCharacter + ) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("Password123!") + + assertThat(isValid).isTrue() + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } + + // ============================================================================================= + // Custom Rule Tests + // ============================================================================================= + + @Test + fun `validate works with custom regex rule`() { + val customRule = PasswordRule.Custom( + // Valid (has upper, lower, digit, 8+ chars, only letters/digits) + regex = Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$"), + errorMessage = "Custom validation failed" + ) + val rules = listOf(customRule) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("Password123") + + assertThat(isValid).isTrue() + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } + + @Test + fun `validate returns custom error message for failing custom rule`() { + val customRule = PasswordRule.Custom( + // Valid (has upper, lower, digit, 8+ chars, only letters/digits) + regex = Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$"), + errorMessage = "Custom validation failed" + ) + val rules = listOf(customRule) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("weak") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage).isEqualTo("Custom validation failed") + } + + // ============================================================================================= + // Error State Management Tests + // ============================================================================================= + + @Test + fun `validate clears previous error when password becomes valid`() { + val rules = listOf(PasswordRule.MinimumLength(8)) + passwordValidator = PasswordValidator(stringProvider, rules) + + passwordValidator.validate("short") + assertThat(passwordValidator.hasError).isTrue() + + val isValid = passwordValidator.validate("longenough") + + assertThat(isValid).isTrue() + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } +} \ No newline at end of file From c1805229ce2137b0aadf495d7ddadfcbb7050876 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Tue, 23 Sep 2025 09:54:34 +0200 Subject: [PATCH 06/41] feat: AuthException parsing for UI (#2222) --- .../firebase/ui/auth/compose/AuthException.kt | 341 ++++++++++++++++++ .../ui/auth/compose/AuthExceptionTest.kt | 139 +++++++ 2 files changed, 480 insertions(+) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/AuthExceptionTest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt new file mode 100644 index 000000000..cb2a9480a --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt @@ -0,0 +1,341 @@ +/* + * 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 com.google.firebase.FirebaseException +import com.google.firebase.auth.FirebaseAuthException +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import com.google.firebase.auth.FirebaseAuthInvalidUserException +import com.google.firebase.auth.FirebaseAuthMultiFactorException +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException +import com.google.firebase.auth.FirebaseAuthUserCollisionException +import com.google.firebase.auth.FirebaseAuthWeakPasswordException + +/** + * Abstract base class representing all possible authentication exceptions in Firebase Auth UI. + * + * This class provides a unified exception hierarchy for authentication operations, allowing + * for consistent error handling across the entire Auth UI system. + * + * Use the companion object [from] method to create specific exception instances from + * Firebase authentication exceptions. + * + * **Example usage:** + * ```kotlin + * try { + * // Perform authentication operation + * } catch (firebaseException: Exception) { + * val authException = AuthException.from(firebaseException) + * when (authException) { + * is AuthException.NetworkException -> { + * // Handle network error + * } + * is AuthException.InvalidCredentialsException -> { + * // Handle invalid credentials + * } + * // ... handle other exception types + * } + * } + * ``` + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + * + * @since 10.0.0 + */ +abstract class AuthException( + message: String, + cause: Throwable? = null +) : Exception(message, cause) { + + /** + * A network error occurred during the authentication operation. + * + * This exception is thrown when there are connectivity issues, timeouts, + * or other network-related problems. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + */ + class NetworkException( + message: String, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * The provided credentials are not valid. + * + * This exception is thrown when the user provides incorrect login information, + * such as wrong email/password combinations or malformed credentials. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + */ + class InvalidCredentialsException( + message: String, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * The user account does not exist. + * + * This exception is thrown when attempting to sign in with credentials + * for a user that doesn't exist in the Firebase Auth system. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + */ + class UserNotFoundException( + message: String, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * The password provided is not strong enough. + * + * This exception is thrown when creating an account or updating a password + * with a password that doesn't meet the security requirements. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + * @property reason The specific reason why the password is considered weak + */ + class WeakPasswordException( + message: String, + cause: Throwable? = null, + val reason: String? = null + ) : AuthException(message, cause) + + /** + * An account with the given email already exists. + * + * This exception is thrown when attempting to create a new account with + * an email address that is already registered. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + * @property email The email address that already exists + */ + class EmailAlreadyInUseException( + message: String, + cause: Throwable? = null, + val email: String? = null + ) : AuthException(message, cause) + + /** + * Too many requests have been made to the server. + * + * This exception is thrown when the client has made too many requests + * in a short period and needs to wait before making additional requests. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + */ + class TooManyRequestsException( + message: String, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * Multi-Factor Authentication is required to proceed. + * + * This exception is thrown when a user has MFA enabled and needs to + * complete additional authentication steps. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + */ + class MfaRequiredException( + message: String, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * Account linking is required to complete sign-in. + * + * This exception is thrown when a user tries to sign in with a provider + * that needs to be linked to an existing account. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + */ + class AccountLinkingRequiredException( + message: String, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * Authentication was cancelled by the user. + * + * This exception is thrown when the user cancels an authentication flow, + * such as dismissing a sign-in dialog or backing out of the process. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + */ + class AuthCancelledException( + message: String, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * An unknown or unhandled error occurred. + * + * This exception is thrown for errors that don't match any of the specific + * exception types or for unexpected system errors. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + */ + class UnknownException( + message: String, + cause: Throwable? = null + ) : AuthException(message, cause) + + companion object { + /** + * Creates an appropriate [AuthException] instance from a Firebase authentication exception. + * + * This method maps known Firebase exception types to their corresponding [AuthException] + * subtypes, providing a consistent exception hierarchy for error handling. + * + * **Mapping:** + * - [FirebaseException] → [NetworkException] (for network-related errors) + * - [FirebaseAuthInvalidCredentialsException] → [InvalidCredentialsException] + * - [FirebaseAuthInvalidUserException] → [UserNotFoundException] + * - [FirebaseAuthWeakPasswordException] → [WeakPasswordException] + * - [FirebaseAuthUserCollisionException] → [EmailAlreadyInUseException] + * - [FirebaseAuthException] with ERROR_TOO_MANY_REQUESTS → [TooManyRequestsException] + * - [FirebaseAuthMultiFactorException] → [MfaRequiredException] + * - Other exceptions → [UnknownException] + * + * **Example:** + * ```kotlin + * try { + * // Firebase auth operation + * } catch (firebaseException: Exception) { + * val authException = AuthException.from(firebaseException) + * handleAuthError(authException) + * } + * ``` + * + * @param firebaseException The Firebase exception to convert + * @return An appropriate [AuthException] subtype + */ + @JvmStatic + fun from(firebaseException: Exception): AuthException { + return when (firebaseException) { + // Handle specific Firebase Auth exceptions first (before general FirebaseException) + is FirebaseAuthInvalidCredentialsException -> { + InvalidCredentialsException( + message = firebaseException.message ?: "Invalid credentials provided", + cause = firebaseException + ) + } + is FirebaseAuthInvalidUserException -> { + when (firebaseException.errorCode) { + "ERROR_USER_NOT_FOUND" -> UserNotFoundException( + message = firebaseException.message ?: "User not found", + cause = firebaseException + ) + "ERROR_USER_DISABLED" -> InvalidCredentialsException( + message = firebaseException.message ?: "User account has been disabled", + cause = firebaseException + ) + else -> UserNotFoundException( + message = firebaseException.message ?: "User account error", + cause = firebaseException + ) + } + } + is FirebaseAuthWeakPasswordException -> { + WeakPasswordException( + message = firebaseException.message ?: "Password is too weak", + cause = firebaseException, + reason = firebaseException.reason + ) + } + is FirebaseAuthUserCollisionException -> { + when (firebaseException.errorCode) { + "ERROR_EMAIL_ALREADY_IN_USE" -> EmailAlreadyInUseException( + message = firebaseException.message ?: "Email address is already in use", + cause = firebaseException, + email = firebaseException.email + ) + "ERROR_ACCOUNT_EXISTS_WITH_DIFFERENT_CREDENTIAL" -> AccountLinkingRequiredException( + message = firebaseException.message ?: "Account already exists with different credentials", + cause = firebaseException + ) + "ERROR_CREDENTIAL_ALREADY_IN_USE" -> AccountLinkingRequiredException( + message = firebaseException.message ?: "Credential is already associated with a different user account", + cause = firebaseException + ) + else -> AccountLinkingRequiredException( + message = firebaseException.message ?: "Account collision error", + cause = firebaseException + ) + } + } + is FirebaseAuthMultiFactorException -> { + MfaRequiredException( + message = firebaseException.message ?: "Multi-factor authentication required", + cause = firebaseException + ) + } + is FirebaseAuthRecentLoginRequiredException -> { + InvalidCredentialsException( + message = firebaseException.message ?: "Recent login required for this operation", + cause = firebaseException + ) + } + is FirebaseAuthException -> { + // Handle FirebaseAuthException and check for specific error codes + when (firebaseException.errorCode) { + "ERROR_TOO_MANY_REQUESTS" -> TooManyRequestsException( + message = firebaseException.message ?: "Too many requests. Please try again later", + cause = firebaseException + ) + else -> UnknownException( + message = firebaseException.message ?: "An unknown authentication error occurred", + cause = firebaseException + ) + } + } + is FirebaseException -> { + // Handle general Firebase exceptions, which include network errors + NetworkException( + message = firebaseException.message ?: "Network error occurred", + cause = firebaseException + ) + } + else -> { + // Check for common cancellation patterns + if (firebaseException.message?.contains("cancelled", ignoreCase = true) == true || + firebaseException.message?.contains("canceled", ignoreCase = true) == true) { + AuthCancelledException( + message = firebaseException.message ?: "Authentication was cancelled", + cause = firebaseException + ) + } else { + UnknownException( + message = firebaseException.message ?: "An unknown error occurred", + cause = firebaseException + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/AuthExceptionTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/AuthExceptionTest.kt new file mode 100644 index 000000000..46620f853 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/AuthExceptionTest.kt @@ -0,0 +1,139 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseException +import com.google.firebase.auth.FirebaseAuthException +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [AuthException] covering exception mapping from Firebase exceptions + * to the unified AuthException hierarchy. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class AuthExceptionTest { + + @Test + fun `from() maps FirebaseException to NetworkException`() { + // Arrange + val firebaseException = object : FirebaseException("Network error occurred") {} + + // Act + val authException = AuthException.from(firebaseException) + + // Assert + assertThat(authException).isInstanceOf(AuthException.NetworkException::class.java) + assertThat(authException.message).isEqualTo("Network error occurred") + assertThat(authException.cause).isEqualTo(firebaseException) + } + + @Test + fun `from() maps FirebaseAuthException with ERROR_TOO_MANY_REQUESTS to TooManyRequestsException`() { + // Arrange + val firebaseException = object : FirebaseAuthException("ERROR_TOO_MANY_REQUESTS", "Too many requests") {} + + // Act + val authException = AuthException.from(firebaseException) + + // Assert + assertThat(authException).isInstanceOf(AuthException.TooManyRequestsException::class.java) + assertThat(authException.message).isEqualTo("Too many requests") + assertThat(authException.cause).isEqualTo(firebaseException) + } + + @Test + fun `from() maps FirebaseAuthException with unknown error code to UnknownException`() { + // Arrange + val firebaseException = object : FirebaseAuthException("ERROR_UNKNOWN", "Unknown auth error") {} + + // Act + val authException = AuthException.from(firebaseException) + + // Assert + assertThat(authException).isInstanceOf(AuthException.UnknownException::class.java) + assertThat(authException.message).isEqualTo("Unknown auth error") + assertThat(authException.cause).isEqualTo(firebaseException) + } + + @Test + fun `from() maps exception with cancelled message to AuthCancelledException`() { + // Arrange + val firebaseException = RuntimeException("Operation was cancelled by user") + + // Act + val authException = AuthException.from(firebaseException) + + // Assert + assertThat(authException).isInstanceOf(AuthException.AuthCancelledException::class.java) + assertThat(authException.message).isEqualTo("Operation was cancelled by user") + assertThat(authException.cause).isEqualTo(firebaseException) + } + + @Test + fun `from() maps unknown exception to UnknownException`() { + // Arrange + val firebaseException = RuntimeException("Unknown error occurred") + + // Act + val authException = AuthException.from(firebaseException) + + // Assert + assertThat(authException).isInstanceOf(AuthException.UnknownException::class.java) + assertThat(authException.message).isEqualTo("Unknown error occurred") + assertThat(authException.cause).isEqualTo(firebaseException) + } + + @Test + fun `all AuthException subclasses extend AuthException`() { + // Arrange & Assert + assertThat(AuthException.NetworkException("Test")).isInstanceOf(AuthException::class.java) + assertThat(AuthException.InvalidCredentialsException("Test")).isInstanceOf(AuthException::class.java) + assertThat(AuthException.UserNotFoundException("Test")).isInstanceOf(AuthException::class.java) + assertThat(AuthException.WeakPasswordException("Test")).isInstanceOf(AuthException::class.java) + assertThat(AuthException.EmailAlreadyInUseException("Test")).isInstanceOf(AuthException::class.java) + assertThat(AuthException.TooManyRequestsException("Test")).isInstanceOf(AuthException::class.java) + assertThat(AuthException.MfaRequiredException("Test")).isInstanceOf(AuthException::class.java) + assertThat(AuthException.AccountLinkingRequiredException("Test")).isInstanceOf(AuthException::class.java) + assertThat(AuthException.AuthCancelledException("Test")).isInstanceOf(AuthException::class.java) + assertThat(AuthException.UnknownException("Test")).isInstanceOf(AuthException::class.java) + } + + @Test + fun `WeakPasswordException stores reason property correctly`() { + // Arrange + val reason = "Password must contain at least one number" + val exception = AuthException.WeakPasswordException("Weak password", null, reason) + + // Assert + assertThat(exception.reason).isEqualTo(reason) + } + + @Test + fun `EmailAlreadyInUseException stores email property correctly`() { + // Arrange + val email = "test@example.com" + val exception = AuthException.EmailAlreadyInUseException("Email in use", null, email) + + // Assert + assertThat(exception.email).isEqualTo(email) + } +} \ No newline at end of file From 59325de5fece7129e4d22cee97811a09d565fd42 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 23 Sep 2025 10:13:59 +0100 Subject: [PATCH 07/41] feat: Localization - AuthUIStringProvider and locale override (#2221) * feat: added context AuthUIConfiguration and default string provider * add/expose existing localized strings to allow overrides * added custom string provider sample, tests for locale overrides * chore: code cleanup --- .../configuration/AuthUIConfiguration.kt | 83 ++++---- .../configuration/AuthUIStringProvider.kt | 81 -------- .../compose/configuration/PasswordRule.kt | 2 + .../stringprovider/AuthUIStringProvider.kt | 187 +++++++++++++++++ .../AuthUIStringProviderSample.kt | 59 ++++++ .../DefaultAuthUIStringProvider.kt | 182 +++++++++++++++++ .../validators/EmailValidator.kt | 2 +- .../validators/FieldValidator.kt | 2 +- .../validators/PasswordValidator.kt | 2 +- auth/src/main/res/values/strings.xml | 1 + .../configuration/AuthUIConfigurationTest.kt | 193 +++++++++++++++--- .../compose/configuration/PasswordRuleTest.kt | 4 +- .../validators/EmailValidatorTest.kt | 6 +- .../validators/PasswordValidatorTest.kt | 5 +- 14 files changed, 647 insertions(+), 162 deletions(-) delete mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProvider.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProviderSample.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/DefaultAuthUIStringProvider.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt index aca1ccf9e..ef70c5f3b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt @@ -14,29 +14,29 @@ package com.firebase.ui.auth.compose.configuration +import android.content.Context import java.util.Locale import com.google.firebase.auth.ActionCodeSettings import androidx.compose.ui.graphics.vector.ImageVector +import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider -fun actionCodeSettings( - block: ActionCodeSettings.Builder.() -> Unit -) = ActionCodeSettings.newBuilder().apply(block).build() +fun actionCodeSettings(block: ActionCodeSettings.Builder.() -> Unit) = + ActionCodeSettings.newBuilder().apply(block).build() -fun authUIConfiguration(block: AuthUIConfigurationBuilder.() -> Unit): AuthUIConfiguration { - val builder = AuthUIConfigurationBuilder() - builder.block() - return builder.build() -} +fun authUIConfiguration(block: AuthUIConfigurationBuilder.() -> Unit) = + AuthUIConfigurationBuilder().apply(block).build() @DslMarker annotation class AuthUIConfigurationDsl @AuthUIConfigurationDsl class AuthUIConfigurationBuilder { + var context: Context? = null private val providers = mutableListOf() var theme: AuthUITheme = AuthUITheme.Default - var stringProvider: AuthUIStringProvider? = null var locale: Locale? = null + var stringProvider: AuthUIStringProvider? = null var isCredentialManagerEnabled: Boolean = true var isMfaEnabled: Boolean = true var isAnonymousUpgradeEnabled: Boolean = false @@ -48,36 +48,16 @@ class AuthUIConfigurationBuilder { var isDisplayNameRequired: Boolean = true var isProviderChoiceAlwaysShown: Boolean = false - fun providers(block: AuthProvidersBuilder.() -> Unit) { - val builder = AuthProvidersBuilder() - builder.block() - providers.addAll(builder.build()) - } + fun providers(block: AuthProvidersBuilder.() -> Unit) = + providers.addAll(AuthProvidersBuilder().apply(block).build()) internal fun build(): AuthUIConfiguration { - validate() - return AuthUIConfiguration( - providers = providers.toList(), - theme = theme, - stringProvider = stringProvider, - locale = locale, - isCredentialManagerEnabled = isCredentialManagerEnabled, - isMfaEnabled = isMfaEnabled, - isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled, - tosUrl = tosUrl, - privacyPolicyUrl = privacyPolicyUrl, - logo = logo, - actionCodeSettings = actionCodeSettings, - isNewEmailAccountsAllowed = isNewEmailAccountsAllowed, - isDisplayNameRequired = isDisplayNameRequired, - isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown - ) - } + val context = requireNotNull(context) { + "Application context is required" + } - private fun validate() { - // At least one provider - if (providers.isEmpty()) { - throw IllegalArgumentException("At least one provider must be configured") + require(providers.isNotEmpty()) { + "At least one provider must be configured" } // No unsupported providers @@ -113,6 +93,24 @@ class AuthUIConfigurationBuilder { else -> null } } + + return AuthUIConfiguration( + context = context, + providers = providers.toList(), + theme = theme, + locale = locale, + stringProvider = stringProvider ?: DefaultAuthUIStringProvider(context, locale), + isCredentialManagerEnabled = isCredentialManagerEnabled, + isMfaEnabled = isMfaEnabled, + isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled, + tosUrl = tosUrl, + privacyPolicyUrl = privacyPolicyUrl, + logo = logo, + actionCodeSettings = actionCodeSettings, + isNewEmailAccountsAllowed = isNewEmailAccountsAllowed, + isDisplayNameRequired = isDisplayNameRequired, + isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown + ) } } @@ -120,6 +118,11 @@ class AuthUIConfigurationBuilder { * Configuration object for the authentication flow. */ class AuthUIConfiguration( + /** + * Application context + */ + val context: Context, + /** * The list of enabled authentication providers. */ @@ -131,14 +134,14 @@ class AuthUIConfiguration( val theme: AuthUITheme = AuthUITheme.Default, /** - * A custom provider for localized strings. + * The locale for internationalization. */ - val stringProvider: AuthUIStringProvider? = null, + val locale: Locale? = null, /** - * The locale for internationalization. + * A custom provider for localized strings. */ - val locale: Locale? = null, + val stringProvider: AuthUIStringProvider = DefaultAuthUIStringProvider(context, locale), /** * Enables integration with Android's Credential Manager API. Defaults to true. diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt deleted file mode 100644 index 0e7080722..000000000 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.firebase.ui.auth.compose.configuration - -import android.content.Context -import com.firebase.ui.auth.R - -/** - * An interface for providing localized string resources. This interface defines methods for all - * user-facing strings, such as initializing(), signInWithGoogle(), invalidEmail(), - * passwordsDoNotMatch(), etc., allowing for complete localization of the UI. - */ -interface AuthUIStringProvider { - /** Loading text displayed during initialization or processing states */ - val initializing: String - - /** Button text for Google sign-in option */ - val signInWithGoogle: String - - /** Error message when email address field is empty */ - val missingEmailAddress: String - - /** Error message when email address format is invalid */ - val invalidEmailAddress: String - - /** Generic error message for incorrect password during sign-in */ - val invalidPassword: String - - /** Error message when password confirmation doesn't match the original password */ - val passwordsDoNotMatch: String - - /** Error message when password doesn't meet minimum length requirement. Should support string formatting with minimum length parameter. */ - val passwordTooShort: String - - /** Error message when password is missing at least one uppercase letter (A-Z) */ - val passwordMissingUppercase: String - - /** Error message when password is missing at least one lowercase letter (a-z) */ - val passwordMissingLowercase: String - - /** Error message when password is missing at least one numeric digit (0-9) */ - val passwordMissingDigit: String - - /** Error message when password is missing at least one special character */ - val passwordMissingSpecialCharacter: String -} - -internal class DefaultAuthUIStringProvider(private val context: Context) : AuthUIStringProvider { - override val initializing: String get() = "" - override val signInWithGoogle: String - get() = context.getString(R.string.fui_sign_in_with_google) - override val missingEmailAddress: String - get() = context.getString(R.string.fui_missing_email_address) - override val invalidEmailAddress: String - get() = context.getString(R.string.fui_invalid_email_address) - override val invalidPassword: String - get() = context.getString(R.string.fui_error_invalid_password) - override val passwordsDoNotMatch: String get() = "" - override val passwordTooShort: String - get() = context.getString(R.string.fui_error_password_too_short) - override val passwordMissingUppercase: String - get() = context.getString(R.string.fui_error_password_missing_uppercase) - override val passwordMissingLowercase: String - get() = context.getString(R.string.fui_error_password_missing_lowercase) - override val passwordMissingDigit: String - get() = context.getString(R.string.fui_error_password_missing_digit) - override val passwordMissingSpecialCharacter: String - get() = context.getString(R.string.fui_error_password_missing_special_character) -} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt index 8f53822f2..5c5d5b125 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt @@ -14,6 +14,8 @@ package com.firebase.ui.auth.compose.configuration +import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider + /** * An abstract class representing a set of validation rules that can be applied to a password field, * typically within the [AuthProvider.Email] configuration. diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProvider.kt new file mode 100644 index 000000000..646727fb7 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProvider.kt @@ -0,0 +1,187 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.stringprovider + +/** + * An interface for providing localized string resources. This interface defines methods for all + * user-facing strings, such as initializing(), signInWithGoogle(), invalidEmailAddress(), + * passwordsDoNotMatch(), etc., allowing for complete localization of the UI. + * + * @sample AuthUIStringProviderSample + */ +interface AuthUIStringProvider { + /** Loading text displayed during initialization or processing states */ + val initializing: String + + /** Text for Google Provider */ + val googleProvider: String + + /** Text for Facebook Provider */ + val facebookProvider: String + + /** Text for Twitter Provider */ + val twitterProvider: String + + /** Text for Github Provider */ + val githubProvider: String + + /** Text for Phone Provider */ + val phoneProvider: String + + /** Text for Email Provider */ + val emailProvider: String + + /** Button text for Google sign-in option */ + val signInWithGoogle: String + + /** Button text for Facebook sign-in option */ + val signInWithFacebook: String + + /** Button text for Twitter sign-in option */ + val signInWithTwitter: String + + /** Button text for Github sign-in option */ + val signInWithGithub: String + + /** Button text for Email sign-in option */ + val signInWithEmail: String + + /** Button text for Phone sign-in option */ + val signInWithPhone: String + + /** Button text for Anonymous sign-in option */ + val signInAnonymously: String + + /** Button text for Apple sign-in option */ + val signInWithApple: String + + /** Button text for Microsoft sign-in option */ + val signInWithMicrosoft: String + + /** Button text for Yahoo sign-in option */ + val signInWithYahoo: String + + /** Error message when email address field is empty */ + val missingEmailAddress: String + + /** Error message when email address format is invalid */ + val invalidEmailAddress: String + + /** Generic error message for incorrect password during sign-in */ + val invalidPassword: String + + /** Error message when password confirmation doesn't match the original password */ + val passwordsDoNotMatch: String + + /** Error message when password doesn't meet minimum length requirement. Should support string formatting with minimum length parameter. */ + val passwordTooShort: String + + /** Error message when password is missing at least one uppercase letter (A-Z) */ + val passwordMissingUppercase: String + + /** Error message when password is missing at least one lowercase letter (a-z) */ + val passwordMissingLowercase: String + + /** Error message when password is missing at least one numeric digit (0-9) */ + val passwordMissingDigit: String + + /** Error message when password is missing at least one special character */ + val passwordMissingSpecialCharacter: String + + // Email Authentication Strings + /** Title for email signup form */ + val titleRegisterEmail: String + + /** Hint for email input field */ + val emailHint: String + + /** Hint for password input field */ + val passwordHint: String + + /** Hint for new password input field */ + val newPasswordHint: String + + /** Hint for name input field */ + val nameHint: String + + /** Button text to save form */ + val buttonTextSave: String + + /** Welcome back header for email users */ + val welcomeBackEmailHeader: String + + /** Trouble signing in link text */ + val troubleSigningIn: String + + // Phone Authentication Strings + /** Phone number entry form title */ + val verifyPhoneNumberTitle: String + + /** Hint for phone input field */ + val phoneHint: String + + /** Hint for country input field */ + val countryHint: String + + /** Invalid phone number error */ + val invalidPhoneNumber: String + + /** Phone verification code entry form title */ + val enterConfirmationCode: String + + /** Button text to verify phone number */ + val verifyPhoneNumber: String + + /** Resend code countdown timer */ + val resendCodeIn: String + + /** Resend code link text */ + val resendCode: String + + /** Verifying progress text */ + val verifying: String + + /** Wrong verification code error */ + val incorrectCodeDialogBody: String + + /** SMS terms of service warning */ + val smsTermsOfService: String + + // Provider Picker Strings + /** Common button text for sign in */ + val signInDefault: String + + /** Common button text for continue */ + val continueText: String + + /** Common button text for next */ + val nextDefault: String + + // General Error Messages + /** General unknown error message */ + val errorUnknown: String + + /** Required field error */ + val requiredField: String + + /** Loading progress text */ + val progressDialogLoading: String + + /** Network error message */ + val noInternet: String + + /** TOTP Code prompt */ + val enterTOTPCode: String +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProviderSample.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProviderSample.kt new file mode 100644 index 000000000..7ddf64522 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProviderSample.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.stringprovider + +import android.content.Context +import com.firebase.ui.auth.compose.configuration.AuthProvider +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.authUIConfiguration + +class AuthUIStringProviderSample { + /** + * Override specific strings while delegating others to default provider + */ + class CustomAuthUIStringProvider( + private val defaultProvider: AuthUIStringProvider + ) : AuthUIStringProvider by defaultProvider { + + // Override only the strings you want to customize + override val signInWithGoogle: String = "Continue with Google • MyApp" + override val signInWithFacebook: String = "Continue with Facebook • MyApp" + + // Add custom branding to common actions + override val continueText: String = "Continue to MyApp" + override val signInDefault: String = "Sign in to MyApp" + + // Custom MFA messaging + override val enterTOTPCode: String = + "Enter the 6-digit code from your authenticator app to secure your MyApp account" + } + + fun createCustomConfiguration(applicationContext: Context): AuthUIConfiguration { + val customStringProvider = + CustomAuthUIStringProvider(DefaultAuthUIStringProvider(applicationContext)) + return authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "" + ) + ) + } + stringProvider = customStringProvider + } + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/DefaultAuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/DefaultAuthUIStringProvider.kt new file mode 100644 index 000000000..96a74cdd7 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/DefaultAuthUIStringProvider.kt @@ -0,0 +1,182 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.stringprovider + +import android.content.Context +import android.content.res.Configuration +import com.firebase.ui.auth.R +import java.util.Locale + +class DefaultAuthUIStringProvider( + private val context: Context, + private val locale: Locale? = null, +) : AuthUIStringProvider { + /** + * Allows overriding locale. + */ + private val localizedContext = locale?.let { locale -> + context.createConfigurationContext( + Configuration(context.resources.configuration).apply { + setLocale(locale) + } + ) + } ?: context + + /** + * Common Strings + */ + override val initializing: String + get() = "Initializing" + + /** + * Auth Provider strings + */ + override val googleProvider: String + get() = localizedContext.getString(R.string.fui_idp_name_google) + override val facebookProvider: String + get() = localizedContext.getString(R.string.fui_idp_name_facebook) + override val twitterProvider: String + get() = localizedContext.getString(R.string.fui_idp_name_twitter) + override val githubProvider: String + get() = localizedContext.getString(R.string.fui_idp_name_github) + override val phoneProvider: String + get() = localizedContext.getString(R.string.fui_idp_name_phone) + override val emailProvider: String + get() = localizedContext.getString(R.string.fui_idp_name_email) + + /** + * Auth Provider Button Strings + */ + override val signInWithGoogle: String + get() = localizedContext.getString(R.string.fui_sign_in_with_google) + override val signInWithFacebook: String + get() = localizedContext.getString(R.string.fui_sign_in_with_facebook) + override val signInWithTwitter: String + get() = localizedContext.getString(R.string.fui_sign_in_with_twitter) + override val signInWithGithub: String + get() = localizedContext.getString(R.string.fui_sign_in_with_github) + override val signInWithEmail: String + get() = localizedContext.getString(R.string.fui_sign_in_with_email) + override val signInWithPhone: String + get() = localizedContext.getString(R.string.fui_sign_in_with_phone) + override val signInAnonymously: String + get() = localizedContext.getString(R.string.fui_sign_in_anonymously) + override val signInWithApple: String + get() = localizedContext.getString(R.string.fui_sign_in_with_apple) + override val signInWithMicrosoft: String + get() = localizedContext.getString(R.string.fui_sign_in_with_microsoft) + override val signInWithYahoo: String + get() = localizedContext.getString(R.string.fui_sign_in_with_yahoo) + + /** + * Email Validator Strings + */ + override val missingEmailAddress: String + get() = localizedContext.getString(R.string.fui_missing_email_address) + override val invalidEmailAddress: String + get() = localizedContext.getString(R.string.fui_invalid_email_address) + + /** + * Password Validator Strings + */ + override val invalidPassword: String + get() = localizedContext.getString(R.string.fui_error_invalid_password) + override val passwordsDoNotMatch: String + get() = localizedContext.getString(R.string.fui_passwords_do_not_match) + override val passwordTooShort: String + get() = localizedContext.getString(R.string.fui_error_password_too_short) + override val passwordMissingUppercase: String + get() = localizedContext.getString(R.string.fui_error_password_missing_uppercase) + override val passwordMissingLowercase: String + get() = localizedContext.getString(R.string.fui_error_password_missing_lowercase) + override val passwordMissingDigit: String + get() = localizedContext.getString(R.string.fui_error_password_missing_digit) + override val passwordMissingSpecialCharacter: String + get() = localizedContext.getString(R.string.fui_error_password_missing_special_character) + + /** + * Email Authentication Strings + */ + override val titleRegisterEmail: String + get() = localizedContext.getString(R.string.fui_title_register_email) + override val emailHint: String + get() = localizedContext.getString(R.string.fui_email_hint) + override val passwordHint: String + get() = localizedContext.getString(R.string.fui_password_hint) + override val newPasswordHint: String + get() = localizedContext.getString(R.string.fui_new_password_hint) + override val nameHint: String + get() = localizedContext.getString(R.string.fui_name_hint) + override val buttonTextSave: String + get() = localizedContext.getString(R.string.fui_button_text_save) + override val welcomeBackEmailHeader: String + get() = localizedContext.getString(R.string.fui_welcome_back_email_header) + override val troubleSigningIn: String + get() = localizedContext.getString(R.string.fui_trouble_signing_in) + + /** + * Phone Authentication Strings + */ + override val verifyPhoneNumberTitle: String + get() = localizedContext.getString(R.string.fui_verify_phone_number_title) + override val phoneHint: String + get() = localizedContext.getString(R.string.fui_phone_hint) + override val countryHint: String + get() = localizedContext.getString(R.string.fui_country_hint) + override val invalidPhoneNumber: String + get() = localizedContext.getString(R.string.fui_invalid_phone_number) + override val enterConfirmationCode: String + get() = localizedContext.getString(R.string.fui_enter_confirmation_code) + override val verifyPhoneNumber: String + get() = localizedContext.getString(R.string.fui_verify_phone_number) + override val resendCodeIn: String + get() = localizedContext.getString(R.string.fui_resend_code_in) + override val resendCode: String + get() = localizedContext.getString(R.string.fui_resend_code) + override val verifying: String + get() = localizedContext.getString(R.string.fui_verifying) + override val incorrectCodeDialogBody: String + get() = localizedContext.getString(R.string.fui_incorrect_code_dialog_body) + override val smsTermsOfService: String + get() = localizedContext.getString(R.string.fui_sms_terms_of_service) + + /** + * Multi-Factor Authentication Strings + */ + override val enterTOTPCode: String + get() = "Enter TOTP Code" + + /** + * Provider Picker Strings + */ + override val signInDefault: String + get() = localizedContext.getString(R.string.fui_sign_in_default) + override val continueText: String + get() = localizedContext.getString(R.string.fui_continue) + override val nextDefault: String + get() = localizedContext.getString(R.string.fui_next_default) + + /** + * General Error Messages + */ + override val errorUnknown: String + get() = localizedContext.getString(R.string.fui_error_unknown) + override val requiredField: String + get() = localizedContext.getString(R.string.fui_required_field) + override val progressDialogLoading: String + get() = localizedContext.getString(R.string.fui_progress_dialog_loading) + override val noInternet: String + get() = localizedContext.getString(R.string.fui_no_internet) +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt index d6b66194f..7acfc8bc1 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt @@ -14,7 +14,7 @@ package com.firebase.ui.auth.compose.configuration.validators -import com.firebase.ui.auth.compose.configuration.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider internal class EmailValidator(override val stringProvider: AuthUIStringProvider) : FieldValidator { private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt index a26741897..efa72188f 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt @@ -14,7 +14,7 @@ package com.firebase.ui.auth.compose.configuration.validators -import com.firebase.ui.auth.compose.configuration.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider /** * An interface for validating input fields. diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt index 35605818e..67cb7d376 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt @@ -14,7 +14,7 @@ package com.firebase.ui.auth.compose.configuration.validators -import com.firebase.ui.auth.compose.configuration.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.PasswordRule internal class PasswordValidator( diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 71b87f547..1d73384e4 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -94,6 +94,7 @@ Incorrect password. + Passwords do not match Password must be at least %1$d characters long Password must contain at least one uppercase letter Password must contain at least one lowercase letter diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt index 0233848e4..95164f638 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -14,13 +14,23 @@ package com.firebase.ui.auth.compose.configuration +import android.content.Context +import android.content.res.Configuration import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider import com.google.common.truth.Truth.assertThat import com.google.firebase.auth.actionCodeSettings import org.junit.Assert.assertThrows +import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import java.util.Locale import kotlin.reflect.KMutableProperty import kotlin.reflect.full.memberProperties @@ -31,8 +41,17 @@ import kotlin.reflect.full.memberProperties * * @suppress Internal test class */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) class AuthUIConfigurationTest { + private lateinit var applicationContext: Context + + @Before + fun setUp() { + applicationContext = ApplicationProvider.getApplicationContext() + } + // ============================================================================================= // Basic Configuration Tests // ============================================================================================= @@ -40,6 +59,7 @@ class AuthUIConfigurationTest { @Test fun `authUIConfiguration with minimal setup uses correct defaults`() { val config = authUIConfiguration { + context = applicationContext providers { provider( AuthProvider.Google( @@ -50,9 +70,10 @@ class AuthUIConfigurationTest { } } + assertThat(config.context).isEqualTo(applicationContext) assertThat(config.providers).hasSize(1) assertThat(config.theme).isEqualTo(AuthUITheme.Default) - assertThat(config.stringProvider).isNull() + assertThat(config.stringProvider).isInstanceOf(DefaultAuthUIStringProvider::class.java) assertThat(config.locale).isNull() assertThat(config.isCredentialManagerEnabled).isTrue() assertThat(config.isMfaEnabled).isTrue() @@ -77,6 +98,7 @@ class AuthUIConfigurationTest { } val config = authUIConfiguration { + context = applicationContext providers { provider( AuthProvider.Google( @@ -105,6 +127,7 @@ class AuthUIConfigurationTest { isProviderChoiceAlwaysShown = true } + assertThat(config.context).isEqualTo(applicationContext) assertThat(config.providers).hasSize(2) assertThat(config.theme).isEqualTo(customTheme) assertThat(config.stringProvider).isEqualTo(customStringProvider) @@ -121,18 +144,146 @@ class AuthUIConfigurationTest { assertThat(config.isProviderChoiceAlwaysShown).isTrue() } + @Test + fun `providers block can be called multiple times and accumulates providers`() { + val config = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "" + ) + ) + } + + providers { + provider( + AuthProvider.Github( + customParameters = mapOf() + ) + ) + } + isCredentialManagerEnabled = true + } + + assertThat(config.providers).hasSize(2) + } + + @Test + fun `authUIConfiguration uses custom string provider`() { + val spanishAuthUIStringProvider = + object : AuthUIStringProvider by DefaultAuthUIStringProvider(applicationContext) { + // Email Validation + override val missingEmailAddress: String = + "Ingrese su dirección de correo para continuar" + override val invalidEmailAddress: String = "Esa dirección de correo no es correcta" + + // Password Validation + override val invalidPassword: String = "Contraseña incorrecta" + override val passwordsDoNotMatch: String = "Las contraseñas no coinciden" + } + + val config = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "" + ) + ) + } + stringProvider = spanishAuthUIStringProvider + } + + assertThat(config.stringProvider.missingEmailAddress) + .isEqualTo(spanishAuthUIStringProvider.missingEmailAddress) + } + + @Test + fun `locale set to FR in authUIConfiguration reflects in DefaultAuthUIStringProvider`() { + val localizedContext = applicationContext.createConfigurationContext( + Configuration(applicationContext.resources.configuration).apply { + setLocale(Locale.FRANCE) + } + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "" + ) + ) + } + locale = Locale.FRANCE + } + + assertThat(config.stringProvider.continueText) + .isEqualTo(localizedContext.getString(R.string.fui_continue)) + } + + @Test + fun `unsupported locale set in authUIConfiguration uses default localized strings`() { + val unsupportedLocale = Locale("zz", "ZZ") + + val config = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Google( + scopes = listOf(), serverClientId + = "" + ) + ) + } + locale = unsupportedLocale + } + + assertThat(config.stringProvider.signInWithGoogle).isNotEmpty() + assertThat(config.stringProvider.continueText).isNotEmpty() + assertThat(config.stringProvider.signInWithGoogle) + .isEqualTo(applicationContext.getString(R.string.fui_sign_in_with_google)) + assertThat(config.stringProvider.continueText) + .isEqualTo(applicationContext.getString(R.string.fui_continue)) + } + // ============================================================================================= // Validation Tests // ============================================================================================= - @Test(expected = IllegalArgumentException::class) + @Test + fun `authUIConfiguration throws when no context configured`() { + try { + authUIConfiguration { + context = applicationContext + providers { + provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) + } + } + } catch (e: Exception) { + assertThat(e.message).isEqualTo("Application context is required") + } + } + + @Test fun `authUIConfiguration throws when no providers configured`() { - authUIConfiguration { } + try { + authUIConfiguration { + context = applicationContext + } + } catch (e: Exception) { + assertThat(e.message).isEqualTo("At least one provider must be configured") + } } @Test fun `validation accepts all supported providers`() { val config = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) provider(AuthProvider.Facebook()) @@ -165,6 +316,7 @@ class AuthUIConfigurationTest { ) authUIConfiguration { + context = applicationContext providers { provider(mockProvider) } @@ -174,6 +326,7 @@ class AuthUIConfigurationTest { @Test(expected = IllegalStateException::class) fun `validate throws when only anonymous provider is configured`() { authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Anonymous) } @@ -183,6 +336,7 @@ class AuthUIConfigurationTest { @Test(expected = IllegalArgumentException::class) fun `validate throws for duplicate providers`() { authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) provider( @@ -198,6 +352,7 @@ class AuthUIConfigurationTest { @Test(expected = IllegalArgumentException::class) fun `validate throws for enableEmailLinkSignIn true when actionCodeSettings is null`() { authUIConfiguration { + context = applicationContext providers { provider( AuthProvider.Email( @@ -217,6 +372,7 @@ class AuthUIConfigurationTest { handleCodeInApp = false } authUIConfiguration { + context = applicationContext providers { provider( AuthProvider.Email( @@ -229,35 +385,6 @@ class AuthUIConfigurationTest { } } - // ============================================================================================= - // Provider Configuration Tests - // ============================================================================================= - - @Test - fun `providers block can be called multiple times and accumulates providers`() { - val config = authUIConfiguration { - providers { - provider( - AuthProvider.Google( - scopes = listOf(), - serverClientId = "" - ) - ) - } - - providers { - provider( - AuthProvider.Github( - customParameters = mapOf() - ) - ) - } - isCredentialManagerEnabled = true - } - - assertThat(config.providers).hasSize(2) - } - // ============================================================================================= // Builder Immutability Tests // ============================================================================================= @@ -265,6 +392,7 @@ class AuthUIConfigurationTest { @Test fun `authUIConfiguration providers list is immutable`() { val config = authUIConfiguration { + context = applicationContext providers { provider( AuthProvider.Google( @@ -297,6 +425,7 @@ class AuthUIConfigurationTest { } val expectedProperties = setOf( + "context", "providers", "theme", "stringProvider", diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt index d3cacb488..a4d5139a6 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt @@ -17,6 +17,8 @@ package com.firebase.ui.auth.compose.configuration import android.content.Context import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test @@ -34,7 +36,7 @@ import org.robolectric.annotation.Config @Config(manifest = Config.NONE) class PasswordRuleTest { - private lateinit var stringProvider: DefaultAuthUIStringProvider + private lateinit var stringProvider: AuthUIStringProvider @Before fun setUp() { diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt index 520908181..3253cfb1c 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt @@ -17,12 +17,12 @@ package com.firebase.ui.auth.compose.configuration.validators import android.content.Context import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.configuration.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mock import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -37,7 +37,7 @@ import org.robolectric.annotation.Config @Config(manifest = Config.NONE) class EmailValidatorTest { - private lateinit var stringProvider: DefaultAuthUIStringProvider + private lateinit var stringProvider: AuthUIStringProvider private lateinit var emailValidator: EmailValidator diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt index 4e8d2e440..a3993bd36 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt @@ -17,7 +17,8 @@ package com.firebase.ui.auth.compose.configuration.validators import android.content.Context import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.configuration.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.PasswordRule import com.google.common.truth.Truth.assertThat import org.junit.Before @@ -37,7 +38,7 @@ import org.robolectric.annotation.Config @Config(manifest = Config.NONE) class PasswordValidatorTest { - private lateinit var stringProvider: DefaultAuthUIStringProvider + private lateinit var stringProvider: AuthUIStringProvider private lateinit var passwordValidator: PasswordValidator @Before From f1dfd0ccb75713c157e203bc9b2eb589be92ab9c Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 24 Sep 2025 13:17:13 +0100 Subject: [PATCH 08/41] feat: Provider Models (AuthProvider + concrete types) (#2223) * feat: added preconditions for specific auth provider configurations * test: covers auth providers with config validations * fix: auth provider validation and tests - validate serverClientId empty string - validate applicationId empty string - remove @Test(expected=) not descriptive - tests covering AuthProviders Google and Facebook config validation --- .../compose/configuration/AuthProvider.kt | 133 +++++- .../configuration/AuthUIConfiguration.kt | 23 +- .../compose/configuration/AuthProviderTest.kt | 399 ++++++++++++++++++ .../configuration/AuthUIConfigurationTest.kt | 112 ++--- 4 files changed, 577 insertions(+), 90 deletions(-) create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt index ef8bb0771..d44a9d6d9 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt @@ -14,8 +14,15 @@ package com.firebase.ui.auth.compose.configuration +import android.content.Context import android.graphics.Color +import android.util.Log import androidx.compose.ui.graphics.vector.ImageVector +import com.firebase.ui.auth.AuthUI +import com.firebase.ui.auth.R +import com.firebase.ui.auth.util.Preconditions +import com.firebase.ui.auth.util.data.PhoneNumberUtils +import com.firebase.ui.auth.util.data.ProviderAvailability import com.google.firebase.auth.ActionCodeSettings import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FacebookAuthProvider @@ -78,6 +85,14 @@ abstract class AuthProvider(open val providerId: String) { */ val isEmailLinkSignInEnabled: Boolean = false, + /** + * Forces email link sign-in to complete on the same device that initiated it. + * + * When enabled, prevents email links from being opened on different devices, + * which is required for security when upgrading anonymous users. Defaults to true. + */ + val isEmailLinkForceSameDeviceEnabled: Boolean = true, + /** * Settings for email link actions. */ @@ -100,11 +115,10 @@ abstract class AuthProvider(open val providerId: String) { ) : AuthProvider(providerId = Provider.EMAIL.id) { fun validate() { if (isEmailLinkSignInEnabled) { - val actionCodeSettings = actionCodeSettings - ?: requireNotNull(actionCodeSettings) { - "ActionCodeSettings cannot be null when using " + - "email link sign in." - } + val actionCodeSettings = requireNotNull(actionCodeSettings) { + "ActionCodeSettings cannot be null when using " + + "email link sign in." + } check(actionCodeSettings.canHandleCodeInApp()) { "You must set canHandleCodeInApp in your " + @@ -118,6 +132,11 @@ abstract class AuthProvider(open val providerId: String) { * Phone number authentication provider configuration. */ class Phone( + /** + * The phone number in international format. + */ + val defaultNumber: String?, + /** * The default country code to pre-select. */ @@ -147,7 +166,31 @@ abstract class AuthProvider(open val providerId: String) { * Enables automatic retrieval of the SMS code. Defaults to true. */ val isAutoRetrievalEnabled: Boolean = true - ) : AuthProvider(providerId = Provider.PHONE.id) + ) : AuthProvider(providerId = Provider.PHONE.id) { + fun validate() { + defaultNumber?.let { + check(PhoneNumberUtils.isValid(it)) { + "Invalid phone number: $it" + } + } + + defaultCountryCode?.let { + check(PhoneNumberUtils.isValidIso(it)) { + "Invalid country iso: $it" + } + } + + allowedCountries?.forEach { code -> + check( + PhoneNumberUtils.isValidIso(code) || + PhoneNumberUtils.isValid(code) + ) { + "Invalid input: You must provide a valid country iso (alpha-2) " + + "or code (e-164). e.g. 'us' or '+1'. Invalid code: $code" + } + } + } + } /** * Google Sign-In provider configuration. @@ -186,12 +229,40 @@ abstract class AuthProvider(open val providerId: String) { providerId = Provider.GOOGLE.id, scopes = scopes, customParameters = customParameters - ) + ) { + fun validate(context: Context) { + if (serverClientId == null) { + Preconditions.checkConfigured( + context, + "Check your google-services plugin configuration, the" + + " default_web_client_id string wasn't populated.", + R.string.default_web_client_id + ) + } else { + require(serverClientId.isNotBlank()) { + "Server client ID cannot be blank." + } + } + + val hasEmailScope = scopes.contains("email") + if (!hasEmailScope) { + Log.w( + "AuthProvider.Google", + "The scopes do not include 'email'. In most cases this is a mistake!" + ) + } + } + } /** * Facebook Login provider configuration. */ class Facebook( + /** + * The Facebook application ID. + */ + val applicationId: String? = null, + /** * The list of scopes (permissions) to request. Defaults to email and public_profile. */ @@ -210,7 +281,30 @@ abstract class AuthProvider(open val providerId: String) { providerId = Provider.FACEBOOK.id, scopes = scopes, customParameters = customParameters - ) + ) { + fun validate(context: Context) { + if (!ProviderAvailability.IS_FACEBOOK_AVAILABLE) { + throw RuntimeException( + "Facebook provider cannot be configured " + + "without dependency. Did you forget to add " + + "'com.facebook.android:facebook-login:VERSION' dependency?" + ) + } + + if (applicationId == null) { + Preconditions.checkConfigured( + context, + "Facebook provider unconfigured. Make sure to " + + "add a `facebook_application_id` string or provide applicationId parameter.", + R.string.facebook_application_id + ) + } else { + require(applicationId.isNotBlank()) { + "Facebook application ID cannot be blank" + } + } + } + } /** * Twitter/X authentication provider configuration. @@ -314,7 +408,16 @@ abstract class AuthProvider(open val providerId: String) { /** * Anonymous authentication provider. It has no configurable properties. */ - object Anonymous : AuthProvider(providerId = Provider.ANONYMOUS.id) + object Anonymous : AuthProvider(providerId = Provider.ANONYMOUS.id) { + fun validate(providers: List) { + if (providers.size == 1 && providers.first() is Anonymous) { + throw IllegalStateException( + "Sign in as guest cannot be the only sign in method. " + + "In this case, sign the user in anonymously your self; no UI is needed." + ) + } + } + } /** * A generic OAuth provider for any unsupported provider. @@ -353,5 +456,15 @@ abstract class AuthProvider(open val providerId: String) { providerId = providerId, scopes = scopes, customParameters = customParameters - ) + ) { + fun validate() { + require(providerId.isNotBlank()) { + "Provider ID cannot be null or empty" + } + + require(buttonLabel.isNotBlank()) { + "Button label cannot be null or empty" + } + } + } } \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt index ef70c5f3b..98be20ac0 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt @@ -68,12 +68,7 @@ class AuthUIConfigurationBuilder { } // Cannot have only anonymous provider - if (providers.size == 1 && providers.first() is AuthProvider.Anonymous) { - throw IllegalStateException( - "Sign in as guest cannot be the only sign in method. " + - "In this case, sign the user in anonymously your self; no UI is needed." - ) - } + AuthProvider.Anonymous.validate(providers) // Check for duplicate providers val providerIds = providers.map { it.providerId } @@ -89,7 +84,21 @@ class AuthUIConfigurationBuilder { // Provider specific validations providers.forEach { provider -> when (provider) { - is AuthProvider.Email -> provider.validate() + is AuthProvider.Email -> { + provider.validate() + + if (isAnonymousUpgradeEnabled && provider.isEmailLinkSignInEnabled) { + check(provider.isEmailLinkForceSameDeviceEnabled) { + "You must force the same device flow when using email link sign in " + + "with anonymous user upgrade" + } + } + } + + is AuthProvider.Phone -> provider.validate() + is AuthProvider.Google -> provider.validate(context) + is AuthProvider.Facebook -> provider.validate(context) + is AuthProvider.GenericOAuth -> provider.validate() else -> null } } diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt new file mode 100644 index 000000000..27685f859 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt @@ -0,0 +1,399 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.google.firebase.auth.actionCodeSettings +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [AuthProvider] covering provider validation rules, configuration constraints, + * and error handling for all supported authentication providers. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class AuthProviderTest { + + private lateinit var applicationContext: Context + + @Before + fun setUp() { + applicationContext = ApplicationProvider.getApplicationContext() + } + + // ============================================================================================= + // Email Provider Tests + // ============================================================================================= + + @Test + fun `email provider with valid configuration should succeed`() { + val provider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = listOf() + ) + + provider.validate() + } + + @Test + fun `email provider with email link enabled and valid action code settings should succeed`() { + val actionCodeSettings = actionCodeSettings { + url = "https://example.com/verify" + handleCodeInApp = true + } + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + actionCodeSettings = actionCodeSettings, + passwordValidationRules = listOf() + ) + + provider.validate() + } + + @Test + fun `email provider with email link enabled but null action code settings should throw`() { + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + actionCodeSettings = null, + passwordValidationRules = listOf() + ) + + try { + provider.validate() + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo( + "ActionCodeSettings cannot be null when using " + + "email link sign in." + ) + } + } + + @Test + fun `email provider with email link enabled but canHandleCodeInApp false should throw`() { + val actionCodeSettings = actionCodeSettings { + url = "https://example.com/verify" + handleCodeInApp = false + } + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + actionCodeSettings = actionCodeSettings, + passwordValidationRules = listOf() + ) + + try { + provider.validate() + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo( + "You must set canHandleCodeInApp in your " + + "ActionCodeSettings to true for Email-Link Sign-in." + ) + } + } + + // ============================================================================================= + // Phone Provider Tests + // ============================================================================================= + + @Test + fun `phone provider with valid configuration should succeed`() { + val provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + + provider.validate() + } + + @Test + fun `phone provider with valid default number should succeed`() { + val provider = AuthProvider.Phone( + defaultNumber = "+1234567890", + defaultCountryCode = null, + allowedCountries = null + ) + + provider.validate() + } + + @Test + fun `phone provider with invalid default number should throw`() { + val provider = AuthProvider.Phone( + defaultNumber = "invalid_number", + defaultCountryCode = null, + allowedCountries = null + ) + + try { + provider.validate() + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo("Invalid phone number: invalid_number") + } + } + + @Test + fun `phone provider with valid default country code should succeed`() { + val provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = "US", + allowedCountries = null + ) + + provider.validate() + } + + @Test + fun `phone provider with invalid default country code should throw`() { + val provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = "invalid", + allowedCountries = null + ) + + try { + provider.validate() + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo("Invalid country iso: invalid") + } + } + + @Test + fun `phone provider with valid allowed countries should succeed`() { + val provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = listOf("US", "CA", "+1") + ) + + provider.validate() + } + + @Test + fun `phone provider with invalid country in allowed list should throw`() { + val provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = listOf("US", "invalid_country") + ) + + try { + provider.validate() + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo( + "Invalid input: You must provide a valid country iso (alpha-2) " + + "or code (e-164). e.g. 'us' or '+1'. Invalid code: invalid_country" + ) + } + } + + @Test + fun `phone provider with valid default number, country code and compatible allowed countries should succeed`() { + val provider = AuthProvider.Phone( + defaultNumber = "+1234567890", + defaultCountryCode = "US", + allowedCountries = listOf("US", "CA") + ) + + provider.validate() + } + + // ============================================================================================= + // Google Provider Tests + // ============================================================================================= + + @Test + fun `google provider with valid configuration should succeed`() { + val provider = AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "test_client_id" + ) + + provider.validate(applicationContext) + } + + @Test + fun `google provider with empty serverClientId string throws`() { + val provider = AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "" + ) + + try { + provider.validate(applicationContext) + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo("Server client ID cannot be blank.") + } + } + + @Test + fun `google provider validates default_web_client_id when serverClientId is null`() { + val provider = AuthProvider.Google( + scopes = listOf("email"), + serverClientId = null + ) + + try { + provider.validate(applicationContext) + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo( + "Check your google-services plugin " + + "configuration, the default_web_client_id string wasn't populated." + ) + } + } + + // ============================================================================================= + // Facebook Provider Tests + // ============================================================================================= + + @Test + fun `facebook provider with valid configuration should succeed`() { + val provider = AuthProvider.Facebook(applicationId = "application_id") + + provider.validate(applicationContext) + } + + @Test + fun `facebook provider with empty application id throws`() { + val provider = AuthProvider.Facebook(applicationId = "") + + try { + provider.validate(applicationContext) + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo("Facebook application ID cannot be blank") + } + } + + @Test + fun `facebook provider validates facebook_application_id when applicationId is null`() { + val provider = AuthProvider.Facebook() + + try { + provider.validate(applicationContext) + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo( + "Facebook provider unconfigured. Make sure to " + + "add a `facebook_application_id` string or provide applicationId parameter." + ) + } + } + + // ============================================================================================= + // Anonymous Provider Tests + // ============================================================================================= + + @Test + fun `anonymous provider as only provider should throw`() { + val providers = listOf(AuthProvider.Anonymous) + + try { + AuthProvider.Anonymous.validate(providers) + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo( + "Sign in as guest cannot be the only sign in method. " + + "In this case, sign the user in anonymously your self; no UI is needed." + ) + } + } + + @Test + fun `anonymous provider with other providers should succeed`() { + val providers = listOf( + AuthProvider.Anonymous, + AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = listOf() + ) + ) + + AuthProvider.Anonymous.validate(providers) + } + + // ============================================================================================= + // GenericOAuth Provider Tests + // ============================================================================================= + + @Test + fun `generic oauth provider with valid configuration should succeed`() { + val provider = AuthProvider.GenericOAuth( + providerId = "custom.provider", + scopes = listOf("read"), + customParameters = mapOf(), + buttonLabel = "Sign in with Custom", + buttonIcon = null, + buttonColor = null + ) + + provider.validate() + } + + @Test + fun `generic oauth provider with blank provider id should throw`() { + val provider = AuthProvider.GenericOAuth( + providerId = "", + scopes = listOf("read"), + customParameters = mapOf(), + buttonLabel = "Sign in with Custom", + buttonIcon = null, + buttonColor = null + ) + + try { + provider.validate() + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo("Provider ID cannot be null or empty") + } + } + + @Test + fun `generic oauth provider with blank button label should throw`() { + val provider = AuthProvider.GenericOAuth( + providerId = "custom.provider", + scopes = listOf("read"), + customParameters = mapOf(), + buttonLabel = "", + buttonIcon = null, + buttonColor = null + ) + + try { + provider.validate() + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo("Button label cannot be null or empty") + } + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt index 95164f638..96a13795e 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -64,7 +64,7 @@ class AuthUIConfigurationTest { provider( AuthProvider.Google( scopes = listOf(), - serverClientId = "" + serverClientId = "test_client_id" ) ) } @@ -103,7 +103,7 @@ class AuthUIConfigurationTest { provider( AuthProvider.Google( scopes = listOf(), - serverClientId = "" + serverClientId = "test_client_id" ) ) provider( @@ -152,7 +152,7 @@ class AuthUIConfigurationTest { provider( AuthProvider.Google( scopes = listOf(), - serverClientId = "" + serverClientId = "test_client_id" ) ) } @@ -190,7 +190,7 @@ class AuthUIConfigurationTest { provider( AuthProvider.Google( scopes = listOf(), - serverClientId = "" + serverClientId = "test_client_id" ) ) } @@ -215,7 +215,7 @@ class AuthUIConfigurationTest { provider( AuthProvider.Google( scopes = listOf(), - serverClientId = "" + serverClientId = "test_client_id" ) ) } @@ -235,8 +235,8 @@ class AuthUIConfigurationTest { providers { provider( AuthProvider.Google( - scopes = listOf(), serverClientId - = "" + scopes = listOf(), + serverClientId = "test_client_id" ) ) } @@ -261,7 +261,7 @@ class AuthUIConfigurationTest { authUIConfiguration { context = applicationContext providers { - provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) + provider(AuthProvider.Google(scopes = listOf(), serverClientId = "test_client_id")) } } } catch (e: Exception) { @@ -285,14 +285,14 @@ class AuthUIConfigurationTest { val config = authUIConfiguration { context = applicationContext providers { - provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) - provider(AuthProvider.Facebook()) + provider(AuthProvider.Google(scopes = listOf(), serverClientId = "test_client_id")) + provider(AuthProvider.Facebook(applicationId = "test_app_id")) provider(AuthProvider.Twitter(customParameters = mapOf())) provider(AuthProvider.Github(customParameters = mapOf())) provider(AuthProvider.Microsoft(customParameters = mapOf(), tenant = null)) provider(AuthProvider.Yahoo(customParameters = mapOf())) provider(AuthProvider.Apple(customParameters = mapOf(), locale = null)) - provider(AuthProvider.Phone(defaultCountryCode = null, allowedCountries = null)) + provider(AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null)) provider( AuthProvider.Email( actionCodeSettings = null, @@ -304,7 +304,7 @@ class AuthUIConfigurationTest { assertThat(config.providers).hasSize(9) } - @Test(expected = IllegalArgumentException::class) + @Test fun `validation throws for unsupported provider`() { val mockProvider = AuthProvider.GenericOAuth( providerId = "unsupported.provider", @@ -315,73 +315,39 @@ class AuthUIConfigurationTest { buttonColor = null ) - authUIConfiguration { - context = applicationContext - providers { - provider(mockProvider) - } - } - } - - @Test(expected = IllegalStateException::class) - fun `validate throws when only anonymous provider is configured`() { - authUIConfiguration { - context = applicationContext - providers { - provider(AuthProvider.Anonymous) + try { + authUIConfiguration { + context = applicationContext + providers { + provider(mockProvider) + } } + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo("Unknown providers: unsupported.provider") } } - @Test(expected = IllegalArgumentException::class) + @Test fun `validate throws for duplicate providers`() { - authUIConfiguration { - context = applicationContext - providers { - provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) - provider( - AuthProvider.Google( - scopes = listOf("email"), - serverClientId = "different" - ) - ) - } - } - } - - @Test(expected = IllegalArgumentException::class) - fun `validate throws for enableEmailLinkSignIn true when actionCodeSettings is null`() { - authUIConfiguration { - context = applicationContext - providers { - provider( - AuthProvider.Email( - isEmailLinkSignInEnabled = true, - actionCodeSettings = null, - passwordValidationRules = listOf() - ) - ) - } - } - } - - @Test(expected = IllegalStateException::class) - fun `validate throws for enableEmailLinkSignIn true when actionCodeSettings canHandleCodeInApp false`() { - val customActionCodeSettings = actionCodeSettings { - url = "https://example.com" - handleCodeInApp = false - } - authUIConfiguration { - context = applicationContext - providers { - provider( - AuthProvider.Email( - isEmailLinkSignInEnabled = true, - actionCodeSettings = customActionCodeSettings, - passwordValidationRules = listOf() + try { + authUIConfiguration { + context = applicationContext + providers { + provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) + provider( + AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "different" + ) ) - ) + } } + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo( + "Each provider can only be set once. Duplicates: google.com" + ) } } @@ -397,7 +363,7 @@ class AuthUIConfigurationTest { provider( AuthProvider.Google( scopes = listOf(), - serverClientId = "" + serverClientId = "test_client_id" ) ) } From b88993f2d836ba5635ef8a1ededf7fe834791e2e Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 25 Sep 2025 10:11:33 +0100 Subject: [PATCH 09/41] feat: AuthUITheme, ProviderStyle and tests (#2224) - added junit ui test library for compose ui tests - refactor: default provider styles - test: integration tests to validate AuthUITheme.Default is injected by default - test: validate fromMaterialTheme inherits clients MaterialTheme when AuthUITheme overridden --- auth/build.gradle.kts | 1 + .../configuration/AuthUIConfiguration.kt | 1 + .../auth/compose/configuration/AuthUITheme.kt | 205 ------------------ .../configuration/theme/AuthUITheme.kt | 130 +++++++++++ .../theme/ProviderStyleDefaults.kt | 105 +++++++++ auth/src/test/AndroidManifest.xml | 24 ++ .../configuration/AuthUIConfigurationTest.kt | 1 + .../configuration/theme/AuthUIThemeTest.kt | 81 +++++++ gradle/libs.versions.toml | 2 + 9 files changed, 345 insertions(+), 205 deletions(-) delete mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt create mode 100644 auth/src/test/AndroidManifest.xml create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/theme/AuthUIThemeTest.kt diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index 8e6d5304e..b26051d1c 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -115,6 +115,7 @@ dependencies { testImplementation(Config.Libs.Test.robolectric) testImplementation(Config.Libs.Test.kotlinReflect) testImplementation(Config.Libs.Provider.facebook) + testImplementation(libs.androidx.ui.test.junit4) debugImplementation(project(":internal:lintchecks")) } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt index 98be20ac0..4d9bc280d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt @@ -20,6 +20,7 @@ import com.google.firebase.auth.ActionCodeSettings import androidx.compose.ui.graphics.vector.ImageVector import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme fun actionCodeSettings(block: ActionCodeSettings.Builder.() -> Unit) = ActionCodeSettings.newBuilder().apply(block).build() diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt deleted file mode 100644 index d2ae7032d..000000000 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUITheme.kt +++ /dev/null @@ -1,205 +0,0 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.firebase.ui.auth.compose.configuration - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Shapes -import androidx.compose.material3.Typography -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -private val LocalAuthUITheme = staticCompositionLocalOf { AuthUITheme.Default } - -/** - * Theming configuration for the entire Auth UI. - */ -class AuthUITheme( - /** - * The color scheme to use. - */ - val colorScheme: ColorScheme, - - /** - * The typography to use. - */ - val typography: Typography, - - /** - * The shapes to use for UI elements. - */ - val shapes: Shapes, - - /** - * A map of provider IDs to custom styling. - */ - val providerStyles: Map = emptyMap() -) { - - /** - * A class nested within AuthUITheme that defines the visual appearance of a specific - * provider button, allowing for per-provider branding and customization. - */ - class ProviderStyle( - /** - * The background color of the button. - */ - val backgroundColor: Color, - - /** - * The color of the text label on the button. - */ - val contentColor: Color, - - /** - * An optional tint color for the provider's icon. If null, - * the icon's intrinsic color is used. - */ - var iconTint: Color? = null, - - /** - * The shape of the button container. Defaults to RoundedCornerShape(4.dp). - */ - val shape: Shape = RoundedCornerShape(4.dp), - - /** - * The shadow elevation for the button. Defaults to 2.dp. - */ - val elevation: Dp = 2.dp - ) - - companion object { - /** - * A standard light theme with Material 3 defaults and - * pre-configured provider styles. - */ - val Default = AuthUITheme( - colorScheme = lightColorScheme(), - typography = Typography(), - shapes = Shapes(), - providerStyles = defaultProviderStyles - ) - - /** - * Creates a theme inheriting the app's current Material - * Theme settings. - */ - @Composable - fun fromMaterialTheme( - providerStyles: Map = Default.providerStyles - ): AuthUITheme { - return AuthUITheme( - colorScheme = MaterialTheme.colorScheme, - typography = MaterialTheme.typography, - shapes = MaterialTheme.shapes, - providerStyles = providerStyles - ) - } - - internal val defaultProviderStyles - get(): Map { - return Provider.entries.associate { provider -> - when (provider) { - Provider.GOOGLE -> { - provider.id to ProviderStyle( - backgroundColor = Color.White, - contentColor = Color(0xFF757575) - ) - } - - Provider.FACEBOOK -> { - provider.id to ProviderStyle( - backgroundColor = Color(0xFF3B5998), - contentColor = Color.White - ) - } - - Provider.TWITTER -> { - provider.id to ProviderStyle( - backgroundColor = Color(0xFF5BAAF4), - contentColor = Color.White - ) - } - - Provider.GITHUB -> { - provider.id to ProviderStyle( - backgroundColor = Color(0xFF24292E), - contentColor = Color.White - ) - } - - Provider.EMAIL -> { - provider.id to ProviderStyle( - backgroundColor = Color(0xFFD0021B), - contentColor = Color.White - ) - } - - Provider.PHONE -> { - provider.id to ProviderStyle( - backgroundColor = Color(0xFF43C5A5), - contentColor = Color.White - ) - } - - Provider.ANONYMOUS -> { - provider.id to ProviderStyle( - backgroundColor = Color(0xFFF4B400), - contentColor = Color.White - ) - } - - Provider.MICROSOFT -> { - provider.id to ProviderStyle( - backgroundColor = Color(0xFF2F2F2F), - contentColor = Color.White - ) - } - - Provider.YAHOO -> { - provider.id to ProviderStyle( - backgroundColor = Color(0xFF720E9E), - contentColor = Color.White - ) - } - - Provider.APPLE -> { - provider.id to ProviderStyle( - backgroundColor = Color.Black, - contentColor = Color.White - ) - } - } - } - } - } -} - -@Composable -fun AuthUITheme( - theme: AuthUITheme = AuthUITheme.Default, - content: @Composable () -> Unit -) { - CompositionLocalProvider(LocalAuthUITheme provides theme) { - content() - } -} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt new file mode 100644 index 000000000..d83cf5923 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Theming configuration for the entire Auth UI. + */ +class AuthUITheme( + /** + * The color scheme to use. + */ + val colorScheme: ColorScheme, + + /** + * The typography to use. + */ + val typography: Typography, + + /** + * The shapes to use for UI elements. + */ + val shapes: Shapes, + + /** + * A map of provider IDs to custom styling. + */ + val providerStyles: Map = emptyMap() +) { + + /** + * A class nested within AuthUITheme that defines the visual appearance of a specific + * provider button, allowing for per-provider branding and customization. + */ + class ProviderStyle( + /** + * The background color of the button. + */ + val backgroundColor: Color, + + /** + * The color of the text label on the button. + */ + val contentColor: Color, + + /** + * An optional tint color for the provider's icon. If null, + * the icon's intrinsic color is used. + */ + var iconTint: Color? = null, + + /** + * The shape of the button container. Defaults to RoundedCornerShape(4.dp). + */ + val shape: Shape = RoundedCornerShape(4.dp), + + /** + * The shadow elevation for the button. Defaults to 2.dp. + */ + val elevation: Dp = 2.dp + ) + + companion object { + /** + * A standard light theme with Material 3 defaults and + * pre-configured provider styles. + */ + val Default = AuthUITheme( + colorScheme = lightColorScheme( + primary = Color(0xFFFFA611) + ), + typography = Typography(), + shapes = Shapes(), + providerStyles = ProviderStyleDefaults.default + ) + + /** + * Creates a theme inheriting the app's current Material + * Theme settings. + */ + @Composable + fun fromMaterialTheme( + providerStyles: Map = ProviderStyleDefaults.default + ): AuthUITheme { + return AuthUITheme( + colorScheme = MaterialTheme.colorScheme, + typography = MaterialTheme.typography, + shapes = MaterialTheme.shapes, + providerStyles = providerStyles + ) + } + + } +} + +@Composable +fun AuthUITheme( + theme: AuthUITheme = AuthUITheme.Default, + content: @Composable () -> Unit +) { + MaterialTheme( + colorScheme = theme.colorScheme, + typography = theme.typography, + shapes = theme.shapes, + content = content + ) +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt new file mode 100644 index 000000000..4f063c9a5 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.theme + +import androidx.compose.ui.graphics.Color +import com.firebase.ui.auth.compose.configuration.Provider + +/** + * Default provider styling configurations for authentication providers. + * + * This object provides brand-appropriate visual styling for each supported authentication + * provider, including background colors, text colors, and other visual properties that + * match each provider's brand guidelines. + * + * The styles are automatically applied when using [AuthUITheme.Default] or can be + * customized by passing a modified map to [AuthUITheme.fromMaterialTheme]. + */ +internal object ProviderStyleDefaults { + val default: Map + get() = Provider.entries.associate { provider -> + when (provider) { + Provider.GOOGLE -> { + provider.id to AuthUITheme.ProviderStyle( + backgroundColor = Color.White, + contentColor = Color(0xFF757575) + ) + } + + Provider.FACEBOOK -> { + provider.id to AuthUITheme.ProviderStyle( + backgroundColor = Color(0xFF3B5998), + contentColor = Color.White + ) + } + + Provider.TWITTER -> { + provider.id to AuthUITheme.ProviderStyle( + backgroundColor = Color(0xFF5BAAF4), + contentColor = Color.White + ) + } + + Provider.GITHUB -> { + provider.id to AuthUITheme.ProviderStyle( + backgroundColor = Color(0xFF24292E), + contentColor = Color.White + ) + } + + Provider.EMAIL -> { + provider.id to AuthUITheme.ProviderStyle( + backgroundColor = Color(0xFFD0021B), + contentColor = Color.White + ) + } + + Provider.PHONE -> { + provider.id to AuthUITheme.ProviderStyle( + backgroundColor = Color(0xFF43C5A5), + contentColor = Color.White + ) + } + + Provider.ANONYMOUS -> { + provider.id to AuthUITheme.ProviderStyle( + backgroundColor = Color(0xFFF4B400), + contentColor = Color.White + ) + } + + Provider.MICROSOFT -> { + provider.id to AuthUITheme.ProviderStyle( + backgroundColor = Color(0xFF2F2F2F), + contentColor = Color.White + ) + } + + Provider.YAHOO -> { + provider.id to AuthUITheme.ProviderStyle( + backgroundColor = Color(0xFF720E9E), + contentColor = Color.White + ) + } + + Provider.APPLE -> { + provider.id to AuthUITheme.ProviderStyle( + backgroundColor = Color.Black, + contentColor = Color.White + ) + } + } + } +} \ No newline at end of file diff --git a/auth/src/test/AndroidManifest.xml b/auth/src/test/AndroidManifest.xml new file mode 100644 index 000000000..66eb2ad51 --- /dev/null +++ b/auth/src/test/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt index 96a13795e..118977c09 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -22,6 +22,7 @@ import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme import com.google.common.truth.Truth.assertThat import com.google.firebase.auth.actionCodeSettings import org.junit.Assert.assertThrows diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/theme/AuthUIThemeTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/theme/AuthUIThemeTest.kt new file mode 100644 index 000000000..f2a9b88cc --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/theme/AuthUIThemeTest.kt @@ -0,0 +1,81 @@ +package com.firebase.ui.auth.compose.configuration.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class AuthUIThemeTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `Default AuthUITheme applies to MaterialTheme`() { + val theme = AuthUITheme.Default + + composeTestRule.setContent { + AuthUITheme { + assertThat(MaterialTheme.colorScheme).isEqualTo(theme.colorScheme) + assertThat(MaterialTheme.typography).isEqualTo(theme.typography) + assertThat(MaterialTheme.shapes).isEqualTo(theme.shapes) + } + } + } + + @Test + fun `fromMaterialTheme inherits client MaterialTheme values`() { + val appLightColorScheme = lightColorScheme( + primary = Color(0xFF6650a4), + secondary = Color(0xFF625b71), + tertiary = Color(0xFF7D5260) + ) + + val appTypography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + ) + + val appShapes = Shapes(extraSmall = RoundedCornerShape(13.dp)) + + composeTestRule.setContent { + MaterialTheme( + colorScheme = appLightColorScheme, + typography = appTypography, + shapes = appShapes, + ) { + AuthUITheme( + theme = AuthUITheme.fromMaterialTheme() + ) { + assertThat(MaterialTheme.colorScheme) + .isEqualTo(appLightColorScheme) + assertThat(MaterialTheme.typography) + .isEqualTo(appTypography) + assertThat(MaterialTheme.shapes) + .isEqualTo(appShapes) + } + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 379fe1b5b..44eb15432 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,8 @@ kotlin = "2.2.0" [libraries] +# Testing +androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } [plugins] compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } \ No newline at end of file From 29a443ffdfefd705c7e69cb072441f8f265848f3 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Thu, 25 Sep 2025 14:15:09 +0200 Subject: [PATCH 10/41] feat: Error Recovery & Dialog (#2225) * feat: Error Recovery & Dialog * fix --- .../ui/auth/compose/ErrorRecoveryDialog.kt | 199 +++++++++++ .../stringprovider/AuthUIStringProvider.kt | 40 +++ .../DefaultAuthUIStringProvider.kt | 30 ++ auth/src/main/res/values-ar/strings.xml | 7 +- auth/src/main/res/values-b+es+419/strings.xml | 7 +- auth/src/main/res/values-bg/strings.xml | 7 +- auth/src/main/res/values-bn/strings.xml | 6 + auth/src/main/res/values-ca/strings.xml | 6 + auth/src/main/res/values-cs/strings.xml | 5 + auth/src/main/res/values-da/strings.xml | 7 +- auth/src/main/res/values-de-rAT/strings.xml | 5 + auth/src/main/res/values-de-rCH/strings.xml | 6 + auth/src/main/res/values-de/strings.xml | 7 +- auth/src/main/res/values-el/strings.xml | 6 + auth/src/main/res/values-en-rAU/strings.xml | 7 +- auth/src/main/res/values-en-rCA/strings.xml | 7 +- auth/src/main/res/values-en-rGB/strings.xml | 7 +- auth/src/main/res/values-en-rIE/strings.xml | 7 +- auth/src/main/res/values-en-rIN/strings.xml | 7 +- auth/src/main/res/values-en-rSG/strings.xml | 7 +- auth/src/main/res/values-en-rZA/strings.xml | 7 +- auth/src/main/res/values-es-rAR/strings.xml | 7 +- auth/src/main/res/values-es-rBO/strings.xml | 7 +- auth/src/main/res/values-es-rCL/strings.xml | 7 +- auth/src/main/res/values-es-rCO/strings.xml | 7 +- auth/src/main/res/values-es-rCR/strings.xml | 7 +- auth/src/main/res/values-es-rDO/strings.xml | 7 +- auth/src/main/res/values-es-rEC/strings.xml | 7 +- auth/src/main/res/values-es-rGT/strings.xml | 7 +- auth/src/main/res/values-es-rHN/strings.xml | 7 +- auth/src/main/res/values-es-rMX/strings.xml | 7 +- auth/src/main/res/values-es-rNI/strings.xml | 7 +- auth/src/main/res/values-es-rPA/strings.xml | 7 +- auth/src/main/res/values-es-rPE/strings.xml | 7 +- auth/src/main/res/values-es-rPR/strings.xml | 7 +- auth/src/main/res/values-es-rPY/strings.xml | 7 +- auth/src/main/res/values-es-rSV/strings.xml | 7 +- auth/src/main/res/values-es-rUS/strings.xml | 7 +- auth/src/main/res/values-es-rUY/strings.xml | 7 +- auth/src/main/res/values-es-rVE/strings.xml | 7 +- auth/src/main/res/values-es/strings.xml | 5 + auth/src/main/res/values-fa/strings.xml | 6 + auth/src/main/res/values-fi/strings.xml | 5 + auth/src/main/res/values-fil/strings.xml | 5 + auth/src/main/res/values-fr-rCH/strings.xml | 6 + auth/src/main/res/values-fr/strings.xml | 7 +- auth/src/main/res/values-gsw/strings.xml | 5 + auth/src/main/res/values-gu/strings.xml | 6 + auth/src/main/res/values-hi/strings.xml | 6 + auth/src/main/res/values-hr/strings.xml | 7 +- auth/src/main/res/values-hu/strings.xml | 5 + auth/src/main/res/values-in/strings.xml | 6 + auth/src/main/res/values-it/strings.xml | 7 +- auth/src/main/res/values-iw/strings.xml | 6 + auth/src/main/res/values-ja/strings.xml | 5 + auth/src/main/res/values-kn/strings.xml | 6 + auth/src/main/res/values-ko/strings.xml | 6 +- auth/src/main/res/values-ln/strings.xml | 6 + auth/src/main/res/values-lt/strings.xml | 6 + auth/src/main/res/values-lv/strings.xml | 6 + auth/src/main/res/values-mo/strings.xml | 6 + auth/src/main/res/values-mr/strings.xml | 6 + auth/src/main/res/values-ms/strings.xml | 6 + auth/src/main/res/values-nb/strings.xml | 5 + auth/src/main/res/values-nl/strings.xml | 7 +- auth/src/main/res/values-no/strings.xml | 6 + auth/src/main/res/values-pl/strings.xml | 7 +- auth/src/main/res/values-pt-rBR/strings.xml | 6 + auth/src/main/res/values-pt-rPT/strings.xml | 6 + auth/src/main/res/values-pt/strings.xml | 7 +- auth/src/main/res/values-ro/strings.xml | 7 +- auth/src/main/res/values-ru/strings.xml | 7 +- auth/src/main/res/values-sk/strings.xml | 5 + auth/src/main/res/values-sl/strings.xml | 6 + auth/src/main/res/values-sr/strings.xml | 6 + auth/src/main/res/values-sv/strings.xml | 5 + auth/src/main/res/values-ta/strings.xml | 6 + auth/src/main/res/values-th/strings.xml | 6 + auth/src/main/res/values-tl/strings.xml | 5 + auth/src/main/res/values-tr/strings.xml | 6 + auth/src/main/res/values-uk/strings.xml | 6 + auth/src/main/res/values-ur/strings.xml | 6 + auth/src/main/res/values-vi/strings.xml | 6 + auth/src/main/res/values-zh-rCN/strings.xml | 6 + auth/src/main/res/values-zh-rHK/strings.xml | 6 + auth/src/main/res/values-zh-rTW/strings.xml | 6 + auth/src/main/res/values-zh/strings.xml | 7 +- auth/src/main/res/values/strings.xml | 9 +- .../compose/ErrorRecoveryDialogLogicTest.kt | 316 ++++++++++++++++++ 89 files changed, 1084 insertions(+), 42 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ErrorRecoveryDialog.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/ErrorRecoveryDialogLogicTest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ErrorRecoveryDialog.kt new file mode 100644 index 000000000..3b9cc0b57 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ErrorRecoveryDialog.kt @@ -0,0 +1,199 @@ +/* + * 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.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.window.DialogProperties +import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider + +/** + * A composable dialog for displaying authentication errors with recovery options. + * + * This dialog provides friendly error messages and actionable recovery suggestions + * based on the specific [AuthException] type. It integrates with [AuthUIStringProvider] + * for localization support. + * + * **Example usage:** + * ```kotlin + * var showError by remember { mutableStateOf(null) } + * + * if (showError != null) { + * ErrorRecoveryDialog( + * error = showError!!, + * stringProvider = stringProvider, + * onRetry = { + * showError = null + * // Retry authentication operation + * }, + * onDismiss = { + * showError = null + * } + * ) + * } + * ``` + * + * @param error The [AuthException] to display recovery information for + * @param stringProvider The [AuthUIStringProvider] for localized strings + * @param onRetry Callback invoked when the user taps the retry action + * @param onDismiss Callback invoked when the user dismisses the dialog + * @param modifier Optional [Modifier] for the dialog + * @param onRecover Optional callback for custom recovery actions based on the exception type + * @param properties Optional [DialogProperties] for dialog configuration + * + * @since 10.0.0 + */ +@Composable +fun ErrorRecoveryDialog( + error: AuthException, + stringProvider: AuthUIStringProvider, + onRetry: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + onRecover: ((AuthException) -> Unit)? = null, + properties: DialogProperties = DialogProperties() +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringProvider.errorDialogTitle, + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Text( + text = getRecoveryMessage(error, stringProvider), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start + ) + }, + confirmButton = { + if (isRecoverable(error)) { + TextButton( + onClick = { + onRecover?.invoke(error) ?: onRetry() + } + ) { + Text( + text = getRecoveryActionText(error, stringProvider), + style = MaterialTheme.typography.labelLarge + ) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringProvider.dismissAction, + style = MaterialTheme.typography.labelLarge + ) + } + }, + modifier = modifier, + properties = properties + ) +} + +/** + * Gets the appropriate recovery message for the given [AuthException]. + * + * @param error The [AuthException] to get the message for + * @param stringProvider The [AuthUIStringProvider] for localized strings + * @return The localized recovery message + */ +private fun getRecoveryMessage( + error: AuthException, + stringProvider: AuthUIStringProvider +): String { + return when (error) { + is AuthException.NetworkException -> stringProvider.networkErrorRecoveryMessage + is AuthException.InvalidCredentialsException -> stringProvider.invalidCredentialsRecoveryMessage + is AuthException.UserNotFoundException -> stringProvider.userNotFoundRecoveryMessage + is AuthException.WeakPasswordException -> { + // Include specific reason if available + val baseMessage = stringProvider.weakPasswordRecoveryMessage + error.reason?.let { reason -> + "$baseMessage\n\nReason: $reason" + } ?: baseMessage + } + is AuthException.EmailAlreadyInUseException -> { + // Include email if available + val baseMessage = stringProvider.emailAlreadyInUseRecoveryMessage + error.email?.let { email -> + "$baseMessage ($email)" + } ?: baseMessage + } + is AuthException.TooManyRequestsException -> stringProvider.tooManyRequestsRecoveryMessage + is AuthException.MfaRequiredException -> stringProvider.mfaRequiredRecoveryMessage + is AuthException.AccountLinkingRequiredException -> stringProvider.accountLinkingRequiredRecoveryMessage + is AuthException.AuthCancelledException -> stringProvider.authCancelledRecoveryMessage + is AuthException.UnknownException -> stringProvider.unknownErrorRecoveryMessage + else -> stringProvider.unknownErrorRecoveryMessage + } +} + +/** + * Gets the appropriate recovery action text for the given [AuthException]. + * + * @param error The [AuthException] to get the action text for + * @param stringProvider The [AuthUIStringProvider] for localized strings + * @return The localized action text + */ +private fun getRecoveryActionText( + error: AuthException, + stringProvider: AuthUIStringProvider +): String { + return when (error) { + is AuthException.AuthCancelledException -> stringProvider.continueText + is AuthException.EmailAlreadyInUseException -> stringProvider.signInDefault // Use existing "Sign in" text + is AuthException.AccountLinkingRequiredException -> stringProvider.continueText // Use "Continue" for linking + is AuthException.MfaRequiredException -> stringProvider.continueText // Use "Continue" for MFA + is AuthException.NetworkException, + is AuthException.InvalidCredentialsException, + is AuthException.UserNotFoundException, + is AuthException.WeakPasswordException, + is AuthException.TooManyRequestsException, + is AuthException.UnknownException -> stringProvider.retryAction + else -> stringProvider.retryAction + } +} + +/** + * Determines if the given [AuthException] is recoverable through user action. + * + * @param error The [AuthException] to check + * @return `true` if the error is recoverable, `false` otherwise + */ +private fun isRecoverable(error: AuthException): Boolean { + return when (error) { + is AuthException.NetworkException -> true + is AuthException.InvalidCredentialsException -> true + is AuthException.UserNotFoundException -> true + is AuthException.WeakPasswordException -> true + is AuthException.EmailAlreadyInUseException -> true + is AuthException.TooManyRequestsException -> false // User must wait + is AuthException.MfaRequiredException -> true + is AuthException.AccountLinkingRequiredException -> true + is AuthException.AuthCancelledException -> true + is AuthException.UnknownException -> true + else -> true + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProvider.kt index 646727fb7..ea88485e3 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProvider.kt @@ -184,4 +184,44 @@ interface AuthUIStringProvider { /** TOTP Code prompt */ val enterTOTPCode: String + + // Error Recovery Dialog Strings + /** Error dialog title */ + val errorDialogTitle: String + + /** Retry action button text */ + val retryAction: String + + /** Dismiss action button text */ + val dismissAction: String + + /** Network error recovery message */ + val networkErrorRecoveryMessage: String + + /** Invalid credentials recovery message */ + val invalidCredentialsRecoveryMessage: String + + /** User not found recovery message */ + val userNotFoundRecoveryMessage: String + + /** Weak password recovery message */ + val weakPasswordRecoveryMessage: String + + /** Email already in use recovery message */ + val emailAlreadyInUseRecoveryMessage: String + + /** Too many requests recovery message */ + val tooManyRequestsRecoveryMessage: String + + /** MFA required recovery message */ + val mfaRequiredRecoveryMessage: String + + /** Account linking required recovery message */ + val accountLinkingRequiredRecoveryMessage: String + + /** Auth cancelled recovery message */ + val authCancelledRecoveryMessage: String + + /** Unknown error recovery message */ + val unknownErrorRecoveryMessage: String } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/DefaultAuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/DefaultAuthUIStringProvider.kt index 96a74cdd7..7df16d9ca 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/DefaultAuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/DefaultAuthUIStringProvider.kt @@ -179,4 +179,34 @@ class DefaultAuthUIStringProvider( get() = localizedContext.getString(R.string.fui_progress_dialog_loading) override val noInternet: String get() = localizedContext.getString(R.string.fui_no_internet) + + /** + * Error Recovery Dialog Strings + */ + override val errorDialogTitle: String + get() = localizedContext.getString(R.string.fui_error_dialog_title) + override val retryAction: String + get() = localizedContext.getString(R.string.fui_error_retry_action) + override val dismissAction: String + get() = localizedContext.getString(R.string.fui_email_link_dismiss_button) + override val networkErrorRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_no_internet) + override val invalidCredentialsRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_error_invalid_password) + override val userNotFoundRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_error_email_does_not_exist) + override val weakPasswordRecoveryMessage: String + get() = localizedContext.resources.getQuantityString(R.plurals.fui_error_weak_password, 6, 6) + override val emailAlreadyInUseRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_email_account_creation_error) + override val tooManyRequestsRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_error_too_many_attempts) + override val mfaRequiredRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_error_mfa_required_message) + override val accountLinkingRequiredRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_error_account_linking_required_message) + override val authCancelledRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_error_auth_cancelled_message) + override val unknownErrorRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_error_unknown) } diff --git a/auth/src/main/res/values-ar/strings.xml b/auth/src/main/res/values-ar/strings.xml index 6c18953e4..3231bd74f 100755 --- a/auth/src/main/res/values-ar/strings.xml +++ b/auth/src/main/res/values-ar/strings.xml @@ -88,5 +88,10 @@ إعادة إرسال الرمز تأكيد ملكية رقم الهاتف عند النقر على “%1$s”، قد يتمّ إرسال رسالة قصيرة SMS وقد يتمّ تطبيق رسوم الرسائل والبيانات. - يشير النقر على “%1$s” إلى موافقتك على %2$s و%3$s. وقد يتمّ إرسال رسالة قصيرة كما قد تنطبق رسوم الرسائل والبيانات. + يشير النقر على "%1$s" إلى موافقتك على %2$s و%3$s. وقد يتمّ إرسال رسالة قصيرة كما قد تنطبق رسوم الرسائل والبيانات. + خطأ في المصادقة + أعد المحاولة + مطلوب تحقق إضافي. يرجى إكمال المصادقة متعددة العوامل. + يجب ربط الحساب. جرب طريقة تسجيل دخول مختلفة. + تم إلغاء المصادقة. أعد المحاولة عندما تكون جاهزاً. diff --git a/auth/src/main/res/values-b+es+419/strings.xml b/auth/src/main/res/values-b+es+419/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-b+es+419/strings.xml +++ b/auth/src/main/res/values-b+es+419/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-bg/strings.xml b/auth/src/main/res/values-bg/strings.xml index c96ed2f29..dc2b315cd 100755 --- a/auth/src/main/res/values-bg/strings.xml +++ b/auth/src/main/res/values-bg/strings.xml @@ -88,5 +88,10 @@ Повторно изпращане на кода Потвърждаване на телефонния номер Докосвайки „%1$s“, може да получите SMS съобщение. То може да се таксува по тарифите за данни и SMS. - Докосвайки „%1$s“, приемате нашите %2$s и %3$s. Възможно е да получите SMS съобщение. То може да се таксува по тарифите за данни и SMS. + Докосвайки „%1$s", приемате нашите %2$s и %3$s. Възможно е да получите SMS съобщение. То може да се таксува по тарифите за данни и SMS. + Грешка при удостоверяване + Опитай отново + Необходима е допълнителна проверка. Моля, завършете многофакторното удостоверяване. + Акаунтът трябва да бъде свързан. Опитайте различен метод за влизане. + Удостоверяването беше отменено. Опитайте отново, когато сте готови. diff --git a/auth/src/main/res/values-bn/strings.xml b/auth/src/main/res/values-bn/strings.xml index bb304053b..bce8d34a7 100755 --- a/auth/src/main/res/values-bn/strings.xml +++ b/auth/src/main/res/values-bn/strings.xml @@ -89,4 +89,10 @@ ফোন নম্বর যাচাই করুন %1$s এ ট্যাপ করলে আপনি একটি এসএমএস পাঠাতে পারেন। মেসেজ ও ডেটার চার্জ প্রযোজ্য। “%1$s” বোতামে ট্যাপ করার অর্থ, আপনি আমাদের %2$s এবং %3$s-এর সাথে সম্মত। একটি এসএমএস পাঠানো হতে পারে। মেসেজ এবং ডেটার উপরে প্রযোজ্য চার্জ লাগতে পারে। + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-ca/strings.xml b/auth/src/main/res/values-ca/strings.xml index e0c126e59..a08a21c1d 100755 --- a/auth/src/main/res/values-ca/strings.xml +++ b/auth/src/main/res/values-ca/strings.xml @@ -89,4 +89,10 @@ Verifica el número de telèfon En tocar %1$s, és possible que s\'enviï un SMS. Es poden aplicar tarifes de dades i missatges. En tocar %1$s, acceptes les nostres %2$s i la nostra %3$s. És possible que s\'enviï un SMS. Es poden aplicar tarifes de dades i missatges. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-cs/strings.xml b/auth/src/main/res/values-cs/strings.xml index cb328a6fa..6ee3d6467 100755 --- a/auth/src/main/res/values-cs/strings.xml +++ b/auth/src/main/res/values-cs/strings.xml @@ -89,4 +89,9 @@ Ověřit telefonní číslo Po klepnutí na možnost %1$s může být odeslána SMS. Mohou být účtovány poplatky za zprávy a data. Klepnutím na tlačítko %1$s vyjadřujete svůj souhlas s dokumenty %2$s a %3$s. Může být odeslána SMS a mohou být účtovány poplatky za zprávy a data. + Chyba ověření + Zkusit znovu + Vyžadováno další ověření. Dokončete prosím vícefaktorové ověření. + Účet je třeba propojit. Zkuste jiný způsob přihlášení. + Ověření bylo zrušeno. Zkuste znovu až budete připraveni. diff --git a/auth/src/main/res/values-da/strings.xml b/auth/src/main/res/values-da/strings.xml index c9c86762e..f177f7d53 100755 --- a/auth/src/main/res/values-da/strings.xml +++ b/auth/src/main/res/values-da/strings.xml @@ -88,5 +88,10 @@ Send koden igen Bekræft telefonnummer Når du trykker på “%1$s”, sendes der måske en sms. Der opkræves muligvis gebyrer for beskeder og data. - Når du trykker på “%1$s”, indikerer du, at du accepterer vores %2$s og %3$s. Der sendes måske en sms. Der opkræves muligvis gebyrer for beskeder og data. + Når du trykker på "%1$s", indikerer du, at du accepterer vores %2$s og %3$s. Der sendes måske en sms. Der opkræves muligvis gebyrer for beskeder og data. + Godkendelsesfejl + Prøv igen + Yderligere bekræftelse påkrævet. Fuldfør venligst multifaktorgodkendelse. + Kontoen skal tilknyttes. Prøv en anden login-metode. + Godkendelsen blev annulleret. Prøv igen når du er klar. diff --git a/auth/src/main/res/values-de-rAT/strings.xml b/auth/src/main/res/values-de-rAT/strings.xml index 378aff0c0..f221da191 100755 --- a/auth/src/main/res/values-de-rAT/strings.xml +++ b/auth/src/main/res/values-de-rAT/strings.xml @@ -89,4 +89,9 @@ Telefonnummer bestätigen Wenn Sie auf “%1$s” tippen, erhalten Sie möglicherweise eine SMS. Es können Gebühren für SMS und Datenübertragung anfallen. Indem Sie auf “%1$s” tippen, stimmen Sie unseren %2$s und unserer %3$s zu. Sie erhalten möglicherweise eine SMS und es können Gebühren für die Nachricht und die Datenübertragung anfallen. + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-de-rCH/strings.xml b/auth/src/main/res/values-de-rCH/strings.xml index 378aff0c0..1909d07a7 100755 --- a/auth/src/main/res/values-de-rCH/strings.xml +++ b/auth/src/main/res/values-de-rCH/strings.xml @@ -89,4 +89,10 @@ Telefonnummer bestätigen Wenn Sie auf “%1$s” tippen, erhalten Sie möglicherweise eine SMS. Es können Gebühren für SMS und Datenübertragung anfallen. Indem Sie auf “%1$s” tippen, stimmen Sie unseren %2$s und unserer %3$s zu. Sie erhalten möglicherweise eine SMS und es können Gebühren für die Nachricht und die Datenübertragung anfallen. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-de/strings.xml b/auth/src/main/res/values-de/strings.xml index 378aff0c0..cd05db0fc 100755 --- a/auth/src/main/res/values-de/strings.xml +++ b/auth/src/main/res/values-de/strings.xml @@ -88,5 +88,10 @@ Code erneut senden Telefonnummer bestätigen Wenn Sie auf “%1$s” tippen, erhalten Sie möglicherweise eine SMS. Es können Gebühren für SMS und Datenübertragung anfallen. - Indem Sie auf “%1$s” tippen, stimmen Sie unseren %2$s und unserer %3$s zu. Sie erhalten möglicherweise eine SMS und es können Gebühren für die Nachricht und die Datenübertragung anfallen. + Indem Sie auf "%1$s" tippen, stimmen Sie unseren %2$s und unserer %3$s zu. Sie erhalten möglicherweise eine SMS und es können Gebühren für die Nachricht und die Datenübertragung anfallen. + Authentifizierungsfehler + Erneut versuchen + Zusätzliche Verifizierung erforderlich. Bitte schließen Sie die Multi-Faktor-Authentifizierung ab. + Das Konto muss verknüpft werden. Bitte versuchen Sie eine andere Anmeldemethode. + Die Authentifizierung wurde abgebrochen. Bitte versuchen Sie es erneut, wenn Sie bereit sind. diff --git a/auth/src/main/res/values-el/strings.xml b/auth/src/main/res/values-el/strings.xml index dd34114d4..08ada6b00 100755 --- a/auth/src/main/res/values-el/strings.xml +++ b/auth/src/main/res/values-el/strings.xml @@ -89,4 +89,10 @@ Επαλήθευση αριθμού τηλεφώνου Αν πατήσετε “%1$s”, μπορεί να σταλεί ένα SMS. Ενδέχεται να ισχύουν χρεώσεις μηνυμάτων και δεδομένων. Αν πατήσετε “%1$s”, δηλώνετε ότι αποδέχεστε τους %2$s και την %3$s. Μπορεί να σταλεί ένα SMS. Ενδέχεται να ισχύουν χρεώσεις μηνυμάτων και δεδομένων. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-en-rAU/strings.xml b/auth/src/main/res/values-en-rAU/strings.xml index 58b0b9245..54501d465 100755 --- a/auth/src/main/res/values-en-rAU/strings.xml +++ b/auth/src/main/res/values-en-rAU/strings.xml @@ -88,5 +88,10 @@ Resend code Verify Phone Number By tapping “%1$s”, an SMS may be sent. Message & data rates may apply. - By tapping “%1$s”, you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + By tapping "%1$s", you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-en-rCA/strings.xml b/auth/src/main/res/values-en-rCA/strings.xml index 58b0b9245..54501d465 100755 --- a/auth/src/main/res/values-en-rCA/strings.xml +++ b/auth/src/main/res/values-en-rCA/strings.xml @@ -88,5 +88,10 @@ Resend code Verify Phone Number By tapping “%1$s”, an SMS may be sent. Message & data rates may apply. - By tapping “%1$s”, you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + By tapping "%1$s", you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-en-rGB/strings.xml b/auth/src/main/res/values-en-rGB/strings.xml index 58b0b9245..54501d465 100755 --- a/auth/src/main/res/values-en-rGB/strings.xml +++ b/auth/src/main/res/values-en-rGB/strings.xml @@ -88,5 +88,10 @@ Resend code Verify Phone Number By tapping “%1$s”, an SMS may be sent. Message & data rates may apply. - By tapping “%1$s”, you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + By tapping "%1$s", you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-en-rIE/strings.xml b/auth/src/main/res/values-en-rIE/strings.xml index 58b0b9245..54501d465 100755 --- a/auth/src/main/res/values-en-rIE/strings.xml +++ b/auth/src/main/res/values-en-rIE/strings.xml @@ -88,5 +88,10 @@ Resend code Verify Phone Number By tapping “%1$s”, an SMS may be sent. Message & data rates may apply. - By tapping “%1$s”, you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + By tapping "%1$s", you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-en-rIN/strings.xml b/auth/src/main/res/values-en-rIN/strings.xml index 58b0b9245..54501d465 100755 --- a/auth/src/main/res/values-en-rIN/strings.xml +++ b/auth/src/main/res/values-en-rIN/strings.xml @@ -88,5 +88,10 @@ Resend code Verify Phone Number By tapping “%1$s”, an SMS may be sent. Message & data rates may apply. - By tapping “%1$s”, you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + By tapping "%1$s", you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-en-rSG/strings.xml b/auth/src/main/res/values-en-rSG/strings.xml index 58b0b9245..54501d465 100755 --- a/auth/src/main/res/values-en-rSG/strings.xml +++ b/auth/src/main/res/values-en-rSG/strings.xml @@ -88,5 +88,10 @@ Resend code Verify Phone Number By tapping “%1$s”, an SMS may be sent. Message & data rates may apply. - By tapping “%1$s”, you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + By tapping "%1$s", you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-en-rZA/strings.xml b/auth/src/main/res/values-en-rZA/strings.xml index 58b0b9245..54501d465 100755 --- a/auth/src/main/res/values-en-rZA/strings.xml +++ b/auth/src/main/res/values-en-rZA/strings.xml @@ -88,5 +88,10 @@ Resend code Verify Phone Number By tapping “%1$s”, an SMS may be sent. Message & data rates may apply. - By tapping “%1$s”, you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + By tapping "%1$s", you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-es-rAR/strings.xml b/auth/src/main/res/values-es-rAR/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rAR/strings.xml +++ b/auth/src/main/res/values-es-rAR/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rBO/strings.xml b/auth/src/main/res/values-es-rBO/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rBO/strings.xml +++ b/auth/src/main/res/values-es-rBO/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rCL/strings.xml b/auth/src/main/res/values-es-rCL/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rCL/strings.xml +++ b/auth/src/main/res/values-es-rCL/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rCO/strings.xml b/auth/src/main/res/values-es-rCO/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rCO/strings.xml +++ b/auth/src/main/res/values-es-rCO/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rCR/strings.xml b/auth/src/main/res/values-es-rCR/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rCR/strings.xml +++ b/auth/src/main/res/values-es-rCR/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rDO/strings.xml b/auth/src/main/res/values-es-rDO/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rDO/strings.xml +++ b/auth/src/main/res/values-es-rDO/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rEC/strings.xml b/auth/src/main/res/values-es-rEC/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rEC/strings.xml +++ b/auth/src/main/res/values-es-rEC/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rGT/strings.xml b/auth/src/main/res/values-es-rGT/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rGT/strings.xml +++ b/auth/src/main/res/values-es-rGT/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rHN/strings.xml b/auth/src/main/res/values-es-rHN/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rHN/strings.xml +++ b/auth/src/main/res/values-es-rHN/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rMX/strings.xml b/auth/src/main/res/values-es-rMX/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rMX/strings.xml +++ b/auth/src/main/res/values-es-rMX/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rNI/strings.xml b/auth/src/main/res/values-es-rNI/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rNI/strings.xml +++ b/auth/src/main/res/values-es-rNI/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rPA/strings.xml b/auth/src/main/res/values-es-rPA/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rPA/strings.xml +++ b/auth/src/main/res/values-es-rPA/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rPE/strings.xml b/auth/src/main/res/values-es-rPE/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rPE/strings.xml +++ b/auth/src/main/res/values-es-rPE/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rPR/strings.xml b/auth/src/main/res/values-es-rPR/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rPR/strings.xml +++ b/auth/src/main/res/values-es-rPR/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rPY/strings.xml b/auth/src/main/res/values-es-rPY/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rPY/strings.xml +++ b/auth/src/main/res/values-es-rPY/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rSV/strings.xml b/auth/src/main/res/values-es-rSV/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rSV/strings.xml +++ b/auth/src/main/res/values-es-rSV/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rUS/strings.xml b/auth/src/main/res/values-es-rUS/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rUS/strings.xml +++ b/auth/src/main/res/values-es-rUS/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rUY/strings.xml b/auth/src/main/res/values-es-rUY/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rUY/strings.xml +++ b/auth/src/main/res/values-es-rUY/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es-rVE/strings.xml b/auth/src/main/res/values-es-rVE/strings.xml index fccdf579c..f2635ab84 100755 --- a/auth/src/main/res/values-es-rVE/strings.xml +++ b/auth/src/main/res/values-es-rVE/strings.xml @@ -88,5 +88,10 @@ Reenviar código Verificar número de teléfono Si presionas “%1$s”, se enviará un SMS. Se aplicarán las tarifas de mensajes y datos. - Si presionas “%1$s”, indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Si presionas "%1$s", indicas que aceptas nuestras %2$s y %3$s. Es posible que se te envíe un SMS. Podrían aplicarse las tarifas de mensajes y datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-es/strings.xml b/auth/src/main/res/values-es/strings.xml index c5decbad9..a4b5704a0 100755 --- a/auth/src/main/res/values-es/strings.xml +++ b/auth/src/main/res/values-es/strings.xml @@ -89,4 +89,9 @@ Verificar número de teléfono Al tocar %1$s, podría enviarse un SMS. Es posible que se apliquen cargos de mensajería y de uso de datos. Si tocas %1$s, confirmas que aceptas nuestras %2$s y nuestra %3$s. Podría enviarse un SMS, por lo que es posible que se apliquen cargos de mensajería y de uso de datos. + Error de autenticación + Intentar de nuevo + Se requiere verificación adicional. Complete la autenticación de múltiples factores. + Es necesario vincular la cuenta. Pruebe con un método de inicio de sesión diferente. + La autenticación fue cancelada. Vuelva a intentarlo cuando esté listo. diff --git a/auth/src/main/res/values-fa/strings.xml b/auth/src/main/res/values-fa/strings.xml index 9085ee1d3..8e3c5f551 100755 --- a/auth/src/main/res/values-fa/strings.xml +++ b/auth/src/main/res/values-fa/strings.xml @@ -89,4 +89,10 @@ تأیید شماره تلفن با ضربه زدن روی «%1$s»، پیامکی برایتان ارسال می‌شود. هزینه پیام و داده اعمال می‌شود. درصورت ضربه‌زدن روی «%1$s»، موافقتتان را با %2$s و %3$s اعلام می‌کنید. پیامکی ارسال می‌شود. ممکن است هزینه داده و «پیام» محاسبه شود. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-fi/strings.xml b/auth/src/main/res/values-fi/strings.xml index 3713aeb0c..f76b85893 100755 --- a/auth/src/main/res/values-fi/strings.xml +++ b/auth/src/main/res/values-fi/strings.xml @@ -89,4 +89,9 @@ Vahvista puhelinnumero Kun napautat %1$s, tekstiviesti voidaan lähettää. Datan ja viestien käyttö voi olla maksullista. Napauttamalla %1$s vahvistat hyväksyväsi seuraavat: %2$s ja %3$s. Tekstiviesti voidaan lähettää, ja datan ja viestien käyttö voi olla maksullista. + Todennusvirhe + Yritä uudelleen + Lisävarmistus vaaditaan. Suorita monitekijätodennus loppuun. + Tili täytyy linkittää. Kokeile eri kirjautumistapaa. + Todennus peruutettiin. Yritä uudelleen kun olet valmis. diff --git a/auth/src/main/res/values-fil/strings.xml b/auth/src/main/res/values-fil/strings.xml index eb4768d02..d0f765fc6 100755 --- a/auth/src/main/res/values-fil/strings.xml +++ b/auth/src/main/res/values-fil/strings.xml @@ -89,4 +89,9 @@ I-verify ang Numero ng Telepono Sa pag-tap sa “%1$s,“ maaaring magpadala ng SMS. Maaaring ipatupad ang mga rate ng pagmemensahe at data. Sa pag-tap sa “%1$s”, ipinababatid mo na tinatanggap mo ang aming %2$s at %3$s. Maaaring magpadala ng SMS. Maaaring ipatupad ang mga rate ng pagmemensahe at data. + Error sa Pagpapatotoo + Subukan Muli + Kailangan ang karagdagang pagpapatotoo. Mangyaring kumpletuhin ang multi-factor authentication. + Kailangang i-link ang account. Mangyaring subukan ang ibang paraan ng pag-sign in. + Ang pagpapatotoo ay nakansela. Mangyaring subukan muli kapag handa ka na. diff --git a/auth/src/main/res/values-fr-rCH/strings.xml b/auth/src/main/res/values-fr-rCH/strings.xml index 86b3110d4..14d56131c 100755 --- a/auth/src/main/res/values-fr-rCH/strings.xml +++ b/auth/src/main/res/values-fr-rCH/strings.xml @@ -89,4 +89,10 @@ Valider le numéro de téléphone En appuyant sur “%1$s”, vous déclencherez peut-être l\'envoi d\'un SMS. Des frais de messages et de données peuvent être facturés. En appuyant sur “%1$s”, vous acceptez les %2$s et les %3$s. Vous déclencherez peut-être l\'envoi d\'un SMS. Des frais de messages et de données peuvent être facturés. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-fr/strings.xml b/auth/src/main/res/values-fr/strings.xml index 86b3110d4..7cb182287 100755 --- a/auth/src/main/res/values-fr/strings.xml +++ b/auth/src/main/res/values-fr/strings.xml @@ -88,5 +88,10 @@ Renvoyer le code Valider le numéro de téléphone En appuyant sur “%1$s”, vous déclencherez peut-être l\'envoi d\'un SMS. Des frais de messages et de données peuvent être facturés. - En appuyant sur “%1$s”, vous acceptez les %2$s et les %3$s. Vous déclencherez peut-être l\'envoi d\'un SMS. Des frais de messages et de données peuvent être facturés. + En appuyant sur "%1$s", vous acceptez les %2$s et les %3$s. Vous déclencherez peut-être l\'envoi d\'un SMS. Des frais de messages et de données peuvent être facturés. + Erreur d\'authentification + Réessayer + Vérification supplémentaire requise. Veuillez compléter l\'authentification à plusieurs facteurs. + Le compte doit être lié. Veuillez essayer une méthode de connexion différente. + L\'authentification a été annulée. Veuillez réessayer quand vous serez prêt. diff --git a/auth/src/main/res/values-gsw/strings.xml b/auth/src/main/res/values-gsw/strings.xml index 378aff0c0..74d1a7623 100755 --- a/auth/src/main/res/values-gsw/strings.xml +++ b/auth/src/main/res/values-gsw/strings.xml @@ -89,4 +89,9 @@ Telefonnummer bestätigen Wenn Sie auf “%1$s” tippen, erhalten Sie möglicherweise eine SMS. Es können Gebühren für SMS und Datenübertragung anfallen. Indem Sie auf “%1$s” tippen, stimmen Sie unseren %2$s und unserer %3$s zu. Sie erhalten möglicherweise eine SMS und es können Gebühren für die Nachricht und die Datenübertragung anfallen. + Authentifizierungsfehler + Erneut versuchen + Zusätzliche Verifizierung erforderlich. Bitte schließen Sie die Multi-Faktor-Authentifizierung ab. + Das Konto muss verknüpft werden. Bitte versuchen Sie eine andere Anmeldemethode. + Die Authentifizierung wurde abgebrochen. Bitte versuchen Sie es erneut, wenn Sie bereit sind. diff --git a/auth/src/main/res/values-gu/strings.xml b/auth/src/main/res/values-gu/strings.xml index e5d55cd9f..a4898d625 100755 --- a/auth/src/main/res/values-gu/strings.xml +++ b/auth/src/main/res/values-gu/strings.xml @@ -89,4 +89,10 @@ ફોન નંબર ચકાસો “%1$s”ને ટૅપ કરવાથી, કદાચ એક SMS મોકલવામાં આવી શકે છે. સંદેશ અને ડેટા શુલ્ક લાગુ થઈ શકે છે. “%1$s” ટૅપ કરીને, તમે સૂચવી રહ્યાં છો કે તમે અમારી %2$s અને %3$sને સ્વીકારો છો. SMS મોકલવામાં આવી શકે છે. સંદેશ અને ડેટા શુલ્ક લાગુ થઈ શકે છે. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-hi/strings.xml b/auth/src/main/res/values-hi/strings.xml index d885dd392..5e479bb25 100755 --- a/auth/src/main/res/values-hi/strings.xml +++ b/auth/src/main/res/values-hi/strings.xml @@ -89,4 +89,10 @@ फ़ोन नंबर की पुष्टि करें “%1$s” पर टैप करने पर, एक मैसेज (एसएमएस) भेजा जा सकता है. मैसेज और डेटा दरें लागू हो सकती हैं. “%1$s” पर टैप करके, आप यह बताते हैं कि आप हमारी %2$s और %3$s को मंज़ूर करते हैं. एक मैसेज (एसएमएस) भेजा जा सकता है. मैसेज और डेटा दरें लागू हो सकती हैं. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-hr/strings.xml b/auth/src/main/res/values-hr/strings.xml index 809c035ae..a7f0527ec 100755 --- a/auth/src/main/res/values-hr/strings.xml +++ b/auth/src/main/res/values-hr/strings.xml @@ -88,5 +88,10 @@ Ponovo pošalji kôd Potvrda telefonskog broja Dodirivanje gumba “%1$s” može dovesti do slanja SMS poruke. Mogu se primijeniti naknade za slanje poruka i podatkovni promet. - Ako dodirnete “%1$s”, potvrđujete da prihvaćate odredbe koje sadrže %2$s i %3$s. Možda ćemo vam poslati SMS. Moguća je naplata poruke i podatkovnog prometa. + Ako dodirnete "%1$s", potvrđujete da prihvaćate odredbe koje sadrže %2$s i %3$s. Možda ćemo vam poslati SMS. Moguća je naplata poruke i podatkovnog prometa. + Greška provjere identiteta + Pokušaj ponovno + Potrebna je dodatna provjera. Molimo dovršite višefaktorsku provjeru identiteta. + Račun mora biti povezan. Pokušajte s drugim načinom prijave. + Provjera identiteta je otkazana. Pokušajte ponovno kad budete spremni. diff --git a/auth/src/main/res/values-hu/strings.xml b/auth/src/main/res/values-hu/strings.xml index c89d279c2..1ea903b33 100755 --- a/auth/src/main/res/values-hu/strings.xml +++ b/auth/src/main/res/values-hu/strings.xml @@ -89,4 +89,9 @@ Telefonszám igazolása Ha a(z) „%1$s” gombra koppint, a rendszer SMS-t küldhet Önnek. A szolgáltató ezért üzenet- és adatforgalmi díjat számíthat fel. A(z) „%1$s” gombra való koppintással elfogadja a következő dokumentumokat: %2$s és %3$s. A rendszer SMS-t küldhet Önnek. A szolgáltató ezért üzenet- és adatforgalmi díjat számíthat fel. + Hitelesítési hiba + Próbáld újra + További ellenőrzés szükséges. Kérjük, fejezze be a többtényezős hitelesítést. + A fiókot össze kell kapcsolni. Próbáljon meg egy másik bejelentkezési módot. + A hitelesítés megszakadt. Próbálja újra, amikor készen áll. diff --git a/auth/src/main/res/values-in/strings.xml b/auth/src/main/res/values-in/strings.xml index a16ec1962..68cf5db3e 100755 --- a/auth/src/main/res/values-in/strings.xml +++ b/auth/src/main/res/values-in/strings.xml @@ -89,4 +89,10 @@ Verifikasi Nomor Telepon Dengan mengetuk “%1$s\", SMS mungkin akan dikirim. Mungkin dikenakan biaya pesan & data. Dengan mengetuk “%1$s”, Anda menyatakan bahwa Anda menyetujui %2$s dan %3$s kami. SMS mungkin akan dikirim. Mungkin dikenakan biaya pesan & data. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-it/strings.xml b/auth/src/main/res/values-it/strings.xml index ed9f48450..e1c37a998 100755 --- a/auth/src/main/res/values-it/strings.xml +++ b/auth/src/main/res/values-it/strings.xml @@ -88,5 +88,10 @@ Invia di nuovo il codice Verifica numero di telefono Se tocchi “%1$s”, è possibile che venga inviato un SMS. Potrebbero essere applicate le tariffe per l\'invio dei messaggi e per il traffico dati. - Se tocchi “%1$s”, accetti i nostri %2$s e le nostre %3$s. È possibile che venga inviato un SMS. Potrebbero essere applicate le tariffe per l\'invio dei messaggi e per il traffico dati. + Se tocchi "%1$s", accetti i nostri %2$s e le nostre %3$s. È possibile che venga inviato un SMS. Potrebbero essere applicate le tariffe per l\'invio dei messaggi e per il traffico dati. + Errore di autenticazione + Riprova + È richiesta una verifica aggiuntiva. Completa l\'autenticazione a più fattori. + L\'account deve essere collegato. Prova un metodo di accesso diverso. + L\'autenticazione è stata annullata. Riprova quando sei pronto. diff --git a/auth/src/main/res/values-iw/strings.xml b/auth/src/main/res/values-iw/strings.xml index 5ace0fb64..f271dafd8 100755 --- a/auth/src/main/res/values-iw/strings.xml +++ b/auth/src/main/res/values-iw/strings.xml @@ -89,4 +89,10 @@ אמת את מספר הטלפון הקשה על “%1$s” עשויה לגרום לשליחה של הודעת SMS. ייתכן שיחולו תעריפי הודעות והעברת נתונים. הקשה על “%1$s”, תפורש כהסכמתך ל%2$s ול%3$s. ייתכן שתישלח הודעת SMS. ייתכנו חיובים בגין שליחת הודעות ושימוש בנתונים. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-ja/strings.xml b/auth/src/main/res/values-ja/strings.xml index c87ba6c85..4be80b287 100755 --- a/auth/src/main/res/values-ja/strings.xml +++ b/auth/src/main/res/values-ja/strings.xml @@ -89,4 +89,9 @@ 電話番号を確認 [%1$s] をタップすると、SMS が送信されます。データ通信料がかかることがあります。 [%1$s] をタップすると、%2$s と %3$s に同意したことになり、SMS が送信されます。データ通信料がかかることがあります。 + 認証エラー + 再試行 + 追加の認証が必要です。多要素認証を完了してください。 + アカウントをリンクする必要があります。別のサインイン方法をお試しください。 + 認証がキャンセルされました。準備ができたら再度お試しください。 diff --git a/auth/src/main/res/values-kn/strings.xml b/auth/src/main/res/values-kn/strings.xml index edde0659b..b23ad1791 100755 --- a/auth/src/main/res/values-kn/strings.xml +++ b/auth/src/main/res/values-kn/strings.xml @@ -89,4 +89,10 @@ ಫೋನ್ ಸಂಖ್ಯೆಯನ್ನು ಪರಿಶೀಲಿಸಿ “%1$s” ಅನ್ನು ಟ್ಯಾಪ್ ಮಾಡುವ ಮೂಲಕ, ಎಸ್‌ಎಂಎಸ್‌ ಅನ್ನು ಕಳುಹಿಸಬಹುದಾಗಿದೆ. ಸಂದೇಶ ಮತ್ತು ಡೇಟಾ ದರಗಳು ಅನ್ವಯಿಸಬಹುದು. “%1$s” ಅನ್ನು ಟ್ಯಾಪ್ ಮಾಡುವ ಮೂಲಕ, ನೀವು ನಮ್ಮ %2$s ಮತ್ತು %3$s ಸ್ವೀಕರಿಸುತ್ತೀರಿ ಎಂದು ನೀವು ಸೂಚಿಸುತ್ತಿರುವಿರಿ. ಎಸ್‌ಎಂಎಸ್‌ ಅನ್ನು ಕಳುಹಿಸಬಹುದಾಗಿದೆ. ಸಂದೇಶ ಮತ್ತು ಡೇಟಾ ದರಗಳು ಅನ್ವಯಿಸಬಹುದು. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-ko/strings.xml b/auth/src/main/res/values-ko/strings.xml index 4127fe1c3..22c9bcbe7 100755 --- a/auth/src/main/res/values-ko/strings.xml +++ b/auth/src/main/res/values-ko/strings.xml @@ -88,5 +88,9 @@ 코드 재전송 전화번호 인증 “%1$s” 버튼을 탭하면 SMS가 발송될 수 있으며, 메시지 및 데이터 요금이 부과될 수 있습니다. - ‘%1$s’ 버튼을 탭하면 %2$s 및 %3$s에 동의하는 것으로 간주됩니다. SMS가 발송될 수 있으며, 메시지 및 데이터 요금이 부과될 수 있습니다. + 인증 오류 + 다시 시도 + 추가 인증이 필요합니다. 다단계 인증을 완료해 주세요. + 계정을 연결해야 합니다. 다른 로그인 방법을 시도해 주세요. + 인증이 취소되었습니다. 준비가 되면 다시 시도해 주세요. diff --git a/auth/src/main/res/values-ln/strings.xml b/auth/src/main/res/values-ln/strings.xml index 86b3110d4..14d56131c 100755 --- a/auth/src/main/res/values-ln/strings.xml +++ b/auth/src/main/res/values-ln/strings.xml @@ -89,4 +89,10 @@ Valider le numéro de téléphone En appuyant sur “%1$s”, vous déclencherez peut-être l\'envoi d\'un SMS. Des frais de messages et de données peuvent être facturés. En appuyant sur “%1$s”, vous acceptez les %2$s et les %3$s. Vous déclencherez peut-être l\'envoi d\'un SMS. Des frais de messages et de données peuvent être facturés. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-lt/strings.xml b/auth/src/main/res/values-lt/strings.xml index 125ddf0fb..175ec43bd 100755 --- a/auth/src/main/res/values-lt/strings.xml +++ b/auth/src/main/res/values-lt/strings.xml @@ -89,4 +89,10 @@ Patvirtinti telefono numerį Palietus „%1$s“ gali būti išsiųstas SMS pranešimas. Gali būti taikomi pranešimų ir duomenų įkainiai. Paliesdami „%1$s“ nurodote, kad sutinkate su %2$s ir %3$s. Gali būti išsiųstas SMS pranešimas, taip pat – taikomi pranešimų ir duomenų įkainiai. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-lv/strings.xml b/auth/src/main/res/values-lv/strings.xml index 7f915b466..5ce841b20 100755 --- a/auth/src/main/res/values-lv/strings.xml +++ b/auth/src/main/res/values-lv/strings.xml @@ -89,4 +89,10 @@ Verificēt tālruņa numuru Pieskaroties pogai %1$s, var tikt nosūtīta īsziņa. Var tikt piemērota maksa par ziņojumiem un datu pārsūtīšanu. Pieskaroties pogai “%1$s”, jūs norādāt, ka piekrītat šādiem dokumentiem: %2$s un %3$s. Var tikt nosūtīta īsziņa. Var tikt piemērota maksa par ziņojumiem un datu pārsūtīšanu. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-mo/strings.xml b/auth/src/main/res/values-mo/strings.xml index f8d72d534..98b2dbbb0 100755 --- a/auth/src/main/res/values-mo/strings.xml +++ b/auth/src/main/res/values-mo/strings.xml @@ -89,4 +89,10 @@ Confirmați numărul de telefon Dacă atingeți „%1$s”, poate fi trimis un SMS. Se pot aplica tarife pentru mesaje și date. Dacă atingeți „%1$s”, sunteți de acord cu %2$s și cu %3$s. Poate fi trimis un SMS. Se pot aplica tarife pentru mesaje și date. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-mr/strings.xml b/auth/src/main/res/values-mr/strings.xml index 722aa21a5..364ce87bc 100755 --- a/auth/src/main/res/values-mr/strings.xml +++ b/auth/src/main/res/values-mr/strings.xml @@ -89,4 +89,10 @@ फोन नंबरची पडताळणी करा “%1$s“ वर टॅप केल्याने, एक एसएमएस पाठवला जाऊ शकतो. मेसेज आणि डेटा शुल्क लागू होऊ शकते. “%1$s” वर टॅप करून, तुम्ही सूचित करता की तुम्ही आमचे %2$s आणि %3$s स्वीकारता. एसएमएस पाठवला जाऊ शकतो. मेसेज आणि डेटा दर लागू केले जाऊ शकते. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-ms/strings.xml b/auth/src/main/res/values-ms/strings.xml index bdba53bb6..fd55613c3 100755 --- a/auth/src/main/res/values-ms/strings.xml +++ b/auth/src/main/res/values-ms/strings.xml @@ -89,4 +89,10 @@ Sahkan Nombor Telefon Dengan mengetik “%1$s”, SMS akan dihantar. Tertakluk pada kadar mesej & data. Dengan mengetik “%1$s”, anda menyatakan bahawa anda menerima %2$s dan %3$s kami. SMS akan dihantar. Tertakluk pada kadar mesej & data. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-nb/strings.xml b/auth/src/main/res/values-nb/strings.xml index a7ebc177a..fe2dab2b4 100755 --- a/auth/src/main/res/values-nb/strings.xml +++ b/auth/src/main/res/values-nb/strings.xml @@ -89,4 +89,9 @@ Bekreft telefonnummeret Når du trykker på «%1$s», kan det bli sendt en SMS. Kostnader for meldinger og datatrafikk kan påløpe. Ved å trykke på «%1$s» godtar du %2$s og %3$s våre. Du kan bli tilsendt en SMS. Kostnader for meldinger og datatrafikk kan påløpe. + Godkjenningsfeil + Prøv igjen + Ytterligere verifisering kreves. Vennligst fullfør multifaktorgodkjenning. + Kontoen må kobles. Prøv en annen påloggingsmetode. + Godkjenning ble avbrutt. Prøv igjen når du er klar. diff --git a/auth/src/main/res/values-nl/strings.xml b/auth/src/main/res/values-nl/strings.xml index 73bc277a9..3fac2b8f4 100755 --- a/auth/src/main/res/values-nl/strings.xml +++ b/auth/src/main/res/values-nl/strings.xml @@ -88,5 +88,10 @@ Code opnieuw verzenden Telefoonnummer verifiëren Als u op “%1$s” tikt, ontvangt u mogelijk een sms. Er kunnen sms- en datakosten in rekening worden gebracht. - Als u op “%1$s” tikt, geeft u aan dat u onze %2$s en ons %3$s accepteert. Mogelijk ontvangt u een sms. Er kunnen sms- en datakosten in rekening worden gebracht. + Als u op "%1$s" tikt, geeft u aan dat u onze %2$s en ons %3$s accepteert. Mogelijk ontvangt u een sms. Er kunnen sms- en datakosten in rekening worden gebracht. + Authenticatiefout + Opnieuw proberen + Aanvullende verificatie vereist. Voltooi de multi-factor authenticatie. + Account moet worden gekoppeld. Probeer een andere inlogmethode. + Authenticatie is geannuleerd. Probeer opnieuw wanneer u klaar bent. diff --git a/auth/src/main/res/values-no/strings.xml b/auth/src/main/res/values-no/strings.xml index a7ebc177a..b3206d53b 100755 --- a/auth/src/main/res/values-no/strings.xml +++ b/auth/src/main/res/values-no/strings.xml @@ -89,4 +89,10 @@ Bekreft telefonnummeret Når du trykker på «%1$s», kan det bli sendt en SMS. Kostnader for meldinger og datatrafikk kan påløpe. Ved å trykke på «%1$s» godtar du %2$s og %3$s våre. Du kan bli tilsendt en SMS. Kostnader for meldinger og datatrafikk kan påløpe. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-pl/strings.xml b/auth/src/main/res/values-pl/strings.xml index 4a4b0770f..d144ecae5 100755 --- a/auth/src/main/res/values-pl/strings.xml +++ b/auth/src/main/res/values-pl/strings.xml @@ -88,5 +88,10 @@ Wyślij kod ponownie Zweryfikuj numer telefonu Gdy klikniesz „%1$s”, może zostać wysłany SMS. Może to skutkować pobraniem opłaty za przesłanie wiadomości i danych. - Klikając „%1$s”, potwierdzasz, że akceptujesz te dokumenty: %2$s i %3$s. Może zostać wysłany SMS. Może to skutkować pobraniem opłat za przesłanie wiadomości i danych. + Klikając „%1$s", potwierdzasz, że akceptujesz te dokumenty: %2$s i %3$s. Może zostać wysłany SMS. Może to skutkować pobraniem opłat za przesłanie wiadomości i danych. + Błąd uwierzytelniania + Spróbuj ponownie + Wymagana dodatkowa weryfikacja. Proszę ukończyć uwierzytelnianie wieloskładnikowe. + Konto musi zostać połączone. Spróbuj innej metody logowania. + Uwierzytelnianie zostało anulowane. Spróbuj ponownie gdy będziesz gotowy. diff --git a/auth/src/main/res/values-pt-rBR/strings.xml b/auth/src/main/res/values-pt-rBR/strings.xml index 283085667..c971e6764 100755 --- a/auth/src/main/res/values-pt-rBR/strings.xml +++ b/auth/src/main/res/values-pt-rBR/strings.xml @@ -89,4 +89,10 @@ Confirmar número de telefone Se você tocar em “%1$s”, um SMS poderá ser enviado e tarifas de mensagens e de dados serão cobradas. Ao tocar em “%1$s”, você concorda com nossos %2$s e a %3$s. Um SMS poderá ser enviado e tarifas de mensagens e de dados poderão ser cobradas. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-pt-rPT/strings.xml b/auth/src/main/res/values-pt-rPT/strings.xml index 2e19b618e..92e2e5bd5 100755 --- a/auth/src/main/res/values-pt-rPT/strings.xml +++ b/auth/src/main/res/values-pt-rPT/strings.xml @@ -89,4 +89,10 @@ Validar número de telefone Ao tocar em “%1$s”, pode gerar o envio de uma SMS. Podem aplicar-se tarifas de mensagens e dados. Ao tocar em “%1$s”, indica que aceita os %2$s e a %3$s. Pode gerar o envio de uma SMS. Podem aplicar-se tarifas de dados e de mensagens. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-pt/strings.xml b/auth/src/main/res/values-pt/strings.xml index 283085667..374ee367c 100755 --- a/auth/src/main/res/values-pt/strings.xml +++ b/auth/src/main/res/values-pt/strings.xml @@ -88,5 +88,10 @@ Reenviar código Confirmar número de telefone Se você tocar em “%1$s”, um SMS poderá ser enviado e tarifas de mensagens e de dados serão cobradas. - Ao tocar em “%1$s”, você concorda com nossos %2$s e a %3$s. Um SMS poderá ser enviado e tarifas de mensagens e de dados poderão ser cobradas. + Ao tocar em "%1$s", você concorda com nossos %2$s e a %3$s. Um SMS poderá ser enviado e tarifas de mensagens e de dados poderão ser cobradas. + Erro de autenticação + Tentar novamente + Verificação adicional necessária. Conclua a autenticação de vários fatores. + A conta precisa ser vinculada. Tente um método de login diferente. + A autenticação foi cancelada. Tente novamente quando estiver pronto. diff --git a/auth/src/main/res/values-ro/strings.xml b/auth/src/main/res/values-ro/strings.xml index f8d72d534..8515515ed 100755 --- a/auth/src/main/res/values-ro/strings.xml +++ b/auth/src/main/res/values-ro/strings.xml @@ -88,5 +88,10 @@ Retrimiteți codul Confirmați numărul de telefon Dacă atingeți „%1$s”, poate fi trimis un SMS. Se pot aplica tarife pentru mesaje și date. - Dacă atingeți „%1$s”, sunteți de acord cu %2$s și cu %3$s. Poate fi trimis un SMS. Se pot aplica tarife pentru mesaje și date. + Dacă atingeți „%1$s", sunteți de acord cu %2$s și cu %3$s. Poate fi trimis un SMS. Se pot aplica tarife pentru mesaje și date. + Eroare de autentificare + Încearcă din nou + Este necesară verificarea suplimentară. Vă rugăm să completați autentificarea cu mai mulți factori. + Contul trebuie conectat. Încercați o metodă de conectare diferită. + Autentificarea a fost anulată. Încercați din nou când sunteți gata. diff --git a/auth/src/main/res/values-ru/strings.xml b/auth/src/main/res/values-ru/strings.xml index 5022f71bc..dd28f7f27 100755 --- a/auth/src/main/res/values-ru/strings.xml +++ b/auth/src/main/res/values-ru/strings.xml @@ -88,5 +88,10 @@ Отправить код ещё раз Подтвердить номер телефона Нажимая кнопку “%1$s”, вы соглашаетесь получить SMS. За его отправку и обмен данными может взиматься плата. - Нажимая кнопку “%1$s”, вы принимаете %2$s и %3$s, а также соглашаетесь получить SMS. За его отправку и обмен данными может взиматься плата. + Нажимая кнопку "%1$s", вы принимаете %2$s и %3$s, а также соглашаетесь получить SMS. За его отправку и обмен данными может взиматься плата. + Ошибка аутентификации + Повторить + Требуется дополнительная проверка. Пожалуйста, завершите многофакторную аутентификацию. + Необходимо связать аккаунт. Попробуйте другой способ входа. + Аутентификация была отменена. Повторите попытку, когда будете готовы. diff --git a/auth/src/main/res/values-sk/strings.xml b/auth/src/main/res/values-sk/strings.xml index 00ea026d8..366367a1f 100755 --- a/auth/src/main/res/values-sk/strings.xml +++ b/auth/src/main/res/values-sk/strings.xml @@ -89,4 +89,9 @@ Overiť telefónne číslo Klepnutím na tlačidlo %1$s možno odoslať SMS. Môžu sa účtovať poplatky za správy a dáta. Klepnutím na tlačidlo %1$s vyjadrujete súhlas s dokumentmi %2$s a %3$s. Môže byť odoslaná SMS a môžu sa účtovať poplatky za správy a dáta. + Chyba overenia + Skúsiť znova + Vyžaduje sa dodatočné overenie. Dokončite prosím viacfaktorové overenie. + Účet je potrebné prepojiť. Skúste iný spôsob prihlásenia. + Overenie bolo zrušené. Skúste znova keď budete pripravení. diff --git a/auth/src/main/res/values-sl/strings.xml b/auth/src/main/res/values-sl/strings.xml index 9aa9516ee..6b1251615 100755 --- a/auth/src/main/res/values-sl/strings.xml +++ b/auth/src/main/res/values-sl/strings.xml @@ -89,4 +89,10 @@ Preverjanje telefonske številke Če se dotaknete možnosti »%1$s«, bo morda poslano sporočilo SMS. Pošiljanje sporočila in prenos podatkov boste morda morali plačati. Če se dotaknete možnosti »%1$s«, potrjujete, da se strinjate z dokumentoma %2$s in %3$s. Morda bo poslano sporočilo SMS. Pošiljanje sporočila in prenos podatkov boste morda morali plačati. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-sr/strings.xml b/auth/src/main/res/values-sr/strings.xml index 73d0f8fd2..662005382 100755 --- a/auth/src/main/res/values-sr/strings.xml +++ b/auth/src/main/res/values-sr/strings.xml @@ -89,4 +89,10 @@ Верификуј број телефона Ако додирнете „%1$s“, можда ћете послати SMS. Могу да вам буду наплаћени трошкови слања поруке и преноса података. Ако додирнете „%1$s“, потврђујете да прихватате документе %2$s и %3$s. Можда ћете послати SMS. Могу да вам буду наплаћени трошкови слања поруке и преноса података. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-sv/strings.xml b/auth/src/main/res/values-sv/strings.xml index efd16a99a..23ad81fd1 100755 --- a/auth/src/main/res/values-sv/strings.xml +++ b/auth/src/main/res/values-sv/strings.xml @@ -89,4 +89,9 @@ Verifiera telefonnummer Genom att trycka på %1$s skickas ett sms. Meddelande- och dataavgifter kan tillkomma. Genom att trycka på %1$s godkänner du våra %2$s och vår %3$s. Ett sms kan skickas. Meddelande- och dataavgifter kan tillkomma. + Autentiseringsfel + Försök igen + Ytterligare verifiering krävs. Vänligen slutför multifaktorautentisering. + Kontot måste länkas. Försök med en annan inloggningsmetod. + Autentisering avbröts. Försök igen när du är redo. diff --git a/auth/src/main/res/values-ta/strings.xml b/auth/src/main/res/values-ta/strings.xml index 6c44d19c8..c18dc8171 100755 --- a/auth/src/main/res/values-ta/strings.xml +++ b/auth/src/main/res/values-ta/strings.xml @@ -89,4 +89,10 @@ ஃபோன் எண்ணைச் சரிபார் “%1$s” என்பதைத் தட்டுவதன் மூலம், SMS அனுப்பப்படலாம். செய்தி மற்றும் தரவுக் கட்டணங்கள் விதிக்கப்படலாம். “%1$s” என்பதைத் தட்டுவதன் மூலம், எங்கள் %2$s மற்றும் %3$sஐ ஏற்பதாகக் குறிப்பிடுகிறீர்கள். SMS அனுப்பப்படலாம். செய்தி மற்றும் தரவுக் கட்டணங்கள் விதிக்கப்படலாம். + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-th/strings.xml b/auth/src/main/res/values-th/strings.xml index 0f3762bc8..39dfffbf2 100755 --- a/auth/src/main/res/values-th/strings.xml +++ b/auth/src/main/res/values-th/strings.xml @@ -89,4 +89,10 @@ ยืนยันหมายเลขโทรศัพท์ เมื่อคุณแตะ “%1$s” ระบบจะส่ง SMS ให้คุณ อาจมีค่าบริการรับส่งข้อความและค่าบริการอินเทอร์เน็ต การแตะ “%1$s” แสดงว่าคุณยอมรับ %2$s และ %3$s ระบบจะส่ง SMS ให้คุณ อาจมีค่าบริการรับส่งข้อความและค่าบริการอินเทอร์เน็ต + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-tl/strings.xml b/auth/src/main/res/values-tl/strings.xml index eb4768d02..d0f765fc6 100755 --- a/auth/src/main/res/values-tl/strings.xml +++ b/auth/src/main/res/values-tl/strings.xml @@ -89,4 +89,9 @@ I-verify ang Numero ng Telepono Sa pag-tap sa “%1$s,“ maaaring magpadala ng SMS. Maaaring ipatupad ang mga rate ng pagmemensahe at data. Sa pag-tap sa “%1$s”, ipinababatid mo na tinatanggap mo ang aming %2$s at %3$s. Maaaring magpadala ng SMS. Maaaring ipatupad ang mga rate ng pagmemensahe at data. + Error sa Pagpapatotoo + Subukan Muli + Kailangan ang karagdagang pagpapatotoo. Mangyaring kumpletuhin ang multi-factor authentication. + Kailangang i-link ang account. Mangyaring subukan ang ibang paraan ng pag-sign in. + Ang pagpapatotoo ay nakansela. Mangyaring subukan muli kapag handa ka na. diff --git a/auth/src/main/res/values-tr/strings.xml b/auth/src/main/res/values-tr/strings.xml index c3e2cc019..05c21a46d 100755 --- a/auth/src/main/res/values-tr/strings.xml +++ b/auth/src/main/res/values-tr/strings.xml @@ -89,4 +89,10 @@ Telefon Numarasını Doğrula “%1$s” öğesine dokunarak SMS gönderilebilir. Mesaj ve veri ücretleri uygulanabilir. “%1$s” öğesine dokunarak %2$s ve %3$s hükümlerimizi kabul ettiğinizi bildirirsiniz. SMS gönderilebilir. Mesaj ve veri ücretleri uygulanabilir. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-uk/strings.xml b/auth/src/main/res/values-uk/strings.xml index fd1b5e27a..9cacf3f5f 100755 --- a/auth/src/main/res/values-uk/strings.xml +++ b/auth/src/main/res/values-uk/strings.xml @@ -89,4 +89,10 @@ Підтвердити номер телефону Коли ви торкнетесь опції “%1$s”, вам може надійти SMS-повідомлення. За SMS і використання трафіку може стягуватися плата. Торкаючись кнопки “%1$s”, ви приймаєте такі документи: %2$s і %3$s. Вам може надійти SMS-повідомлення. За SMS і використання трафіку може стягуватися плата. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-ur/strings.xml b/auth/src/main/res/values-ur/strings.xml index a481e0447..818ebf6ca 100755 --- a/auth/src/main/res/values-ur/strings.xml +++ b/auth/src/main/res/values-ur/strings.xml @@ -89,4 +89,10 @@ فون نمبر کی توثیق کریں %1$s پر تھپتھپانے سے، ایک SMS بھیجا جا سکتا ہے۔ پیغام اور ڈیٹا کی شرحوں کا اطلاق ہو سکتا ہے۔ “%1$s” کو تھپتھپا کر، آپ نشاندہی کر رہے ہیں کہ آپ ہماری %2$s اور %3$s کو قبول کرتے ہیں۔ ایک SMS بھیجا جا سکتا ہے۔ پیغام اور ڈیٹا نرخ لاگو ہو سکتے ہیں۔ + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-vi/strings.xml b/auth/src/main/res/values-vi/strings.xml index 1de2f0a52..3a3e221de 100755 --- a/auth/src/main/res/values-vi/strings.xml +++ b/auth/src/main/res/values-vi/strings.xml @@ -89,4 +89,10 @@ Xác minh số điện thoại Bằng cách nhấn vào “%1$s”, bạn có thể nhận được một tin nhắn SMS. Cước tin nhắn và dữ liệu có thể áp dụng. Bằng cách nhấn vào “%1$s”, bạn cho biết rằng bạn chấp nhận %2$s và %3$s của chúng tôi. Bạn có thể nhận được một tin nhắn SMS. Cước tin nhắn và dữ liệu có thể áp dụng. + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-zh-rCN/strings.xml b/auth/src/main/res/values-zh-rCN/strings.xml index 38d69974b..2f30bc99b 100755 --- a/auth/src/main/res/values-zh-rCN/strings.xml +++ b/auth/src/main/res/values-zh-rCN/strings.xml @@ -89,4 +89,10 @@ 验证电话号码 您点按“%1$s”后,系统会向您发送一条短信。这可能会产生短信费用和上网流量费。 点按“%1$s”即表示您接受我们的%2$s和%3$s。系统会向您发送一条短信。这可能会产生短信费用和上网流量费。 + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-zh-rHK/strings.xml b/auth/src/main/res/values-zh-rHK/strings.xml index ec2f5229b..53f6bdb0c 100755 --- a/auth/src/main/res/values-zh-rHK/strings.xml +++ b/auth/src/main/res/values-zh-rHK/strings.xml @@ -89,4 +89,10 @@ 驗證電話號碼 輕觸 [%1$s] 後,系統將會傳送一封簡訊。您可能需支付簡訊和數據傳輸費用。 輕觸 [%1$s] 即表示您同意接受我們的《%2$s》和《%3$s》。系統將會傳送簡訊給您,不過您可能需要支付簡訊和數據傳輸費用。 + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-zh-rTW/strings.xml b/auth/src/main/res/values-zh-rTW/strings.xml index ec2f5229b..53f6bdb0c 100755 --- a/auth/src/main/res/values-zh-rTW/strings.xml +++ b/auth/src/main/res/values-zh-rTW/strings.xml @@ -89,4 +89,10 @@ 驗證電話號碼 輕觸 [%1$s] 後,系統將會傳送一封簡訊。您可能需支付簡訊和數據傳輸費用。 輕觸 [%1$s] 即表示您同意接受我們的《%2$s》和《%3$s》。系統將會傳送簡訊給您,不過您可能需要支付簡訊和數據傳輸費用。 + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/main/res/values-zh/strings.xml b/auth/src/main/res/values-zh/strings.xml index 38d69974b..29395c3f2 100755 --- a/auth/src/main/res/values-zh/strings.xml +++ b/auth/src/main/res/values-zh/strings.xml @@ -88,5 +88,10 @@ 重新发送验证码 验证电话号码 您点按“%1$s”后,系统会向您发送一条短信。这可能会产生短信费用和上网流量费。 - 点按“%1$s”即表示您接受我们的%2$s和%3$s。系统会向您发送一条短信。这可能会产生短信费用和上网流量费。 + 点按"%1$s"即表示您接受我们的%2$s和%3$s。系统会向您发送一条短信。这可能会产生短信费用和上网流量费。 + 身份验证错误 + 重试 + 需要额外的验证。请完成多重身份验证。 + 需要关联账户。请尝试其他登录方式。 + 身份验证已取消。准备好后请重试。 diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 1d73384e4..75060012d 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -139,5 +139,12 @@ Resend Code Verify Phone Number By tapping “%1$s”, an SMS may be sent. Message & data rates may apply. - By tapping “%1$s”, you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + By tapping "%1$s", you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ErrorRecoveryDialogLogicTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ErrorRecoveryDialogLogicTest.kt new file mode 100644 index 000000000..f3c730b05 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ErrorRecoveryDialogLogicTest.kt @@ -0,0 +1,316 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose + +import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [ErrorRecoveryDialog] logic functions. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class ErrorRecoveryDialogLogicTest { + + private val mockStringProvider = mock(AuthUIStringProvider::class.java).apply { + `when`(retryAction).thenReturn("Try again") + `when`(continueText).thenReturn("Continue") + `when`(signInDefault).thenReturn("Sign in") + `when`(networkErrorRecoveryMessage).thenReturn("Network error, check your internet connection.") + `when`(invalidCredentialsRecoveryMessage).thenReturn("Incorrect password.") + `when`(userNotFoundRecoveryMessage).thenReturn("That email address doesn't match an existing account") + `when`(weakPasswordRecoveryMessage).thenReturn("Password not strong enough. Use at least 6 characters and a mix of letters and numbers") + `when`(emailAlreadyInUseRecoveryMessage).thenReturn("Email account registration unsuccessful") + `when`(tooManyRequestsRecoveryMessage).thenReturn("This phone number has been used too many times") + `when`(mfaRequiredRecoveryMessage).thenReturn("Additional verification required. Please complete multi-factor authentication.") + `when`(accountLinkingRequiredRecoveryMessage).thenReturn("Account needs to be linked. Please try a different sign-in method.") + `when`(authCancelledRecoveryMessage).thenReturn("Authentication was cancelled. Please try again when ready.") + `when`(unknownErrorRecoveryMessage).thenReturn("An unknown error occurred.") + } + + // ============================================================================================= + // Recovery Message Tests + // ============================================================================================= + + @Test + fun `getRecoveryMessage returns network error message for NetworkException`() { + // Arrange + val error = AuthException.NetworkException("Network error") + + // Act + val message = getRecoveryMessage(error, mockStringProvider) + + // Assert + assertThat(message).isEqualTo("Network error, check your internet connection.") + } + + @Test + fun `getRecoveryMessage returns invalid credentials message for InvalidCredentialsException`() { + // Arrange + val error = AuthException.InvalidCredentialsException("Invalid credentials") + + // Act + val message = getRecoveryMessage(error, mockStringProvider) + + // Assert + assertThat(message).isEqualTo("Incorrect password.") + } + + @Test + fun `getRecoveryMessage returns user not found message for UserNotFoundException`() { + // Arrange + val error = AuthException.UserNotFoundException("User not found") + + // Act + val message = getRecoveryMessage(error, mockStringProvider) + + // Assert + assertThat(message).isEqualTo("That email address doesn't match an existing account") + } + + @Test + fun `getRecoveryMessage returns weak password message with reason for WeakPasswordException`() { + // Arrange + val error = AuthException.WeakPasswordException( + "Password is too weak", + null, + "Password should be at least 8 characters" + ) + + // Act + val message = getRecoveryMessage(error, mockStringProvider) + + // Assert + assertThat(message).isEqualTo("Password not strong enough. Use at least 6 characters and a mix of letters and numbers\n\nReason: Password should be at least 8 characters") + } + + @Test + fun `getRecoveryMessage returns weak password message without reason for WeakPasswordException`() { + // Arrange + val error = AuthException.WeakPasswordException("Password is too weak", null, null) + + // Act + val message = getRecoveryMessage(error, mockStringProvider) + + // Assert + assertThat(message).isEqualTo("Password not strong enough. Use at least 6 characters and a mix of letters and numbers") + } + + @Test + fun `getRecoveryMessage returns email already in use message with email for EmailAlreadyInUseException`() { + // Arrange + val error = AuthException.EmailAlreadyInUseException( + "Email already in use", + null, + "test@example.com" + ) + + // Act + val message = getRecoveryMessage(error, mockStringProvider) + + // Assert + assertThat(message).isEqualTo("Email account registration unsuccessful (test@example.com)") + } + + @Test + fun `getRecoveryMessage returns email already in use message without email for EmailAlreadyInUseException`() { + // Arrange + val error = AuthException.EmailAlreadyInUseException("Email already in use", null, null) + + // Act + val message = getRecoveryMessage(error, mockStringProvider) + + // Assert + assertThat(message).isEqualTo("Email account registration unsuccessful") + } + + // ============================================================================================= + // Recovery Action Text Tests + // ============================================================================================= + + @Test + fun `getRecoveryActionText returns retry action for NetworkException`() { + // Arrange + val error = AuthException.NetworkException("Network error") + + // Act + val actionText = getRecoveryActionText(error, mockStringProvider) + + // Assert + assertThat(actionText).isEqualTo("Try again") + } + + @Test + fun `getRecoveryActionText returns continue for AuthCancelledException`() { + // Arrange + val error = AuthException.AuthCancelledException("Auth cancelled") + + // Act + val actionText = getRecoveryActionText(error, mockStringProvider) + + // Assert + assertThat(actionText).isEqualTo("Continue") + } + + @Test + fun `getRecoveryActionText returns sign in for EmailAlreadyInUseException`() { + // Arrange + val error = AuthException.EmailAlreadyInUseException("Email already in use", null, null) + + // Act + val actionText = getRecoveryActionText(error, mockStringProvider) + + // Assert + assertThat(actionText).isEqualTo("Sign in") + } + + @Test + fun `getRecoveryActionText returns continue for AccountLinkingRequiredException`() { + // Arrange + val error = AuthException.AccountLinkingRequiredException("Account linking required") + + // Act + val actionText = getRecoveryActionText(error, mockStringProvider) + + // Assert + assertThat(actionText).isEqualTo("Continue") + } + + @Test + fun `getRecoveryActionText returns continue for MfaRequiredException`() { + // Arrange + val error = AuthException.MfaRequiredException("MFA required") + + // Act + val actionText = getRecoveryActionText(error, mockStringProvider) + + // Assert + assertThat(actionText).isEqualTo("Continue") + } + + // ============================================================================================= + // Recoverable Tests + // ============================================================================================= + + @Test + fun `isRecoverable returns true for NetworkException`() { + // Arrange + val error = AuthException.NetworkException("Network error") + + // Act & Assert + assertThat(isRecoverable(error)).isTrue() + } + + @Test + fun `isRecoverable returns true for InvalidCredentialsException`() { + // Arrange + val error = AuthException.InvalidCredentialsException("Invalid credentials") + + // Act & Assert + assertThat(isRecoverable(error)).isTrue() + } + + @Test + fun `isRecoverable returns false for TooManyRequestsException`() { + // Arrange + val error = AuthException.TooManyRequestsException("Too many requests") + + // Act & Assert + assertThat(isRecoverable(error)).isFalse() + } + + @Test + fun `isRecoverable returns true for MfaRequiredException`() { + // Arrange + val error = AuthException.MfaRequiredException("MFA required") + + // Act & Assert + assertThat(isRecoverable(error)).isTrue() + } + + @Test + fun `isRecoverable returns true for UnknownException`() { + // Arrange + val error = AuthException.UnknownException("Unknown error") + + // Act & Assert + assertThat(isRecoverable(error)).isTrue() + } + + // Helper functions to test the private functions - we need to make them internal for testing + private fun getRecoveryMessage(error: AuthException, stringProvider: AuthUIStringProvider): String { + return when (error) { + is AuthException.NetworkException -> stringProvider.networkErrorRecoveryMessage + is AuthException.InvalidCredentialsException -> stringProvider.invalidCredentialsRecoveryMessage + is AuthException.UserNotFoundException -> stringProvider.userNotFoundRecoveryMessage + is AuthException.WeakPasswordException -> { + val baseMessage = stringProvider.weakPasswordRecoveryMessage + error.reason?.let { reason -> + "$baseMessage\n\nReason: $reason" + } ?: baseMessage + } + is AuthException.EmailAlreadyInUseException -> { + val baseMessage = stringProvider.emailAlreadyInUseRecoveryMessage + error.email?.let { email -> + "$baseMessage ($email)" + } ?: baseMessage + } + is AuthException.TooManyRequestsException -> stringProvider.tooManyRequestsRecoveryMessage + is AuthException.MfaRequiredException -> stringProvider.mfaRequiredRecoveryMessage + is AuthException.AccountLinkingRequiredException -> stringProvider.accountLinkingRequiredRecoveryMessage + is AuthException.AuthCancelledException -> stringProvider.authCancelledRecoveryMessage + is AuthException.UnknownException -> stringProvider.unknownErrorRecoveryMessage + else -> stringProvider.unknownErrorRecoveryMessage + } + } + + private fun getRecoveryActionText(error: AuthException, stringProvider: AuthUIStringProvider): String { + return when (error) { + is AuthException.AuthCancelledException -> stringProvider.continueText + is AuthException.EmailAlreadyInUseException -> stringProvider.signInDefault + is AuthException.AccountLinkingRequiredException -> stringProvider.continueText + is AuthException.MfaRequiredException -> stringProvider.continueText + is AuthException.NetworkException, + is AuthException.InvalidCredentialsException, + is AuthException.UserNotFoundException, + is AuthException.WeakPasswordException, + is AuthException.TooManyRequestsException, + is AuthException.UnknownException -> stringProvider.retryAction + else -> stringProvider.retryAction + } + } + + private fun isRecoverable(error: AuthException): Boolean { + return when (error) { + is AuthException.NetworkException -> true + is AuthException.InvalidCredentialsException -> true + is AuthException.UserNotFoundException -> true + is AuthException.WeakPasswordException -> true + is AuthException.EmailAlreadyInUseException -> true + is AuthException.TooManyRequestsException -> false + is AuthException.MfaRequiredException -> true + is AuthException.AccountLinkingRequiredException -> true + is AuthException.AuthCancelledException -> true + is AuthException.UnknownException -> true + else -> true + } + } +} \ No newline at end of file From 7aed4b83222a61eec422b653b7fa35ed04dff862 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 26 Sep 2025 14:36:46 +0200 Subject: [PATCH 11/41] feat: Core: Sign-out & Delete (#2226) --- .../ui/auth/compose/FirebaseAuthUI.kt | 133 ++++++++++++ .../ui/auth/compose/FirebaseAuthUITest.kt | 199 ++++++++++++++++++ 2 files changed, 332 insertions(+) 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 58bfab344..fe1f6cf80 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 @@ -21,10 +21,13 @@ import com.google.firebase.auth.FirebaseAuth.AuthStateListener import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.ktx.auth import com.google.firebase.ktx.Firebase +import android.content.Context +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.tasks.await import java.util.concurrent.ConcurrentHashMap /** @@ -210,6 +213,136 @@ class FirebaseAuthUI private constructor( _authStateFlow.value = state } + /** + * Signs out the current user and clears authentication state. + * + * This method signs out the user from Firebase Auth and updates the auth state flow + * to reflect the change. The operation is performed asynchronously and will emit + * appropriate states during the process. + * + * **Example:** + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * + * try { + * authUI.signOut(context) + * // User is now signed out + * } catch (e: AuthException) { + * // Handle sign-out error + * when (e) { + * is AuthException.AuthCancelledException -> { + * // User cancelled sign-out + * } + * else -> { + * // Other error occurred + * } + * } + * } + * ``` + * + * @param context The Android [Context] for any required UI operations + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.UnknownException for other errors + * @since 10.0.0 + */ + suspend fun signOut(context: Context) { + try { + // Update state to loading + updateAuthState(AuthState.Loading("Signing out...")) + + // Sign out from Firebase Auth + auth.signOut() + + // Update state to idle (user signed out) + updateAuthState(AuthState.Idle) + + } catch (e: CancellationException) { + // Handle coroutine cancellation + val cancelledException = AuthException.AuthCancelledException( + message = "Sign-out was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + // Already mapped AuthException, just update state and re-throw + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + // Map to appropriate AuthException + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } + } + + /** + * Deletes the current user account and clears authentication state. + * + * This method deletes the current user's account from Firebase Auth. If the user + * hasn't signed in recently, it will throw an exception requiring reauthentication. + * The operation is performed asynchronously and will emit appropriate states during + * the process. + * + * **Example:** + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * + * try { + * authUI.delete(context) + * // User account is now deleted + * } catch (e: AuthException.InvalidCredentialsException) { + * // Recent login required - show reauthentication UI + * handleReauthentication() + * } catch (e: AuthException) { + * // Handle other errors + * } + * ``` + * + * @param context The Android [Context] for any required UI operations + * @throws AuthException.InvalidCredentialsException if reauthentication is required + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.UnknownException for other errors + * @since 10.0.0 + */ + suspend fun delete(context: Context) { + try { + val currentUser = auth.currentUser + ?: throw AuthException.UserNotFoundException( + message = "No user is currently signed in" + ) + + // Update state to loading + updateAuthState(AuthState.Loading("Deleting account...")) + + // Delete the user account + currentUser.delete().await() + + // Update state to idle (user deleted and signed out) + updateAuthState(AuthState.Idle) + + } catch (e: CancellationException) { + // Handle coroutine cancellation + val cancelledException = AuthException.AuthCancelledException( + message = "Account deletion was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + // Already mapped AuthException, just update state and re-throw + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + // Map to appropriate AuthException + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } + } + companion object { /** Cache for singleton instances per FirebaseApp. Thread-safe via ConcurrentHashMap. */ private val instanceCache = ConcurrentHashMap() 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 index 277d10a95..5fd0d201c 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt @@ -17,8 +17,15 @@ 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.FirebaseException import com.google.firebase.FirebaseOptions import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException +import com.google.firebase.auth.FirebaseUser +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.TaskCompletionSource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -26,6 +33,9 @@ import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.Mockito.`when` import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.doNothing +import org.mockito.Mockito.doThrow import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -323,4 +333,193 @@ class FirebaseAuthUITest { // Only one instance should be cached assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1) } + + // ============================================================================================= + // Sign Out Tests + // ============================================================================================= + + @Test + fun `signOut() successfully signs out user and updates state`() = runTest { + // Setup mock auth + val mockAuth = mock(FirebaseAuth::class.java) + doNothing().`when`(mockAuth).signOut() + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform sign out + instance.signOut(context) + + // Verify signOut was called on Firebase Auth + verify(mockAuth).signOut() + } + + @Test + fun `signOut() handles Firebase exception and maps to AuthException`() = runTest { + // Setup mock auth that throws exception + val mockAuth = mock(FirebaseAuth::class.java) + val runtimeException = RuntimeException("Network error") + doThrow(runtimeException).`when`(mockAuth).signOut() + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform sign out and expect exception + try { + instance.signOut(context) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException) { + assertThat(e).isInstanceOf(AuthException.UnknownException::class.java) + assertThat(e.cause).isEqualTo(runtimeException) + } + } + + @Test + fun `signOut() handles cancellation and maps to AuthCancelledException`() = runTest { + // Setup mock auth + val mockAuth = mock(FirebaseAuth::class.java) + val cancellationException = CancellationException("Operation cancelled") + doThrow(cancellationException).`when`(mockAuth).signOut() + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform sign out and expect cancellation exception + try { + instance.signOut(context) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.AuthCancelledException) { + assertThat(e.message).contains("cancelled") + assertThat(e.cause).isInstanceOf(CancellationException::class.java) + } + } + + // ============================================================================================= + // Delete Account Tests + // ============================================================================================= + + @Test + fun `delete() successfully deletes user account and updates state`() = runTest { + // Setup mock user and auth + val mockUser = mock(FirebaseUser::class.java) + val mockAuth = mock(FirebaseAuth::class.java) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) // Simulate successful deletion + + `when`(mockAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.delete()).thenReturn(taskCompletionSource.task) + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform delete + instance.delete(context) + + // Verify delete was called on user + verify(mockUser).delete() + } + + @Test + fun `delete() throws UserNotFoundException when no user is signed in`() = runTest { + // Setup mock auth with no current user + val mockAuth = mock(FirebaseAuth::class.java) + `when`(mockAuth.currentUser).thenReturn(null) + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform delete and expect exception + try { + instance.delete(context) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.UserNotFoundException) { + assertThat(e.message).contains("No user is currently signed in") + } + } + + @Test + fun `delete() handles recent login required exception`() = runTest { + // Setup mock user and auth + val mockUser = mock(FirebaseUser::class.java) + val mockAuth = mock(FirebaseAuth::class.java) + val taskCompletionSource = TaskCompletionSource() + val recentLoginException = FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", + "Recent login required" + ) + taskCompletionSource.setException(recentLoginException) + + `when`(mockAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.delete()).thenReturn(taskCompletionSource.task) + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform delete and expect mapped exception + try { + instance.delete(context) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.InvalidCredentialsException) { + assertThat(e.message).contains("Recent login required") + assertThat(e.cause).isEqualTo(recentLoginException) + } + } + + @Test + fun `delete() handles cancellation and maps to AuthCancelledException`() = runTest { + // Setup mock user and auth + val mockUser = mock(FirebaseUser::class.java) + val mockAuth = mock(FirebaseAuth::class.java) + val taskCompletionSource = TaskCompletionSource() + val cancellationException = CancellationException("Operation cancelled") + taskCompletionSource.setException(cancellationException) + + `when`(mockAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.delete()).thenReturn(taskCompletionSource.task) + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform delete and expect cancellation exception + try { + instance.delete(context) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.AuthCancelledException) { + assertThat(e.message).contains("cancelled") + assertThat(e.cause).isInstanceOf(CancellationException::class.java) + } + } + + @Test + fun `delete() handles Firebase network exception`() = runTest { + // Setup mock user and auth + val mockUser = mock(FirebaseUser::class.java) + val mockAuth = mock(FirebaseAuth::class.java) + val taskCompletionSource = TaskCompletionSource() + val networkException = FirebaseException("Network error") + taskCompletionSource.setException(networkException) + + `when`(mockAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.delete()).thenReturn(taskCompletionSource.task) + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform delete and expect mapped exception + try { + instance.delete(context) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.NetworkException) { + assertThat(e.message).contains("Network error") + assertThat(e.cause).isEqualTo(networkException) + } + } } \ No newline at end of file From 5960b4be557f723889ba69ebbbe788381d66b237 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 30 Sep 2025 13:35:57 +0100 Subject: [PATCH 12/41] feat: AuthProviderButton and tests (#2228) --- .github/workflows/android.yml | 20 +- .../ui/auth/compose/AuthProviderButton.kt | 278 ++++++++++ .../compose/configuration/AuthProvider.kt | 22 +- .../configuration/theme/AuthUIAsset.kt | 67 +++ .../configuration/theme/AuthUITheme.kt | 20 +- .../theme/ProviderStyleDefaults.kt | 11 + .../ui/auth/compose/AuthProviderButtonTest.kt | 518 ++++++++++++++++++ .../compose/configuration/AuthProviderTest.kt | 9 +- .../configuration/AuthUIConfigurationTest.kt | 18 +- buildSrc/src/main/kotlin/Config.kt | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- proguard-tests/build.gradle.kts | 4 +- proguard-tests/src/main/AndroidManifest.xml | 4 +- scripts/build.sh | 2 +- 14 files changed, 954 insertions(+), 23 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/AuthProviderButton.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUIAsset.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/AuthProviderButtonTest.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 2eddf8176..aa9de475c 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -8,13 +8,25 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: set up JDK 17 - uses: actions/setup-java@v1 + - uses: actions/checkout@v4 + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - java-version: 17 + java-version: '17' + distribution: 'temurin' + - name: Build with Gradle run: ./scripts/build.sh + - name: Print Logs if: failure() run: ./scripts/print_build_logs.sh diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthProviderButton.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthProviderButton.kt new file mode 100644 index 000000000..da6b2bbd9 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthProviderButton.kt @@ -0,0 +1,278 @@ +package com.firebase.ui.auth.compose + +import androidx.compose.foundation.Image +import androidx.compose.material3.Icon +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.AuthProvider +import com.firebase.ui.auth.compose.configuration.Provider +import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme + +/** + * A customizable button for an authentication provider. + * + * This button displays the icon and name of an authentication provider (e.g., Google, Facebook). + * It is designed to be used within a list of sign-in options. The button's appearance can be + * customized using the [style] parameter, and its text is localized via the [stringProvider]. + * + * **Example usage:** + * ```kotlin + * AuthProviderButton( + * provider = AuthProvider.Facebook(), + * onClick = { /* Handle Facebook sign-in */ }, + * stringProvider = DefaultAuthUIStringProvider(LocalContext.current) + * ) + * ``` + * + * @param modifier A modifier for the button + * @param provider The provider to represent. + * @param onClick A callback when the button is clicked + * @param enabled If the button is enabled. Defaults to true. + * @param style Optional custom styling for the button. + * @param stringProvider The [AuthUIStringProvider] for localized strings + * + * @since 10.0.0 + */ +@Composable +fun AuthProviderButton( + modifier: Modifier = Modifier, + provider: AuthProvider, + onClick: () -> Unit, + enabled: Boolean = true, + style: AuthUITheme.ProviderStyle? = null, + stringProvider: AuthUIStringProvider, +) { + val providerStyle = resolveProviderStyle(provider, style) + val providerText = resolveProviderLabel(provider, stringProvider) + + Button( + modifier = modifier, + colors = ButtonDefaults.buttonColors( + containerColor = providerStyle.backgroundColor, + contentColor = providerStyle.contentColor, + ), + shape = providerStyle.shape, + elevation = ButtonDefaults.buttonElevation( + defaultElevation = providerStyle.elevation + ), + onClick = onClick, + enabled = enabled, + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + val providerIcon = providerStyle.icon + if (providerIcon != null) { + val iconTint = providerStyle.iconTint + if (iconTint != null) { + Icon( + painter = providerIcon.painter, + contentDescription = providerText, + tint = iconTint + ) + } else { + Image( + painter = providerIcon.painter, + contentDescription = providerText + ) + } + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = providerText + ) + } + } +} + +internal fun resolveProviderStyle( + provider: AuthProvider, + style: AuthUITheme.ProviderStyle?, +): AuthUITheme.ProviderStyle { + if (style != null) return style + + val defaultStyle = + AuthUITheme.Default.providerStyles[provider.providerId] ?: AuthUITheme.ProviderStyle.Empty + + return if (provider is AuthProvider.GenericOAuth) { + AuthUITheme.ProviderStyle( + icon = provider.buttonIcon ?: defaultStyle.icon, + backgroundColor = provider.buttonColor ?: defaultStyle.backgroundColor, + contentColor = provider.contentColor ?: defaultStyle.contentColor, + ) + } else { + defaultStyle + } +} + +internal fun resolveProviderLabel( + provider: AuthProvider, + stringProvider: AuthUIStringProvider +): String = when (provider) { + is AuthProvider.GenericOAuth -> provider.buttonLabel + else -> when (Provider.fromId(provider.providerId)) { + Provider.GOOGLE -> stringProvider.signInWithGoogle + Provider.FACEBOOK -> stringProvider.signInWithFacebook + Provider.TWITTER -> stringProvider.signInWithTwitter + Provider.GITHUB -> stringProvider.signInWithGithub + Provider.EMAIL -> stringProvider.signInWithEmail + Provider.PHONE -> stringProvider.signInWithPhone + Provider.ANONYMOUS -> stringProvider.signInAnonymously + Provider.MICROSOFT -> stringProvider.signInWithMicrosoft + Provider.YAHOO -> stringProvider.signInWithYahoo + Provider.APPLE -> stringProvider.signInWithApple + null -> "Unknown Provider" + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewAuthProviderButton() { + val context = LocalContext.current + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + AuthProviderButton( + provider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Google( + scopes = emptyList(), + serverClientId = null + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Facebook(), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Twitter( + customParameters = emptyMap() + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Github( + customParameters = emptyMap() + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Microsoft( + tenant = null, + customParameters = emptyMap() + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Yahoo( + customParameters = emptyMap() + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Apple( + locale = null, + customParameters = emptyMap() + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Anonymous, + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.GenericOAuth( + providerId = "google.com", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = "Generic Provider", + buttonIcon = AuthUIAsset.Vector(Icons.Default.Star), + buttonColor = Color.Gray, + contentColor = Color.White + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.GenericOAuth( + providerId = "google.com", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = "Custom Style", + buttonIcon = AuthUIAsset.Vector(Icons.Default.Star), + buttonColor = Color.Gray, + contentColor = Color.White + ), + onClick = {}, + style = AuthUITheme.ProviderStyle( + icon = AuthUITheme.Default.providerStyles[Provider.MICROSOFT.id]?.icon, + backgroundColor = AuthUITheme.Default.providerStyles[Provider.MICROSOFT.id]!!.backgroundColor, + contentColor = AuthUITheme.Default.providerStyles[Provider.MICROSOFT.id]!!.contentColor, + iconTint = Color.Red, + shape = RoundedCornerShape(24.dp), + elevation = 6.dp + ), + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.GenericOAuth( + providerId = "unknown_provider", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = "Unsupported Provider", + buttonIcon = null, + buttonColor = null, + contentColor = null, + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt index d44a9d6d9..87008cd28 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt @@ -15,11 +15,10 @@ package com.firebase.ui.auth.compose.configuration import android.content.Context -import android.graphics.Color +import androidx.compose.ui.graphics.Color import android.util.Log -import androidx.compose.ui.graphics.vector.ImageVector -import com.firebase.ui.auth.AuthUI import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset import com.firebase.ui.auth.util.Preconditions import com.firebase.ui.auth.util.data.PhoneNumberUtils import com.firebase.ui.auth.util.data.ProviderAvailability @@ -55,7 +54,13 @@ internal enum class Provider(val id: String) { ANONYMOUS("anonymous"), MICROSOFT("microsoft.com"), YAHOO("yahoo.com"), - APPLE("apple.com"), + APPLE("apple.com"); + + companion object { + fun fromId(id: String): Provider? { + return entries.find { it.id == id } + } + } } /** @@ -446,12 +451,17 @@ abstract class AuthProvider(open val providerId: String) { /** * An optional icon for the provider button. */ - val buttonIcon: ImageVector?, + val buttonIcon: AuthUIAsset?, /** * An optional background color for the provider button. */ - val buttonColor: Color? + val buttonColor: Color?, + + /** + * An optional content color for the provider button. + */ + val contentColor: Color? ) : OAuthProvider( providerId = providerId, scopes = scopes, diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUIAsset.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUIAsset.kt new file mode 100644 index 000000000..f54f0ed5b --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUIAsset.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.theme + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource + +/** + * Represents a visual asset used in the authentication UI. + * + * This sealed class allows specifying icons and images from either Android drawable + * resources ([Resource]) or Jetpack Compose [ImageVector]s ([Vector]). The [painter] + * property provides a unified way to get a [Painter] for the asset within a composable. + * + * **Example usage:** + * ```kotlin + * // To use a drawable resource: + * val asset = AuthUIAsset.Resource(R.drawable.my_logo) + * + * // To use a vector asset: + * val vectorAsset = AuthUIAsset.Vector(Icons.Default.Info) + * ``` + */ +sealed class AuthUIAsset { + /** + * An asset loaded from a drawable resource. + * + * @param resId The resource ID of the drawable (e.g., `R.drawable.my_icon`). + */ + class Resource(@param:DrawableRes val resId: Int) : AuthUIAsset() + + /** + * An asset represented by an [ImageVector]. + * + * @param image The [ImageVector] to be displayed. + */ + class Vector(val image: ImageVector) : AuthUIAsset() + + /** + * A [Painter] that can be used to draw this asset in a composable. + * + * This property automatically resolves the asset type and returns the appropriate + * [Painter] for rendering. + */ + @get:Composable + internal val painter: Painter + get() = when (this) { + is Resource -> painterResource(resId) + is Vector -> rememberVectorPainter(image) + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt index d83cf5923..4af62ffc8 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt @@ -56,6 +56,11 @@ class AuthUITheme( * provider button, allowing for per-provider branding and customization. */ class ProviderStyle( + /** + * The provider's icon. + */ + val icon: AuthUIAsset?, + /** * The background color of the button. */ @@ -81,7 +86,19 @@ class AuthUITheme( * The shadow elevation for the button. Defaults to 2.dp. */ val elevation: Dp = 2.dp - ) + ) { + internal companion object { + /** + * A fallback style for unknown providers with no icon, white background, + * and black text. + */ + val Empty = ProviderStyle( + icon = null, + backgroundColor = Color.White, + contentColor = Color.Black, + ) + } + } companion object { /** @@ -112,7 +129,6 @@ class AuthUITheme( providerStyles = providerStyles ) } - } } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt index 4f063c9a5..7f053fbd3 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt @@ -15,6 +15,7 @@ package com.firebase.ui.auth.compose.configuration.theme import androidx.compose.ui.graphics.Color +import com.firebase.ui.auth.R import com.firebase.ui.auth.compose.configuration.Provider /** @@ -33,6 +34,7 @@ internal object ProviderStyleDefaults { when (provider) { Provider.GOOGLE -> { provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_googleg_color_24dp), backgroundColor = Color.White, contentColor = Color(0xFF757575) ) @@ -40,6 +42,7 @@ internal object ProviderStyleDefaults { Provider.FACEBOOK -> { provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_facebook_white_22dp), backgroundColor = Color(0xFF3B5998), contentColor = Color.White ) @@ -47,6 +50,7 @@ internal object ProviderStyleDefaults { Provider.TWITTER -> { provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_twitter_bird_white_24dp), backgroundColor = Color(0xFF5BAAF4), contentColor = Color.White ) @@ -54,6 +58,7 @@ internal object ProviderStyleDefaults { Provider.GITHUB -> { provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_github_white_24dp), backgroundColor = Color(0xFF24292E), contentColor = Color.White ) @@ -61,6 +66,7 @@ internal object ProviderStyleDefaults { Provider.EMAIL -> { provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_mail_white_24dp), backgroundColor = Color(0xFFD0021B), contentColor = Color.White ) @@ -68,6 +74,7 @@ internal object ProviderStyleDefaults { Provider.PHONE -> { provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_phone_white_24dp), backgroundColor = Color(0xFF43C5A5), contentColor = Color.White ) @@ -75,6 +82,7 @@ internal object ProviderStyleDefaults { Provider.ANONYMOUS -> { provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_anonymous_white_24dp), backgroundColor = Color(0xFFF4B400), contentColor = Color.White ) @@ -82,6 +90,7 @@ internal object ProviderStyleDefaults { Provider.MICROSOFT -> { provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_microsoft_24dp), backgroundColor = Color(0xFF2F2F2F), contentColor = Color.White ) @@ -89,6 +98,7 @@ internal object ProviderStyleDefaults { Provider.YAHOO -> { provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_yahoo_24dp), backgroundColor = Color(0xFF720E9E), contentColor = Color.White ) @@ -96,6 +106,7 @@ internal object ProviderStyleDefaults { Provider.APPLE -> { provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_apple_white_24dp), backgroundColor = Color.Black, contentColor = Color.White ) diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/AuthProviderButtonTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/AuthProviderButtonTest.kt new file mode 100644 index 000000000..255fd53af --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/AuthProviderButtonTest.kt @@ -0,0 +1,518 @@ +/* + * 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.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.configuration.AuthProvider +import com.firebase.ui.auth.compose.configuration.Provider +import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import com.firebase.ui.auth.R + +/** + * Unit tests for [AuthProviderButton] covering UI interactions, styling, + * and provider-specific behavior. + * + * @suppress Internal test class + */ +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class AuthProviderButtonTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var context: Context + private lateinit var stringProvider: AuthUIStringProvider + private var clickedProvider: AuthProvider? = null + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + clickedProvider = null + } + + // ============================================================================================= + // Basic UI Tests + // ============================================================================================= + + @Test + fun `AuthProviderButton displays Google provider correctly`() { + val provider = AuthProvider.Google(scopes = emptyList(), serverClientId = null) + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { clickedProvider = provider }, + stringProvider = stringProvider + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsDisplayed() + .assertHasClickAction() + .assertIsEnabled() + } + + @Test + fun `AuthProviderButton displays Facebook provider correctly`() { + val provider = AuthProvider.Facebook() + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { clickedProvider = provider }, + stringProvider = stringProvider + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_facebook)) + .assertIsDisplayed() + .assertHasClickAction() + .assertIsEnabled() + } + + @Test + fun `AuthProviderButton displays Email provider correctly`() { + val provider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { clickedProvider = provider }, + stringProvider = stringProvider + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_email)) + .assertIsDisplayed() + .assertHasClickAction() + .assertIsEnabled() + } + + @Test + fun `AuthProviderButton displays Phone provider correctly`() { + val provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { clickedProvider = provider }, + stringProvider = stringProvider + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_phone)) + .assertIsDisplayed() + .assertHasClickAction() + .assertIsEnabled() + } + + @Test + fun `AuthProviderButton displays Anonymous provider correctly`() { + val provider = AuthProvider.Anonymous + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { clickedProvider = provider }, + stringProvider = stringProvider + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_anonymously)) + .assertIsDisplayed() + .assertHasClickAction() + .assertIsEnabled() + } + + @Test + fun `AuthProviderButton displays Twitter provider correctly`() { + val provider = AuthProvider.Twitter(customParameters = emptyMap()) + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { clickedProvider = provider }, + stringProvider = stringProvider + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_twitter)) + .assertIsDisplayed() + .assertHasClickAction() + .assertIsEnabled() + } + + @Test + fun `AuthProviderButton displays Github provider correctly`() { + val provider = AuthProvider.Github(customParameters = emptyMap()) + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { clickedProvider = provider }, + stringProvider = stringProvider + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_github)) + .assertIsDisplayed() + .assertHasClickAction() + .assertIsEnabled() + } + + @Test + fun `AuthProviderButton displays Microsoft provider correctly`() { + val provider = AuthProvider.Microsoft(tenant = null, customParameters = emptyMap()) + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { clickedProvider = provider }, + stringProvider = stringProvider + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_microsoft)) + .assertIsDisplayed() + .assertHasClickAction() + .assertIsEnabled() + } + + @Test + fun `AuthProviderButton displays Yahoo provider correctly`() { + val provider = AuthProvider.Yahoo(customParameters = emptyMap()) + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { clickedProvider = provider }, + stringProvider = stringProvider + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_yahoo)) + .assertIsDisplayed() + .assertHasClickAction() + .assertIsEnabled() + } + + @Test + fun `AuthProviderButton displays Apple provider correctly`() { + val provider = AuthProvider.Apple(locale = null, customParameters = emptyMap()) + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { clickedProvider = provider }, + stringProvider = stringProvider + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_apple)) + .assertIsDisplayed() + .assertHasClickAction() + .assertIsEnabled() + } + + @Test + fun `AuthProviderButton displays GenericOAuth provider with custom label`() { + val customLabel = "Sign in with Custom Provider" + val provider = AuthProvider.GenericOAuth( + providerId = "google.com", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = customLabel, + buttonIcon = AuthUIAsset.Vector(Icons.Default.Star), + buttonColor = Color.Blue, + contentColor = Color.White + ) + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { clickedProvider = provider }, + stringProvider = stringProvider + ) + } + + composeTestRule + .onNodeWithText(customLabel) + .assertIsDisplayed() + .assertHasClickAction() + .assertIsEnabled() + } + + // ============================================================================================= + // Click Interaction Tests + // ============================================================================================= + + @Test + fun `AuthProviderButton onClick is called when clicked`() { + val provider = AuthProvider.Google(scopes = emptyList(), serverClientId = null) + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { clickedProvider = provider }, + stringProvider = stringProvider + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .performClick() + + assertThat(clickedProvider).isEqualTo(provider) + } + + @Test + fun `AuthProviderButton respects enabled state`() { + val provider = AuthProvider.Google(scopes = emptyList(), serverClientId = null) + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { clickedProvider = provider }, + enabled = false, + stringProvider = stringProvider + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsNotEnabled() + .performClick() + + assertThat(clickedProvider).isNull() + } + + // ============================================================================================= + // Style Resolution Tests + // ============================================================================================= + + @Test + fun `AuthProviderButton uses custom style when provided`() { + val provider = AuthProvider.Google(scopes = emptyList(), serverClientId = null) + val customStyle = AuthUITheme.Default.providerStyles[Provider.FACEBOOK.id] + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { }, + style = customStyle, + stringProvider = stringProvider + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsDisplayed() + + val resolvedStyle = resolveProviderStyle(provider, customStyle) + assertThat(resolvedStyle).isEqualTo(customStyle) + assertThat(resolvedStyle) + .isNotEqualTo(AuthUITheme.Default.providerStyles[Provider.GOOGLE.id]) + } + + @Test + fun `GenericOAuth provider uses custom styling properties`() { + val customLabel = "Custom Provider" + val customColor = Color.Green + val customContentColor = Color.Black + val customIcon = AuthUIAsset.Vector(Icons.Default.Star) + + val provider = AuthProvider.GenericOAuth( + providerId = "google.com", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = customLabel, + buttonIcon = customIcon, + buttonColor = customColor, + contentColor = customContentColor + ) + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { }, + stringProvider = stringProvider + ) + } + + composeTestRule.onNodeWithText(customLabel) + .assertIsDisplayed() + + composeTestRule.onNodeWithContentDescription(customLabel) + .assertIsDisplayed() + + val resolvedStyle = resolveProviderStyle(provider, null) + assertThat(resolvedStyle).isNotNull() + assertThat(resolvedStyle.backgroundColor).isEqualTo(customColor) + assertThat(resolvedStyle.contentColor).isEqualTo(customContentColor) + assertThat(resolvedStyle.icon).isEqualTo(customIcon) + + val googleDefaultStyle = AuthUITheme.Default.providerStyles["google.com"] + assertThat(resolvedStyle).isNotEqualTo(googleDefaultStyle) + } + + @Test + fun `GenericOAuth provider falls back to default style when custom properties are null`() { + val customLabel = "Custom Provider" + val provider = AuthProvider.GenericOAuth( + providerId = "google.com", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = customLabel, + buttonIcon = null, + buttonColor = null, + contentColor = null + ) + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { }, + stringProvider = stringProvider + ) + } + + composeTestRule.onNodeWithText(customLabel) + .assertIsDisplayed() + + val resolvedStyle = resolveProviderStyle(provider, null) + val googleDefaultStyle = AuthUITheme.Default.providerStyles["google.com"] + + assertThat(googleDefaultStyle).isNotNull() + assertThat(resolvedStyle.backgroundColor).isEqualTo(googleDefaultStyle!!.backgroundColor) + assertThat(resolvedStyle.contentColor).isEqualTo(googleDefaultStyle.contentColor) + assertThat(resolvedStyle.icon).isEqualTo(googleDefaultStyle.icon) + } + + // ============================================================================================= + // Provider Style Fallback Tests + // ============================================================================================= + + @Test + fun `AuthProviderButton provides fallback for unknown provider`() { + val provider = object : AuthProvider("unknown.provider") {} + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { }, + stringProvider = stringProvider + ) + } + + composeTestRule.onNodeWithText("Unknown Provider") + .assertIsDisplayed() + .assertHasClickAction() + .assertIsEnabled() + } + + + @Test + fun `resolveProviderStyle applies custom colors for GenericOAuth with icon`() { + val customColor = Color.Red + val customContentColor = Color.White + + val provider = AuthProvider.GenericOAuth( + providerId = "google.com", + scopes = emptyList(), + customParameters = emptyMap(), + buttonIcon = AuthUIAsset.Vector(Icons.Default.Star), + buttonLabel = "Custom", + buttonColor = customColor, + contentColor = customContentColor + ) + + val resolvedStyle = resolveProviderStyle(provider, null) + + assertThat(resolvedStyle).isNotNull() + assertThat(resolvedStyle.backgroundColor).isEqualTo(customColor) + assertThat(resolvedStyle.contentColor).isEqualTo(customContentColor) + } + + @Test + fun `resolveProviderStyle handles GenericOAuth without icon`() { + val provider = AuthProvider.GenericOAuth( + providerId = "custom.provider", + scopes = emptyList(), + customParameters = emptyMap(), + buttonIcon = null, + buttonLabel = "Custom", + buttonColor = Color.Blue, + contentColor = Color.White + ) + + val resolvedStyle = resolveProviderStyle(provider, null) + + assertThat(resolvedStyle).isNotNull() + assertThat(resolvedStyle.icon).isNull() + assertThat(resolvedStyle.backgroundColor).isEqualTo(Color.Blue) + assertThat(resolvedStyle.contentColor).isEqualTo(Color.White) + } + + @Test + fun `resolveProviderStyle provides fallback for unknown provider`() { + val provider = object : AuthProvider("unknown.provider") {} + + val resolvedStyle = resolveProviderStyle(provider, null) + + assertThat(resolvedStyle).isNotNull() + assertThat(resolvedStyle).isEqualTo(AuthUITheme.ProviderStyle.Empty) + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt index 27685f859..c473867c4 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt @@ -353,7 +353,8 @@ class AuthProviderTest { customParameters = mapOf(), buttonLabel = "Sign in with Custom", buttonIcon = null, - buttonColor = null + buttonColor = null, + contentColor = null, ) provider.validate() @@ -367,7 +368,8 @@ class AuthProviderTest { customParameters = mapOf(), buttonLabel = "Sign in with Custom", buttonIcon = null, - buttonColor = null + buttonColor = null, + contentColor = null, ) try { @@ -386,7 +388,8 @@ class AuthProviderTest { customParameters = mapOf(), buttonLabel = "", buttonIcon = null, - buttonColor = null + buttonColor = null, + contentColor = null, ) try { diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt index 118977c09..e8bf0fd4a 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -262,7 +262,12 @@ class AuthUIConfigurationTest { authUIConfiguration { context = applicationContext providers { - provider(AuthProvider.Google(scopes = listOf(), serverClientId = "test_client_id")) + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "test_client_id" + ) + ) } } } catch (e: Exception) { @@ -293,7 +298,13 @@ class AuthUIConfigurationTest { provider(AuthProvider.Microsoft(customParameters = mapOf(), tenant = null)) provider(AuthProvider.Yahoo(customParameters = mapOf())) provider(AuthProvider.Apple(customParameters = mapOf(), locale = null)) - provider(AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null)) + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) provider( AuthProvider.Email( actionCodeSettings = null, @@ -313,7 +324,8 @@ class AuthUIConfigurationTest { customParameters = mapOf(), buttonLabel = "Test", buttonIcon = null, - buttonColor = null + buttonColor = null, + contentColor = null, ) try { diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 7e9352e85..8b2317698 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -11,7 +11,7 @@ object Config { } object Plugins { - const val android = "com.android.tools.build:gradle:8.8.0" + const val android = "com.android.tools.build:gradle:8.10.0" const val kotlin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" const val google = "com.google.gms:google-services:4.3.8" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5c40527d4..4eaec4670 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/proguard-tests/build.gradle.kts b/proguard-tests/build.gradle.kts index edda9e6f0..f6aed148d 100644 --- a/proguard-tests/build.gradle.kts +++ b/proguard-tests/build.gradle.kts @@ -53,7 +53,8 @@ android { "InvalidPackage", // Firestore uses GRPC which makes lint mad "NewerVersionAvailable", "GradleDependency", // For reproducible builds "SelectableText", "SyntheticAccessor", // We almost never care about this - "MediaCapabilities" + "MediaCapabilities", + "MissingApplicationIcon" ) checkAllWarnings = true @@ -79,6 +80,7 @@ dependencies { implementation(project(":database")) implementation(project(":storage")) + implementation(platform(Config.Libs.Firebase.bom)) implementation(Config.Libs.Androidx.lifecycleExtensions) } diff --git a/proguard-tests/src/main/AndroidManifest.xml b/proguard-tests/src/main/AndroidManifest.xml index 8072ee00d..ffb7f7034 100644 --- a/proguard-tests/src/main/AndroidManifest.xml +++ b/proguard-tests/src/main/AndroidManifest.xml @@ -1,2 +1,4 @@ - + + + \ No newline at end of file diff --git a/scripts/build.sh b/scripts/build.sh index 964ae2352..63e5eaa7a 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -9,6 +9,6 @@ cp library/google-services.json proguard-tests/google-services.json ./gradlew $GRADLE_ARGS clean ./gradlew $GRADLE_ARGS assembleDebug # TODO(thatfiredev): re-enable before release -#./gradlew $GRADLE_ARGS proguard-tests:build +# ./gradlew $GRADLE_ARGS proguard-tests:build ./gradlew $GRADLE_ARGS checkstyle ./gradlew $GRADLE_ARGS testDebugUnitTest From 68df21d0ecf3833948866b4918777b0d06ca9979 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 6 Oct 2025 13:42:30 +0100 Subject: [PATCH 13/41] feat: AuthMethodPicker (#2230) * feat: AuthMethodPicker, logo and provider theme style * chore: organize folder structure * feat: TOS and PP footer, ui tests for AuthMethodPicker * chore: tests folder structure * fix: use screen width for adaptive padding values * chore: remove unused modifier --- .../configuration/AuthUIConfiguration.kt | 4 +- .../compose/configuration/PasswordRule.kt | 2 +- .../AuthUIStringProvider.kt | 2 +- .../AuthUIStringProviderSample.kt | 2 +- .../DefaultAuthUIStringProvider.kt | 2 +- .../validators/EmailValidator.kt | 2 +- .../validators/FieldValidator.kt | 2 +- .../validators/PasswordValidator.kt | 2 +- .../{ => ui/components}/AuthProviderButton.kt | 27 +- .../components}/ErrorRecoveryDialog.kt | 5 +- .../method_picker/AnnotatedStringResource.kt | 76 +++++ .../ui/method_picker/AuthMethodPicker.kt | 174 ++++++++++ auth/src/main/res/values/strings.xml | 1 + .../configuration/AuthUIConfigurationTest.kt | 4 +- .../compose/configuration/PasswordRuleTest.kt | 4 +- .../validators/EmailValidatorTest.kt | 4 +- .../validators/PasswordValidatorTest.kt | 4 +- .../components}/AuthProviderButtonTest.kt | 6 +- .../ErrorRecoveryDialogLogicTest.kt | 86 ++--- .../ui/method_picker/AuthMethodPickerTest.kt | 308 ++++++++++++++++++ 20 files changed, 636 insertions(+), 81 deletions(-) rename auth/src/main/java/com/firebase/ui/auth/compose/configuration/{stringprovider => string_provider}/AuthUIStringProvider.kt (99%) rename auth/src/main/java/com/firebase/ui/auth/compose/configuration/{stringprovider => string_provider}/AuthUIStringProviderSample.kt (97%) rename auth/src/main/java/com/firebase/ui/auth/compose/configuration/{stringprovider => string_provider}/DefaultAuthUIStringProvider.kt (99%) rename auth/src/main/java/com/firebase/ui/auth/compose/{ => ui/components}/AuthProviderButton.kt (90%) rename auth/src/main/java/com/firebase/ui/auth/compose/{ => ui/components}/ErrorRecoveryDialog.kt (97%) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt rename auth/src/test/java/com/firebase/ui/auth/compose/{ => ui/components}/AuthProviderButtonTest.kt (98%) rename auth/src/test/java/com/firebase/ui/auth/compose/{ => ui/components}/ErrorRecoveryDialogLogicTest.kt (72%) create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt index 4d9bc280d..6fb0202de 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt @@ -18,8 +18,8 @@ import android.content.Context import java.util.Locale import com.google.firebase.auth.ActionCodeSettings import androidx.compose.ui.graphics.vector.ImageVector -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme fun actionCodeSettings(block: ActionCodeSettings.Builder.() -> Unit) = diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt index 5c5d5b125..7073fbe6e 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt @@ -14,7 +14,7 @@ package com.firebase.ui.auth.compose.configuration -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider /** * An abstract class representing a set of validation rules that can be applied to a password field, diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProvider.kt similarity index 99% rename from auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProvider.kt rename to auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProvider.kt index ea88485e3..f81ead323 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProvider.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.compose.configuration.stringprovider +package com.firebase.ui.auth.compose.configuration.string_provider /** * An interface for providing localized string resources. This interface defines methods for all diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProviderSample.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProviderSample.kt similarity index 97% rename from auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProviderSample.kt rename to auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProviderSample.kt index 7ddf64522..af0c830cc 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/AuthUIStringProviderSample.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProviderSample.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.compose.configuration.stringprovider +package com.firebase.ui.auth.compose.configuration.string_provider import android.content.Context import com.firebase.ui.auth.compose.configuration.AuthProvider diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/DefaultAuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt similarity index 99% rename from auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/DefaultAuthUIStringProvider.kt rename to auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt index 7df16d9ca..5eba036af 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/stringprovider/DefaultAuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.compose.configuration.stringprovider +package com.firebase.ui.auth.compose.configuration.string_provider import android.content.Context import android.content.res.Configuration diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt index 7acfc8bc1..30582a309 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt @@ -14,7 +14,7 @@ package com.firebase.ui.auth.compose.configuration.validators -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider internal class EmailValidator(override val stringProvider: AuthUIStringProvider) : FieldValidator { private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt index efa72188f..88cf98875 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt @@ -14,7 +14,7 @@ package com.firebase.ui.auth.compose.configuration.validators -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider /** * An interface for validating input fields. diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt index 67cb7d376..2d9efafc1 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt @@ -14,7 +14,7 @@ package com.firebase.ui.auth.compose.configuration.validators -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.PasswordRule internal class PasswordValidator( diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthProviderButton.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt similarity index 90% rename from auth/src/main/java/com/firebase/ui/auth/compose/AuthProviderButton.kt rename to auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt index da6b2bbd9..255d6c59e 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/AuthProviderButton.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt @@ -1,12 +1,14 @@ -package com.firebase.ui.auth.compose +package com.firebase.ui.auth.compose.ui.components import androidx.compose.foundation.Image import androidx.compose.material3.Icon import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -19,12 +21,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.firebase.ui.auth.compose.configuration.AuthProvider import com.firebase.ui.auth.compose.configuration.Provider -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme @@ -63,10 +67,11 @@ fun AuthProviderButton( stringProvider: AuthUIStringProvider, ) { val providerStyle = resolveProviderStyle(provider, style) - val providerText = resolveProviderLabel(provider, stringProvider) + val providerLabel = resolveProviderLabel(provider, stringProvider) Button( modifier = modifier, + contentPadding = PaddingValues(horizontal = 12.dp), colors = ButtonDefaults.buttonColors( containerColor = providerStyle.backgroundColor, contentColor = providerStyle.contentColor, @@ -79,7 +84,9 @@ fun AuthProviderButton( enabled = enabled, ) { Row( - verticalAlignment = Alignment.CenterVertically + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start ) { val providerIcon = providerStyle.icon if (providerIcon != null) { @@ -87,19 +94,21 @@ fun AuthProviderButton( if (iconTint != null) { Icon( painter = providerIcon.painter, - contentDescription = providerText, + contentDescription = providerLabel, tint = iconTint ) } else { Image( painter = providerIcon.painter, - contentDescription = providerText + contentDescription = providerLabel ) } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(12.dp)) } Text( - text = providerText + text = providerLabel, + overflow = TextOverflow.Ellipsis, + maxLines = 1, ) } } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt similarity index 97% rename from auth/src/main/java/com/firebase/ui/auth/compose/ErrorRecoveryDialog.kt rename to auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt index 3b9cc0b57..732a48662 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ErrorRecoveryDialog.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.compose +package com.firebase.ui.auth.compose.ui.components import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme @@ -22,7 +22,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.window.DialogProperties -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider /** * A composable dialog for displaying authentication errors with recovery options. diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt new file mode 100644 index 000000000..4c98be9ac --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt @@ -0,0 +1,76 @@ +package com.firebase.ui.auth.compose.ui.method_picker + +import android.content.Context +import android.content.Intent +import androidx.annotation.StringRes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.core.net.toUri + +@Composable +internal fun AnnotatedStringResource( + context: Context, + modifier: Modifier = Modifier, + @StringRes id: Int, + vararg links: Pair, + inPreview: Boolean = false, + previewText: String? = null, +) { + val labels = links.map { it.first }.toTypedArray() + + val template = if (inPreview && previewText != null) { + previewText + } else { + stringResource(id = id, *labels) + } + + val annotated = buildAnnotatedString { + var currentIndex = 0 + + links.forEach { (label, url) -> + val start = template.indexOf(label, currentIndex).takeIf { it >= 0 } ?: return@forEach + + append(template.substring(currentIndex, start)) + + withLink( + LinkAnnotation.Url( + url, + styles = TextLinkStyles( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ) + ) + ) { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + context.startActivity(intent) + } + ) { + append(label) + } + + currentIndex = start + label.length + } + + if (currentIndex < template.length) { + append(template.substring(currentIndex)) + } + } + + Text( + modifier = modifier, + text = annotated, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt new file mode 100644 index 000000000..855e3d6b3 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt @@ -0,0 +1,174 @@ +package com.firebase.ui.auth.compose.ui.method_picker + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.compose.ui.components.AuthProviderButton + +/** + * Renders the provider selection screen. + * + * **Example usage:** + * ```kotlin + * AuthMethodPicker( + * providers = listOf( + * AuthProvider.Google(), + * AuthProvider.Email(), + * ), + * onProviderSelected = { provider -> /* ... */ } + * ) + * ``` + * + * @param modifier A modifier for the screen layout. + * @param providers The list of providers to display. + * @param logo An optional logo to display. + * @param onProviderSelected A callback when a provider is selected. + * @param customLayout An optional custom layout composable for the provider buttons. + * @param termsOfServiceUrl The URL for the Terms of Service. + * @param privacyPolicyUrl The URL for the Privacy Policy. + * + * @since 10.0.0 + */ +@Composable +fun AuthMethodPicker( + modifier: Modifier = Modifier, + providers: List, + logo: AuthUIAsset? = null, + onProviderSelected: (AuthProvider) -> Unit, + customLayout: @Composable ((List, (AuthProvider) -> Unit) -> Unit)? = null, + termsOfServiceUrl: String? = null, + privacyPolicyUrl: String? = null, +) { + val context = LocalContext.current + val inPreview = LocalInspectionMode.current + + Column( + modifier = modifier + ) { + logo?.let { + Image( + modifier = Modifier + .weight(0.4f) + .align(Alignment.CenterHorizontally), + painter = it.painter, + contentDescription = if (inPreview) "" + else stringResource(R.string.fui_auth_method_picker_logo) + ) + } + if (customLayout != null) { + customLayout(providers, onProviderSelected) + } else { + BoxWithConstraints( + modifier = Modifier + .weight(1f), + ) { + val paddingWidth = maxWidth.value * 0.23 + LazyColumn( + modifier = Modifier + .padding(horizontal = paddingWidth.dp) + .testTag("AuthMethodPicker LazyColumn"), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + itemsIndexed(providers) { index, provider -> + Box( + modifier = Modifier + .padding(bottom = if (index < providers.lastIndex) 16.dp else 0.dp) + ) { + AuthProviderButton( + modifier = Modifier + .fillMaxWidth(), + onClick = { + onProviderSelected(provider) + }, + provider = provider, + stringProvider = DefaultAuthUIStringProvider(context) + ) + } + } + } + } + } + AnnotatedStringResource( + context = context, + inPreview = inPreview, + previewText = "By continuing, you accept our Terms of Service and Privacy Policy.", + modifier = Modifier.padding(vertical = 16.dp), + id = R.string.fui_tos_and_pp, + links = arrayOf( + "Terms of Service" to (termsOfServiceUrl ?: ""), + "Privacy Policy" to (privacyPolicyUrl ?: "") + ) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewAuthMethodPicker() { + Column( + modifier = Modifier + .fillMaxSize() + ) { + AuthMethodPicker( + providers = listOf( + AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ), + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + ), + AuthProvider.Google( + scopes = emptyList(), + serverClientId = null + ), + AuthProvider.Facebook(), + AuthProvider.Twitter( + customParameters = emptyMap() + ), + AuthProvider.Github( + customParameters = emptyMap() + ), + AuthProvider.Microsoft( + tenant = null, + customParameters = emptyMap() + ), + AuthProvider.Yahoo( + customParameters = emptyMap() + ), + AuthProvider.Apple( + locale = null, + customParameters = emptyMap() + ), + AuthProvider.Anonymous, + ), + logo = AuthUIAsset.Resource(R.drawable.fui_ic_check_circle_black_128dp), + onProviderSelected = { provider -> + + }, + termsOfServiceUrl = "https://example.com/terms", + privacyPolicyUrl = "https://example.com/privacy" + ) + } +} \ No newline at end of file diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 75060012d..5790ca8d2 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ Email + "Auth method picker logo" Sign in with Google Sign in with Facebook Sign in with Twitter diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt index e8bf0fd4a..f08be227f 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -20,8 +20,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme import com.google.common.truth.Truth.assertThat import com.google.firebase.auth.actionCodeSettings diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt index a4d5139a6..dbaccbcfb 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt @@ -17,8 +17,8 @@ package com.firebase.ui.auth.compose.configuration import android.content.Context import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt index 3253cfb1c..3715d63ec 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt @@ -17,8 +17,8 @@ package com.firebase.ui.auth.compose.configuration.validators import android.content.Context import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt index a3993bd36..27d34b6a6 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt @@ -17,8 +17,8 @@ package com.firebase.ui.auth.compose.configuration.validators import android.content.Context import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.PasswordRule import com.google.common.truth.Truth.assertThat import org.junit.Before diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/AuthProviderButtonTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt similarity index 98% rename from auth/src/test/java/com/firebase/ui/auth/compose/AuthProviderButtonTest.kt rename to auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt index 255fd53af..faae2cf48 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/AuthProviderButtonTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.compose +package com.firebase.ui.auth.compose.ui.components import android.content.Context import androidx.compose.material.icons.Icons @@ -29,8 +29,8 @@ import androidx.compose.ui.test.performClick import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.compose.configuration.AuthProvider import com.firebase.ui.auth.compose.configuration.Provider -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.stringprovider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme import com.google.common.truth.Truth.assertThat diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ErrorRecoveryDialogLogicTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialogLogicTest.kt similarity index 72% rename from auth/src/test/java/com/firebase/ui/auth/compose/ErrorRecoveryDialogLogicTest.kt rename to auth/src/test/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialogLogicTest.kt index f3c730b05..6a4f5df2f 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/ErrorRecoveryDialogLogicTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialogLogicTest.kt @@ -1,25 +1,11 @@ -/* - * 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 +package com.firebase.ui.auth.compose.ui.components -import com.firebase.ui.auth.compose.configuration.stringprovider.AuthUIStringProvider -import com.google.common.truth.Truth.assertThat +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.google.common.truth.Truth import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito.mock -import org.mockito.Mockito.`when` +import org.mockito.Mockito import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -30,20 +16,20 @@ import org.robolectric.annotation.Config @Config(manifest = Config.NONE) class ErrorRecoveryDialogLogicTest { - private val mockStringProvider = mock(AuthUIStringProvider::class.java).apply { - `when`(retryAction).thenReturn("Try again") - `when`(continueText).thenReturn("Continue") - `when`(signInDefault).thenReturn("Sign in") - `when`(networkErrorRecoveryMessage).thenReturn("Network error, check your internet connection.") - `when`(invalidCredentialsRecoveryMessage).thenReturn("Incorrect password.") - `when`(userNotFoundRecoveryMessage).thenReturn("That email address doesn't match an existing account") - `when`(weakPasswordRecoveryMessage).thenReturn("Password not strong enough. Use at least 6 characters and a mix of letters and numbers") - `when`(emailAlreadyInUseRecoveryMessage).thenReturn("Email account registration unsuccessful") - `when`(tooManyRequestsRecoveryMessage).thenReturn("This phone number has been used too many times") - `when`(mfaRequiredRecoveryMessage).thenReturn("Additional verification required. Please complete multi-factor authentication.") - `when`(accountLinkingRequiredRecoveryMessage).thenReturn("Account needs to be linked. Please try a different sign-in method.") - `when`(authCancelledRecoveryMessage).thenReturn("Authentication was cancelled. Please try again when ready.") - `when`(unknownErrorRecoveryMessage).thenReturn("An unknown error occurred.") + private val mockStringProvider = Mockito.mock(AuthUIStringProvider::class.java).apply { + Mockito.`when`(retryAction).thenReturn("Try again") + Mockito.`when`(continueText).thenReturn("Continue") + Mockito.`when`(signInDefault).thenReturn("Sign in") + Mockito.`when`(networkErrorRecoveryMessage).thenReturn("Network error, check your internet connection.") + Mockito.`when`(invalidCredentialsRecoveryMessage).thenReturn("Incorrect password.") + Mockito.`when`(userNotFoundRecoveryMessage).thenReturn("That email address doesn't match an existing account") + Mockito.`when`(weakPasswordRecoveryMessage).thenReturn("Password not strong enough. Use at least 6 characters and a mix of letters and numbers") + Mockito.`when`(emailAlreadyInUseRecoveryMessage).thenReturn("Email account registration unsuccessful") + Mockito.`when`(tooManyRequestsRecoveryMessage).thenReturn("This phone number has been used too many times") + Mockito.`when`(mfaRequiredRecoveryMessage).thenReturn("Additional verification required. Please complete multi-factor authentication.") + Mockito.`when`(accountLinkingRequiredRecoveryMessage).thenReturn("Account needs to be linked. Please try a different sign-in method.") + Mockito.`when`(authCancelledRecoveryMessage).thenReturn("Authentication was cancelled. Please try again when ready.") + Mockito.`when`(unknownErrorRecoveryMessage).thenReturn("An unknown error occurred.") } // ============================================================================================= @@ -59,7 +45,7 @@ class ErrorRecoveryDialogLogicTest { val message = getRecoveryMessage(error, mockStringProvider) // Assert - assertThat(message).isEqualTo("Network error, check your internet connection.") + Truth.assertThat(message).isEqualTo("Network error, check your internet connection.") } @Test @@ -71,7 +57,7 @@ class ErrorRecoveryDialogLogicTest { val message = getRecoveryMessage(error, mockStringProvider) // Assert - assertThat(message).isEqualTo("Incorrect password.") + Truth.assertThat(message).isEqualTo("Incorrect password.") } @Test @@ -83,7 +69,7 @@ class ErrorRecoveryDialogLogicTest { val message = getRecoveryMessage(error, mockStringProvider) // Assert - assertThat(message).isEqualTo("That email address doesn't match an existing account") + Truth.assertThat(message).isEqualTo("That email address doesn't match an existing account") } @Test @@ -99,7 +85,7 @@ class ErrorRecoveryDialogLogicTest { val message = getRecoveryMessage(error, mockStringProvider) // Assert - assertThat(message).isEqualTo("Password not strong enough. Use at least 6 characters and a mix of letters and numbers\n\nReason: Password should be at least 8 characters") + Truth.assertThat(message).isEqualTo("Password not strong enough. Use at least 6 characters and a mix of letters and numbers\n\nReason: Password should be at least 8 characters") } @Test @@ -111,7 +97,7 @@ class ErrorRecoveryDialogLogicTest { val message = getRecoveryMessage(error, mockStringProvider) // Assert - assertThat(message).isEqualTo("Password not strong enough. Use at least 6 characters and a mix of letters and numbers") + Truth.assertThat(message).isEqualTo("Password not strong enough. Use at least 6 characters and a mix of letters and numbers") } @Test @@ -127,7 +113,7 @@ class ErrorRecoveryDialogLogicTest { val message = getRecoveryMessage(error, mockStringProvider) // Assert - assertThat(message).isEqualTo("Email account registration unsuccessful (test@example.com)") + Truth.assertThat(message).isEqualTo("Email account registration unsuccessful (test@example.com)") } @Test @@ -139,7 +125,7 @@ class ErrorRecoveryDialogLogicTest { val message = getRecoveryMessage(error, mockStringProvider) // Assert - assertThat(message).isEqualTo("Email account registration unsuccessful") + Truth.assertThat(message).isEqualTo("Email account registration unsuccessful") } // ============================================================================================= @@ -155,7 +141,7 @@ class ErrorRecoveryDialogLogicTest { val actionText = getRecoveryActionText(error, mockStringProvider) // Assert - assertThat(actionText).isEqualTo("Try again") + Truth.assertThat(actionText).isEqualTo("Try again") } @Test @@ -167,7 +153,7 @@ class ErrorRecoveryDialogLogicTest { val actionText = getRecoveryActionText(error, mockStringProvider) // Assert - assertThat(actionText).isEqualTo("Continue") + Truth.assertThat(actionText).isEqualTo("Continue") } @Test @@ -179,7 +165,7 @@ class ErrorRecoveryDialogLogicTest { val actionText = getRecoveryActionText(error, mockStringProvider) // Assert - assertThat(actionText).isEqualTo("Sign in") + Truth.assertThat(actionText).isEqualTo("Sign in") } @Test @@ -191,7 +177,7 @@ class ErrorRecoveryDialogLogicTest { val actionText = getRecoveryActionText(error, mockStringProvider) // Assert - assertThat(actionText).isEqualTo("Continue") + Truth.assertThat(actionText).isEqualTo("Continue") } @Test @@ -203,7 +189,7 @@ class ErrorRecoveryDialogLogicTest { val actionText = getRecoveryActionText(error, mockStringProvider) // Assert - assertThat(actionText).isEqualTo("Continue") + Truth.assertThat(actionText).isEqualTo("Continue") } // ============================================================================================= @@ -216,7 +202,7 @@ class ErrorRecoveryDialogLogicTest { val error = AuthException.NetworkException("Network error") // Act & Assert - assertThat(isRecoverable(error)).isTrue() + Truth.assertThat(isRecoverable(error)).isTrue() } @Test @@ -225,7 +211,7 @@ class ErrorRecoveryDialogLogicTest { val error = AuthException.InvalidCredentialsException("Invalid credentials") // Act & Assert - assertThat(isRecoverable(error)).isTrue() + Truth.assertThat(isRecoverable(error)).isTrue() } @Test @@ -234,7 +220,7 @@ class ErrorRecoveryDialogLogicTest { val error = AuthException.TooManyRequestsException("Too many requests") // Act & Assert - assertThat(isRecoverable(error)).isFalse() + Truth.assertThat(isRecoverable(error)).isFalse() } @Test @@ -243,7 +229,7 @@ class ErrorRecoveryDialogLogicTest { val error = AuthException.MfaRequiredException("MFA required") // Act & Assert - assertThat(isRecoverable(error)).isTrue() + Truth.assertThat(isRecoverable(error)).isTrue() } @Test @@ -252,7 +238,7 @@ class ErrorRecoveryDialogLogicTest { val error = AuthException.UnknownException("Unknown error") // Act & Assert - assertThat(isRecoverable(error)).isTrue() + Truth.assertThat(isRecoverable(error)).isTrue() } // Helper functions to test the private functions - we need to make them internal for testing diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt new file mode 100644 index 000000000..17d736ca7 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt @@ -0,0 +1,308 @@ +package com.firebase.ui.auth.compose.ui.method_picker + +import android.content.Context +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.AuthProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset +import com.google.common.truth.Truth +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [AuthMethodPicker] covering UI interactions, provider selection, + * scroll tests, logo display, and custom layouts. + * + * @suppress Internal test class + */ +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class AuthMethodPickerTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var context: Context + private var selectedProvider: AuthProvider? = null + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + selectedProvider = null + } + + // ============================================================================================= + // Basic UI Tests + // ============================================================================================= + + @Test + fun `AuthMethodPicker displays all providers`() { + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null), + AuthProvider.Facebook(), + AuthProvider.Email(actionCodeSettings = null, passwordValidationRules = emptyList()) + ) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it } + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_facebook)) + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_email)) + .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun `AuthMethodPicker displays terms of service text`() { + val context = ApplicationProvider.getApplicationContext() + val links = arrayOf("Terms of Service" to "", "Privacy Policy" to "") + val labels = links.map { it.first }.toTypedArray() + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it } + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_tos_and_pp, *labels)) + .assertIsDisplayed() + } + + @Test + fun `AuthMethodPicker displays logo when provided`() { + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + logo = AuthUIAsset.Resource(R.drawable.fui_ic_check_circle_black_128dp), + onProviderSelected = { selectedProvider = it } + ) + } + + composeTestRule + .onNodeWithContentDescription(context.getString(R.string.fui_auth_method_picker_logo)) + .assertIsDisplayed() + } + + @Test + fun `AuthMethodPicker does not display logo when null`() { + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + logo = null, + onProviderSelected = { selectedProvider = it } + ) + } + + composeTestRule + .onNodeWithContentDescription(context.getString(R.string.fui_auth_method_picker_logo)) + .assertIsNotDisplayed() + } + + @Test + fun `AuthMethodPicker displays logo and providers together`() { + val context = ApplicationProvider.getApplicationContext() + val links = arrayOf("Terms of Service" to "", "Privacy Policy" to "") + val labels = links.map { it.first }.toTypedArray() + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + logo = AuthUIAsset.Resource(R.drawable.fui_ic_check_circle_black_128dp), + onProviderSelected = { selectedProvider = it } + ) + } + + composeTestRule + .onNodeWithContentDescription(context.getString(R.string.fui_auth_method_picker_logo)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_tos_and_pp, *labels)) + .assertIsDisplayed() + } + + @Test + fun `AuthMethodPicker calls onProviderSelected when Provider is clicked`() { + val googleProvider = AuthProvider.Google(scopes = emptyList(), serverClientId = null) + val providers = listOf(googleProvider) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it } + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .performClick() + + Truth.assertThat(selectedProvider).isEqualTo(googleProvider) + } + + // ============================================================================================= + // Custom Layout Tests + // ============================================================================================= + + @Test + fun `AuthMethodPicker uses custom layout when provided`() { + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + var customLayoutCalled = false + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it }, + customLayout = { _, _ -> + customLayoutCalled = true + Text("Custom Layout") + } + ) + } + + Truth.assertThat(customLayoutCalled).isTrue() + composeTestRule + .onNodeWithText("Custom Layout") + .assertIsDisplayed() + } + + @Test + fun `AuthMethodPicker custom layout receives providers list`() { + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null), + AuthProvider.Facebook() + ) + var receivedProviders: List? = null + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it }, + customLayout = { providersList, _ -> + receivedProviders = providersList + } + ) + } + + Truth.assertThat(receivedProviders).isEqualTo(providers) + } + + @Test + fun `AuthMethodPicker custom layout can trigger provider selection`() { + val googleProvider = AuthProvider.Google(scopes = emptyList(), serverClientId = null) + val providers = listOf(googleProvider) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it }, + customLayout = { providersList, onSelected -> + Button(onClick = { onSelected(providersList[0]) }) { + Text("Custom Button") + } + } + ) + } + + composeTestRule + .onNodeWithText("Custom Button") + .performClick() + + Truth.assertThat(selectedProvider).isEqualTo(googleProvider) + } + + // ============================================================================================= + // Scrolling Tests + // ============================================================================================= + + @Test + fun `AuthMethodPicker allows scrolling through many providers`() { + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null), + AuthProvider.Facebook(), + AuthProvider.Twitter(customParameters = emptyMap()), + AuthProvider.Github(customParameters = emptyMap()), + AuthProvider.Microsoft(tenant = null, customParameters = emptyMap()), + AuthProvider.Yahoo(customParameters = emptyMap()), + AuthProvider.Apple(locale = null, customParameters = emptyMap()), + AuthProvider.Email(actionCodeSettings = null, passwordValidationRules = emptyList()), + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ), + AuthProvider.Anonymous + ) + + composeTestRule.setContent { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it } + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithTag("AuthMethodPicker LazyColumn") + .performScrollToNode(hasText(context.getString(R.string.fui_sign_in_anonymously))) + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_anonymously)) + .assertIsDisplayed() + } +} \ No newline at end of file From 4d1aefd2686640089a0fb28dcf969760a342fb52 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Tue, 7 Oct 2025 11:15:47 +0200 Subject: [PATCH 14/41] feat: MFA Config Model (MfaConfiguration, MfaFactor) (#2234) --- .../compose/configuration/MfaConfiguration.kt | 42 +++++ .../auth/compose/configuration/MfaFactor.kt | 33 ++++ .../configuration/MfaConfigurationTest.kt | 162 ++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/MfaConfiguration.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/MfaFactor.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/MfaConfigurationTest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/MfaConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/MfaConfiguration.kt new file mode 100644 index 000000000..393ef0f26 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/MfaConfiguration.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration + +/** + * Configuration class for Multi-Factor Authentication (MFA) enrollment and verification behavior. + * + * This class controls which MFA factors are available to users, whether enrollment is mandatory, + * and whether recovery codes are generated. + * + * @property allowedFactors List of MFA factors that users are permitted to enroll in. + * Defaults to [MfaFactor.Sms, MfaFactor.Totp]. + * @property requireEnrollment Whether MFA enrollment is mandatory for all users. + * When true, users must enroll in at least one MFA factor. + * Defaults to false. + * @property enableRecoveryCodes Whether to generate and provide recovery codes to users + * after successful MFA enrollment. These codes can be used + * as a backup authentication method. Defaults to true. + */ +class MfaConfiguration( + val allowedFactors: List = listOf(MfaFactor.Sms, MfaFactor.Totp), + val requireEnrollment: Boolean = false, + val enableRecoveryCodes: Boolean = true +) { + init { + require(allowedFactors.isNotEmpty()) { + "At least one MFA factor must be allowed" + } + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/MfaFactor.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/MfaFactor.kt new file mode 100644 index 000000000..78926ace4 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/MfaFactor.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration + +/** + * Represents the different Multi-Factor Authentication (MFA) factors that can be used + * for enrollment and verification. + */ +enum class MfaFactor { + /** + * SMS-based authentication factor. + * Users receive a verification code via text message to their registered phone number. + */ + Sms, + + /** + * Time-based One-Time Password (TOTP) authentication factor. + * Users generate verification codes using an authenticator app. + */ + Totp +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/MfaConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/MfaConfigurationTest.kt new file mode 100644 index 000000000..aa80d4533 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/MfaConfigurationTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [MfaConfiguration] covering default values, custom configurations, + * and validation rules. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class MfaConfigurationTest { + + // ============================================================================================= + // Default Configuration Tests + // ============================================================================================= + + @Test + fun `MfaConfiguration with defaults uses correct values`() { + val config = MfaConfiguration() + + assertThat(config.allowedFactors).containsExactly(MfaFactor.Sms, MfaFactor.Totp) + assertThat(config.requireEnrollment).isFalse() + assertThat(config.enableRecoveryCodes).isTrue() + } + + @Test + fun `MfaConfiguration default allowedFactors includes both Sms and Totp`() { + val config = MfaConfiguration() + + assertThat(config.allowedFactors).hasSize(2) + assertThat(config.allowedFactors).contains(MfaFactor.Sms) + assertThat(config.allowedFactors).contains(MfaFactor.Totp) + } + + // ============================================================================================= + // Custom Configuration Tests + // ============================================================================================= + + @Test + fun `MfaConfiguration with custom allowedFactors only Sms`() { + val config = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms) + ) + + assertThat(config.allowedFactors).containsExactly(MfaFactor.Sms) + assertThat(config.allowedFactors).hasSize(1) + } + + @Test + fun `MfaConfiguration with custom allowedFactors only Totp`() { + val config = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Totp) + ) + + assertThat(config.allowedFactors).containsExactly(MfaFactor.Totp) + assertThat(config.allowedFactors).hasSize(1) + } + + @Test + fun `MfaConfiguration with requireEnrollment enabled`() { + val config = MfaConfiguration( + requireEnrollment = true + ) + + assertThat(config.requireEnrollment).isTrue() + } + + @Test + fun `MfaConfiguration with enableRecoveryCodes disabled`() { + val config = MfaConfiguration( + enableRecoveryCodes = false + ) + + assertThat(config.enableRecoveryCodes).isFalse() + } + + @Test + fun `MfaConfiguration with all custom values`() { + val config = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms), + requireEnrollment = true, + enableRecoveryCodes = false + ) + + assertThat(config.allowedFactors).containsExactly(MfaFactor.Sms) + assertThat(config.requireEnrollment).isTrue() + assertThat(config.enableRecoveryCodes).isFalse() + } + + // ============================================================================================= + // Validation Tests + // ============================================================================================= + + @Test + fun `MfaConfiguration throws when allowedFactors is empty`() { + try { + MfaConfiguration( + allowedFactors = emptyList() + ) + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo("At least one MFA factor must be allowed") + } + } + + @Test + fun `MfaConfiguration allows both factors in any order`() { + val config1 = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp) + ) + val config2 = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Totp, MfaFactor.Sms) + ) + + assertThat(config1.allowedFactors).hasSize(2) + assertThat(config2.allowedFactors).hasSize(2) + assertThat(config1.allowedFactors).containsExactly(MfaFactor.Sms, MfaFactor.Totp) + assertThat(config2.allowedFactors).containsExactly(MfaFactor.Totp, MfaFactor.Sms) + } + + // ============================================================================================= + // MfaFactor Enum Tests + // ============================================================================================= + + @Test + fun `MfaFactor enum has exactly two values`() { + val factors = MfaFactor.entries + + assertThat(factors).hasSize(2) + assertThat(factors).containsExactly(MfaFactor.Sms, MfaFactor.Totp) + } + + @Test + fun `MfaFactor Sms has correct name`() { + assertThat(MfaFactor.Sms.name).isEqualTo("Sms") + } + + @Test + fun `MfaFactor Totp has correct name`() { + assertThat(MfaFactor.Totp.name).isEqualTo("Totp") + } +} From 3cfde410acbbbb5386ee1e4bd33b73281302f514 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:55:01 +0100 Subject: [PATCH 15/41] feat: AuthTextField (#2231) * feat: AuthMethodPicker, logo and provider theme style * chore: organize folder structure * feat: TOS and PP footer, ui tests for AuthMethodPicker * chore: tests folder structure * chore: use version catalog for compose deps * feat: AuthTextField with validation * test: AuthTextField and field validations * chore: update doc comments * refactor: remove libs.versions.toml catalog file --- auth/build.gradle.kts | 5 +- .../compose/ui/components/AuthTextField.kt | 201 ++++++++ .../ui/components/AuthTextFieldTest.kt | 449 ++++++++++++++++++ build.gradle.kts | 2 +- buildSrc/src/main/kotlin/Config.kt | 6 +- gradle/libs.versions.toml | 9 - 6 files changed, 658 insertions(+), 14 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt delete mode 100644 gradle/libs.versions.toml diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index b26051d1c..3175b0a9d 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -4,7 +4,7 @@ plugins { id("com.android.library") id("com.vanniktech.maven.publish") id("org.jetbrains.kotlin.android") - alias(libs.plugins.compose.compiler) + id("org.jetbrains.kotlin.plugin.compose") version Config.kotlinVersion } android { @@ -84,6 +84,7 @@ dependencies { implementation(Config.Libs.Androidx.Compose.activityCompose) implementation(Config.Libs.Androidx.materialDesign) implementation(Config.Libs.Androidx.activity) + implementation(Config.Libs.Androidx.Compose.materialIconsExtended) // The new activity result APIs force us to include Fragment 1.3.0 // See https://issuetracker.google.com/issues/152554847 implementation(Config.Libs.Androidx.fragment) @@ -115,7 +116,7 @@ dependencies { testImplementation(Config.Libs.Test.robolectric) testImplementation(Config.Libs.Test.kotlinReflect) testImplementation(Config.Libs.Provider.facebook) - testImplementation(libs.androidx.ui.test.junit4) + testImplementation(Config.Libs.Test.composeUiTestJunit4) debugImplementation(project(":internal:lintchecks")) } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt new file mode 100644 index 000000000..66f2b475e --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt @@ -0,0 +1,201 @@ +package com.firebase.ui.auth.compose.ui.components + +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.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.configuration.validators.FieldValidator +import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator + +/** + * A customizable input field with built-in validation display. + * + * **Example usage:** + * ```kotlin + * val emailTextValue = remember { mutableStateOf("") } + * + * val emailValidator = remember { + * EmailValidator(stringProvider = DefaultAuthUIStringProvider(context)) + * } + * + * AuthTextField( + * value = emailTextValue, + * onValueChange = { emailTextValue.value = it }, + * label = { + * Text("Email") + * }, + * validator = emailValidator + * ) + * ``` + * + * @param modifier A modifier for the field. + * @param value The current value of the text field. + * @param onValueChange A callback when the value changes. + * @param label The label for the text field. + * @param enabled If the field is enabled. + * @param isError Manually set the error state. + * @param errorMessage A custom error message to display. + * @param validator A validator to automatically handle error state and messages. + * @param keyboardOptions Keyboard options for the field. + * @param keyboardActions Keyboard actions for the field. + * @param visualTransformation Visual transformation for the input (e.g., password). + * @param leadingIcon An optional icon to display at the start of the field. + * @param trailingIcon An optional icon to display at the start of the field. + */ +@Composable +fun AuthTextField( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit, + label: @Composable (() -> Unit)? = null, + enabled: Boolean = true, + isError: Boolean? = null, + errorMessage: String? = null, + validator: FieldValidator? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, +) { + val isSecureTextField = validator is PasswordValidator + var passwordVisible by remember { mutableStateOf(false) } + + TextField( + modifier = modifier, + value = value, + onValueChange = { newValue -> + onValueChange(newValue) + validator?.validate(newValue) + }, + label = label, + singleLine = true, + enabled = enabled, + isError = isError ?: validator?.hasError ?: false, + supportingText = { + if (validator?.hasError ?: false) { + Text(text = errorMessage ?: validator.errorMessage) + } + }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = if (isSecureTextField && !passwordVisible) + PasswordVisualTransformation() else visualTransformation, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon ?: { + if (isSecureTextField) { + IconButton( + onClick = { + passwordVisible = !passwordVisible + } + ) { + Icon( + imageVector = if (passwordVisible) + Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +internal fun PreviewAuthTextField() { + val context = LocalContext.current + val nameTextValue = remember { mutableStateOf("") } + val emailTextValue = remember { mutableStateOf("") } + val passwordTextValue = remember { mutableStateOf("") } + val emailValidator = remember { + EmailValidator(stringProvider = DefaultAuthUIStringProvider(context)) + } + val passwordValidator = remember { + PasswordValidator( + stringProvider = DefaultAuthUIStringProvider(context), + rules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireUppercase, + PasswordRule.RequireLowercase, + ) + ) + } + + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AuthTextField( + value = nameTextValue.value, + label = { + Text("Name") + }, + onValueChange = { text -> + nameTextValue.value = text + }, + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = emailTextValue.value, + validator = emailValidator, + label = { + Text("Email") + }, + onValueChange = { text -> + emailTextValue.value = text + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = passwordTextValue.value, + validator = passwordValidator, + label = { + Text("Password") + }, + onValueChange = { text -> + passwordTextValue.value = text + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "" + ) + } + ) + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt new file mode 100644 index 000000000..21629a1a2 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt @@ -0,0 +1,449 @@ +/* + * 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.ui.components + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [AuthTextField] covering UI interactions, validation, + * password visibility toggle, and error states. + * + * @suppress Internal test class + */ +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class AuthTextFieldTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var context: Context + private lateinit var stringProvider: AuthUIStringProvider + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + } + + // ============================================================================================= + // Basic UI Tests + // ============================================================================================= + + @Test + fun `AuthTextField displays correctly with basic configuration`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Name") } + ) + } + + composeTestRule + .onNodeWithText("Name") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField displays initial value`() { + composeTestRule.setContent { + AuthTextField( + value = "test@example.com", + onValueChange = { }, + label = { Text("Email") } + ) + } + + composeTestRule + .onNodeWithText("Email") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("test@example.com") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField updates value on text input`() { + composeTestRule.setContent { + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Email") } + ) + } + + composeTestRule + .onNodeWithText("Email") + .performTextInput("test@example.com") + + composeTestRule + .onNodeWithText("Email") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("test@example.com") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField respects enabled state`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") }, + enabled = false + ) + } + + composeTestRule + .onNodeWithText("Email") + .assertIsNotEnabled() + } + + @Test + fun `AuthTextField is enabled by default`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") } + ) + } + + composeTestRule + .onNodeWithText("Email") + .assertIsEnabled() + } + + @Test + fun `AuthTextField displays leading icon when provided`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Email Icon" + ) + } + ) + } + + composeTestRule + .onNodeWithContentDescription("Email Icon") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField displays custom trailing icon when provided`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") }, + trailingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Custom Trailing Icon" + ) + } + ) + } + + composeTestRule + .onNodeWithContentDescription("Custom Trailing Icon") + .assertIsDisplayed() + } + + // ============================================================================================= + // Validation Tests + // ============================================================================================= + + @Test + fun `AuthTextField validates email correctly with EmailValidator`() { + composeTestRule.setContent { + val emailValidator = remember { + EmailValidator(stringProvider = stringProvider) + } + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Email") }, + validator = emailValidator + ) + } + + composeTestRule + .onNodeWithText("Email") + .performTextInput("invalid-email") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.invalidEmailAddress) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Email") + .performTextClearance() + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.missingEmailAddress) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Email") + .performTextInput("valid@example.com") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.missingEmailAddress) + .assertIsNotDisplayed() + composeTestRule + .onNodeWithText(stringProvider.invalidEmailAddress) + .assertIsNotDisplayed() + } + + @Test + fun `AuthTextField displays custom error message when provided`() { + composeTestRule.setContent { + val emailValidator = remember { + EmailValidator(stringProvider = stringProvider) + } + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Email") }, + validator = emailValidator, + errorMessage = "Custom error message" + ) + } + + composeTestRule + .onNodeWithText("Email") + .performTextInput("invalid") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText("Custom error message") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField validates password with PasswordValidator`() { + composeTestRule.setContent { + val passwordValidator = remember { + PasswordValidator( + stringProvider = stringProvider, + rules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireUppercase, + PasswordRule.RequireLowercase + ) + ) + } + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Password") }, + validator = passwordValidator + ) + } + + composeTestRule + .onNodeWithText("Password") + .performTextInput("short") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.passwordTooShort.format(8)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Password") + .performTextClearance() + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.invalidPassword) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Password") + .performTextInput("pass@1234") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.passwordMissingUppercase) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Password") + .performTextInput("ValidPass123") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.passwordTooShort.format(8)) + .assertIsNotDisplayed() + composeTestRule + .onNodeWithText(stringProvider.passwordMissingLowercase) + .assertIsNotDisplayed() + composeTestRule + .onNodeWithText(stringProvider.passwordMissingUppercase) + .assertIsNotDisplayed() + } + + // ============================================================================================= + // Password Visibility Toggle Tests + // ============================================================================================= + + @Test + fun `AuthTextField shows password visibility toggle for PasswordValidator`() { + composeTestRule.setContent { + AuthTextField( + value = "password123", + onValueChange = { }, + label = { Text("Password") }, + validator = PasswordValidator( + stringProvider = stringProvider, + rules = emptyList() + ) + ) + } + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField toggles password visibility when icon is clicked`() { + composeTestRule.setContent { + AuthTextField( + value = "password123", + onValueChange = { }, + label = { Text("Password") }, + validator = PasswordValidator( + stringProvider = stringProvider, + rules = emptyList() + ) + ) + } + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertIsDisplayed() + .performClick() + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithContentDescription("Hide password") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField hides password visibility toggle for non-password fields`() { + composeTestRule.setContent { + AuthTextField( + value = "test@example.com", + onValueChange = { }, + label = { Text("Email") }, + ) + } + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertDoesNotExist() + + composeTestRule + .onNodeWithContentDescription("Hide password") + .assertDoesNotExist() + } + + @Test + fun `AuthTextField respects custom trailing icon over password toggle`() { + composeTestRule.setContent { + AuthTextField( + value = "password123", + onValueChange = { }, + label = { Text("Password") }, + validator = PasswordValidator( + stringProvider = stringProvider, + rules = emptyList() + ), + trailingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Custom Icon" + ) + } + ) + } + + composeTestRule + .onNodeWithContentDescription("Custom Icon") + .assertIsDisplayed() + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertDoesNotExist() + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 105624a8b..41393496e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,7 +19,7 @@ buildscript { plugins { id("com.github.ben-manes.versions") version "0.20.0" - alias(libs.plugins.compose.compiler) apply false + id("org.jetbrains.kotlin.plugin.compose") version Config.kotlinVersion apply false } allprojects { diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 8b2317698..a21ca69bc 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -2,7 +2,7 @@ object Config { const val version = "10.0.0-SNAPSHOT" val submodules = listOf("auth", "common", "firestore", "database", "storage") - private const val kotlinVersion = "2.2.0" + const val kotlinVersion = "2.2.0" object SdkVersions { const val compile = 35 @@ -50,7 +50,8 @@ object Config { const val tooling = "androidx.compose.ui:ui-tooling" const val foundation = "androidx.compose.foundation:foundation" const val material3 = "androidx.compose.material3:material3" - const val activityCompose = "androidx.activity:activity-compose" + const val materialIconsExtended = "androidx.compose.material:material-icons-extended" + const val activityCompose = "androidx.activity:activity-compose:1.9.0" } } @@ -95,6 +96,7 @@ object Config { const val rules = "androidx.test:rules:1.5.0" const val kotlinReflect = "org.jetbrains.kotlin:kotlin-reflect" + const val composeUiTestJunit4 = "androidx.compose.ui:ui-test-junit4" } object Lint { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml deleted file mode 100644 index 44eb15432..000000000 --- a/gradle/libs.versions.toml +++ /dev/null @@ -1,9 +0,0 @@ -[versions] -kotlin = "2.2.0" - -[libraries] -# Testing -androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } - -[plugins] -compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } \ No newline at end of file From a2ba642a0a113723f5f9b152d068ce51bebc585b Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Tue, 7 Oct 2025 15:36:27 +0200 Subject: [PATCH 16/41] feat: CountryData & Selector Utilities (#2235) --- .../ui/auth/compose/data/Countries.kt | 260 +++++++++++++ .../ui/auth/compose/data/CountryData.kt | 62 ++++ .../ui/auth/compose/data/CountryUtils.kt | 117 ++++++ .../ui/auth/compose/data/CountryDataTest.kt | 347 ++++++++++++++++++ 4 files changed, 786 insertions(+) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/data/Countries.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/data/CountryData.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/data/CountryUtils.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/data/CountryDataTest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/data/Countries.kt b/auth/src/main/java/com/firebase/ui/auth/compose/data/Countries.kt new file mode 100644 index 000000000..3ba0dc497 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/data/Countries.kt @@ -0,0 +1,260 @@ +/* + * 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.data + +/** + * Complete list of countries with their dial codes and ISO country codes. + * Auto-generated from ISO 3166-1 standard. + */ +val ALL_COUNTRIES: List = listOf( + CountryData("Afghanistan", "+93", "AF", countryCodeToFlagEmoji("AF")), + CountryData("Albania", "+355", "AL", countryCodeToFlagEmoji("AL")), + CountryData("Algeria", "+213", "DZ", countryCodeToFlagEmoji("DZ")), + CountryData("American Samoa", "+684", "AS", countryCodeToFlagEmoji("AS")), + CountryData("Andorra", "+376", "AD", countryCodeToFlagEmoji("AD")), + CountryData("Angola", "+244", "AO", countryCodeToFlagEmoji("AO")), + CountryData("Anguilla", "+264", "AI", countryCodeToFlagEmoji("AI")), + CountryData("Antigua and Barbuda", "+268", "AG", countryCodeToFlagEmoji("AG")), + CountryData("Argentina", "+54", "AR", countryCodeToFlagEmoji("AR")), + CountryData("Armenia", "+374", "AM", countryCodeToFlagEmoji("AM")), + CountryData("Aruba", "+297", "AW", countryCodeToFlagEmoji("AW")), + CountryData("Australia", "+61", "AU", countryCodeToFlagEmoji("AU")), + CountryData("Austria", "+43", "AT", countryCodeToFlagEmoji("AT")), + CountryData("Azerbaijan", "+994", "AZ", countryCodeToFlagEmoji("AZ")), + CountryData("Bahamas", "+242", "BS", countryCodeToFlagEmoji("BS")), + CountryData("Bahrain", "+973", "BH", countryCodeToFlagEmoji("BH")), + CountryData("Bangladesh", "+880", "BD", countryCodeToFlagEmoji("BD")), + CountryData("Barbados", "+246", "BB", countryCodeToFlagEmoji("BB")), + CountryData("Belarus", "+375", "BY", countryCodeToFlagEmoji("BY")), + CountryData("Belgium", "+32", "BE", countryCodeToFlagEmoji("BE")), + CountryData("Belize", "+501", "BZ", countryCodeToFlagEmoji("BZ")), + CountryData("Benin", "+229", "BJ", countryCodeToFlagEmoji("BJ")), + CountryData("Bermuda", "+441", "BM", countryCodeToFlagEmoji("BM")), + CountryData("Bhutan", "+975", "BT", countryCodeToFlagEmoji("BT")), + CountryData("Bolivia", "+591", "BO", countryCodeToFlagEmoji("BO")), + CountryData("Bosnia and Herzegovina", "+387", "BA", countryCodeToFlagEmoji("BA")), + CountryData("Botswana", "+267", "BW", countryCodeToFlagEmoji("BW")), + CountryData("Brazil", "+55", "BR", countryCodeToFlagEmoji("BR")), + CountryData("British Indian Ocean Territory", "+246", "IO", countryCodeToFlagEmoji("IO")), + CountryData("Brunei", "+673", "BN", countryCodeToFlagEmoji("BN")), + CountryData("Bulgaria", "+359", "BG", countryCodeToFlagEmoji("BG")), + CountryData("Burkina Faso", "+226", "BF", countryCodeToFlagEmoji("BF")), + CountryData("Burundi", "+257", "BI", countryCodeToFlagEmoji("BI")), + CountryData("Cambodia", "+855", "KH", countryCodeToFlagEmoji("KH")), + CountryData("Cameroon", "+237", "CM", countryCodeToFlagEmoji("CM")), + CountryData("Canada", "+1", "CA", countryCodeToFlagEmoji("CA")), + CountryData("Cape Verde", "+238", "CV", countryCodeToFlagEmoji("CV")), + CountryData("Cayman Islands", "+345", "KY", countryCodeToFlagEmoji("KY")), + CountryData("Central African Republic", "+236", "CF", countryCodeToFlagEmoji("CF")), + CountryData("Chad", "+235", "TD", countryCodeToFlagEmoji("TD")), + CountryData("Chile", "+56", "CL", countryCodeToFlagEmoji("CL")), + CountryData("China", "+86", "CN", countryCodeToFlagEmoji("CN")), + CountryData("Colombia", "+57", "CO", countryCodeToFlagEmoji("CO")), + CountryData("Comoros", "+269", "KM", countryCodeToFlagEmoji("KM")), + CountryData("Congo", "+242", "CG", countryCodeToFlagEmoji("CG")), + CountryData("Congo (DRC)", "+243", "CD", countryCodeToFlagEmoji("CD")), + CountryData("Cook Islands", "+682", "CK", countryCodeToFlagEmoji("CK")), + CountryData("Costa Rica", "+506", "CR", countryCodeToFlagEmoji("CR")), + CountryData("Côte d'Ivoire", "+225", "CI", countryCodeToFlagEmoji("CI")), + CountryData("Croatia", "+385", "HR", countryCodeToFlagEmoji("HR")), + CountryData("Cuba", "+53", "CU", countryCodeToFlagEmoji("CU")), + CountryData("Curaçao", "+599", "CW", countryCodeToFlagEmoji("CW")), + CountryData("Cyprus", "+357", "CY", countryCodeToFlagEmoji("CY")), + CountryData("Czech Republic", "+420", "CZ", countryCodeToFlagEmoji("CZ")), + CountryData("Denmark", "+45", "DK", countryCodeToFlagEmoji("DK")), + CountryData("Djibouti", "+253", "DJ", countryCodeToFlagEmoji("DJ")), + CountryData("Dominica", "+767", "DM", countryCodeToFlagEmoji("DM")), + CountryData("Dominican Republic", "+809", "DO", countryCodeToFlagEmoji("DO")), + CountryData("Ecuador", "+593", "EC", countryCodeToFlagEmoji("EC")), + CountryData("Egypt", "+20", "EG", countryCodeToFlagEmoji("EG")), + CountryData("El Salvador", "+503", "SV", countryCodeToFlagEmoji("SV")), + CountryData("Equatorial Guinea", "+240", "GQ", countryCodeToFlagEmoji("GQ")), + CountryData("Eritrea", "+291", "ER", countryCodeToFlagEmoji("ER")), + CountryData("Estonia", "+372", "EE", countryCodeToFlagEmoji("EE")), + CountryData("Ethiopia", "+251", "ET", countryCodeToFlagEmoji("ET")), + CountryData("Falkland Islands", "+500", "FK", countryCodeToFlagEmoji("FK")), + CountryData("Faroe Islands", "+298", "FO", countryCodeToFlagEmoji("FO")), + CountryData("Fiji", "+679", "FJ", countryCodeToFlagEmoji("FJ")), + CountryData("Finland", "+358", "FI", countryCodeToFlagEmoji("FI")), + CountryData("France", "+33", "FR", countryCodeToFlagEmoji("FR")), + CountryData("French Guiana", "+594", "GF", countryCodeToFlagEmoji("GF")), + CountryData("French Polynesia", "+689", "PF", countryCodeToFlagEmoji("PF")), + CountryData("Gabon", "+241", "GA", countryCodeToFlagEmoji("GA")), + CountryData("Gambia", "+220", "GM", countryCodeToFlagEmoji("GM")), + CountryData("Georgia", "+995", "GE", countryCodeToFlagEmoji("GE")), + CountryData("Germany", "+49", "DE", countryCodeToFlagEmoji("DE")), + CountryData("Ghana", "+233", "GH", countryCodeToFlagEmoji("GH")), + CountryData("Gibraltar", "+350", "GI", countryCodeToFlagEmoji("GI")), + CountryData("Greece", "+30", "GR", countryCodeToFlagEmoji("GR")), + CountryData("Greenland", "+299", "GL", countryCodeToFlagEmoji("GL")), + CountryData("Grenada", "+473", "GD", countryCodeToFlagEmoji("GD")), + CountryData("Guadeloupe", "+590", "GP", countryCodeToFlagEmoji("GP")), + CountryData("Guam", "+671", "GU", countryCodeToFlagEmoji("GU")), + CountryData("Guatemala", "+502", "GT", countryCodeToFlagEmoji("GT")), + CountryData("Guernsey", "+1481", "GG", countryCodeToFlagEmoji("GG")), + CountryData("Guinea", "+224", "GN", countryCodeToFlagEmoji("GN")), + CountryData("Guinea-Bissau", "+245", "GW", countryCodeToFlagEmoji("GW")), + CountryData("Guyana", "+592", "GY", countryCodeToFlagEmoji("GY")), + CountryData("Haiti", "+509", "HT", countryCodeToFlagEmoji("HT")), + CountryData("Honduras", "+504", "HN", countryCodeToFlagEmoji("HN")), + CountryData("Hong Kong", "+852", "HK", countryCodeToFlagEmoji("HK")), + CountryData("Hungary", "+36", "HU", countryCodeToFlagEmoji("HU")), + CountryData("Iceland", "+354", "IS", countryCodeToFlagEmoji("IS")), + CountryData("India", "+91", "IN", countryCodeToFlagEmoji("IN")), + CountryData("Indonesia", "+62", "ID", countryCodeToFlagEmoji("ID")), + CountryData("Iran", "+98", "IR", countryCodeToFlagEmoji("IR")), + CountryData("Iraq", "+964", "IQ", countryCodeToFlagEmoji("IQ")), + CountryData("Ireland", "+353", "IE", countryCodeToFlagEmoji("IE")), + CountryData("Isle of Man", "+44", "IM", countryCodeToFlagEmoji("IM")), + CountryData("Israel", "+972", "IL", countryCodeToFlagEmoji("IL")), + CountryData("Italy", "+39", "IT", countryCodeToFlagEmoji("IT")), + CountryData("Jamaica", "+876", "JM", countryCodeToFlagEmoji("JM")), + CountryData("Japan", "+81", "JP", countryCodeToFlagEmoji("JP")), + CountryData("Jersey", "+44", "JE", countryCodeToFlagEmoji("JE")), + CountryData("Jordan", "+962", "JO", countryCodeToFlagEmoji("JO")), + CountryData("Kazakhstan", "+7", "KZ", countryCodeToFlagEmoji("KZ")), + CountryData("Kenya", "+254", "KE", countryCodeToFlagEmoji("KE")), + CountryData("Kiribati", "+686", "KI", countryCodeToFlagEmoji("KI")), + CountryData("Kosovo", "+383", "XK", countryCodeToFlagEmoji("XK")), + CountryData("Kuwait", "+965", "KW", countryCodeToFlagEmoji("KW")), + CountryData("Kyrgyzstan", "+996", "KG", countryCodeToFlagEmoji("KG")), + CountryData("Laos", "+856", "LA", countryCodeToFlagEmoji("LA")), + CountryData("Latvia", "+371", "LV", countryCodeToFlagEmoji("LV")), + CountryData("Lebanon", "+961", "LB", countryCodeToFlagEmoji("LB")), + CountryData("Lesotho", "+266", "LS", countryCodeToFlagEmoji("LS")), + CountryData("Liberia", "+231", "LR", countryCodeToFlagEmoji("LR")), + CountryData("Libya", "+218", "LY", countryCodeToFlagEmoji("LY")), + CountryData("Liechtenstein", "+423", "LI", countryCodeToFlagEmoji("LI")), + CountryData("Lithuania", "+370", "LT", countryCodeToFlagEmoji("LT")), + CountryData("Luxembourg", "+352", "LU", countryCodeToFlagEmoji("LU")), + CountryData("Macao", "+853", "MO", countryCodeToFlagEmoji("MO")), + CountryData("Macedonia", "+389", "MK", countryCodeToFlagEmoji("MK")), + CountryData("Madagascar", "+261", "MG", countryCodeToFlagEmoji("MG")), + CountryData("Malawi", "+265", "MW", countryCodeToFlagEmoji("MW")), + CountryData("Malaysia", "+60", "MY", countryCodeToFlagEmoji("MY")), + CountryData("Maldives", "+960", "MV", countryCodeToFlagEmoji("MV")), + CountryData("Mali", "+223", "ML", countryCodeToFlagEmoji("ML")), + CountryData("Malta", "+356", "MT", countryCodeToFlagEmoji("MT")), + CountryData("Marshall Islands", "+692", "MH", countryCodeToFlagEmoji("MH")), + CountryData("Martinique", "+596", "MQ", countryCodeToFlagEmoji("MQ")), + CountryData("Mauritania", "+222", "MR", countryCodeToFlagEmoji("MR")), + CountryData("Mauritius", "+230", "MU", countryCodeToFlagEmoji("MU")), + CountryData("Mayotte", "+262", "YT", countryCodeToFlagEmoji("YT")), + CountryData("Mexico", "+52", "MX", countryCodeToFlagEmoji("MX")), + CountryData("Micronesia", "+691", "FM", countryCodeToFlagEmoji("FM")), + CountryData("Moldova", "+373", "MD", countryCodeToFlagEmoji("MD")), + CountryData("Monaco", "+377", "MC", countryCodeToFlagEmoji("MC")), + CountryData("Mongolia", "+976", "MN", countryCodeToFlagEmoji("MN")), + CountryData("Montenegro", "+382", "ME", countryCodeToFlagEmoji("ME")), + CountryData("Montserrat", "+664", "MS", countryCodeToFlagEmoji("MS")), + CountryData("Morocco", "+212", "MA", countryCodeToFlagEmoji("MA")), + CountryData("Mozambique", "+258", "MZ", countryCodeToFlagEmoji("MZ")), + CountryData("Myanmar", "+95", "MM", countryCodeToFlagEmoji("MM")), + CountryData("Namibia", "+264", "NA", countryCodeToFlagEmoji("NA")), + CountryData("Nauru", "+674", "NR", countryCodeToFlagEmoji("NR")), + CountryData("Nepal", "+977", "NP", countryCodeToFlagEmoji("NP")), + CountryData("Netherlands", "+31", "NL", countryCodeToFlagEmoji("NL")), + CountryData("New Caledonia", "+687", "NC", countryCodeToFlagEmoji("NC")), + CountryData("New Zealand", "+64", "NZ", countryCodeToFlagEmoji("NZ")), + CountryData("Nicaragua", "+505", "NI", countryCodeToFlagEmoji("NI")), + CountryData("Niger", "+227", "NE", countryCodeToFlagEmoji("NE")), + CountryData("Nigeria", "+234", "NG", countryCodeToFlagEmoji("NG")), + CountryData("Niue", "+683", "NU", countryCodeToFlagEmoji("NU")), + CountryData("Norfolk Island", "+672", "NF", countryCodeToFlagEmoji("NF")), + CountryData("North Korea", "+850", "KP", countryCodeToFlagEmoji("KP")), + CountryData("Northern Mariana Islands", "+670", "MP", countryCodeToFlagEmoji("MP")), + CountryData("Norway", "+47", "NO", countryCodeToFlagEmoji("NO")), + CountryData("Oman", "+968", "OM", countryCodeToFlagEmoji("OM")), + CountryData("Pakistan", "+92", "PK", countryCodeToFlagEmoji("PK")), + CountryData("Palau", "+680", "PW", countryCodeToFlagEmoji("PW")), + CountryData("Palestine", "+970", "PS", countryCodeToFlagEmoji("PS")), + CountryData("Panama", "+507", "PA", countryCodeToFlagEmoji("PA")), + CountryData("Papua New Guinea", "+675", "PG", countryCodeToFlagEmoji("PG")), + CountryData("Paraguay", "+595", "PY", countryCodeToFlagEmoji("PY")), + CountryData("Peru", "+51", "PE", countryCodeToFlagEmoji("PE")), + CountryData("Philippines", "+63", "PH", countryCodeToFlagEmoji("PH")), + CountryData("Poland", "+48", "PL", countryCodeToFlagEmoji("PL")), + CountryData("Portugal", "+351", "PT", countryCodeToFlagEmoji("PT")), + CountryData("Puerto Rico", "+787", "PR", countryCodeToFlagEmoji("PR")), + CountryData("Qatar", "+974", "QA", countryCodeToFlagEmoji("QA")), + CountryData("Réunion", "+262", "RE", countryCodeToFlagEmoji("RE")), + CountryData("Romania", "+40", "RO", countryCodeToFlagEmoji("RO")), + CountryData("Russia", "+7", "RU", countryCodeToFlagEmoji("RU")), + CountryData("Rwanda", "+250", "RW", countryCodeToFlagEmoji("RW")), + CountryData("Saint Barthélemy", "+590", "BL", countryCodeToFlagEmoji("BL")), + CountryData("Saint Helena", "+290", "SH", countryCodeToFlagEmoji("SH")), + CountryData("Saint Kitts and Nevis", "+869", "KN", countryCodeToFlagEmoji("KN")), + CountryData("Saint Lucia", "+758", "LC", countryCodeToFlagEmoji("LC")), + CountryData("Saint Martin", "+590", "MF", countryCodeToFlagEmoji("MF")), + CountryData("Saint Pierre and Miquelon", "+508", "PM", countryCodeToFlagEmoji("PM")), + CountryData("Saint Vincent and the Grenadines", "+784", "VC", countryCodeToFlagEmoji("VC")), + CountryData("Samoa", "+685", "WS", countryCodeToFlagEmoji("WS")), + CountryData("San Marino", "+378", "SM", countryCodeToFlagEmoji("SM")), + CountryData("Sao Tome and Principe", "+239", "ST", countryCodeToFlagEmoji("ST")), + CountryData("Saudi Arabia", "+966", "SA", countryCodeToFlagEmoji("SA")), + CountryData("Senegal", "+221", "SN", countryCodeToFlagEmoji("SN")), + CountryData("Serbia", "+381", "RS", countryCodeToFlagEmoji("RS")), + CountryData("Seychelles", "+248", "SC", countryCodeToFlagEmoji("SC")), + CountryData("Sierra Leone", "+232", "SL", countryCodeToFlagEmoji("SL")), + CountryData("Singapore", "+65", "SG", countryCodeToFlagEmoji("SG")), + CountryData("Sint Maarten", "+599", "SX", countryCodeToFlagEmoji("SX")), + CountryData("Slovakia", "+421", "SK", countryCodeToFlagEmoji("SK")), + CountryData("Slovenia", "+386", "SI", countryCodeToFlagEmoji("SI")), + CountryData("Solomon Islands", "+677", "SB", countryCodeToFlagEmoji("SB")), + CountryData("Somalia", "+252", "SO", countryCodeToFlagEmoji("SO")), + CountryData("South Africa", "+27", "ZA", countryCodeToFlagEmoji("ZA")), + CountryData("South Korea", "+82", "KR", countryCodeToFlagEmoji("KR")), + CountryData("South Sudan", "+211", "SS", countryCodeToFlagEmoji("SS")), + CountryData("Spain", "+34", "ES", countryCodeToFlagEmoji("ES")), + CountryData("Sri Lanka", "+94", "LK", countryCodeToFlagEmoji("LK")), + CountryData("Sudan", "+249", "SD", countryCodeToFlagEmoji("SD")), + CountryData("Suriname", "+597", "SR", countryCodeToFlagEmoji("SR")), + CountryData("Swaziland", "+268", "SZ", countryCodeToFlagEmoji("SZ")), + CountryData("Sweden", "+46", "SE", countryCodeToFlagEmoji("SE")), + CountryData("Switzerland", "+41", "CH", countryCodeToFlagEmoji("CH")), + CountryData("Syria", "+963", "SY", countryCodeToFlagEmoji("SY")), + CountryData("Taiwan", "+886", "TW", countryCodeToFlagEmoji("TW")), + CountryData("Tajikistan", "+992", "TJ", countryCodeToFlagEmoji("TJ")), + CountryData("Tanzania", "+255", "TZ", countryCodeToFlagEmoji("TZ")), + CountryData("Thailand", "+66", "TH", countryCodeToFlagEmoji("TH")), + CountryData("Timor-Leste", "+670", "TL", countryCodeToFlagEmoji("TL")), + CountryData("Togo", "+228", "TG", countryCodeToFlagEmoji("TG")), + CountryData("Tokelau", "+690", "TK", countryCodeToFlagEmoji("TK")), + CountryData("Tonga", "+676", "TO", countryCodeToFlagEmoji("TO")), + CountryData("Trinidad and Tobago", "+868", "TT", countryCodeToFlagEmoji("TT")), + CountryData("Tunisia", "+216", "TN", countryCodeToFlagEmoji("TN")), + CountryData("Turkey", "+90", "TR", countryCodeToFlagEmoji("TR")), + CountryData("Turkmenistan", "+993", "TM", countryCodeToFlagEmoji("TM")), + CountryData("Turks and Caicos Islands", "+649", "TC", countryCodeToFlagEmoji("TC")), + CountryData("Tuvalu", "+688", "TV", countryCodeToFlagEmoji("TV")), + CountryData("Uganda", "+256", "UG", countryCodeToFlagEmoji("UG")), + CountryData("Ukraine", "+380", "UA", countryCodeToFlagEmoji("UA")), + CountryData("United Arab Emirates", "+971", "AE", countryCodeToFlagEmoji("AE")), + CountryData("United Kingdom", "+44", "GB", countryCodeToFlagEmoji("GB")), + CountryData("United States", "+1", "US", countryCodeToFlagEmoji("US")), + CountryData("Uruguay", "+598", "UY", countryCodeToFlagEmoji("UY")), + CountryData("Uzbekistan", "+998", "UZ", countryCodeToFlagEmoji("UZ")), + CountryData("Vanuatu", "+678", "VU", countryCodeToFlagEmoji("VU")), + CountryData("Vatican City", "+379", "VA", countryCodeToFlagEmoji("VA")), + CountryData("Venezuela", "+58", "VE", countryCodeToFlagEmoji("VE")), + CountryData("Vietnam", "+84", "VN", countryCodeToFlagEmoji("VN")), + CountryData("Virgin Islands (British)", "+284", "VG", countryCodeToFlagEmoji("VG")), + CountryData("Virgin Islands (U.S.)", "+340", "VI", countryCodeToFlagEmoji("VI")), + CountryData("Wallis and Futuna", "+681", "WF", countryCodeToFlagEmoji("WF")), + CountryData("Western Sahara", "+212", "EH", countryCodeToFlagEmoji("EH")), + CountryData("Yemen", "+967", "YE", countryCodeToFlagEmoji("YE")), + CountryData("Zambia", "+260", "ZM", countryCodeToFlagEmoji("ZM")), + CountryData("Zimbabwe", "+263", "ZW", countryCodeToFlagEmoji("ZW")) +) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/data/CountryData.kt b/auth/src/main/java/com/firebase/ui/auth/compose/data/CountryData.kt new file mode 100644 index 000000000..7deb276b7 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/data/CountryData.kt @@ -0,0 +1,62 @@ +/* + * 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.data + +/** + * Represents country information for phone number authentication. + * + * @property name The display name of the country (e.g., "United States"). + * @property dialCode The international dialing code (e.g., "+1"). + * @property countryCode The ISO 3166-1 alpha-2 country code (e.g., "US"). + * @property flagEmoji The flag emoji for the country (e.g., "🇺🇸"). + */ +data class CountryData( + val name: String, + val dialCode: String, + val countryCode: String, + val flagEmoji: String +) { + /** + * Returns a formatted display string combining flag emoji and country name. + */ + fun getDisplayName(): String = "$flagEmoji $name" + + /** + * Returns a formatted string with dial code. + */ + fun getDisplayNameWithDialCode(): String = "$flagEmoji $name ($dialCode)" +} + +/** + * Converts an ISO 3166-1 alpha-2 country code to its corresponding flag emoji. + * + * @param countryCode The two-letter country code (e.g., "US", "GB", "FR"). + * @return The flag emoji string, or an empty string if the code is invalid. + */ +fun countryCodeToFlagEmoji(countryCode: String): String { + if (countryCode.length != 2) return "" + + val uppercaseCode = countryCode.uppercase() + val baseCodePoint = 0x1F1E6 // Regional Indicator Symbol Letter A + val charCodeOffset = 'A'.code + + val firstChar = uppercaseCode[0].code + val secondChar = uppercaseCode[1].code + + val firstCodePoint = baseCodePoint + (firstChar - charCodeOffset) + val secondCodePoint = baseCodePoint + (secondChar - charCodeOffset) + + return String(intArrayOf(firstCodePoint, secondCodePoint), 0, 2) +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/data/CountryUtils.kt b/auth/src/main/java/com/firebase/ui/auth/compose/data/CountryUtils.kt new file mode 100644 index 000000000..e6ef16f06 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/data/CountryUtils.kt @@ -0,0 +1,117 @@ +/* + * 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.data + +import java.text.Normalizer +import java.util.Locale + +/** + * Utility functions for searching and filtering countries. + */ +object CountryUtils { + + // Lazy-initialized maps for fast lookups + private val countryCodeMap: Map by lazy { + ALL_COUNTRIES.associateBy { it.countryCode.uppercase() } + } + + private val dialCodeMap: Map> by lazy { + ALL_COUNTRIES.groupBy { it.dialCode } + } + + /** + * Finds a country by its ISO 3166-1 alpha-2 country code. + * + * @param countryCode The two-letter country code (e.g., "US", "GB"). + * @return The CountryData or null if not found. + */ + fun findByCountryCode(countryCode: String): CountryData? { + return countryCodeMap[countryCode.uppercase()] + } + + /** + * Finds all countries with the given dial code. + * + * @param dialCode The international dialing code (e.g., "+1", "+44"). + * @return List of countries with that dial code, or empty list if none found. + */ + fun findByDialCode(dialCode: String): List { + return dialCodeMap[dialCode] ?: emptyList() + } + + /** + * Searches for countries by name. Supports partial matching and diacritic-insensitive search. + * + * @param query The search query. + * @return List of countries matching the query, or empty list if none found. + */ + fun searchByName(query: String): List { + val trimmedQuery = query.trim() + if (trimmedQuery.isEmpty()) return emptyList() + + val normalizedQuery = normalizeString(trimmedQuery) + + return ALL_COUNTRIES.filter { country -> + normalizeString(country.name).contains(normalizedQuery, ignoreCase = true) + } + } + + /** + * Filters countries by allowed country codes. + * + * @param allowedCountryCodes Set of allowed ISO 3166-1 alpha-2 country codes. + * @return List of countries that are in the allowed set. + */ + fun filterByAllowedCountries(allowedCountryCodes: Set): List { + if (allowedCountryCodes.isEmpty()) return ALL_COUNTRIES + + val uppercaseAllowed = allowedCountryCodes.map { it.uppercase() }.toSet() + return ALL_COUNTRIES.filter { it.countryCode.uppercase() in uppercaseAllowed } + } + + /** + * Gets the default country based on the device's locale. + * + * @return The CountryData for the device's country, or United States as fallback. + */ + fun getDefaultCountry(): CountryData { + val deviceCountryCode = Locale.getDefault().country + return findByCountryCode(deviceCountryCode) ?: findByCountryCode("US")!! + } + + /** + * Formats a phone number with the country's dial code. + * + * @param dialCode The country dial code (e.g., "+1"). + * @param phoneNumber The local phone number. + * @return The formatted international phone number. + */ + fun formatPhoneNumber(dialCode: String, phoneNumber: String): String { + val cleanNumber = phoneNumber.replace(Regex("[^0-9]"), "") + return "$dialCode$cleanNumber" + } + + /** + * Normalizes a string by removing diacritics and converting to lowercase. + * + * @param value The string to normalize. + * @return The normalized string. + */ + private fun normalizeString(value: String): String { + return Normalizer.normalize(value, Normalizer.Form.NFD) + .replace(Regex("\\p{M}"), "") + .lowercase() + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/data/CountryDataTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/data/CountryDataTest.kt new file mode 100644 index 000000000..8c7064f8f --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/data/CountryDataTest.kt @@ -0,0 +1,347 @@ +/* + * 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.data + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [CountryData] and related utilities. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class CountryDataTest { + + // ============================================================================================= + // CountryData Tests + // ============================================================================================= + + @Test + fun `CountryData has correct properties`() { + val country = CountryData( + name = "United States", + dialCode = "+1", + countryCode = "US", + flagEmoji = "🇺🇸" + ) + + assertThat(country.name).isEqualTo("United States") + assertThat(country.dialCode).isEqualTo("+1") + assertThat(country.countryCode).isEqualTo("US") + assertThat(country.flagEmoji).isEqualTo("🇺🇸") + } + + @Test + fun `getDisplayName returns formatted name with flag`() { + val country = CountryData( + name = "United Kingdom", + dialCode = "+44", + countryCode = "GB", + flagEmoji = "🇬🇧" + ) + + assertThat(country.getDisplayName()).isEqualTo("🇬🇧 United Kingdom") + } + + @Test + fun `getDisplayNameWithDialCode returns formatted name with flag and dial code`() { + val country = CountryData( + name = "France", + dialCode = "+33", + countryCode = "FR", + flagEmoji = "🇫🇷" + ) + + assertThat(country.getDisplayNameWithDialCode()).isEqualTo("🇫🇷 France (+33)") + } + + // ============================================================================================= + // Flag Emoji Tests + // ============================================================================================= + + @Test + fun `countryCodeToFlagEmoji generates correct emoji for US`() { + val emoji = countryCodeToFlagEmoji("US") + assertThat(emoji).isEqualTo("🇺🇸") + } + + @Test + fun `countryCodeToFlagEmoji generates correct emoji for GB`() { + val emoji = countryCodeToFlagEmoji("GB") + assertThat(emoji).isEqualTo("🇬🇧") + } + + @Test + fun `countryCodeToFlagEmoji generates correct emoji for FR`() { + val emoji = countryCodeToFlagEmoji("FR") + assertThat(emoji).isEqualTo("🇫🇷") + } + + @Test + fun `countryCodeToFlagEmoji works with lowercase codes`() { + val emoji = countryCodeToFlagEmoji("de") + assertThat(emoji).isEqualTo("🇩🇪") + } + + @Test + fun `countryCodeToFlagEmoji returns empty string for invalid code length`() { + val emoji = countryCodeToFlagEmoji("USA") + assertThat(emoji).isEmpty() + } + + @Test + fun `countryCodeToFlagEmoji returns empty string for empty code`() { + val emoji = countryCodeToFlagEmoji("") + assertThat(emoji).isEmpty() + } + + @Test + fun `countryCodeToFlagEmoji returns empty string for single character`() { + val emoji = countryCodeToFlagEmoji("U") + assertThat(emoji).isEmpty() + } + + // ============================================================================================= + // Country List Tests + // ============================================================================================= + + @Test + fun `ALL_COUNTRIES contains expected number of countries`() { + assertThat(ALL_COUNTRIES).isNotEmpty() + assertThat(ALL_COUNTRIES.size).isGreaterThan(200) + } + + @Test + fun `ALL_COUNTRIES contains United States`() { + val us = ALL_COUNTRIES.find { it.countryCode == "US" } + assertThat(us).isNotNull() + assertThat(us?.name).isEqualTo("United States") + assertThat(us?.dialCode).isEqualTo("+1") + } + + @Test + fun `ALL_COUNTRIES contains United Kingdom`() { + val uk = ALL_COUNTRIES.find { it.countryCode == "GB" } + assertThat(uk).isNotNull() + assertThat(uk?.name).isEqualTo("United Kingdom") + assertThat(uk?.dialCode).isEqualTo("+44") + } + + @Test + fun `ALL_COUNTRIES contains France`() { + val france = ALL_COUNTRIES.find { it.countryCode == "FR" } + assertThat(france).isNotNull() + assertThat(france?.name).isEqualTo("France") + assertThat(france?.dialCode).isEqualTo("+33") + } + + @Test + fun `ALL_COUNTRIES has no duplicate country codes`() { + val countryCodes = ALL_COUNTRIES.map { it.countryCode } + val uniqueCodes = countryCodes.toSet() + assertThat(countryCodes.size).isEqualTo(uniqueCodes.size) + } + + @Test + fun `ALL_COUNTRIES all entries have valid flag emojis`() { + ALL_COUNTRIES.forEach { country -> + assertThat(country.flagEmoji).isNotEmpty() + } + } + + @Test + fun `ALL_COUNTRIES all entries have dial codes starting with plus`() { + ALL_COUNTRIES.forEach { country -> + assertThat(country.dialCode).startsWith("+") + } + } + + @Test + fun `ALL_COUNTRIES all entries have two-letter country codes`() { + ALL_COUNTRIES.forEach { country -> + assertThat(country.countryCode).hasLength(2) + } + } + + // ============================================================================================= + // CountryUtils - Lookup Tests + // ============================================================================================= + + @Test + fun `findByCountryCode returns correct country for US`() { + val country = CountryUtils.findByCountryCode("US") + assertThat(country).isNotNull() + assertThat(country?.name).isEqualTo("United States") + assertThat(country?.dialCode).isEqualTo("+1") + } + + @Test + fun `findByCountryCode is case insensitive`() { + val country = CountryUtils.findByCountryCode("us") + assertThat(country).isNotNull() + assertThat(country?.countryCode).isEqualTo("US") + } + + @Test + fun `findByCountryCode returns null for invalid code`() { + val country = CountryUtils.findByCountryCode("XX") + assertThat(country).isNull() + } + + @Test + fun `findByDialCode returns countries with +1 dial code`() { + val countries = CountryUtils.findByDialCode("+1") + assertThat(countries).isNotEmpty() + assertThat(countries.map { it.countryCode }).contains("US") + assertThat(countries.map { it.countryCode }).contains("CA") + } + + @Test + fun `findByDialCode returns countries with +44 dial code`() { + val countries = CountryUtils.findByDialCode("+44") + assertThat(countries).isNotEmpty() + val countryCodes = countries.map { it.countryCode } + assertThat(countryCodes).contains("GB") + } + + @Test + fun `findByDialCode returns empty list for non-existent dial code`() { + val countries = CountryUtils.findByDialCode("+9999") + assertThat(countries).isEmpty() + } + + // ============================================================================================= + // CountryUtils - Search Tests + // ============================================================================================= + + @Test + fun `searchByName finds United States`() { + val countries = CountryUtils.searchByName("United States") + assertThat(countries).isNotEmpty() + assertThat(countries[0].countryCode).isEqualTo("US") + } + + @Test + fun `searchByName finds countries with partial match`() { + val countries = CountryUtils.searchByName("United") + assertThat(countries).isNotEmpty() + val names = countries.map { it.name } + assertThat(names).contains("United States") + assertThat(names).contains("United Kingdom") + assertThat(names).contains("United Arab Emirates") + } + + @Test + fun `searchByName is case insensitive`() { + val countries = CountryUtils.searchByName("france") + assertThat(countries).isNotEmpty() + assertThat(countries[0].countryCode).isEqualTo("FR") + } + + @Test + fun `searchByName handles diacritics`() { + val countries = CountryUtils.searchByName("Cote d'Ivoire") + assertThat(countries).isNotEmpty() + assertThat(countries[0].countryCode).isEqualTo("CI") + } + + @Test + fun `searchByName returns empty list for empty query`() { + val countries = CountryUtils.searchByName("") + assertThat(countries).isEmpty() + } + + @Test + fun `searchByName returns empty list for whitespace query`() { + val countries = CountryUtils.searchByName(" ") + assertThat(countries).isEmpty() + } + + // ============================================================================================= + // CountryUtils - Filter Tests + // ============================================================================================= + + @Test + fun `filterByAllowedCountries returns only allowed countries`() { + val allowedCodes = setOf("US", "GB", "FR") + val filtered = CountryUtils.filterByAllowedCountries(allowedCodes) + + assertThat(filtered).hasSize(3) + val countryCodes = filtered.map { it.countryCode } + assertThat(countryCodes).containsExactly("US", "GB", "FR") + } + + @Test + fun `filterByAllowedCountries is case insensitive`() { + val allowedCodes = setOf("us", "gb") + val filtered = CountryUtils.filterByAllowedCountries(allowedCodes) + + assertThat(filtered).hasSize(2) + val countryCodes = filtered.map { it.countryCode } + assertThat(countryCodes).containsExactly("US", "GB") + } + + @Test + fun `filterByAllowedCountries returns all countries when set is empty`() { + val filtered = CountryUtils.filterByAllowedCountries(emptySet()) + assertThat(filtered).hasSize(ALL_COUNTRIES.size) + } + + @Test + fun `filterByAllowedCountries returns empty list for non-existent codes`() { + val allowedCodes = setOf("XX", "YY") + val filtered = CountryUtils.filterByAllowedCountries(allowedCodes) + assertThat(filtered).isEmpty() + } + + // ============================================================================================= + // CountryUtils - Default Country Tests + // ============================================================================================= + + @Test + fun `getDefaultCountry returns a valid country`() { + val country = CountryUtils.getDefaultCountry() + assertThat(country).isNotNull() + assertThat(country.countryCode).isNotEmpty() + assertThat(country.dialCode).isNotEmpty() + } + + // ============================================================================================= + // CountryUtils - Formatting Tests + // ============================================================================================= + + @Test + fun `formatPhoneNumber combines dial code and phone number`() { + val formatted = CountryUtils.formatPhoneNumber("+1", "5551234567") + assertThat(formatted).isEqualTo("+15551234567") + } + + @Test + fun `formatPhoneNumber removes non-numeric characters from phone number`() { + val formatted = CountryUtils.formatPhoneNumber("+1", "(555) 123-4567") + assertThat(formatted).isEqualTo("+15551234567") + } + + @Test + fun `formatPhoneNumber handles phone number with spaces`() { + val formatted = CountryUtils.formatPhoneNumber("+44", "20 1234 5678") + assertThat(formatted).isEqualTo("+442012345678") + } +} From 10b6dc73e04a1f371dcf8a11bdefa85d4ce57417 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 8 Oct 2025 15:08:41 +0100 Subject: [PATCH 17/41] feat: Email Provider Integration (#2233) * feat: Email provider integration - added: sign in, sign up, reset password, email link and anonymous auto upgrade - upgrade mockito - fixed spying mocked objects in new library test error * feat: add PasswordResetLinkSent state * chore: remove unused methods * chore: remove unused comments and code * chore: remove unused imports, reformat * chore: remove comments * chore: remove comments * handle authState exceptions * fix: mockito 5 upgrade stubbing issues --- auth/build.gradle.kts | 20 +- .../firebase/ui/auth/compose/AuthException.kt | 77 +- .../com/firebase/ui/auth/compose/AuthState.kt | 53 +- .../ui/auth/compose/FirebaseAuthUI.kt | 7 +- .../configuration/AuthUIConfiguration.kt | 10 +- .../compose/configuration/PasswordRule.kt | 2 +- .../{ => auth_provider}/AuthProvider.kt | 151 ++- .../EmailAuthProvider+FirebaseAuthUI.kt | 960 ++++++++++++++++++ .../AuthUIStringProviderSample.kt | 2 +- .../DefaultAuthUIStringProvider.kt | 6 +- .../theme/ProviderStyleDefaults.kt | 2 +- .../validators/PasswordValidator.kt | 2 +- .../ui/components/AuthProviderButton.kt | 8 +- .../ui/components/ErrorRecoveryDialog.kt | 3 + .../ui/method_picker/AuthMethodPicker.kt | 2 +- .../util/EmailLinkPersistenceManager.kt | 158 +++ .../compose/FirebaseAuthUIAuthStateTest.kt | 4 +- .../ui/auth/compose/FirebaseAuthUITest.kt | 28 +- .../configuration/AuthUIConfigurationTest.kt | 1 + .../{ => auth_provider}/AuthProviderTest.kt | 16 +- .../EmailAuthProviderFirebaseAuthUITest.kt | 726 +++++++++++++ .../validators/PasswordValidatorTest.kt | 2 +- .../ui/components/AuthProviderButtonTest.kt | 6 +- .../ui/method_picker/AuthMethodPickerTest.kt | 2 +- .../ui/auth/testhelpers/TestHelper.java | 25 +- buildSrc/src/main/kotlin/Config.kt | 5 + 26 files changed, 2184 insertions(+), 94 deletions(-) rename auth/src/main/java/com/firebase/ui/auth/compose/configuration/{ => auth_provider}/AuthProvider.kt (69%) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/util/EmailLinkPersistenceManager.kt rename auth/src/test/java/com/firebase/ui/auth/compose/configuration/{ => auth_provider}/AuthProviderTest.kt (95%) create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index 3175b0a9d..0fd30ade8 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -85,12 +85,13 @@ dependencies { implementation(Config.Libs.Androidx.materialDesign) implementation(Config.Libs.Androidx.activity) implementation(Config.Libs.Androidx.Compose.materialIconsExtended) + implementation(Config.Libs.Androidx.datastorePreferences) // The new activity result APIs force us to include Fragment 1.3.0 // See https://issuetracker.google.com/issues/152554847 implementation(Config.Libs.Androidx.fragment) implementation(Config.Libs.Androidx.customTabs) implementation(Config.Libs.Androidx.constraint) - implementation("androidx.credentials:credentials:1.3.0") + implementation(Config.Libs.Androidx.credentials) implementation("androidx.credentials:credentials-play-services-auth:1.3.0") implementation(Config.Libs.Androidx.lifecycleExtensions) @@ -111,12 +112,27 @@ dependencies { testImplementation(Config.Libs.Test.junit) testImplementation(Config.Libs.Test.truth) - testImplementation(Config.Libs.Test.mockito) testImplementation(Config.Libs.Test.core) testImplementation(Config.Libs.Test.robolectric) testImplementation(Config.Libs.Test.kotlinReflect) testImplementation(Config.Libs.Provider.facebook) + testImplementation(Config.Libs.Test.mockitoCore) + testImplementation(Config.Libs.Test.mockitoInline) + testImplementation(Config.Libs.Test.mockitoKotlin) + testImplementation(Config.Libs.Androidx.credentials) testImplementation(Config.Libs.Test.composeUiTestJunit4) debugImplementation(project(":internal:lintchecks")) } + +val mockitoAgent by configurations.creating + +dependencies { + mockitoAgent(Config.Libs.Test.mockitoCore) { + isTransitive = false + } +} + +tasks.withType().configureEach { + jvmArgs("-javaagent:${mockitoAgent.asPath}") +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt index cb2a9480a..a1206b10b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt @@ -14,6 +14,7 @@ package com.firebase.ui.auth.compose +import com.firebase.ui.auth.compose.AuthException.Companion.from import com.google.firebase.FirebaseException import com.google.firebase.auth.FirebaseAuthException import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException @@ -204,6 +205,39 @@ abstract class AuthException( cause: Throwable? = null ) : AuthException(message, cause) + class InvalidEmailLinkException( + cause: Throwable? = null + ) : AuthException("You are are attempting to sign in with an invalid email link", cause) + + class EmailLinkWrongDeviceException( + cause: Throwable? = null + ) : AuthException("You must open the email link on the same device.", cause) + + class EmailLinkCrossDeviceLinkingException( + cause: Throwable? = null + ) : AuthException( + "You must determine if you want to continue linking or " + + "complete the sign in", cause + ) + + class EmailLinkPromptForEmailException( + cause: Throwable? = null + ) : AuthException("Please enter your email to continue signing in", cause) + + class EmailLinkDifferentAnonymousUserException( + cause: Throwable? = null + ) : AuthException( + "The session associated with this sign-in request has either expired or " + + "was cleared", cause + ) + + class EmailMismatchException( + cause: Throwable? = null + ) : AuthException( + "You are are attempting to sign in a different email than previously " + + "provided", cause + ) + companion object { /** * Creates an appropriate [AuthException] instance from a Firebase authentication exception. @@ -244,22 +278,26 @@ abstract class AuthException( cause = firebaseException ) } + is FirebaseAuthInvalidUserException -> { when (firebaseException.errorCode) { "ERROR_USER_NOT_FOUND" -> UserNotFoundException( message = firebaseException.message ?: "User not found", cause = firebaseException ) + "ERROR_USER_DISABLED" -> InvalidCredentialsException( message = firebaseException.message ?: "User account has been disabled", cause = firebaseException ) + else -> UserNotFoundException( message = firebaseException.message ?: "User account error", cause = firebaseException ) } } + is FirebaseAuthWeakPasswordException -> { WeakPasswordException( message = firebaseException.message ?: "Password is too weak", @@ -267,52 +305,68 @@ abstract class AuthException( reason = firebaseException.reason ) } + is FirebaseAuthUserCollisionException -> { when (firebaseException.errorCode) { "ERROR_EMAIL_ALREADY_IN_USE" -> EmailAlreadyInUseException( - message = firebaseException.message ?: "Email address is already in use", + message = firebaseException.message + ?: "Email address is already in use", cause = firebaseException, email = firebaseException.email ) + "ERROR_ACCOUNT_EXISTS_WITH_DIFFERENT_CREDENTIAL" -> AccountLinkingRequiredException( - message = firebaseException.message ?: "Account already exists with different credentials", + message = firebaseException.message + ?: "Account already exists with different credentials", cause = firebaseException ) + "ERROR_CREDENTIAL_ALREADY_IN_USE" -> AccountLinkingRequiredException( - message = firebaseException.message ?: "Credential is already associated with a different user account", + message = firebaseException.message + ?: "Credential is already associated with a different user account", cause = firebaseException ) + else -> AccountLinkingRequiredException( message = firebaseException.message ?: "Account collision error", cause = firebaseException ) } } + is FirebaseAuthMultiFactorException -> { MfaRequiredException( - message = firebaseException.message ?: "Multi-factor authentication required", + message = firebaseException.message + ?: "Multi-factor authentication required", cause = firebaseException ) } + is FirebaseAuthRecentLoginRequiredException -> { InvalidCredentialsException( - message = firebaseException.message ?: "Recent login required for this operation", + message = firebaseException.message + ?: "Recent login required for this operation", cause = firebaseException ) } + is FirebaseAuthException -> { // Handle FirebaseAuthException and check for specific error codes when (firebaseException.errorCode) { "ERROR_TOO_MANY_REQUESTS" -> TooManyRequestsException( - message = firebaseException.message ?: "Too many requests. Please try again later", + message = firebaseException.message + ?: "Too many requests. Please try again later", cause = firebaseException ) + else -> UnknownException( - message = firebaseException.message ?: "An unknown authentication error occurred", + message = firebaseException.message + ?: "An unknown authentication error occurred", cause = firebaseException ) } } + is FirebaseException -> { // Handle general Firebase exceptions, which include network errors NetworkException( @@ -320,10 +374,15 @@ abstract class AuthException( cause = firebaseException ) } + else -> { // Check for common cancellation patterns - if (firebaseException.message?.contains("cancelled", ignoreCase = true) == true || - firebaseException.message?.contains("canceled", ignoreCase = true) == true) { + if (firebaseException.message?.contains( + "cancelled", + ignoreCase = true + ) == true || + firebaseException.message?.contains("canceled", ignoreCase = true) == true + ) { AuthCancelledException( message = firebaseException.message ?: "Authentication was cancelled", cause = firebaseException diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt index d2163500a..db11dc4f2 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt @@ -14,6 +14,9 @@ package com.firebase.ui.auth.compose +import com.firebase.ui.auth.compose.AuthState.Companion.Cancelled +import com.firebase.ui.auth.compose.AuthState.Companion.Idle +import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactorResolver @@ -72,8 +75,8 @@ abstract class AuthState private constructor() { if (this === other) return true if (other !is Success) return false return result == other.result && - user == other.user && - isNewUser == other.isNewUser + user == other.user && + isNewUser == other.isNewUser } override fun hashCode(): Int { @@ -101,7 +104,7 @@ abstract class AuthState private constructor() { if (this === other) return true if (other !is Error) return false return exception == other.exception && - isRecoverable == other.isRecoverable + isRecoverable == other.isRecoverable } override fun hashCode(): Int { @@ -137,7 +140,7 @@ abstract class AuthState private constructor() { if (this === other) return true if (other !is RequiresMfa) return false return resolver == other.resolver && - hint == other.hint + hint == other.hint } override fun hashCode(): Int { @@ -164,7 +167,7 @@ abstract class AuthState private constructor() { if (this === other) return true if (other !is RequiresEmailVerification) return false return user == other.user && - email == other.email + email == other.email } override fun hashCode(): Int { @@ -191,7 +194,7 @@ abstract class AuthState private constructor() { if (this === other) return true if (other !is RequiresProfileCompletion) return false return user == other.user && - missingFields == other.missingFields + missingFields == other.missingFields } override fun hashCode(): Int { @@ -204,6 +207,42 @@ abstract class AuthState private constructor() { "AuthState.RequiresProfileCompletion(user=$user, missingFields=$missingFields)" } + /** + * Pending credential for an anonymous upgrade merge conflict. + * + * Emitted when an anonymous user attempts to convert to a permanent account but + * Firebase detects that the target email already belongs to another user. The UI can + * prompt the user to resolve the conflict by signing in with the existing account and + * later linking the stored [pendingCredential]. + */ + class MergeConflict( + val pendingCredential: AuthCredential + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MergeConflict) return false + return pendingCredential == other.pendingCredential + } + + override fun hashCode(): Int { + var result = pendingCredential.hashCode() + result = 31 * result + pendingCredential.hashCode() + return result + } + + override fun toString(): String = + "AuthState.MergeConflict(pendingCredential=$pendingCredential)" + } + + /** + * Password reset link has been sent to the user's email. + */ + class PasswordResetLinkSent : AuthState() { + override fun equals(other: Any?): Boolean = other is PasswordResetLinkSent + override fun hashCode(): Int = javaClass.hashCode() + override fun toString(): String = "AuthState.PasswordResetLinkSent" + } + companion object { /** * Creates an Idle state instance. @@ -219,4 +258,4 @@ abstract class AuthState private constructor() { @JvmStatic val Cancelled: Cancelled = Cancelled() } -} \ No newline at end of file +} 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 fe1f6cf80..21617b1b7 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 @@ -14,6 +14,7 @@ package com.firebase.ui.auth.compose +import android.content.Context import androidx.annotation.RestrictTo import com.google.firebase.FirebaseApp import com.google.firebase.auth.FirebaseAuth @@ -21,7 +22,6 @@ import com.google.firebase.auth.FirebaseAuth.AuthStateListener import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.ktx.auth import com.google.firebase.ktx.Firebase -import android.content.Context import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow @@ -168,7 +168,8 @@ class FirebaseAuthUI private constructor( // Check if email verification is required if (!currentUser.isEmailVerified && currentUser.email != null && - currentUser.providerData.any { it.providerId == "password" }) { + currentUser.providerData.any { it.providerId == "password" } + ) { AuthState.RequiresEmailVerification( user = currentUser, email = currentUser.email!! @@ -374,7 +375,7 @@ class FirebaseAuthUI private constructor( } catch (e: IllegalStateException) { throw IllegalStateException( "Default FirebaseApp is not initialized. " + - "Make sure to call FirebaseApp.initializeApp(Context) first.", + "Make sure to call FirebaseApp.initializeApp(Context) first.", e ) } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt index 6fb0202de..1267aea84 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt @@ -15,15 +15,15 @@ package com.firebase.ui.auth.compose.configuration import android.content.Context -import java.util.Locale -import com.google.firebase.auth.ActionCodeSettings import androidx.compose.ui.graphics.vector.ImageVector +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvidersBuilder +import com.firebase.ui.auth.compose.configuration.auth_provider.Provider import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme - -fun actionCodeSettings(block: ActionCodeSettings.Builder.() -> Unit) = - ActionCodeSettings.newBuilder().apply(block).build() +import com.google.firebase.auth.ActionCodeSettings +import java.util.Locale fun authUIConfiguration(block: AuthUIConfigurationBuilder.() -> Unit) = AuthUIConfigurationBuilder().apply(block).build() diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt index 7073fbe6e..383265501 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt @@ -18,7 +18,7 @@ import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringPr /** * An abstract class representing a set of validation rules that can be applied to a password field, - * typically within the [AuthProvider.Email] configuration. + * typically within the [com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider.Email] configuration. */ abstract class PasswordRule { /** diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt similarity index 69% rename from auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt rename to auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt index 87008cd28..1c9b88048 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt @@ -12,23 +12,33 @@ * limitations under the License. */ -package com.firebase.ui.auth.compose.configuration +package com.firebase.ui.auth.compose.configuration.auth_provider import android.content.Context -import androidx.compose.ui.graphics.Color +import android.net.Uri import android.util.Log +import androidx.compose.ui.graphics.Color +import androidx.datastore.preferences.core.stringPreferencesKey import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.AuthUIConfigurationDsl +import com.firebase.ui.auth.compose.configuration.PasswordRule import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset import com.firebase.ui.auth.util.Preconditions +import com.firebase.ui.auth.util.data.ContinueUrlBuilder import com.firebase.ui.auth.util.data.PhoneNumberUtils import com.firebase.ui.auth.util.data.ProviderAvailability import com.google.firebase.auth.ActionCodeSettings import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FacebookAuthProvider +import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.GithubAuthProvider import com.google.firebase.auth.GoogleAuthProvider import com.google.firebase.auth.PhoneAuthProvider import com.google.firebase.auth.TwitterAuthProvider +import com.google.firebase.auth.UserProfileChangeRequest +import com.google.firebase.auth.actionCodeSettings +import kotlinx.coroutines.tasks.await @AuthUIConfigurationDsl class AuthProvidersBuilder { @@ -44,11 +54,11 @@ class AuthProvidersBuilder { /** * Enum class to represent all possible providers. */ -internal enum class Provider(val id: String) { - GOOGLE(GoogleAuthProvider.PROVIDER_ID), - FACEBOOK(FacebookAuthProvider.PROVIDER_ID), - TWITTER(TwitterAuthProvider.PROVIDER_ID), - GITHUB(GithubAuthProvider.PROVIDER_ID), +internal enum class Provider(val id: String, val isSocialProvider: Boolean = false) { + GOOGLE(GoogleAuthProvider.PROVIDER_ID, isSocialProvider = true), + FACEBOOK(FacebookAuthProvider.PROVIDER_ID, isSocialProvider = true), + TWITTER(TwitterAuthProvider.PROVIDER_ID, isSocialProvider = true), + GITHUB(GithubAuthProvider.PROVIDER_ID, isSocialProvider = true), EMAIL(EmailAuthProvider.PROVIDER_ID), PHONE(PhoneAuthProvider.PROVIDER_ID), ANONYMOUS("anonymous"), @@ -76,6 +86,69 @@ abstract class OAuthProvider( * Base abstract class for authentication providers. */ abstract class AuthProvider(open val providerId: String) { + + companion object { + internal fun canUpgradeAnonymous(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean { + val currentUser = auth.currentUser + return config.isAnonymousUpgradeEnabled + && currentUser != null + && currentUser.isAnonymous + } + + /** + * Merges profile information (display name and photo URL) with the current user's profile. + * + * This method updates the user's profile only if the current profile is incomplete + * (missing display name or photo URL). This prevents overwriting existing profile data. + * + * **Use case:** + * After creating a new user account or linking credentials, update the profile with + * information from the sign-up form or social provider. + * + * @param auth The [FirebaseAuth] instance + * @param displayName The display name to set (if current is empty) + * @param photoUri The photo URL to set (if current is null) + * + * **Note:** This operation always succeeds to minimize login interruptions. + * Failures are logged but don't prevent sign-in completion. + */ + internal suspend fun mergeProfile( + auth: FirebaseAuth, + displayName: String?, + photoUri: Uri? + ) { + try { + val currentUser = auth.currentUser ?: return + + // Only update if current profile is incomplete + val currentDisplayName = currentUser.displayName + val currentPhotoUrl = currentUser.photoUrl + + if (!currentDisplayName.isNullOrEmpty() && currentPhotoUrl != null) { + // Profile is complete, no need to update + return + } + + // Build profile update with provided values + val nameToSet = + if (currentDisplayName.isNullOrEmpty()) displayName else currentDisplayName + val photoToSet = currentPhotoUrl ?: photoUri + + if (nameToSet != null || photoToSet != null) { + val profileUpdates = UserProfileChangeRequest.Builder() + .setDisplayName(nameToSet) + .setPhotoUri(photoToSet) + .build() + + currentUser.updateProfile(profileUpdates).await() + } + } catch (e: Exception) { + // Log error but don't throw - profile update failure shouldn't prevent sign-in + Log.e("AuthProvider.Email", "Error updating profile", e) + } + } + } + /** * Email/Password authentication provider configuration. */ @@ -118,7 +191,18 @@ abstract class AuthProvider(open val providerId: String) { */ val passwordValidationRules: List ) : AuthProvider(providerId = Provider.EMAIL.id) { - fun validate() { + companion object { + const val SESSION_ID_LENGTH = 10 + val KEY_EMAIL = stringPreferencesKey("com.firebase.ui.auth.data.client.email") + val KEY_PROVIDER = stringPreferencesKey("com.firebase.ui.auth.data.client.provider") + val KEY_ANONYMOUS_USER_ID = + stringPreferencesKey("com.firebase.ui.auth.data.client.auid") + val KEY_SESSION_ID = stringPreferencesKey("com.firebase.ui.auth.data.client.sid") + val KEY_IDP_TOKEN = stringPreferencesKey("com.firebase.ui.auth.data.client.idpToken") + val KEY_IDP_SECRET = stringPreferencesKey("com.firebase.ui.auth.data.client.idpSecret") + } + + internal fun validate() { if (isEmailLinkSignInEnabled) { val actionCodeSettings = requireNotNull(actionCodeSettings) { "ActionCodeSettings cannot be null when using " + @@ -131,6 +215,47 @@ abstract class AuthProvider(open val providerId: String) { } } } + + // For Send Email Link + internal fun addSessionInfoToActionCodeSettings( + sessionId: String, + anonymousUserId: String, + ): ActionCodeSettings { + requireNotNull(actionCodeSettings) { + "ActionCodeSettings is required for email link sign in" + } + + val continueUrl = continueUrl(actionCodeSettings.url) { + appendSessionId(sessionId) + appendAnonymousUserId(anonymousUserId) + appendForceSameDeviceBit(isEmailLinkForceSameDeviceEnabled) + appendProviderId(providerId) + } + + return actionCodeSettings { + url = continueUrl + handleCodeInApp = actionCodeSettings.canHandleCodeInApp() + iosBundleId = actionCodeSettings.iosBundle + setAndroidPackageName( + actionCodeSettings.androidPackageName ?: "", + actionCodeSettings.androidInstallApp, + actionCodeSettings.androidMinimumVersion + ) + } + } + + // For Sign In With Email Link + internal fun isDifferentDevice( + sessionIdFromLocal: String?, + sessionIdFromLink: String + ): Boolean { + return sessionIdFromLocal == null || sessionIdFromLocal.isEmpty() + || sessionIdFromLink.isEmpty() + || (sessionIdFromLink != sessionIdFromLocal) + } + + private fun continueUrl(continueUrl: String, block: ContinueUrlBuilder.() -> Unit) = + ContinueUrlBuilder(continueUrl).apply(block).build() } /** @@ -172,7 +297,7 @@ abstract class AuthProvider(open val providerId: String) { */ val isAutoRetrievalEnabled: Boolean = true ) : AuthProvider(providerId = Provider.PHONE.id) { - fun validate() { + internal fun validate() { defaultNumber?.let { check(PhoneNumberUtils.isValid(it)) { "Invalid phone number: $it" @@ -235,7 +360,7 @@ abstract class AuthProvider(open val providerId: String) { scopes = scopes, customParameters = customParameters ) { - fun validate(context: Context) { + internal fun validate(context: Context) { if (serverClientId == null) { Preconditions.checkConfigured( context, @@ -287,7 +412,7 @@ abstract class AuthProvider(open val providerId: String) { scopes = scopes, customParameters = customParameters ) { - fun validate(context: Context) { + internal fun validate(context: Context) { if (!ProviderAvailability.IS_FACEBOOK_AVAILABLE) { throw RuntimeException( "Facebook provider cannot be configured " + @@ -414,7 +539,7 @@ abstract class AuthProvider(open val providerId: String) { * Anonymous authentication provider. It has no configurable properties. */ object Anonymous : AuthProvider(providerId = Provider.ANONYMOUS.id) { - fun validate(providers: List) { + internal fun validate(providers: List) { if (providers.size == 1 && providers.first() is Anonymous) { throw IllegalStateException( "Sign in as guest cannot be the only sign in method. " + @@ -467,7 +592,7 @@ abstract class AuthProvider(open val providerId: String) { scopes = scopes, customParameters = customParameters ) { - fun validate() { + internal fun validate() { require(providerId.isNotBlank()) { "Provider ID cannot be null or empty" } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 000000000..d917e80d6 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,960 @@ +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.content.Context +import android.net.Uri +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider.Companion.mergeProfile +import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager +import com.firebase.ui.auth.util.data.EmailLinkParser +import com.firebase.ui.auth.util.data.SessionUtils +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.ActionCodeSettings +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthUserCollisionException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.tasks.await + +/** + * Holds credential information for account linking with email link sign-in. + * + * When a user tries to sign in with a social provider (Google, Facebook, etc.) but an + * email link account exists with that email, this data is used to link the accounts + * after email link authentication completes. + * + * @property providerType The provider ID (e.g., "google.com", "facebook.com") + * @property idToken The ID token from the provider (required for Google, optional for Facebook) + * @property accessToken The access token from the provider (required for Facebook, optional for Google) + */ +internal class CredentialForLinking( + val providerType: String, + val idToken: String?, + val accessToken: String? +) + +/** + * Creates an email/password account or links the credential to an anonymous user. + * + * Mirrors the legacy email sign-up handler: validates password strength, validates custom + * password rules, checks if new accounts are allowed, chooses between + * `createUserWithEmailAndPassword` and `linkWithCredential`, merges the supplied display name + * into the Firebase profile, and emits [AuthState.MergeConflict] when anonymous upgrade + * encounters an existing account for the email. + * + * **Flow:** + * 1. Check if new accounts are allowed (for non-upgrade flows) + * 2. Validate password length against [AuthProvider.Email.minimumPasswordLength] + * 3. Validate password against custom [AuthProvider.Email.passwordValidationRules] + * 4. If upgrading anonymous user: link credential to existing anonymous account + * 5. Otherwise: create new account with `createUserWithEmailAndPassword` + * 6. Merge display name into user profile + * + * @param context Android [Context] for localized strings + * @param config Auth UI configuration describing provider settings + * @param provider Email provider configuration + * @param name Optional display name collected during sign-up + * @param email Email address for the new account + * @param password Password for the new account + * + * @return [AuthResult] containing the newly created or linked user, or null if failed + * + * @throws AuthException.UserNotFoundException if new accounts are not allowed + * @throws AuthException.WeakPasswordException if the password fails validation rules + * @throws AuthException.InvalidCredentialsException if the email or password is invalid + * @throws AuthException.EmailAlreadyInUseException if the email already exists + * @throws AuthException.AuthCancelledException if the coroutine is cancelled + * @throws AuthException.NetworkException for network-related failures + * + * **Example: Normal sign-up** + * ```kotlin + * try { + * val result = firebaseAuthUI.createOrLinkUserWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * name = "John Doe", + * email = "john@example.com", + * password = "SecurePass123!" + * ) + * // User account created successfully + * } catch (e: AuthException.WeakPasswordException) { + * // Password doesn't meet validation rules + * } catch (e: AuthException.EmailAlreadyInUseException) { + * // Email already exists - redirect to sign-in + * } + * ``` + * + * **Example: Anonymous user upgrade** + * ```kotlin + * // User is currently signed in anonymously + * try { + * val result = firebaseAuthUI.createOrLinkUserWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * name = "Jane Smith", + * email = "jane@example.com", + * password = "MyPassword456" + * ) + * // Anonymous account upgraded to permanent email/password account + * } catch (e: AuthException) { + * // Check if AuthState.MergeConflict was emitted + * // This means email already exists - show merge conflict UI + * } + * ``` + */ +internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Email, + name: String?, + email: String, + password: String +): AuthResult? { + val canUpgrade = canUpgradeAnonymous(config, auth) + val pendingCredential = + if (canUpgrade) EmailAuthProvider.getCredential(email, password) else null + + try { + // Check if new accounts are allowed (only for non-upgrade flows) + if (!canUpgrade && !provider.isNewAccountsAllowed) { + throw AuthException.UserNotFoundException( + message = context.getString(R.string.fui_error_email_does_not_exist) + ) + } + + // Validate minimum password length + if (password.length < provider.minimumPasswordLength) { + throw AuthException.InvalidCredentialsException( + message = context.getString(R.string.fui_error_password_too_short) + .format(provider.minimumPasswordLength) + ) + } + + // Validate password against custom rules + for (rule in provider.passwordValidationRules) { + if (!rule.isValid(password)) { + throw AuthException.WeakPasswordException( + message = rule.getErrorMessage(config.stringProvider), + reason = "Password does not meet custom validation rules" + ) + } + } + + updateAuthState(AuthState.Loading("Creating user...")) + val result = if (canUpgrade) { + auth.currentUser?.linkWithCredential(requireNotNull(pendingCredential))?.await() + } else { + auth.createUserWithEmailAndPassword(email, password).await() + }.also { authResult -> + authResult?.user?.let { + // Merge display name into profile (photoUri is always null for email/password) + mergeProfile(auth, name, null) + } + } + updateAuthState(AuthState.Idle) + return result + } catch (e: FirebaseAuthUserCollisionException) { + val authException = AuthException.from(e) + if (canUpgrade && pendingCredential != null) { + // Anonymous upgrade collision: emit merge conflict state + updateAuthState(AuthState.MergeConflict(pendingCredential)) + } else { + updateAuthState(AuthState.Error(authException)) + } + throw authException + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Create or link user with email and password was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs in a user with email and password, optionally linking a social credential. + * + * This method handles both normal sign-in and anonymous upgrade flows. In anonymous upgrade + * scenarios, it validates credentials in a scratch auth instance before emitting a merge + * conflict state. + * + * **Flow:** + * 1. If anonymous upgrade: + * - Create scratch auth instance to validate credential + * - If linking social provider: sign in with email, then link social credential (safe link) + * - Otherwise: just validate email credential + * - Emit [AuthState.MergeConflict] after successful validation + * 2. If normal sign-in: + * - Sign in with email/password + * - If credential provided: link it and merge profile + * + * @param context Android [Context] for creating scratch auth instance + * @param config Auth UI configuration describing provider settings + * @param email Email address for sign-in + * @param password Password for sign-in + * @param credentialForLinking Optional social provider credential to link after sign-in + * + * @return [AuthResult] containing the signed-in user, or null if validation-only (anonymous upgrade) + * + * @throws AuthException.InvalidCredentialsException if email or password is incorrect + * @throws AuthException.UserNotFoundException if the user doesn't exist + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException for network-related failures + * + * **Example: Normal sign-in** + * ```kotlin + * try { + * val result = firebaseAuthUI.signInWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com", + * password = "password123" + * ) + * // User signed in successfully + * } catch (e: AuthException.InvalidCredentialsException) { + * // Wrong password + * } + * ``` + * + * **Example: Sign-in with social credential linking** + * ```kotlin + * // User tried to sign in with Google, but account exists with email/password + * // Prompt for password, then link Google credential + * val googleCredential = GoogleAuthProvider.getCredential(idToken, null) + * + * val result = firebaseAuthUI.signInWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com", + * password = "password123", + * credentialForLinking = googleCredential + * ) + * // User signed in with email/password AND Google is now linked + * // Profile updated with Google display name and photo + * ``` + * + * **Example: Anonymous upgrade validation** + * ```kotlin + * // User is anonymous, wants to upgrade with existing email/password account + * try { + * firebaseAuthUI.signInWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "existing@example.com", + * password = "password123" + * ) + * } catch (e: AuthException) { + * // AuthState.MergeConflict emitted + * // UI shows merge conflict resolution screen + * } + * ``` + */ +internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( + context: Context, + config: AuthUIConfiguration, + email: String, + password: String, + credentialForLinking: AuthCredential? = null, +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Signing in...")) + return if (canUpgradeAnonymous(config, auth)) { + // Anonymous upgrade flow: validate credential in scratch auth + val credentialToValidate = EmailAuthProvider.getCredential(email, password) + + // Check if we're linking a social provider credential + val isSocialProvider = credentialForLinking != null && + (Provider.fromId(credentialForLinking.provider)?.isSocialProvider ?: false) + + // Create scratch auth instance to avoid losing anonymous user state + val appExplicitlyForValidation = FirebaseApp.initializeApp( + context, + auth.app.options, + "FUIAuthScratchApp_${System.currentTimeMillis()}" + ) + val authExplicitlyForValidation = FirebaseAuth + .getInstance(appExplicitlyForValidation) + + if (isSocialProvider) { + // Safe link: sign in with email, then link social credential + authExplicitlyForValidation + .signInWithCredential(credentialToValidate).await() + .user?.linkWithCredential(credentialForLinking)?.await() + .also { + // Emit merge conflict after successful validation + updateAuthState(AuthState.MergeConflict(credentialToValidate)) + } + } else { + // Just validate the email credential + // No linking for non-federated IDPs + authExplicitlyForValidation + .signInWithCredential(credentialToValidate).await() + .also { + // Emit merge conflict after successful validation + // Merge failure occurs because account exists and user is anonymous + updateAuthState(AuthState.MergeConflict(credentialToValidate)) + } + } + } else { + // Normal sign-in + auth.signInWithEmailAndPassword(email, password).await() + .also { result -> + // If there's a credential to link, link it after sign-in + if (credentialForLinking != null) { + return result.user?.linkWithCredential(credentialForLinking)?.await() + .also { linkResult -> + // Merge profile from social provider + linkResult?.user?.let { user -> + mergeProfile( + auth, + user.displayName, + user.photoUrl + ) + } + } + } + } + }.also { + updateAuthState(AuthState.Idle) + } + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with email and password was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs in with a credential or links it to an existing anonymous user. + * + * This method handles both normal sign-in and anonymous upgrade flows. After successful + * authentication, it merges profile information (display name and photo URL) into the + * Firebase user profile if provided. + * + * **Flow:** + * 1. Check if user is anonymous and upgrade is enabled + * 2. If yes: Link credential to anonymous user + * 3. If no: Sign in with credential + * 4. Merge profile information (name, photo) into Firebase user + * 5. Handle collision exceptions by emitting [AuthState.MergeConflict] + * + * @param config The [AuthUIConfiguration] containing authentication settings + * @param credential The [AuthCredential] to use for authentication. Can be from any provider. + * @param displayName Optional display name from the provider to merge into the user profile + * @param photoUrl Optional photo URL from the provider to merge into the user profile + * + * @return [AuthResult] containing the authenticated user + * + * @throws AuthException.InvalidCredentialsException if credential is invalid or expired + * @throws AuthException.EmailAlreadyInUseException if linking and email is already in use + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * + * **Example: Google Sign-In** + * ```kotlin + * val googleCredential = GoogleAuthProvider.getCredential(idToken, null) + * val displayName = "John Doe" // From Google profile + * val photoUrl = Uri.parse("https://...") // From Google profile + * + * val result = firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = googleCredential, + * displayName = displayName, + * photoUrl = photoUrl + * ) + * // User signed in with Google AND profile updated with Google data + * ``` + * + * **Example: Phone Auth** + * ```kotlin + * val phoneCredential = PhoneAuthProvider.getCredential(verificationId, code) + * + * val result = firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = phoneCredential + * ) + * // User signed in with phone number + * ``` + * + * **Example: Phone Auth with Collision (Anonymous Upgrade)** + * ```kotlin + * // User is currently anonymous, trying to link a phone number + * val phoneCredential = PhoneAuthProvider.getCredential(verificationId, code) + * + * try { + * firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = phoneCredential + * ) + * } catch (e: FirebaseAuthUserCollisionException) { + * // Phone number already exists on another account + * // AuthState.MergeConflict emitted with updatedCredential + * // UI can show merge conflict resolution screen + * } + * ``` + * + * **Example: Email Link Sign-In** + * ```kotlin + * val emailLinkCredential = EmailAuthProvider.getCredentialWithLink( + * email = "user@example.com", + * emailLink = emailLink + * ) + * + * val result = firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = emailLinkCredential + * ) + * // User signed in with email link (passwordless) + * ``` + */ +internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( + config: AuthUIConfiguration, + credential: AuthCredential, + displayName: String? = null, + photoUrl: Uri? = null +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Signing in user...")) + return if (canUpgradeAnonymous(config, auth)) { + auth.currentUser?.linkWithCredential(credential)?.await() + } else { + auth.signInWithCredential(credential).await() + }.also { result -> + // Merge profile information from the provider + result?.user?.let { + mergeProfile(auth, displayName, photoUrl) + } + updateAuthState(AuthState.Idle) + } + } catch (e: FirebaseAuthUserCollisionException) { + // Special handling for collision exceptions + val authException = AuthException.from(e) + + if (canUpgradeAnonymous(config, auth)) { + // Anonymous upgrade collision: emit merge conflict with updated credential + val updatedCredential = e.updatedCredential + if (updatedCredential != null) { + updateAuthState(AuthState.MergeConflict(updatedCredential)) + } else { + updateAuthState(AuthState.Error(authException)) + } + } else { + updateAuthState(AuthState.Error(authException)) + } + throw authException + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in and link with credential was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Sends a passwordless sign-in link to the specified email address. + * + * This method initiates the email-link (passwordless) authentication flow by sending + * an email containing a magic link. The link includes session information for validation + * and security. Optionally supports account linking when a user tries to sign in with + * a social provider but an email link account exists. + * + * **How it works:** + * 1. Generates a unique session ID for same-device validation + * 2. Retrieves anonymous user ID if upgrading anonymous account + * 3. Enriches the [ActionCodeSettings] URL with session data (session ID, anonymous user ID, force same-device flag) + * 4. Sends the email via [com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail] + * 5. Saves session data to DataStore for validation when the user clicks the link + * 6. User receives email with a magic link containing the session information + * 7. When user clicks link, app opens via deep link and calls [signInWithEmailLink] to complete authentication + * + * **Account Linking Support:** + * If a user tries to sign in with a social provider (Google, Facebook) but an email link + * account already exists with that email, you can link the accounts by: + * 1. Catching the [FirebaseAuthUserCollisionException] from the social sign-in attempt + * 2. Calling this method with [credentialForLinking] containing the social provider tokens + * 3. When [signInWithEmailLink] completes, it automatically retrieves and links the saved credential + * + * **Session Security:** + * - **Session ID**: Random 10-character string for same-device validation + * - **Anonymous User ID**: Stored if upgrading anonymous account to prevent account hijacking + * - **Force Same Device**: Can be configured via [AuthProvider.Email.isEmailLinkForceSameDeviceEnabled] + * - All session data is validated in [signInWithEmailLink] before completing authentication + * + * @param context Android [Context] for DataStore access + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider The [AuthProvider.Email] configuration with [ActionCodeSettings] + * @param email The email address to send the sign-in link to + * @param credentialForLinking Optional credential linking data. If provided, this credential + * will be automatically linked after email link sign-in completes. Pass null for basic + * email link sign-in without account linking. + * + * @throws AuthException.InvalidCredentialsException if email is invalid + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws IllegalStateException if ActionCodeSettings is not configured + * + * **Example 1: Basic email link sign-in** + * ```kotlin + * // Send the email link + * firebaseAuthUI.sendSignInLinkToEmail( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com" + * ) + * // Show "Check your email" UI to user + * + * // Later, when user clicks the link in their email: + * // (In your deep link handling Activity) + * val emailLink = intent.data.toString() + * firebaseAuthUI.signInWithEmailLink( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com", + * emailLink = emailLink + * ) + * // User is now signed in + * ``` + * + * **Example 2: Complete account linking flow (Google → Email Link)** + * ```kotlin + * // Step 1: User tries to sign in with Google + * try { + * val googleAccount = GoogleSignIn.getLastSignedInAccount(context) + * val googleIdToken = googleAccount?.idToken + * val googleCredential = GoogleAuthProvider.getCredential(googleIdToken, null) + * + * firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = googleCredential + * ) + * } catch (e: FirebaseAuthUserCollisionException) { + * // Email already exists with Email Link provider + * + * // Step 2: Send email link with credential for linking + * firebaseAuthUI.sendSignInLinkToEmail( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = email, + * credentialForLinking = CredentialForLinking( + * providerType = "google.com", + * idToken = googleIdToken, // From GoogleSignInAccount + * accessToken = null + * ) + * ) + * + * // Step 3: Show "Check your email" UI + * } + * + * // Step 4: User clicks email link → App opens + * // (In your deep link handling Activity) + * val emailLink = intent.data.toString() + * firebaseAuthUI.signInWithEmailLink( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = email, + * emailLink = emailLink + * ) + * // signInWithEmailLink automatically: + * // 1. Signs in with email link + * // 2. Retrieves the saved Google credential from DataStore + * // 3. Links the Google credential to the email link account + * // 4. User is now signed in with both Email Link AND Google linked + * ``` + * + * **Example 3: Anonymous user upgrade** + * ```kotlin + * // User is currently signed in anonymously + * // Send email link to upgrade anonymous account to permanent email account + * firebaseAuthUI.sendSignInLinkToEmail( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com" + * ) + * // Session includes anonymous user ID for validation + * // When user clicks link, anonymous account is upgraded to permanent account + * ``` + * @see signInWithEmailLink + * @see EmailLinkPersistenceManager.saveCredentialForLinking + * @see com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail + */ +internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Email, + email: String, + credentialForLinking: CredentialForLinking? = null +) { + try { + updateAuthState(AuthState.Loading("Sending sign in email link...")) + + // Get anonymousUserId if can upgrade anonymously else default to empty string. + // NOTE: check for empty string instead of null to validate anonymous user ID matches + // when sign in from email link + val anonymousUserId = + if (canUpgradeAnonymous(config, auth)) (auth.currentUser?.uid + ?: "") else "" + + // Generate sessionId + val sessionId = + SessionUtils.generateRandomAlphaNumericString(AuthProvider.Email.SESSION_ID_LENGTH) + + // If credential provided, save it for linking after email link sign-in + if (credentialForLinking != null) { + EmailLinkPersistenceManager.saveCredentialForLinking( + context = context, + providerType = credentialForLinking.providerType, + idToken = credentialForLinking.idToken, + accessToken = credentialForLinking.accessToken + ) + } + + // Modify actionCodeSettings Url to include sessionId, anonymousUserId, force same + // device flag + val updatedActionCodeSettings = + provider.addSessionInfoToActionCodeSettings(sessionId, anonymousUserId) + + auth.sendSignInLinkToEmail(email, updatedActionCodeSettings).await() + + // Save Email to dataStore for use in signInWithEmailLink + EmailLinkPersistenceManager.saveEmail(context, email, sessionId, anonymousUserId) + + updateAuthState(AuthState.Idle) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Send sign in link to email was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs in a user using an email link (passwordless authentication). + * + * This method completes the email link sign-in flow after the user clicks the magic link + * sent to their email. It validates the link, extracts session information, and either + * signs in the user normally or upgrades an anonymous account based on configuration. + * + * **Flow:** + * 1. User receives email with magic link + * 2. User clicks link, app opens via deep link + * 3. Activity extracts emailLink from Intent.data + * 4. This method validates and completes sign-in + * + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider The [AuthProvider.Email] configuration with email-link settings + * @param email The email address of the user (retrieved from DataStore or user input) + * @param emailLink The complete deep link URL received from the Intent. + * + * This URL contains: + * - Firebase action code (oobCode) for authentication + * - Session ID (ui_sid) for same-device validation + * - Anonymous user ID (ui_auid) if upgrading anonymous account + * - Force same-device flag (ui_sd) for security enforcement + * + * Example: + * `https://yourapp.page.link/emailSignIn?oobCode=ABC123&continueUrl=...` + * + * @throws AuthException.InvalidCredentialsException if the email link is invalid or expired + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.UnknownException for other errors + * + * @see sendSignInLinkToEmail for sending the initial email link + */ +internal suspend fun FirebaseAuthUI.signInWithEmailLink( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Email, + email: String, + emailLink: String, +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Signing in with email link...")) + + // Validate link format + if (!auth.isSignInWithEmailLink(emailLink)) { + throw AuthException.InvalidEmailLinkException() + } + + // Validate email is not empty + if (email.isEmpty()) { + throw AuthException.EmailMismatchException() + } + + // Parse email link for session data + val parser = EmailLinkParser(emailLink) + val sessionIdFromLink = parser.sessionId + val anonymousUserIdFromLink = parser.anonymousUserId + val oobCode = parser.oobCode + val providerIdFromLink = parser.providerId + val isEmailLinkForceSameDeviceEnabled = parser.forceSameDeviceBit + + // Retrieve stored session record from DataStore + val sessionRecord = EmailLinkPersistenceManager.retrieveSessionRecord(context) + val storedSessionId = sessionRecord?.sessionId + + // Check if this is a different device flow + val isDifferentDevice = provider.isDifferentDevice( + sessionIdFromLocal = storedSessionId, + sessionIdFromLink = sessionIdFromLink + ) + + if (isDifferentDevice) { + // Handle cross-device flow + // Session ID must always be present in the link + if (sessionIdFromLink.isNullOrEmpty()) { + throw AuthException.InvalidEmailLinkException() + } + + // These scenarios require same-device flow + if (isEmailLinkForceSameDeviceEnabled || !anonymousUserIdFromLink.isNullOrEmpty()) { + throw AuthException.EmailLinkWrongDeviceException() + } + + // Validate the action code + auth.checkActionCode(oobCode).await() + + // If there's a provider ID, this is a linking flow which can't be done cross-device + if (!providerIdFromLink.isNullOrEmpty()) { + throw AuthException.EmailLinkCrossDeviceLinkingException() + } + + // Link is valid but we need the user to provide their email + throw AuthException.EmailLinkPromptForEmailException() + } + + // Validate anonymous user ID matches (same-device flow) + if (!anonymousUserIdFromLink.isNullOrEmpty()) { + val currentUser = auth.currentUser + if (currentUser == null || !currentUser.isAnonymous || currentUser.uid != anonymousUserIdFromLink) { + throw AuthException.EmailLinkDifferentAnonymousUserException() + } + } + + // Get credential for linking from session record + val storedCredentialForLink = sessionRecord?.credentialForLinking + val emailLinkCredential = EmailAuthProvider.getCredentialWithLink(email, emailLink) + + val result = if (storedCredentialForLink == null) { + // Normal Flow: Just sign in with email link + signInAndLinkWithCredential(config, emailLinkCredential) + ?: throw AuthException.UnknownException("Sign in failed") + } else { + // Linking Flow: Sign in with email link, then link the social credential + if (canUpgradeAnonymous(config, auth)) { + // Anonymous upgrade: Use safe link pattern with scratch auth + val appExplicitlyForValidation = FirebaseApp.initializeApp( + context, + auth.app.options, + "FUIAuthScratchApp_${System.currentTimeMillis()}" + ) + val authExplicitlyForValidation = FirebaseAuth + .getInstance(appExplicitlyForValidation) + + // Safe link: Validate that both credentials can be linked + val emailResult = authExplicitlyForValidation + .signInWithCredential(emailLinkCredential).await() + + val linkResult = emailResult.user + ?.linkWithCredential(storedCredentialForLink)?.await() + + // If safe link succeeds, emit merge conflict for UI to handle + if (linkResult?.user != null) { + updateAuthState( + AuthState.MergeConflict( + storedCredentialForLink + ) + ) + } + + // Return the link result (will be non-null if successful) + linkResult + } else { + // Non-upgrade: Sign in with email link, then link social credential + val emailLinkResult = auth.signInWithCredential(emailLinkCredential).await() + + // Link the social credential + val linkResult = emailLinkResult.user + ?.linkWithCredential(storedCredentialForLink)?.await() + + // Merge profile from the linked social credential + linkResult?.user?.let { user -> + mergeProfile(auth, user.displayName, user.photoUrl) + } + + // Update to success state + if (linkResult?.user != null) { + updateAuthState( + AuthState.Success( + result = linkResult, + user = linkResult.user!!, + isNewUser = linkResult.additionalUserInfo?.isNewUser ?: false + ) + ) + } + + linkResult + } + } + + // Clear DataStore after success + EmailLinkPersistenceManager.clear(context) + + return result + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with email link was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Sends a password reset email to the specified email address. + * + * This method initiates the "forgot password" flow by sending an email to the user + * with a link to reset their password. The user will receive an email from Firebase + * containing a link that allows them to set a new password for their account. + * + * **Flow:** + * 1. Validate the email address exists in Firebase Auth + * 2. Send password reset email to the user + * 3. Emit [AuthState.PasswordResetLinkSent] state + * 4. User clicks link in email to reset password + * 5. User is redirected to Firebase-hosted password reset page (or custom URL if configured) + * + * **Error Handling:** + * - If the email doesn't exist: throws [AuthException.UserNotFoundException] + * - If the email is invalid: throws [AuthException.InvalidCredentialsException] + * - If network error occurs: throws [AuthException.NetworkException] + * + * @param email The email address to send the password reset email to + * @param actionCodeSettings Optional [ActionCodeSettings] to configure the password reset link. + * Use this to customize the continue URL, dynamic link domain, and other settings. + * + * @throws AuthException.UserNotFoundException if no account exists with this email + * @throws AuthException.InvalidCredentialsException if the email format is invalid + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.UnknownException for other errors + * + * **Example 1: Basic password reset** + * ```kotlin + * try { + * firebaseAuthUI.sendPasswordResetEmail( + * email = "user@example.com" + * ) + * // Show success message: "Password reset email sent to $email" + * } catch (e: AuthException.UserNotFoundException) { + * // Show error: "No account exists with this email" + * } catch (e: AuthException.InvalidCredentialsException) { + * // Show error: "Invalid email address" + * } + * ``` + * + * **Example 2: Custom password reset with ActionCodeSettings** + * ```kotlin + * val actionCodeSettings = ActionCodeSettings.newBuilder() + * .setUrl("https://myapp.com/resetPassword") // Continue URL after reset + * .setHandleCodeInApp(false) // Use Firebase-hosted reset page + * .setAndroidPackageName( + * "com.myapp", + * true, // Install if not available + * null // Minimum version + * ) + * .build() + * + * firebaseAuthUI.sendPasswordResetEmail( + * email = "user@example.com", + * actionCodeSettings = actionCodeSettings + * ) + * // User receives email with custom continue URL + * ``` + * + * @see com.google.firebase.auth.ActionCodeSettings + */ +internal suspend fun FirebaseAuthUI.sendPasswordResetEmail( + email: String, + actionCodeSettings: ActionCodeSettings? = null +) { + try { + updateAuthState(AuthState.Loading("Sending password reset email...")) + auth.sendPasswordResetEmail(email, actionCodeSettings).await() + updateAuthState(AuthState.PasswordResetLinkSent()) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Send password reset email was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProviderSample.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProviderSample.kt index af0c830cc..453f28cda 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProviderSample.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProviderSample.kt @@ -15,9 +15,9 @@ package com.firebase.ui.auth.compose.configuration.string_provider import android.content.Context -import com.firebase.ui.auth.compose.configuration.AuthProvider import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider class AuthUIStringProviderSample { /** diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt index 5eba036af..5c5e867e5 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -196,7 +196,11 @@ class DefaultAuthUIStringProvider( override val userNotFoundRecoveryMessage: String get() = localizedContext.getString(R.string.fui_error_email_does_not_exist) override val weakPasswordRecoveryMessage: String - get() = localizedContext.resources.getQuantityString(R.plurals.fui_error_weak_password, 6, 6) + get() = localizedContext.resources.getQuantityString( + R.plurals.fui_error_weak_password, + 6, + 6 + ) override val emailAlreadyInUseRecoveryMessage: String get() = localizedContext.getString(R.string.fui_email_account_creation_error) override val tooManyRequestsRecoveryMessage: String diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt index 7f053fbd3..ec5bbdd53 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt @@ -16,7 +16,7 @@ package com.firebase.ui.auth.compose.configuration.theme import androidx.compose.ui.graphics.Color import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.configuration.Provider +import com.firebase.ui.auth.compose.configuration.auth_provider.Provider /** * Default provider styling configurations for authentication providers. diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt index 2d9efafc1..65f163fea 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt @@ -14,8 +14,8 @@ package com.firebase.ui.auth.compose.configuration.validators -import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider internal class PasswordValidator( override val stringProvider: AuthUIStringProvider, diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt index 255d6c59e..8bed40873 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt @@ -1,32 +1,30 @@ package com.firebase.ui.auth.compose.ui.components import androidx.compose.foundation.Image -import androidx.compose.material3.Icon import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.compose.configuration.AuthProvider -import com.firebase.ui.auth.compose.configuration.Provider +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.Provider import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt index 732a48662..6698adc67 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt @@ -135,6 +135,7 @@ private fun getRecoveryMessage( "$baseMessage\n\nReason: $reason" } ?: baseMessage } + is AuthException.EmailAlreadyInUseException -> { // Include email if available val baseMessage = stringProvider.emailAlreadyInUseRecoveryMessage @@ -142,6 +143,7 @@ private fun getRecoveryMessage( "$baseMessage ($email)" } ?: baseMessage } + is AuthException.TooManyRequestsException -> stringProvider.tooManyRequestsRecoveryMessage is AuthException.MfaRequiredException -> stringProvider.mfaRequiredRecoveryMessage is AuthException.AccountLinkingRequiredException -> stringProvider.accountLinkingRequiredRecoveryMessage @@ -173,6 +175,7 @@ private fun getRecoveryActionText( is AuthException.WeakPasswordException, is AuthException.TooManyRequestsException, is AuthException.UnknownException -> stringProvider.retryAction + else -> stringProvider.retryAction } } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt index 855e3d6b3..58466f37e 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.configuration.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset import com.firebase.ui.auth.compose.ui.components.AuthProviderButton diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/util/EmailLinkPersistenceManager.kt b/auth/src/main/java/com/firebase/ui/auth/compose/util/EmailLinkPersistenceManager.kt new file mode 100644 index 000000000..3764151c0 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/util/EmailLinkPersistenceManager.kt @@ -0,0 +1,158 @@ +/* + * 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.util + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.FacebookAuthProvider +import com.google.firebase.auth.GoogleAuthProvider +import kotlinx.coroutines.flow.first + +private val Context.dataStore: DataStore by preferencesDataStore(name = "com.firebase.ui.auth.util.data.EmailLinkPersistenceManager") + +/** + * Manages saving/retrieving from DataStore for email link sign in. + * + * This class provides persistence for email link authentication sessions, including: + * - Email address + * - Session ID for same-device validation + * - Anonymous user ID for upgrade flows + * - Social provider credentials for linking flows + * + * @since 10.0.0 + */ +object EmailLinkPersistenceManager { + + /** + * Saves email and session information to DataStore for email link sign-in. + * + * @param context Android context for DataStore access + * @param email Email address to save + * @param sessionId Unique session identifier for same-device validation + * @param anonymousUserId Optional anonymous user ID for upgrade flows + */ + suspend fun saveEmail( + context: Context, + email: String, + sessionId: String, + anonymousUserId: String? + ) { + context.dataStore.edit { prefs -> + prefs[AuthProvider.Email.KEY_EMAIL] = email + prefs[AuthProvider.Email.KEY_SESSION_ID] = sessionId + prefs[AuthProvider.Email.KEY_ANONYMOUS_USER_ID] = anonymousUserId ?: "" + } + } + + /** + * Saves social provider credential information to DataStore for linking after email link sign-in. + * + * This is called when a user attempts to sign in with a social provider (Google/Facebook) + * but an email link account with the same email already exists. The credential is saved + * and will be linked after the user completes email link authentication. + * + * @param context Android context for DataStore access + * @param providerType Provider ID ("google.com", "facebook.com", etc.) + * @param idToken ID token from the provider + * @param accessToken Access token from the provider (optional, used by Facebook) + */ + suspend fun saveCredentialForLinking( + context: Context, + providerType: String, + idToken: String?, + accessToken: String? + ) { + context.dataStore.edit { prefs -> + prefs[AuthProvider.Email.KEY_PROVIDER] = providerType + prefs[AuthProvider.Email.KEY_IDP_TOKEN] = idToken ?: "" + prefs[AuthProvider.Email.KEY_IDP_SECRET] = accessToken ?: "" + } + } + + /** + * Retrieves session information from DataStore. + * + * @param context Android context for DataStore access + * @return SessionRecord containing saved session data, or null if no session exists + */ + suspend fun retrieveSessionRecord(context: Context): SessionRecord? { + val prefs = context.dataStore.data.first() + val email = prefs[AuthProvider.Email.KEY_EMAIL] + val sessionId = prefs[AuthProvider.Email.KEY_SESSION_ID] + + if (email == null || sessionId == null) { + return null + } + + val anonymousUserId = prefs[AuthProvider.Email.KEY_ANONYMOUS_USER_ID] + val providerType = prefs[AuthProvider.Email.KEY_PROVIDER] + val idToken = prefs[AuthProvider.Email.KEY_IDP_TOKEN] + val accessToken = prefs[AuthProvider.Email.KEY_IDP_SECRET] + + // Rebuild credential if we have provider data + val credentialForLinking = if (providerType != null && idToken != null) { + when (providerType) { + "google.com" -> GoogleAuthProvider.getCredential(idToken, accessToken) + "facebook.com" -> FacebookAuthProvider.getCredential(accessToken ?: "") + else -> null + } + } else { + null + } + + return SessionRecord( + sessionId = sessionId, + email = email, + anonymousUserId = anonymousUserId, + credentialForLinking = credentialForLinking + ) + } + + /** + * Clears all saved data from DataStore. + * + * @param context Android context for DataStore access + */ + suspend fun clear(context: Context) { + context.dataStore.edit { prefs -> + prefs.remove(AuthProvider.Email.KEY_SESSION_ID) + prefs.remove(AuthProvider.Email.KEY_EMAIL) + prefs.remove(AuthProvider.Email.KEY_ANONYMOUS_USER_ID) + prefs.remove(AuthProvider.Email.KEY_PROVIDER) + prefs.remove(AuthProvider.Email.KEY_IDP_TOKEN) + prefs.remove(AuthProvider.Email.KEY_IDP_SECRET) + } + } + + /** + * Holds the necessary information to complete the email link sign in flow. + * + * @property sessionId Unique session identifier for same-device validation + * @property email Email address for sign-in + * @property anonymousUserId Optional anonymous user ID for upgrade flows + * @property credentialForLinking Optional social provider credential to link after sign-in + */ + data class SessionRecord( + val sessionId: String, + val email: String, + val anonymousUserId: String?, + val credentialForLinking: AuthCredential? + ) +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt index 333f00c7a..98e72d6fd 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt @@ -24,11 +24,11 @@ import com.google.firebase.auth.FirebaseAuth.AuthStateListener import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactorResolver import com.google.firebase.auth.UserInfo +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.delay import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before @@ -36,9 +36,9 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentCaptor import org.mockito.Mock -import org.mockito.Mockito.`when` import org.mockito.Mockito.mock import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config 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 index 5fd0d201c..6b61afaae 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt @@ -14,7 +14,9 @@ package com.firebase.ui.auth.compose +import android.content.Context import androidx.test.core.app.ApplicationProvider +import com.google.android.gms.tasks.TaskCompletionSource import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseException @@ -22,8 +24,6 @@ import com.google.firebase.FirebaseOptions import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException import com.google.firebase.auth.FirebaseUser -import com.google.android.gms.tasks.Task -import com.google.android.gms.tasks.TaskCompletionSource import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.runTest import org.junit.After @@ -31,11 +31,11 @@ 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.Mockito.verify import org.mockito.Mockito.doNothing import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -64,7 +64,7 @@ class FirebaseAuthUITest { FirebaseAuthUI.clearInstanceCache() // Clear any existing Firebase apps - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() FirebaseApp.getApps(context).forEach { app -> app.delete() } @@ -346,7 +346,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform sign out instance.signOut(context) @@ -364,7 +364,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform sign out and expect exception try { @@ -385,7 +385,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform sign out and expect cancellation exception try { @@ -414,7 +414,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform delete instance.delete(context) @@ -431,7 +431,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform delete and expect exception try { @@ -459,7 +459,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform delete and expect mapped exception try { @@ -485,7 +485,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform delete and expect cancellation exception try { @@ -511,7 +511,7 @@ class FirebaseAuthUITest { // Create instance with mock auth val instance = FirebaseAuthUI.create(defaultApp, mockAuth) - val context = ApplicationProvider.getApplicationContext() + val context = ApplicationProvider.getApplicationContext() // Perform delete and expect mapped exception try { diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt index f08be227f..31045ba13 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt similarity index 95% rename from auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt rename to auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt index c473867c4..3e6ab28ca 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt @@ -1,18 +1,4 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.firebase.ui.auth.compose.configuration +package com.firebase.ui.auth.compose.configuration.auth_provider import android.content.Context import androidx.test.core.app.ApplicationProvider diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt new file mode 100644 index 000000000..79bbbcfef --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt @@ -0,0 +1,726 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +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.google.android.gms.tasks.TaskCompletionSource +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.ActionCodeSettings +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import com.google.firebase.auth.FirebaseAuthInvalidUserException +import com.google.firebase.auth.FirebaseAuthUserCollisionException +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.GoogleAuthProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mock +import org.mockito.Mockito.anyString +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Comprehensive unit tests for Email Authentication provider methods in FirebaseAuthUI. + * + * Tests cover all email auth methods: + * - createOrLinkUserWithEmailAndPassword + * - signInWithEmailAndPassword + * - signInAndLinkWithCredential + * - sendSignInLinkToEmail + * - signInWithEmailLink + * - sendPasswordResetEmail + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class EmailAuthProviderFirebaseAuthUITest { + + @Mock + private lateinit var mockFirebaseAuth: FirebaseAuth + + private lateinit var firebaseApp: FirebaseApp + private lateinit var applicationContext: Context + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + FirebaseAuthUI.clearInstanceCache() + + applicationContext = ApplicationProvider.getApplicationContext() + + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + firebaseApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + try { + firebaseApp.delete() + } catch (_: Exception) { + // Ignore if already deleted + } + } + + // ============================================================================================= + // createOrLinkUserWithEmailAndPassword Tests + // ============================================================================================= + + @Test + fun `Create user with email and password without anonymous upgrade should succeed`() = runTest { + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`(mockFirebaseAuth.createUserWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + + verify(mockFirebaseAuth) + .createUserWithEmailAndPassword("test@example.com", "Pass@123") + } + + @Test + fun `Link user with email and password with anonymous upgrade should succeed`() = runTest { + mockStatic(EmailAuthProvider::class.java).use { mockedProvider -> + val mockCredential = mock(AuthCredential::class.java) + mockedProvider.`when` { + EmailAuthProvider.getCredential("test@example.com", "Pass@123") + }.thenReturn(mockCredential) + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`( + mockFirebaseAuth.currentUser?.linkWithCredential( + ArgumentMatchers.any(AuthCredential::class.java) + ) + ).thenReturn(taskCompletionSource.task) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + + mockedProvider.verify { + EmailAuthProvider.getCredential("test@example.com", "Pass@123") + } + verify(mockAnonymousUser).linkWithCredential(mockCredential) + } + } + + @Test + fun `createOrLinkUserWithEmailAndPassword - rejects weak password`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "weak" + ) + } catch (e: Exception) { + assertThat(e.message).contains( + applicationContext + .getString(R.string.fui_error_password_too_short) + .format(emailProvider.minimumPasswordLength) + ) + } + + verify(mockFirebaseAuth, never()) + .createUserWithEmailAndPassword(anyString(), anyString()) + } + + @Test + fun `createOrLinkUserWithEmailAndPassword - validates custom password rules`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = listOf(PasswordRule.RequireUppercase) + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "pass@123" + ) + } catch (e: Exception) { + assertThat(e.message).isEqualTo(applicationContext.getString(R.string.fui_error_password_missing_uppercase)) + } + } + + @Test + fun `createOrLinkUserWithEmailAndPassword - respects isNewAccountsAllowed setting`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList(), + isNewAccountsAllowed = false + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + } catch (e: Exception) { + assertThat(e.message) + .isEqualTo(applicationContext.getString(R.string.fui_error_email_does_not_exist)) + } + } + + @Test + fun `createOrLinkUserWithEmailAndPassword - handles collision exception`() = runTest { + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockAnonymousUser.email).thenReturn("test@example.com") + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + + val collisionException = FirebaseAuthUserCollisionException( + "ERROR_EMAIL_ALREADY_IN_USE", + "Email already in use" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(collisionException) + `when`(mockAnonymousUser.linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java))) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + } catch (e: AuthException) { + assertThat(e.cause).isEqualTo(collisionException) + val currentState = instance.authStateFlow().first { it is AuthState.MergeConflict } + assertThat(currentState).isInstanceOf(AuthState.MergeConflict::class.java) + val mergeConflict = currentState as AuthState.MergeConflict + assertThat(mergeConflict.pendingCredential).isNotNull() + } + } + + // ============================================================================================= + // signInWithEmailAndPassword Tests + // ============================================================================================= + + @Test + fun `signInWithEmailAndPassword - successful sign in`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + val result = instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123" + ) + + assertThat(result).isNotNull() + assertThat(result?.user).isEqualTo(mockUser) + verify(mockFirebaseAuth).signInWithEmailAndPassword("test@example.com", "Pass@123") + } + + @Test + fun `signInWithEmailAndPassword - handles invalid credentials`() = runTest { + val invalidCredentialsException = FirebaseAuthInvalidCredentialsException( + "ERROR_WRONG_PASSWORD", + "Wrong password" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(invalidCredentialsException) + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + try { + instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123" + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.InvalidCredentialsException) { + assertThat(e.cause).isEqualTo(invalidCredentialsException) + } + } + + @Test + fun `signInWithEmailAndPassword - handles user not found`() = runTest { + val userNotFoundException = FirebaseAuthInvalidUserException( + "ERROR_USER_NOT_FOUND", + "User not found" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(userNotFoundException) + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + try { + instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123" + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.UserNotFoundException) { + assertThat(e.cause).isEqualTo(userNotFoundException) + } + } + + @Test + fun `signInWithEmailAndPassword - links credential after sign in`() = runTest { + val googleCredential = GoogleAuthProvider.getCredential("google-id-token", null) + val mockUser = mock(FirebaseUser::class.java) + val signInAuthResult = mock(AuthResult::class.java) + `when`(signInAuthResult.user).thenReturn(mockUser) + val signInTask = TaskCompletionSource() + signInTask.setResult(signInAuthResult) + + val linkAuthResult = mock(AuthResult::class.java) + `when`(linkAuthResult.user).thenReturn(mockUser) + val linkTask = TaskCompletionSource() + linkTask.setResult(linkAuthResult) + + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(signInTask.task) + `when`(mockUser.linkWithCredential(googleCredential)) + .thenReturn(linkTask.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123", + credentialForLinking = googleCredential + ) + + verify(mockUser).linkWithCredential(googleCredential) + } + + // ============================================================================================= + // signInAndLinkWithCredential Tests + // ============================================================================================= + + @Test + fun `signInAndLinkWithCredential - successful sign in with credential`() = runTest { + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithCredential(credential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + val result = instance.signInAndLinkWithCredential( + config = config, + credential = credential + ) + + assertThat(result).isNotNull() + assertThat(result?.user).isEqualTo(mockUser) + verify(mockFirebaseAuth).signInWithCredential(credential) + } + + @Test + fun `signInAndLinkWithCredential - handles anonymous upgrade`() = runTest { + val anonymousUser = mock(FirebaseUser::class.java) + `when`(anonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(anonymousUser) + + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(anonymousUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(anonymousUser.linkWithCredential(credential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + val result = instance.signInAndLinkWithCredential( + config = config, + credential = credential + ) + + assertThat(result).isNotNull() + verify(anonymousUser).linkWithCredential(credential) + verify(mockFirebaseAuth, never()).signInWithCredential(credential) + } + + @Test + fun `signInAndLinkWithCredential - handles collision and emits MergeConflict`() = runTest { + val anonymousUser = mock(FirebaseUser::class.java) + `when`(anonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(anonymousUser) + + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + val updatedCredential = EmailAuthProvider.getCredential("test@example.com", "Pass@123") + + val collisionException = FirebaseAuthUserCollisionException( + "ERROR_CREDENTIAL_ALREADY_IN_USE", + "Credential already in use" + ) + // Set updatedCredential using reflection + val field = FirebaseAuthUserCollisionException::class.java.getDeclaredField("zza") + field.isAccessible = true + field.set(collisionException, updatedCredential) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(collisionException) + `when`(anonymousUser.linkWithCredential(credential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + try { + instance.signInAndLinkWithCredential( + config = config, + credential = credential + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException) { + // Expected + } + + val currentState = instance.authStateFlow().first { it is AuthState.MergeConflict } + assertThat(currentState).isInstanceOf(AuthState.MergeConflict::class.java) + val mergeConflict = currentState as AuthState.MergeConflict + assertThat(mergeConflict.pendingCredential).isEqualTo(updatedCredential) + } + + // ============================================================================================= + // sendPasswordResetEmail Tests + // ============================================================================================= + + @Test + fun `sendPasswordResetEmail - successfully sends reset email`() = runTest { + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`(mockFirebaseAuth.sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + )).thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + instance.sendPasswordResetEmail("test@example.com") + + verify(mockFirebaseAuth).sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + ) + + val finalState = instance.authStateFlow().first { it is AuthState.PasswordResetLinkSent } + assertThat(finalState).isInstanceOf(AuthState.PasswordResetLinkSent::class.java) + } + + @Test + fun `sendPasswordResetEmail - sends with ActionCodeSettings`() = runTest { + val actionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://myapp.com/resetPassword") + .setHandleCodeInApp(false) + .build() + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`(mockFirebaseAuth.sendPasswordResetEmail("test@example.com", actionCodeSettings)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + instance.sendPasswordResetEmail("test@example.com", actionCodeSettings) + + verify(mockFirebaseAuth).sendPasswordResetEmail("test@example.com", actionCodeSettings) + + val finalState = instance.authStateFlow().first { it is AuthState.PasswordResetLinkSent } + assertThat(finalState).isInstanceOf(AuthState.PasswordResetLinkSent::class.java) + } + + @Test + fun `sendPasswordResetEmail - handles user not found`() = runTest { + val userNotFoundException = FirebaseAuthInvalidUserException( + "ERROR_USER_NOT_FOUND", + "User not found" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(userNotFoundException) + `when`(mockFirebaseAuth.sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + )).thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + try { + instance.sendPasswordResetEmail("test@example.com") + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.UserNotFoundException) { + assertThat(e.cause).isEqualTo(userNotFoundException) + } + } + + @Test + fun `sendPasswordResetEmail - handles invalid email`() = runTest { + val invalidEmailException = FirebaseAuthInvalidCredentialsException( + "ERROR_INVALID_EMAIL", + "Invalid email" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(invalidEmailException) + `when`(mockFirebaseAuth.sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + )).thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + try { + instance.sendPasswordResetEmail("test@example.com") + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.InvalidCredentialsException) { + assertThat(e.cause).isEqualTo(invalidEmailException) + } + } + + @Test + fun `sendPasswordResetEmail - handles cancellation`() = runTest { + val cancellationException = CancellationException("Operation cancelled") + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(cancellationException) + `when`(mockFirebaseAuth.sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + )).thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + try { + instance.sendPasswordResetEmail("test@example.com") + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.AuthCancelledException) { + assertThat(e.message).contains("cancelled") + assertThat(e.cause).isInstanceOf(CancellationException::class.java) + } + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt index 27d34b6a6..af36c1e18 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt @@ -17,9 +17,9 @@ package com.firebase.ui.auth.compose.configuration.validators import android.content.Context import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.PasswordRule import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider -import com.firebase.ui.auth.compose.configuration.PasswordRule import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt index faae2cf48..3baa351e0 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt @@ -27,8 +27,9 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.core.app.ApplicationProvider -import com.firebase.ui.auth.compose.configuration.AuthProvider -import com.firebase.ui.auth.compose.configuration.Provider +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.Provider import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset @@ -40,7 +41,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import com.firebase.ui.auth.R /** * Unit tests for [AuthProviderButton] covering UI interactions, styling, diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt index 17d736ca7..2b500a924 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R -import com.firebase.ui.auth.compose.configuration.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset import com.google.common.truth.Truth import org.junit.Before diff --git a/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java b/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java index 2efed02da..2311a45b8 100644 --- a/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java +++ b/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java @@ -45,6 +45,7 @@ import static com.firebase.ui.auth.AuthUI.EMAIL_LINK_PROVIDER; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; @@ -75,11 +76,19 @@ public static void initialize() { } private static void spyContextAndResources() { - CONTEXT = spy(CONTEXT); - when(CONTEXT.getApplicationContext()) - .thenReturn(CONTEXT); - Resources spiedResources = spy(CONTEXT.getResources()); - when(CONTEXT.getResources()).thenReturn(spiedResources); + // In Mockito 5.x with inline mock maker, we can spy on final classes + // Use doReturn().when() pattern to avoid calling real methods during stubbing + if (!org.mockito.Mockito.mockingDetails(CONTEXT).isSpy()) { + CONTEXT = spy(CONTEXT); + } + doReturn(CONTEXT).when(CONTEXT).getApplicationContext(); + + // Get and spy on Resources, ensuring the spy is properly returned + Resources originalResources = CONTEXT.getResources(); + if (!org.mockito.Mockito.mockingDetails(originalResources).isSpy()) { + Resources spiedResources = spy(originalResources); + doReturn(spiedResources).when(CONTEXT).getResources(); + } } private static void initializeApp(Context context) { @@ -94,9 +103,9 @@ private static void initializeApp(Context context) { } private static void initializeProviders() { - when(CONTEXT.getString(R.string.firebase_web_host)).thenReturn("abc"); - when(CONTEXT.getString(R.string.default_web_client_id)).thenReturn("abc"); - when(CONTEXT.getString(R.string.facebook_application_id)).thenReturn("abc"); + doReturn("abc").when(CONTEXT).getString(R.string.firebase_web_host); + doReturn("abc").when(CONTEXT).getString(R.string.default_web_client_id); + doReturn("abc").when(CONTEXT).getString(R.string.facebook_application_id); } public static FirebaseUser getMockFirebaseUser() { diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index a21ca69bc..481ee19cd 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -42,6 +42,8 @@ object Config { const val recyclerView = "androidx.recyclerview:recyclerview:1.2.1" const val materialDesign = "com.google.android.material:material:1.4.0" + const val datastorePreferences = "androidx.datastore:datastore-preferences:1.1.1" + const val credentials = "androidx.credentials:credentials:1.3.0" object Compose { const val bom = "androidx.compose:compose-bom:2025.08.00" const val ui = "androidx.compose.ui:ui" @@ -88,6 +90,9 @@ object Config { const val junitExt = "androidx.test.ext:junit:1.1.5" const val truth = "com.google.truth:truth:0.42" const val mockito = "org.mockito:mockito-android:2.21.0" + const val mockitoCore = "org.mockito:mockito-core:5.19.0" + const val mockitoInline = "org.mockito:mockito-inline:5.2.0" + const val mockitoKotlin = "org.mockito.kotlin:mockito-kotlin:6.0.0" const val robolectric = "org.robolectric:robolectric:4.14" const val core = "androidx.test:core:1.5.0" From ddf6719ec4f301606c329a13782a5d37ac681907 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 10 Oct 2025 10:22:14 +0100 Subject: [PATCH 18/41] feat: EmailAuthScreen (Stateful + Slot) (#2236) * feat: AuthMethodPicker, logo and provider theme style * chore: organize folder structure * feat: TOS and PP footer, ui tests for AuthMethodPicker * chore: tests folder structure * chore: use version catalog for compose deps * feat: AuthTextField with validation * test: AuthTextField and field validations * chore: update doc comments * wip: Email Provider integration * chore: upgrade mockito, fix: spying mocked objects in new library * wip: Email provider integration * wip: Email provider integration * wip: Email provider integration * feat: Email provider integration * wip: SignIn, SignUp, ResetPassword flows * refactor: remove libs.versions.toml catalog file * add sample app compose module * wip: SignInUI and EmailAuthScreen sample * feat: Email provider integration - added: sign in, sign up, reset password, email link and anonymous auto upgrade - upgrade mockito - fixed spying mocked objects in new library test error * wip: SignUp UI * feat: add PasswordResetLinkSent state * fix: use isSecureTextField for password fields * wip: SignUp * fix: passwordResetActionCodeSettings for send password reset link * fix: combine Firebase and internal auth state flows to prioritize non-idle internal updates * wip: SignUp * chore: remove unused methods * chore: remove unused comments and code * chore: remove unused imports, reformat * chore: remove comments * chore: remove comments * handle authState exceptions * fix: mockito 5 upgrade stubbing issues * wip: Email link, deep link * chore: add copyright message * refactor: rename to emailLinkActionCodeSettings in AuthProvider.Email and passwordResetActionCodeSettings in AuthUIConfiguration * feat: add dark theme * feat: Email sign in link * fix: test doesn't capture initial Idle state * fix: CI run issues * fix: CI run issues * fix: opt out of edge to edge in app module * fix: remove opt out of edge to edge in app module --- auth/src/main/AndroidManifest.xml | 12 +- .../com/firebase/ui/auth/compose/AuthState.kt | 9 + .../ui/auth/compose/FirebaseAuthUI.kt | 84 ++--- .../configuration/AuthUIConfiguration.kt | 20 +- .../auth_provider/AuthProvider.kt | 29 +- .../EmailAuthProvider+FirebaseAuthUI.kt | 90 +++--- .../configuration/theme/AuthUITheme.kt | 26 +- .../validators/GeneralFieldValidator.kt | 52 ++++ .../ui/components/AuthProviderButton.kt | 16 +- .../compose/ui/components/AuthTextField.kt | 25 +- .../ui/components/ErrorRecoveryDialog.kt | 4 +- .../ui/components/TermsAndPrivacyForm.kt | 73 +++++ .../method_picker/AnnotatedStringResource.kt | 14 + .../ui/method_picker/AuthMethodPicker.kt | 16 +- .../compose/ui/screens/EmailAuthScreen.kt | 288 ++++++++++++++++++ .../screens/EmailSignInLinkHandlerActivity.kt | 109 +++++++ .../compose/ui/screens/ResetPasswordUI.kt | 216 +++++++++++++ .../ui/auth/compose/ui/screens/SignInUI.kt | 278 +++++++++++++++++ .../ui/auth/compose/ui/screens/SignUpUI.kt | 273 +++++++++++++++++ .../compose/FirebaseAuthUIAuthStateTest.kt | 26 +- .../configuration/AuthUIConfigurationTest.kt | 13 +- .../auth_provider/AuthProviderTest.kt | 10 +- .../EmailAuthProviderFirebaseAuthUITest.kt | 26 +- .../ui/components/AuthProviderButtonTest.kt | 2 +- .../ui/components/AuthTextFieldTest.kt | 4 +- .../ui/method_picker/AuthMethodPickerTest.kt | 10 +- build.gradle | 10 +- composeapp/.gitignore | 1 + composeapp/build.gradle.kts | 68 +++++ composeapp/proguard-rules.pro | 21 ++ composeapp/src/main/AndroidManifest.xml | 43 +++ .../com/firebase/composeapp/MainActivity.kt | 103 +++++++ .../composeapp/ui/screens/MainScreen.kt | 151 +++++++++ .../com/firebase/composeapp/ui/theme/Color.kt | 11 + .../com/firebase/composeapp/ui/theme/Theme.kt | 58 ++++ .../com/firebase/composeapp/ui/theme/Type.kt | 34 +++ .../drawable-v24/ic_launcher_foreground.xml | 30 ++ .../res/drawable/ic_launcher_background.xml | 170 +++++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes composeapp/src/main/res/values/colors.xml | 10 + composeapp/src/main/res/values/strings.xml | 3 + composeapp/src/main/res/values/themes.xml | 5 + settings.gradle | 12 +- 54 files changed, 2304 insertions(+), 163 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/GeneralFieldValidator.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TermsAndPrivacyForm.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailSignInLinkHandlerActivity.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/ResetPasswordUI.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignInUI.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignUpUI.kt create mode 100644 composeapp/.gitignore create mode 100644 composeapp/build.gradle.kts create mode 100644 composeapp/proguard-rules.pro create mode 100644 composeapp/src/main/AndroidManifest.xml create mode 100644 composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt create mode 100644 composeapp/src/main/java/com/firebase/composeapp/ui/screens/MainScreen.kt create mode 100644 composeapp/src/main/java/com/firebase/composeapp/ui/theme/Color.kt create mode 100644 composeapp/src/main/java/com/firebase/composeapp/ui/theme/Theme.kt create mode 100644 composeapp/src/main/java/com/firebase/composeapp/ui/theme/Type.kt create mode 100644 composeapp/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 composeapp/src/main/res/drawable/ic_launcher_background.xml create mode 100644 composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 composeapp/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 composeapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 composeapp/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 composeapp/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 composeapp/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 composeapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 composeapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 composeapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 composeapp/src/main/res/values/colors.xml create mode 100644 composeapp/src/main/res/values/strings.xml create mode 100644 composeapp/src/main/res/values/themes.xml diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml index bb1a19204..a883affca 100644 --- a/auth/src/main/AndroidManifest.xml +++ b/auth/src/main/AndroidManifest.xml @@ -83,8 +83,8 @@ @@ -119,6 +119,16 @@ + + + + + + = callbackFlow { - // Set initial state based on current auth state - val initialState = auth.currentUser?.let { user -> - AuthState.Success(result = null, user = user, isNewUser = false) - } ?: AuthState.Idle + fun authStateFlow(): Flow { + // Create a flow from FirebaseAuth state listener + val firebaseAuthFlow = callbackFlow { + // Set initial state based on current auth state + val initialState = auth.currentUser?.let { user -> + AuthState.Success(result = null, user = user, isNewUser = false) + } ?: AuthState.Idle - trySend(initialState) + trySend(initialState) - // Create auth state listener - val authStateListener = AuthStateListener { firebaseAuth -> - val currentUser = firebaseAuth.currentUser - val state = if (currentUser != null) { - // Check if email verification is required - if (!currentUser.isEmailVerified && - currentUser.email != null && - currentUser.providerData.any { it.providerId == "password" } - ) { - AuthState.RequiresEmailVerification( - user = currentUser, - email = currentUser.email!! - ) + // Create auth state listener + val authStateListener = AuthStateListener { firebaseAuth -> + val currentUser = firebaseAuth.currentUser + val state = if (currentUser != null) { + // Check if email verification is required + if (!currentUser.isEmailVerified && + currentUser.email != null && + currentUser.providerData.any { it.providerId == "password" } + ) { + AuthState.RequiresEmailVerification( + user = currentUser, + email = currentUser.email!! + ) + } else { + AuthState.Success( + result = null, + user = currentUser, + isNewUser = false + ) + } } else { - AuthState.Success( - result = null, - user = currentUser, - isNewUser = false - ) + AuthState.Idle } - } else { - AuthState.Idle + trySend(state) } - trySend(state) - } - // Add listener - auth.addAuthStateListener(authStateListener) + // Add listener + auth.addAuthStateListener(authStateListener) - // Also observe internal state changes - _authStateFlow.value.let { currentState -> - if (currentState !is AuthState.Idle && currentState !is AuthState.Success) { - trySend(currentState) + // Remove listener when flow collection is cancelled + awaitClose { + auth.removeAuthStateListener(authStateListener) } } - // Remove listener when flow collection is cancelled - awaitClose { - auth.removeAuthStateListener(authStateListener) + // Also observe internal state changes + return combine( + firebaseAuthFlow, + _authStateFlow + ) { firebaseState, internalState -> + // Prefer non-idle internal states (like PasswordResetLinkSent, Error, etc.) + if (internalState !is AuthState.Idle) internalState else firebaseState } } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt index 1267aea84..5bbda6cb9 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt @@ -44,7 +44,7 @@ class AuthUIConfigurationBuilder { var tosUrl: String? = null var privacyPolicyUrl: String? = null var logo: ImageVector? = null - var actionCodeSettings: ActionCodeSettings? = null + var passwordResetActionCodeSettings: ActionCodeSettings? = null var isNewEmailAccountsAllowed: Boolean = true var isDisplayNameRequired: Boolean = true var isProviderChoiceAlwaysShown: Boolean = false @@ -85,17 +85,7 @@ class AuthUIConfigurationBuilder { // Provider specific validations providers.forEach { provider -> when (provider) { - is AuthProvider.Email -> { - provider.validate() - - if (isAnonymousUpgradeEnabled && provider.isEmailLinkSignInEnabled) { - check(provider.isEmailLinkForceSameDeviceEnabled) { - "You must force the same device flow when using email link sign in " + - "with anonymous user upgrade" - } - } - } - + is AuthProvider.Email -> provider.validate(isAnonymousUpgradeEnabled) is AuthProvider.Phone -> provider.validate() is AuthProvider.Google -> provider.validate(context) is AuthProvider.Facebook -> provider.validate(context) @@ -116,7 +106,7 @@ class AuthUIConfigurationBuilder { tosUrl = tosUrl, privacyPolicyUrl = privacyPolicyUrl, logo = logo, - actionCodeSettings = actionCodeSettings, + passwordResetActionCodeSettings = passwordResetActionCodeSettings, isNewEmailAccountsAllowed = isNewEmailAccountsAllowed, isDisplayNameRequired = isDisplayNameRequired, isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown @@ -184,9 +174,9 @@ class AuthUIConfiguration( val logo: ImageVector? = null, /** - * Configuration for email link sign-in. + * Configuration for sending email reset link. */ - val actionCodeSettings: ActionCodeSettings? = null, + val passwordResetActionCodeSettings: ActionCodeSettings? = null, /** * Allows new email accounts to be created. Defaults to true. diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt index 1c9b88048..bb5f2b9b3 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt @@ -174,7 +174,7 @@ abstract class AuthProvider(open val providerId: String) { /** * Settings for email link actions. */ - val actionCodeSettings: ActionCodeSettings?, + val emailLinkActionCodeSettings: ActionCodeSettings?, /** * Allows new accounts to be created. Defaults to true. @@ -202,9 +202,11 @@ abstract class AuthProvider(open val providerId: String) { val KEY_IDP_SECRET = stringPreferencesKey("com.firebase.ui.auth.data.client.idpSecret") } - internal fun validate() { + internal fun validate( + isAnonymousUpgradeEnabled: Boolean = false + ) { if (isEmailLinkSignInEnabled) { - val actionCodeSettings = requireNotNull(actionCodeSettings) { + val actionCodeSettings = requireNotNull(emailLinkActionCodeSettings) { "ActionCodeSettings cannot be null when using " + "email link sign in." } @@ -213,6 +215,13 @@ abstract class AuthProvider(open val providerId: String) { "You must set canHandleCodeInApp in your " + "ActionCodeSettings to true for Email-Link Sign-in." } + + if (isAnonymousUpgradeEnabled) { + check(isEmailLinkForceSameDeviceEnabled) { + "You must force the same device flow when using email link sign in " + + "with anonymous user upgrade" + } + } } } @@ -221,11 +230,11 @@ abstract class AuthProvider(open val providerId: String) { sessionId: String, anonymousUserId: String, ): ActionCodeSettings { - requireNotNull(actionCodeSettings) { + requireNotNull(emailLinkActionCodeSettings) { "ActionCodeSettings is required for email link sign in" } - val continueUrl = continueUrl(actionCodeSettings.url) { + val continueUrl = continueUrl(emailLinkActionCodeSettings.url) { appendSessionId(sessionId) appendAnonymousUserId(anonymousUserId) appendForceSameDeviceBit(isEmailLinkForceSameDeviceEnabled) @@ -234,12 +243,12 @@ abstract class AuthProvider(open val providerId: String) { return actionCodeSettings { url = continueUrl - handleCodeInApp = actionCodeSettings.canHandleCodeInApp() - iosBundleId = actionCodeSettings.iosBundle + handleCodeInApp = emailLinkActionCodeSettings.canHandleCodeInApp() + iosBundleId = emailLinkActionCodeSettings.iosBundle setAndroidPackageName( - actionCodeSettings.androidPackageName ?: "", - actionCodeSettings.androidInstallApp, - actionCodeSettings.androidMinimumVersion + emailLinkActionCodeSettings.androidPackageName ?: "", + emailLinkActionCodeSettings.androidInstallApp, + emailLinkActionCodeSettings.androidMinimumVersion ) } } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index d917e80d6..a65fcac8a 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -1,3 +1,17 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.firebase.ui.auth.compose.configuration.auth_provider import android.content.Context @@ -661,7 +675,7 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( // Save Email to dataStore for use in signInWithEmailLink EmailLinkPersistenceManager.saveEmail(context, email, sessionId, anonymousUserId) - updateAuthState(AuthState.Idle) + updateAuthState(AuthState.EmailSignInLinkSent()) } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Send sign in link to email was cancelled", @@ -713,7 +727,8 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( * * @see sendSignInLinkToEmail for sending the initial email link */ -internal suspend fun FirebaseAuthUI.signInWithEmailLink( +// TODO(demolaf: make this internal when done testing email link sign in with composeapp +suspend fun FirebaseAuthUI.signInWithEmailLink( context: Context, config: AuthUIConfiguration, provider: AuthProvider.Email, @@ -778,7 +793,10 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( // Validate anonymous user ID matches (same-device flow) if (!anonymousUserIdFromLink.isNullOrEmpty()) { val currentUser = auth.currentUser - if (currentUser == null || !currentUser.isAnonymous || currentUser.uid != anonymousUserIdFromLink) { + if (currentUser == null + || !currentUser.isAnonymous + || currentUser.uid != anonymousUserIdFromLink + ) { throw AuthException.EmailLinkDifferentAnonymousUserException() } } @@ -790,7 +808,6 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( val result = if (storedCredentialForLink == null) { // Normal Flow: Just sign in with email link signInAndLinkWithCredential(config, emailLinkCredential) - ?: throw AuthException.UnknownException("Sign in failed") } else { // Linking Flow: Sign in with email link, then link the social credential if (canUpgradeAnonymous(config, auth)) { @@ -804,54 +821,39 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( .getInstance(appExplicitlyForValidation) // Safe link: Validate that both credentials can be linked - val emailResult = authExplicitlyForValidation + val result = authExplicitlyForValidation .signInWithCredential(emailLinkCredential).await() - - val linkResult = emailResult.user - ?.linkWithCredential(storedCredentialForLink)?.await() - - // If safe link succeeds, emit merge conflict for UI to handle - if (linkResult?.user != null) { - updateAuthState( - AuthState.MergeConflict( - storedCredentialForLink + .user?.linkWithCredential(storedCredentialForLink)?.await() + .also { result -> + // If safe link succeeds, emit merge conflict for UI to handle + updateAuthState( + AuthState.MergeConflict( + storedCredentialForLink + ) ) - ) - } - - // Return the link result (will be non-null if successful) - linkResult + } + return result } else { // Non-upgrade: Sign in with email link, then link social credential - val emailLinkResult = auth.signInWithCredential(emailLinkCredential).await() - - // Link the social credential - val linkResult = emailLinkResult.user - ?.linkWithCredential(storedCredentialForLink)?.await() - - // Merge profile from the linked social credential - linkResult?.user?.let { user -> - mergeProfile(auth, user.displayName, user.photoUrl) - } - - // Update to success state - if (linkResult?.user != null) { - updateAuthState( - AuthState.Success( - result = linkResult, - user = linkResult.user!!, - isNewUser = linkResult.additionalUserInfo?.isNewUser ?: false - ) - ) - } - - linkResult + val result = auth.signInWithCredential(emailLinkCredential).await() + // Link the social credential + .user?.linkWithCredential(storedCredentialForLink)?.await() + .also { result -> + result?.user?.let { user -> + // Merge profile from the linked social credential + mergeProfile( + auth, + user.displayName, + user.photoUrl + ) + } + } + return result } } - // Clear DataStore after success EmailLinkPersistenceManager.clear(context) - + updateAuthState(AuthState.Idle) return result } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt index 4af62ffc8..122da349e 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt @@ -14,11 +14,15 @@ package com.firebase.ui.auth.compose.configuration.theme +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Shapes +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color @@ -106,9 +110,14 @@ class AuthUITheme( * pre-configured provider styles. */ val Default = AuthUITheme( - colorScheme = lightColorScheme( - primary = Color(0xFFFFA611) - ), + colorScheme = lightColorScheme(), + typography = Typography(), + shapes = Shapes(), + providerStyles = ProviderStyleDefaults.default + ) + + val DefaultDark = AuthUITheme( + colorScheme = darkColorScheme(), typography = Typography(), shapes = Shapes(), providerStyles = ProviderStyleDefaults.default @@ -129,12 +138,21 @@ class AuthUITheme( providerStyles = providerStyles ) } + + @OptIn(ExperimentalMaterial3Api::class) + @get:Composable + val topAppBarColors + get() = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary + ) } } @Composable fun AuthUITheme( - theme: AuthUITheme = AuthUITheme.Default, + theme: AuthUITheme = if (isSystemInDarkTheme()) + AuthUITheme.DefaultDark else AuthUITheme.Default, content: @Composable () -> Unit ) { MaterialTheme( diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/GeneralFieldValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/GeneralFieldValidator.kt new file mode 100644 index 000000000..74a08fe68 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/GeneralFieldValidator.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.validators + +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider + +internal class GeneralFieldValidator( + override val stringProvider: AuthUIStringProvider, + val isValid: ((String) -> Boolean)? = null, + val customMessage: String? = null, +) : FieldValidator { + private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + + override val hasError: Boolean + get() = _validationStatus.hasError + + override val errorMessage: String + get() = _validationStatus.errorMessage ?: "" + + override fun validate(value: String): Boolean { + if (value.isEmpty()) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.requiredField + ) + return false + } + + if (isValid != null && !isValid(value)) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = customMessage + ) + return false + } + + _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + return true + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt index 8bed40873..fb6b9098f 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt @@ -1,3 +1,17 @@ +/* + * 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.ui.components import androidx.compose.foundation.Image @@ -164,7 +178,7 @@ private fun PreviewAuthProviderButton() { ) { AuthProviderButton( provider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = emptyList() ), onClick = {}, diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt index 66f2b475e..200dd0ece 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt @@ -1,10 +1,26 @@ +/* + * 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.ui.components 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.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -75,6 +91,7 @@ fun AuthTextField( value: String, onValueChange: (String) -> Unit, label: @Composable (() -> Unit)? = null, + isSecureTextField: Boolean = false, enabled: Boolean = true, isError: Boolean? = null, errorMessage: String? = null, @@ -85,11 +102,11 @@ fun AuthTextField( leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, ) { - val isSecureTextField = validator is PasswordValidator var passwordVisible by remember { mutableStateOf(false) } TextField( - modifier = modifier, + modifier = modifier + .fillMaxWidth(), value = value, onValueChange = { newValue -> onValueChange(newValue) @@ -150,7 +167,8 @@ internal fun PreviewAuthTextField() { Column( modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .padding(horizontal = 16.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -184,6 +202,7 @@ internal fun PreviewAuthTextField() { AuthTextField( value = passwordTextValue.value, validator = passwordValidator, + isSecureTextField = true, label = { Text("Password") }, diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt index 6698adc67..42f7339a9 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt @@ -65,7 +65,7 @@ import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringPr fun ErrorRecoveryDialog( error: AuthException, stringProvider: AuthUIStringProvider, - onRetry: () -> Unit, + onRetry: (AuthException) -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, onRecover: ((AuthException) -> Unit)? = null, @@ -90,7 +90,7 @@ fun ErrorRecoveryDialog( if (isRecoverable(error)) { TextButton( onClick = { - onRecover?.invoke(error) ?: onRetry() + onRecover?.invoke(error) ?: onRetry(error) } ) { Text( diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TermsAndPrivacyForm.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TermsAndPrivacyForm.kt new file mode 100644 index 000000000..82694d3dc --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TermsAndPrivacyForm.kt @@ -0,0 +1,73 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp + +@Composable +fun TermsAndPrivacyForm( + modifier: Modifier = Modifier, + tosUrl: String?, + ppUrl: String? +) { + val uriHandler = LocalUriHandler.current + Row( + modifier = modifier, + ) { + TextButton( + onClick = { + tosUrl?.let { + uriHandler.openUri(it) + } + }, + contentPadding = PaddingValues.Zero, + ) { + Text( + text = "Terms of Service", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + Spacer(modifier = Modifier.width(24.dp)) + TextButton( + onClick = { + ppUrl?.let { + uriHandler.openUri(it) + } + }, + contentPadding = PaddingValues.Zero, + ) { + Text( + text = "Privacy Policy", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt index 4c98be9ac..0c16fb31d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt @@ -1,3 +1,17 @@ +/* + * 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.ui.method_picker import android.content.Context diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt index 58466f37e..6082e9f1b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt @@ -1,3 +1,17 @@ +/* + * 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.ui.method_picker import androidx.compose.foundation.Image @@ -131,7 +145,7 @@ fun PreviewAuthMethodPicker() { AuthMethodPicker( providers = listOf( AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = emptyList() ), AuthProvider.Phone( diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt new file mode 100644 index 000000000..4fbebd2bf --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt @@ -0,0 +1,288 @@ +/* + * 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.ui.screens + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.createOrLinkUserWithEmailAndPassword +import com.firebase.ui.auth.compose.configuration.auth_provider.sendPasswordResetEmail +import com.firebase.ui.auth.compose.configuration.auth_provider.sendSignInLinkToEmail +import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailAndPassword +import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailLink +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.ui.components.ErrorRecoveryDialog +import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager +import com.google.firebase.auth.AuthResult +import kotlinx.coroutines.launch + +enum class EmailAuthMode { + SignIn, + SignUp, + ResetPassword, +} + +/** + * A class passed to the content slot, containing all the necessary information to render custom + * UIs for sign-in, sign-up, and password reset flows. + * + * @param mode An enum representing the current UI mode. Use a when expression on this to render + * the correct screen. + * @param isLoading true when an asynchronous operation (like signing in or sending an email) + * is in progress. + * @param error An optional error message to display to the user. + * @param email The current value of the email input field. + * @param onEmailChange (Modes: [EmailAuthMode.SignIn], [EmailAuthMode.SignUp], + * [EmailAuthMode.ResetPassword]) A callback to be invoked when the email input changes. + * @param password An optional custom layout composable for the provider buttons. + * @param onPasswordChange (Modes: [EmailAuthMode.SignIn], [EmailAuthMode.SignUp]) The current + * value of the password input field. + * @param confirmPassword (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when the password + * input changes. + * @param onConfirmPasswordChange (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when + * the password confirmation input changes. + * @param displayName (Mode: [EmailAuthMode.SignUp]) The current value of the display name field. + * @param onDisplayNameChange (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when the + * display name input changes. + * @param onSignInClick (Mode: [EmailAuthMode.SignIn]) A callback to be invoked to attempt a + * sign-in with the provided credentials. + * @param onSignUpClick (Mode: [EmailAuthMode.SignUp]) A callback to be invoked to attempt to + * create a new account. + * @param onSendResetLinkClick (Mode: [EmailAuthMode.ResetPassword]) A callback to be invoked to + * send a password reset email. + * @param resetLinkSent (Mode: [EmailAuthMode.ResetPassword]) true after the password reset link + * has been successfully sent. + * @param emailSignInLinkSent (Mode: [EmailAuthMode.SignIn]) true after the email sign in link has + * been successfully sent. + * @param onGoToSignUp A callback to switch the UI to the SignUp mode. + * @param onGoToSignIn A callback to switch the UI to the SignIn mode. + * @param onGoToResetPassword A callback to switch the UI to the ResetPassword mode. + */ +class EmailAuthContentState( + val mode: EmailAuthMode, + val isLoading: Boolean = false, + val error: String? = null, + val email: String, + val onEmailChange: (String) -> Unit, + val password: String, + val onPasswordChange: (String) -> Unit, + val confirmPassword: String, + val onConfirmPasswordChange: (String) -> Unit, + val displayName: String, + val onDisplayNameChange: (String) -> Unit, + val onSignInClick: () -> Unit, + val onSignUpClick: () -> Unit, + val onSendResetLinkClick: () -> Unit, + val resetLinkSent: Boolean = false, + val emailSignInLinkSent: Boolean = false, + val onGoToSignUp: () -> Unit, + val onGoToSignIn: () -> Unit, + val onGoToResetPassword: () -> Unit, +) + +/** + * A stateful composable that manages the logic for all email-based authentication flows, + * including sign-in, sign-up, and password reset. It exposes the state for the current mode to + * a custom UI via a trailing lambda (slot), allowing for complete visual customization. + * + * @param configuration + * @param onSuccess + * @param onError + * @param onCancel + * @param content + */ +@Composable +fun EmailAuthScreen( + context: Context, + configuration: AuthUIConfiguration, + authUI: FirebaseAuthUI, + onSuccess: (AuthResult) -> Unit, + onError: (AuthException) -> Unit, + onCancel: () -> Unit, + content: @Composable ((EmailAuthContentState) -> Unit)? = null, +) { + val provider = configuration.providers.filterIsInstance().first() + val stringProvider = DefaultAuthUIStringProvider(context) + val coroutineScope = rememberCoroutineScope() + + val mode = rememberSaveable { mutableStateOf(EmailAuthMode.SignIn) } + val displayNameValue = rememberSaveable { mutableStateOf("") } + val emailTextValue = rememberSaveable { mutableStateOf("") } + val passwordTextValue = rememberSaveable { mutableStateOf("") } + val confirmPasswordTextValue = rememberSaveable { mutableStateOf("") } + + // Used for clearing text fields when switching EmailAuthMode changes + val textValues = listOf( + displayNameValue, + emailTextValue, + passwordTextValue, + confirmPasswordTextValue + ) + + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + val isLoading = authState is AuthState.Loading + val errorMessage = + if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null + val resetLinkSent = authState is AuthState.PasswordResetLinkSent + val emailSignInLinkSent = authState is AuthState.EmailSignInLinkSent + + val isErrorDialogVisible = + remember(authState) { mutableStateOf(authState is AuthState.Error) } + + LaunchedEffect(authState) { + Log.d("EmailAuthScreen", "Current state: $authState") + when (val state = authState) { + is AuthState.Success -> { + state.result?.let { result -> + onSuccess(result) + } + } + + is AuthState.Error -> { + onError(AuthException.from(state.exception)) + } + + is AuthState.Cancelled -> { + onCancel() + } + + else -> Unit + } + } + + val state = EmailAuthContentState( + mode = mode.value, + displayName = displayNameValue.value, + email = emailTextValue.value, + password = passwordTextValue.value, + confirmPassword = confirmPasswordTextValue.value, + isLoading = isLoading, + error = errorMessage, + resetLinkSent = resetLinkSent, + emailSignInLinkSent = emailSignInLinkSent, + onEmailChange = { email -> + emailTextValue.value = email + }, + onPasswordChange = { password -> + passwordTextValue.value = password + }, + onConfirmPasswordChange = { confirmPassword -> + confirmPasswordTextValue.value = confirmPassword + }, + onDisplayNameChange = { displayName -> + displayNameValue.value = displayName + }, + onSignInClick = { + coroutineScope.launch { + try { + if (provider.isEmailLinkSignInEnabled) { + authUI.sendSignInLinkToEmail( + context = context, + config = configuration, + provider = provider, + email = emailTextValue.value, + credentialForLinking = null, + ) + } else { + authUI.signInWithEmailAndPassword( + context = context, + config = configuration, + email = emailTextValue.value, + password = passwordTextValue.value, + credentialForLinking = null, + ) + } + } catch (e: Exception) { + + } + } + }, + onSignUpClick = { + coroutineScope.launch { + try { + authUI.createOrLinkUserWithEmailAndPassword( + context = context, + config = configuration, + provider = provider, + name = displayNameValue.value, + email = emailTextValue.value, + password = passwordTextValue.value, + ) + } catch (e: Exception) { + + } + } + }, + onSendResetLinkClick = { + coroutineScope.launch { + try { + authUI.sendPasswordResetEmail( + email = emailTextValue.value, + actionCodeSettings = configuration.passwordResetActionCodeSettings, + ) + } catch (e: Exception) { + + } + } + }, + onGoToSignUp = { + textValues.forEach { it.value = "" } + mode.value = EmailAuthMode.SignUp + }, + onGoToSignIn = { + textValues.forEach { it.value = "" } + mode.value = EmailAuthMode.SignIn + }, + onGoToResetPassword = { + textValues.forEach { it.value = "" } + mode.value = EmailAuthMode.ResetPassword + } + ) + + if (isErrorDialogVisible.value) { + ErrorRecoveryDialog( + error = when ((authState as AuthState.Error).exception) { + is AuthException -> (authState as AuthState.Error).exception as AuthException + else -> AuthException + .from((authState as AuthState.Error).exception) + }, + stringProvider = stringProvider, + onRetry = { exception -> + when (exception) { + is AuthException.InvalidCredentialsException -> state.onSignInClick() + is AuthException.EmailAlreadyInUseException -> state.onGoToSignIn() + } + isErrorDialogVisible.value = false + }, + onDismiss = { + isErrorDialogVisible.value = false + }, + ) + } + + content?.invoke(state) +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailSignInLinkHandlerActivity.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailSignInLinkHandlerActivity.kt new file mode 100644 index 000000000..e6e8ca7dd --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailSignInLinkHandlerActivity.kt @@ -0,0 +1,109 @@ +/* + * 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.ui.screens + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import com.firebase.ui.auth.compose.FirebaseAuthUI + +/** + * Activity that handles email link deep links for passwordless authentication. + * + * ## Setup (Required) + * + * Add this activity to your app's `AndroidManifest.xml`: + * ```xml + * + * + * + * + * + * + * + * + * ``` + * + * Configure matching ActionCodeSettings: + * ```kotlin + * val provider = AuthProvider.Email( + * emailLinkActionCodeSettings = actionCodeSettings { + * url = "https://yourapp.com" // Must match android:host above + * handleCodeInApp = true + * setAndroidPackageName("com.yourapp.package", true, null) + * }, + * isEmailLinkSignInEnabled = true + * ) + * ``` + * + * By default, users see a dialog "Open with Browser or App?" on first click. + * For auto-opening without dialog, set up App Links verification: + * https://developer.android.com/training/app-links/verify-android-applinks + * + * @see FirebaseAuthUI.sendSignInLinkToEmail + */ +class EmailSignInLinkHandlerActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Extract email link from deep link intent + val emailLink = intent.data?.toString() + + if (emailLink.isNullOrEmpty()) { + // No valid email link, just finish + finish() + return + } + + // Redirect to app's launch activity with the email link + // The app should check for this extra in onCreate and handle email link sign-in + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + + if (launchIntent != null) { + launchIntent.apply { + // Clear the back stack and start fresh + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + // Pass the email link to the launch activity + putExtra(EXTRA_EMAIL_LINK, emailLink) + } + startActivity(launchIntent) + } + + finish() + } + + companion object { + /** + * Intent extra key for the email link. + * + * Check for this extra in your MainActivity's onCreate to detect email link sign-in: + * ```kotlin + * val emailLink = intent.getStringExtra(EmailSignInLinkHandlerActivity.EXTRA_EMAIL_LINK) + * if (emailLink != null) { + * // Handle email link sign-in + * firebaseAuthUI.signInWithEmailLink(...) + * } + * ``` + */ + const val EXTRA_EMAIL_LINK = "com.firebase.ui.auth.EXTRA_EMAIL_LINK" + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/ResetPasswordUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/ResetPasswordUI.kt new file mode 100644 index 000000000..79177c085 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/ResetPasswordUI.kt @@ -0,0 +1,216 @@ +/* + * 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.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.ui.components.AuthTextField +import com.firebase.ui.auth.compose.ui.components.TermsAndPrivacyForm + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ResetPasswordUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + email: String, + resetLinkSent: Boolean, + onEmailChange: (String) -> Unit, + onSendResetLink: () -> Unit, + onGoToSignIn: () -> Unit, +) { + + val context = LocalContext.current + val stringProvider = DefaultAuthUIStringProvider(context) + val emailValidator = remember { + EmailValidator(stringProvider) + } + + val isFormValid = remember(email) { + derivedStateOf { emailValidator.validate(email) } + } + + val isDialogVisible = remember(resetLinkSent) { mutableStateOf(resetLinkSent) } + + if (isDialogVisible.value) { + AlertDialog( + title = { + Text( + text = "Reset Link Sent", + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Text( + text = "Check your email $email", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start + ) + }, + confirmButton = { + TextButton( + onClick = { + isDialogVisible.value = false + } + ) { + Text("Dismiss") + } + }, + onDismissRequest = { + isDialogVisible.value = false + }, + ) + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text("Recover Password") + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp), + ) { + AuthTextField( + value = email, + validator = emailValidator, + enabled = !isLoading, + label = { + Text(stringProvider.emailHint) + }, + onValueChange = { text -> + onEmailChange(text) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .align(Alignment.End), + ) { + Button( + onClick = { + onGoToSignIn() + }, + enabled = !isLoading, + ) { + Text("Sign In") + } + Spacer(modifier = Modifier.width(16.dp)) + Button( + onClick = { + onSendResetLink() + }, + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text("Send") + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewResetPasswordUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = false, + isEmailLinkForceSameDeviceEnabled = true, + emailLinkActionCodeSettings = null, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + + AuthUITheme { + ResetPasswordUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + email = "", + isLoading = false, + resetLinkSent = true, + onEmailChange = { email -> }, + onSendResetLink = {}, + onGoToSignIn = {}, + ) + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignInUI.kt new file mode 100644 index 000000000..edc7ffa9e --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignInUI.kt @@ -0,0 +1,278 @@ +/* + * 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.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator +import com.firebase.ui.auth.compose.ui.components.AuthTextField +import com.firebase.ui.auth.compose.ui.components.TermsAndPrivacyForm + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignInUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + emailSignInLinkSent: Boolean, + email: String, + password: String, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onSignInClick: () -> Unit, + onGoToSignUp: () -> Unit, + onGoToResetPassword: () -> Unit, +) { + val context = LocalContext.current + val provider = configuration.providers.filterIsInstance().first() + val stringProvider = DefaultAuthUIStringProvider(context) + val emailValidator = remember { EmailValidator(stringProvider) } + val passwordValidator = remember { + PasswordValidator(stringProvider = stringProvider, rules = emptyList()) + } + + val isFormValid = remember(email, password) { + derivedStateOf { + listOf( + emailValidator.validate(email), + if (!provider.isEmailLinkSignInEnabled) + passwordValidator.validate(password) else true, + ).all { it } + } + } + + val isDialogVisible = + remember(emailSignInLinkSent) { mutableStateOf(emailSignInLinkSent) } + + if (isDialogVisible.value) { + AlertDialog( + title = { + Text( + text = "Email Sign In Link Sent", + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Text( + text = "Check your email $email", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start + ) + }, + confirmButton = { + TextButton( + onClick = { + isDialogVisible.value = false + } + ) { + Text("Dismiss") + } + }, + onDismissRequest = { + isDialogVisible.value = false + }, + ) + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(stringProvider.signInDefault) + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp), + ) { + AuthTextField( + value = email, + validator = emailValidator, + enabled = !isLoading, + label = { + Text(stringProvider.emailHint) + }, + onValueChange = { text -> + onEmailChange(text) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + if (!provider.isEmailLinkSignInEnabled) { + AuthTextField( + value = password, + validator = passwordValidator, + enabled = !isLoading, + isSecureTextField = true, + label = { + Text(stringProvider.passwordHint) + }, + onValueChange = { text -> + onPasswordChange(text) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + TextButton( + modifier = Modifier + .align(Alignment.Start), + onClick = { + onGoToResetPassword() + }, + enabled = !isLoading, + contentPadding = PaddingValues.Zero + ) { + Text( + modifier = modifier, + text = stringProvider.troubleSigningIn, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .align(Alignment.End), + ) { + // Signup is hidden for email link sign in + if (!provider.isEmailLinkSignInEnabled) { + Button( + onClick = { + onGoToSignUp() + }, + enabled = !isLoading, + ) { + Text(stringProvider.titleRegisterEmail) + } + Spacer(modifier = Modifier.width(16.dp)) + } + Button( + onClick = { + // TODO(demolaf): When signIn is fired if Exception is UserNotFound + // then we check if provider.isNewAccountsAllowed then we show signUp + // else we show an error dialog stating signup is not allowed + onSignInClick() + }, + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text(stringProvider.signInDefault) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewSignInUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = false, + isEmailLinkForceSameDeviceEnabled = true, + emailLinkActionCodeSettings = null, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + + AuthUITheme { + SignInUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + email = "", + password = "", + isLoading = false, + emailSignInLinkSent = false, + onEmailChange = { email -> }, + onPasswordChange = { password -> }, + onSignInClick = {}, + onGoToSignUp = {}, + onGoToResetPassword = {}, + ) + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignUpUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignUpUI.kt new file mode 100644 index 000000000..9d138e8a8 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignUpUI.kt @@ -0,0 +1,273 @@ +/* + * 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.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.configuration.validators.GeneralFieldValidator +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator +import com.firebase.ui.auth.compose.ui.components.AuthTextField +import com.firebase.ui.auth.compose.ui.components.TermsAndPrivacyForm + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignUpUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + displayName: String, + email: String, + password: String, + confirmPassword: String, + onDisplayNameChange: (String) -> Unit, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onConfirmPasswordChange: (String) -> Unit, + onGoToSignIn: () -> Unit, + onSignUpClick: () -> Unit, +) { + val provider = configuration.providers.filterIsInstance().first() + val context = LocalContext.current + val stringProvider = DefaultAuthUIStringProvider(context) + val displayNameValidator = remember { GeneralFieldValidator(stringProvider) } + val emailValidator = remember { EmailValidator(stringProvider) } + val passwordValidator = remember { + PasswordValidator( + stringProvider = stringProvider, + rules = provider.passwordValidationRules + ) + } + val confirmPasswordValidator = remember(password) { + GeneralFieldValidator( + stringProvider = stringProvider, + isValid = { value -> + value == password + }, + customMessage = stringProvider.passwordsDoNotMatch + ) + } + + val isFormValid = remember(displayName, email, password, confirmPassword) { + derivedStateOf { + listOf( + displayNameValidator.validate(displayName), + emailValidator.validate(email), + passwordValidator.validate(password), + confirmPasswordValidator.validate(confirmPassword) + ).all { it } + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text("Sign up") + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp), + ) { + AuthTextField( + value = email, + validator = emailValidator, + enabled = !isLoading, + label = { + Text("Email") + }, + onValueChange = { text -> + onEmailChange(text) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + if (provider.isDisplayNameRequired) { + AuthTextField( + value = displayName, + validator = displayNameValidator, + enabled = !isLoading, + label = { + Text("First & last Name") + }, + onValueChange = { text -> + onDisplayNameChange(text) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.AccountCircle, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + } + AuthTextField( + value = password, + validator = passwordValidator, + enabled = !isLoading, + isSecureTextField = true, + label = { + Text("Password") + }, + onValueChange = { text -> + onPasswordChange(text) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = confirmPassword, + validator = confirmPasswordValidator, + enabled = !isLoading, + isSecureTextField = true, + label = { + Text("Confirm Password") + }, + onValueChange = { text -> + onConfirmPasswordChange(text) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .align(Alignment.End), + ) { + Button( + onClick = { + onGoToSignIn() + }, + enabled = !isLoading, + ) { + Text("Sign In") + } + Spacer(modifier = Modifier.width(16.dp)) + Button( + onClick = { + onSignUpClick() + }, + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text("Sign Up") + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewSignUpUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = false, + isEmailLinkForceSameDeviceEnabled = true, + emailLinkActionCodeSettings = null, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + + AuthUITheme { + SignUpUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + isLoading = false, + displayName = "", + email = "", + password = "", + confirmPassword = "", + onDisplayNameChange = { name -> }, + onEmailChange = { email -> }, + onPasswordChange = { password -> }, + onConfirmPasswordChange = { confirmPassword -> }, + onSignUpClick = {}, + onGoToSignIn = {} + ) + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt index 98e72d6fd..3b21b368a 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt @@ -287,25 +287,31 @@ class FirebaseAuthUIAuthStateTest { // Given initial idle state `when`(mockFirebaseAuth.currentUser).thenReturn(null) - // When updating auth state internally - authUI.updateAuthState(AuthState.Loading("Signing in...")) - - // Then the flow should reflect the updated state + // Start collecting the flow to capture initial state val states = mutableListOf() val job = launch { - authUI.authStateFlow().take(2).toList(states) + authUI.authStateFlow().take(3).toList(states) } - // Update state again + // Wait for initial state to be collected delay(100) + + // When updating auth state internally + authUI.updateAuthState(AuthState.Loading("Signing in...")) + + // Wait for state update to propagate + delay(100) + + // Update state again authUI.updateAuthState(AuthState.Cancelled) job.join() - // The first state should be Idle (initial), second should be Loading - assertThat(states[0]).isEqualTo(AuthState.Idle) - // Note: The internal state update may not be immediately visible in the flow - // because the auth state listener overrides it + // Verify the emitted states + assertThat(states).hasSize(3) + assertThat(states[0]).isEqualTo(AuthState.Idle) // Initial state + assertThat(states[1]).isInstanceOf(AuthState.Loading::class.java) // After first update + assertThat(states[2]).isEqualTo(AuthState.Cancelled) // After second update } // ============================================================================================= diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt index 31045ba13..39316e2ff 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -83,7 +83,7 @@ class AuthUIConfigurationTest { assertThat(config.tosUrl).isNull() assertThat(config.privacyPolicyUrl).isNull() assertThat(config.logo).isNull() - assertThat(config.actionCodeSettings).isNull() + assertThat(config.passwordResetActionCodeSettings).isNull() assertThat(config.isNewEmailAccountsAllowed).isTrue() assertThat(config.isDisplayNameRequired).isTrue() assertThat(config.isProviderChoiceAlwaysShown).isFalse() @@ -94,7 +94,7 @@ class AuthUIConfigurationTest { val customTheme = AuthUITheme.Default val customStringProvider = mock(AuthUIStringProvider::class.java) val customLocale = Locale.US - val customActionCodeSettings = actionCodeSettings { + val customPasswordResetActionCodeSettings = actionCodeSettings { url = "https://example.com/verify" handleCodeInApp = true } @@ -123,7 +123,7 @@ class AuthUIConfigurationTest { tosUrl = "https://example.com/tos" privacyPolicyUrl = "https://example.com/privacy" logo = Icons.Default.AccountCircle - actionCodeSettings = customActionCodeSettings + passwordResetActionCodeSettings = customPasswordResetActionCodeSettings isNewEmailAccountsAllowed = false isDisplayNameRequired = false isProviderChoiceAlwaysShown = true @@ -140,7 +140,8 @@ class AuthUIConfigurationTest { assertThat(config.tosUrl).isEqualTo("https://example.com/tos") assertThat(config.privacyPolicyUrl).isEqualTo("https://example.com/privacy") assertThat(config.logo).isEqualTo(Icons.Default.AccountCircle) - assertThat(config.actionCodeSettings).isEqualTo(customActionCodeSettings) + assertThat(config.passwordResetActionCodeSettings) + .isEqualTo(customPasswordResetActionCodeSettings) assertThat(config.isNewEmailAccountsAllowed).isFalse() assertThat(config.isDisplayNameRequired).isFalse() assertThat(config.isProviderChoiceAlwaysShown).isTrue() @@ -308,7 +309,7 @@ class AuthUIConfigurationTest { ) provider( AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = listOf() ) ) @@ -416,7 +417,7 @@ class AuthUIConfigurationTest { "tosUrl", "privacyPolicyUrl", "logo", - "actionCodeSettings", + "passwordResetActionCodeSettings", "isNewEmailAccountsAllowed", "isDisplayNameRequired", "isProviderChoiceAlwaysShown" diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt index 3e6ab28ca..a07a8abc0 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt @@ -34,7 +34,7 @@ class AuthProviderTest { @Test fun `email provider with valid configuration should succeed`() { val provider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = listOf() ) @@ -50,7 +50,7 @@ class AuthProviderTest { val provider = AuthProvider.Email( isEmailLinkSignInEnabled = true, - actionCodeSettings = actionCodeSettings, + emailLinkActionCodeSettings = actionCodeSettings, passwordValidationRules = listOf() ) @@ -61,7 +61,7 @@ class AuthProviderTest { fun `email provider with email link enabled but null action code settings should throw`() { val provider = AuthProvider.Email( isEmailLinkSignInEnabled = true, - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = listOf() ) @@ -85,7 +85,7 @@ class AuthProviderTest { val provider = AuthProvider.Email( isEmailLinkSignInEnabled = true, - actionCodeSettings = actionCodeSettings, + emailLinkActionCodeSettings = actionCodeSettings, passwordValidationRules = listOf() ) @@ -319,7 +319,7 @@ class AuthProviderTest { val providers = listOf( AuthProvider.Anonymous, AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = listOf() ) ) diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt index 79bbbcfef..00ebc75c4 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt @@ -123,7 +123,7 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) val emailProvider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = emptyList() ) val config = authUIConfiguration { @@ -165,7 +165,7 @@ class EmailAuthProviderFirebaseAuthUITest { ).thenReturn(taskCompletionSource.task) val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) val emailProvider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = emptyList() ) val config = authUIConfiguration { @@ -196,7 +196,7 @@ class EmailAuthProviderFirebaseAuthUITest { fun `createOrLinkUserWithEmailAndPassword - rejects weak password`() = runTest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) val emailProvider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = emptyList() ) val config = authUIConfiguration { @@ -231,7 +231,7 @@ class EmailAuthProviderFirebaseAuthUITest { fun `createOrLinkUserWithEmailAndPassword - validates custom password rules`() = runTest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) val emailProvider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = listOf(PasswordRule.RequireUppercase) ) val config = authUIConfiguration { @@ -259,7 +259,7 @@ class EmailAuthProviderFirebaseAuthUITest { fun `createOrLinkUserWithEmailAndPassword - respects isNewAccountsAllowed setting`() = runTest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) val emailProvider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = emptyList(), isNewAccountsAllowed = false ) @@ -303,7 +303,7 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) val emailProvider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = emptyList() ) val config = authUIConfiguration { @@ -348,7 +348,7 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) val emailProvider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = emptyList() ) val config = authUIConfiguration { @@ -383,7 +383,7 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) val emailProvider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = emptyList() ) val config = authUIConfiguration { @@ -419,7 +419,7 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) val emailProvider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = emptyList() ) val config = authUIConfiguration { @@ -463,7 +463,7 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) val emailProvider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = emptyList() ) val config = authUIConfiguration { @@ -501,7 +501,7 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) val emailProvider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = emptyList() ) val config = authUIConfiguration { @@ -537,7 +537,7 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) val emailProvider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = emptyList() ) val config = authUIConfiguration { @@ -583,7 +583,7 @@ class EmailAuthProviderFirebaseAuthUITest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) val emailProvider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = emptyList() ) val config = authUIConfiguration { diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt index 3baa351e0..c68f1e6e9 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt @@ -111,7 +111,7 @@ class AuthProviderButtonTest { @Test fun `AuthProviderButton displays Email provider correctly`() { val provider = AuthProvider.Email( - actionCodeSettings = null, + emailLinkActionCodeSettings = null, passwordValidationRules = emptyList() ) diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt index 21629a1a2..101317483 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt @@ -355,12 +355,13 @@ class AuthTextFieldTest { // ============================================================================================= @Test - fun `AuthTextField shows password visibility toggle for PasswordValidator`() { + fun `AuthTextField shows password visibility toggle when isSecureTextField`() { composeTestRule.setContent { AuthTextField( value = "password123", onValueChange = { }, label = { Text("Password") }, + isSecureTextField = true, validator = PasswordValidator( stringProvider = stringProvider, rules = emptyList() @@ -380,6 +381,7 @@ class AuthTextFieldTest { value = "password123", onValueChange = { }, label = { Text("Password") }, + isSecureTextField = true, validator = PasswordValidator( stringProvider = stringProvider, rules = emptyList() diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt index 2b500a924..bdb1578ff 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt @@ -56,7 +56,10 @@ class AuthMethodPickerTest { val providers = listOf( AuthProvider.Google(scopes = emptyList(), serverClientId = null), AuthProvider.Facebook(), - AuthProvider.Email(actionCodeSettings = null, passwordValidationRules = emptyList()) + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) ) composeTestRule.setContent { @@ -277,7 +280,10 @@ class AuthMethodPickerTest { AuthProvider.Microsoft(tenant = null, customParameters = emptyMap()), AuthProvider.Yahoo(customParameters = emptyMap()), AuthProvider.Apple(locale = null, customParameters = emptyMap()), - AuthProvider.Email(actionCodeSettings = null, passwordValidationRules = emptyList()), + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ), AuthProvider.Phone( defaultNumber = null, defaultCountryCode = null, diff --git a/build.gradle b/build.gradle index e75ec13b3..b3a5a9caa 100644 --- a/build.gradle +++ b/build.gradle @@ -1,2 +1,10 @@ -// This empty file along with settings.gradle help Android Studio recognize the project +buildscript { + dependencies { + classpath libs.kotlin.gradle.plugin + } +} +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.compose.compiler) apply false +}// This empty file along with settings.gradle help Android Studio recognize the project // as a gradle project, despite the use of .gradle.kts scripts. \ No newline at end of file diff --git a/composeapp/.gitignore b/composeapp/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/composeapp/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/composeapp/build.gradle.kts b/composeapp/build.gradle.kts new file mode 100644 index 000000000..92f555ad7 --- /dev/null +++ b/composeapp/build.gradle.kts @@ -0,0 +1,68 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("com.google.gms.google-services") apply false +} + +android { + namespace = "com.firebase.composeapp" + compileSdk = Config.SdkVersions.compile + + defaultConfig { + applicationId = "com.firebase.composeapp" + minSdk = Config.SdkVersions.min + targetSdk = Config.SdkVersions.target + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":auth")) + + implementation(Config.Libs.Kotlin.jvm) + implementation(Config.Libs.Androidx.lifecycleRuntime) + implementation(Config.Libs.Androidx.Compose.activityCompose) + implementation(platform(Config.Libs.Androidx.Compose.bom)) + implementation(Config.Libs.Androidx.Compose.ui) + implementation(Config.Libs.Androidx.Compose.uiGraphics) + implementation(Config.Libs.Androidx.Compose.toolingPreview) + implementation(Config.Libs.Androidx.Compose.material3) + + testImplementation(Config.Libs.Test.junit) + androidTestImplementation(Config.Libs.Test.junitExt) + androidTestImplementation(platform(Config.Libs.Androidx.Compose.bom)) + androidTestImplementation(Config.Libs.Test.composeUiTestJunit4) + + debugImplementation(Config.Libs.Androidx.Compose.tooling) + + implementation(platform(Config.Libs.Firebase.bom)) +} + +// Only apply google-services plugin if the google-services.json file exists +if (rootProject.file("composeapp/google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") +} \ No newline at end of file diff --git a/composeapp/proguard-rules.pro b/composeapp/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/composeapp/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/composeapp/src/main/AndroidManifest.xml b/composeapp/src/main/AndroidManifest.xml new file mode 100644 index 000000000..99b6bad7d --- /dev/null +++ b/composeapp/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt new file mode 100644 index 000000000..decb7a452 --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt @@ -0,0 +1,103 @@ +package com.firebase.composeapp + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.firebase.composeapp.ui.screens.MainScreen +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.auth_provider.signInWithEmailLink +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.ui.screens.EmailSignInLinkHandlerActivity +import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.actionCodeSettings +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + FirebaseApp.initializeApp(applicationContext) + val authUI = FirebaseAuthUI.getInstance() + + // Check if this is an email link sign-in flow + val emailLink = intent.getStringExtra( + EmailSignInLinkHandlerActivity.EXTRA_EMAIL_LINK + ) + + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = true, + isEmailLinkForceSameDeviceEnabled = true, + emailLinkActionCodeSettings = actionCodeSettings { + // The continue URL - where to redirect after email link is clicked + 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, + ) + ) + + val configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "https://policies.google.com/terms?hl=en-NG&fg=1" + privacyPolicyUrl = "https://policies.google.com/privacy?hl=en-NG&fg=1" + } + + if (emailLink != null) { + lifecycleScope.launch { + try { + val emailFromSession = EmailLinkPersistenceManager + .retrieveSessionRecord(applicationContext)?.email + + if (emailFromSession != null) { + authUI.signInWithEmailLink( + context = applicationContext, + config = configuration, + provider = provider, + email = emailFromSession, + emailLink = emailLink, + ) + } + } catch (e: Exception) { + // Error handling is done via AuthState.Error in the auth flow + } + } + } + + setContent { + AuthUITheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + MainScreen( + context = applicationContext, + configuration = configuration, + authUI = authUI, + provider = provider + ) + } + } + } + } +} \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/MainScreen.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/MainScreen.kt new file mode 100644 index 000000000..4e1aeaf66 --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/MainScreen.kt @@ -0,0 +1,151 @@ +package com.firebase.composeapp.ui.screens + +import android.content.Context +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.ui.screens.EmailAuthMode +import com.firebase.ui.auth.compose.ui.screens.EmailAuthScreen +import com.firebase.ui.auth.compose.ui.screens.ResetPasswordUI +import com.firebase.ui.auth.compose.ui.screens.SignInUI +import com.firebase.ui.auth.compose.ui.screens.SignUpUI +import kotlinx.coroutines.launch + +@Composable +fun MainScreen( + context: Context, + configuration: AuthUIConfiguration, + authUI: FirebaseAuthUI, + provider: AuthProvider.Email +) { + val coroutineScope = rememberCoroutineScope() + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + + when (authState) { + is AuthState.Success -> { + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Authenticated User - (Success): ${authUI.getCurrentUser()?.email}", + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + coroutineScope.launch { + authUI.signOut(context) + } + } + ) { + Text("Sign Out") + } + } + } + + is AuthState.RequiresEmailVerification -> { + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Authenticated User - " + + "(RequiresEmailVerification): " + + "${(authState as AuthState.RequiresEmailVerification).user.email}", + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + coroutineScope.launch { + authUI.signOut(context) + } + } + ) { + Text("Sign Out") + } + } + } + + else -> { + EmailAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + onSuccess = { result -> }, + onError = { exception -> }, + onCancel = { }, + ) { state -> + when (state.mode) { + EmailAuthMode.SignIn -> { + SignInUI( + configuration = configuration, + email = state.email, + isLoading = state.isLoading, + emailSignInLinkSent = state.emailSignInLinkSent, + password = state.password, + onEmailChange = state.onEmailChange, + onPasswordChange = state.onPasswordChange, + onSignInClick = state.onSignInClick, + onGoToSignUp = state.onGoToSignUp, + onGoToResetPassword = state.onGoToResetPassword, + ) + } + + EmailAuthMode.SignUp -> { + SignUpUI( + configuration = configuration, + isLoading = state.isLoading, + displayName = state.displayName, + email = state.email, + password = state.password, + confirmPassword = state.confirmPassword, + onDisplayNameChange = state.onDisplayNameChange, + onEmailChange = state.onEmailChange, + onPasswordChange = state.onPasswordChange, + onConfirmPasswordChange = state.onConfirmPasswordChange, + onSignUpClick = state.onSignUpClick, + onGoToSignIn = state.onGoToSignIn, + ) + } + + EmailAuthMode.ResetPassword -> { + ResetPasswordUI( + configuration = configuration, + isLoading = state.isLoading, + email = state.email, + resetLinkSent = state.resetLinkSent, + onEmailChange = state.onEmailChange, + onSendResetLink = state.onSendResetLinkClick, + onGoToSignIn = state.onGoToSignIn + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Color.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Color.kt new file mode 100644 index 000000000..994915613 --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.firebase.composeapp.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Theme.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Theme.kt new file mode 100644 index 000000000..08bd9441a --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.firebase.composeapp.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun FirebaseUIAndroidTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Type.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Type.kt new file mode 100644 index 000000000..ea0657654 --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.firebase.composeapp.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/composeapp/src/main/res/drawable-v24/ic_launcher_foreground.xml b/composeapp/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..fde1368fc --- /dev/null +++ b/composeapp/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/composeapp/src/main/res/drawable/ic_launcher_background.xml b/composeapp/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..1e4408cae --- /dev/null +++ b/composeapp/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/composeapp/src/main/res/mipmap-hdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/composeapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/composeapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 zcmV-Y3$650Nk&FW3jhFDMM6+kP&il$0000G0000-002h-06|PpNWB9900E$G+qN-D z+81ABX7q?;bwx%xBg?kcwr$(C-Tex-ZCkHUw(Y9#+`E5-zuONG5fgw~E2WDng@Bc@ z24xy+R1n%~6xI#u9vJ8zREI)sb<&Il(016}Z~V1n^PU3-_H17A*Bf^o)&{_uBv}Py zulRfeE8g(g6HFhk_?o_;0@tz?1I+l+Y#Q*;RVC?(ud`_cU-~n|AX-b`JHrOIqn(-t&rOg-o`#C zh0LPxmbOAEb;zHTu!R3LDh1QO zZTf-|lJNUxi-PpcbRjw3n~n-pG;$+dIF6eqM5+L();B2O2tQ~|p{PlpNcvDbd1l%c zLtXn%lu(3!aNK!V#+HNn_D3lp z2%l+hK-nsj|Bi9;V*WIcQRTt5j90A<=am+cc`J zTYIN|PsYAhJ|=&h*4wI4ebv-C=Be#u>}%m;a{IGmJDU`0snWS&$9zdrT(z8#{OZ_Y zxwJx!ZClUi%YJjD6Xz@OP8{ieyJB=tn?>zaI-4JN;rr`JQbb%y5h2O-?_V@7pG_+y z(lqAsqYr!NyVb0C^|uclHaeecG)Sz;WV?rtoqOdAAN{j%?Uo%owya(F&qps@Id|Of zo@~Y-(YmfB+chv^%*3g4k3R0WqvuYUIA+8^SGJ{2Bl$X&X&v02>+0$4?di(34{pt* zG=f#yMs@Y|b&=HyH3k4yP&goF2LJ#tBLJNNDo6lG06r}ghC-pC4Q*=x3;|+W04zte zAl>l4kzUBQFYF(E`KJy?ZXd1tnfbH+Z~SMmA21KokJNs#eqcXWKUIC>{TuoKe^vhF z);H)o`t9j~`$h1D`#bxe@E`oE`cM9w(@)5Bp8BNukIwM>wZHfd0S;5bcXA*5KT3bj zc&_~`&{z7u{Et!Z_k78H75gXf4g8<_ul!H$eVspPeU3j&&Au=2R*Zp#M9$9s;fqwgzfiX=E_?BwVcfx3tG9Q-+<5fw z%Hs64z)@Q*%s3_Xd5>S4dg$s>@rN^ixeVj*tqu3ZV)biDcFf&l?lGwsa zWj3rvK}?43c{IruV2L`hUU0t^MemAn3U~x3$4mFDxj=Byowu^Q+#wKRPrWywLjIAp z9*n}eQ9-gZmnd9Y0WHtwi2sn6n~?i#n9VN1B*074_VbZZ=WrpkMYr{RsI ztM_8X1)J*DZejxkjOTRJ&a*lrvMKBQURNP#K)a5wIitfu(CFYV4FT?LUB$jVwJSZz zNBFTWg->Yk0j&h3e*a5>B=-xM7dE`IuOQna!u$OoxLlE;WdrNlN)1 z7**de7-hZ!(%_ZllHBLg`Ir#|t>2$*xVOZ-ADZKTN?{(NUeLU9GbuG-+Axf*AZ-P1 z0ZZ*fx+ck4{XtFsbcc%GRStht@q!m*ImssGwuK+P@%gEK!f5dHymg<9nSCXsB6 zQ*{<`%^bxB($Z@5286^-A(tR;r+p7B%^%$N5h%lb*Vlz-?DL9x;!j<5>~kmXP$E}m zQV|7uv4SwFs0jUervsxVUm>&9Y3DBIzc1XW|CUZrUdb<&{@D5yuLe%Xniw^x&{A2s z0q1+owDSfc3Gs?ht;3jw49c#mmrViUfX-yvc_B*wY|Lo7; zGh!t2R#BHx{1wFXReX*~`NS-LpSX z#TV*miO^~B9PF%O0huw!1Zv>^d0G3$^8dsC6VI!$oKDKiXdJt{mGkyA`+Gwd4D-^1qtNTUK)`N*=NTG-6}=5k6suNfdLt*dt8D| z%H#$k)z#ZRcf|zDWB|pn<3+7Nz>?WW9WdkO5(a^m+D4WRJ9{wc>Y}IN)2Kbgn;_O? zGqdr&9~|$Y0tP=N(k7^Eu;iO*w+f%W`20BNo)=Xa@M_)+o$4LXJyiw{F?a633SC{B zl~9FH%?^Rm*LVz`lkULs)%idDX^O)SxQol(3jDRyBVR!7d`;ar+D7do)jQ}m`g$TevUD5@?*P8)voa?kEe@_hl{_h8j&5eB-5FrYW&*FHVt$ z$kRF9Nstj%KRzpjdd_9wO=4zO8ritN*NPk_9avYrsF(!4))tm{Ga#OY z(r{0buexOzu7+rw8E08Gxd`LTOID{*AC1m*6Nw@osfB%0oBF5sf<~wH1kL;sd zo)k6^VyRFU`)dt*iX^9&QtWbo6yE8XXH?`ztvpiOLgI3R+=MOBQ9=rMVgi<*CU%+d1PQQ0a1U=&b0vkF207%xU0ssI2 literal 0 HcmV?d00001 diff --git a/composeapp/src/main/res/mipmap-mdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/composeapp/src/main/res/mipmap-xhdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/composeapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/composeapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 zcmV-U53%r4Nk&FS4*&pHMM6+kP&il$0000G0001A003VA06|PpNSy@$00HoY|G(*G z+qV7x14$dSO^Re!iqt-AAIE9iwr$(CZQJL$blA4B`>;C3fBY6Q8_YSjb2%a=fc}4E zrSzssacq<^nmW|Rs93PJni30R<8w<(bK_$LO4L?!_OxLl$}K$MUEllnMK|rg=f3;y z*?;3j|Nh>)p0JQ3A~rf(MibH2r+)3cyV1qF&;8m{w-S*y+0mM){KTK^M5}ksc`qX3 zy>rf^b>~l>SSHds8(I@hz3&PD@LmEs4&prkT=BjsBCXTMhN$_)+kvnl0bLKW5rEsj z*d#KXGDB4P&>etx0X+`R19yC=LS)j!mgs5M0L~+o-T~Jl!p!AJxnGAhV%~rhYUL4hlWhgES3Kb5oA&X z{}?3OBSS-{!v$nCIGj->(-TAG)8LR{htr41^gxsT8yqt2@DEG6Yl`Uma3Nd4;YUoW zTbkYl3CMU5ypMF3EIkYmWL|*BknM`0+Kq6CpvO(y$#j94e+q{vI{Zp8cV_6RK!`&C zob$*5Q|$IZ09dW=L!V zw@#2wviu|<#3lgGE8GEhcx+zBt`} zOwP8j9X%^f7i_bth4PiJ$LYtFJSCN$3xwDN;8mr*B;CJwBP2G0TMq0uNt7S^DO_wE zepk!Wrn#Z#03j{`c*Rf~y3o7?J}w?tEELRUR2cgxB*Y{LzA#pxHgf}q?u5idu>077 zd^=p)`nA}6e`|@`p?u}YU66PP_MA}Zqqe!c{nK&z%Jwq1N4e_q<#4g^xaz=ao;u|6 zwpRcW2Lax=ZGbx=Q*HhlJ`Ns#Y*r0*%!T?P*TTiX;rb)$CGLz=rSUum$)3Qyv{BL2 zO*=OI2|%(Yz~`pNEOnLp>+?T@glq-DujlIp?hdJeZ7ctP4_OKx|5@EOps3rr(pWzg zK4d3&oN-X2qN(d_MkfwB4I)_)!I_6nj2iA9u^pQ{;GckGLxBGrJUM2Wdda!k)Y>lq zmjws>dVQ*vW9lvEMkiN3wE-__6OWD0txS&Qn0n22cyj4Q*8(nG4!G{6OOwNvsrPIL zCl-$W9UwkEUVuLwyD%|inbOF*xMODZ4VMEVAq_zUxZ+K#Gdqf!DW$5f)?7UNOFMz! zrB~tuu=6X2FE(p^iqgxr+?ZK;=yz`e;C$#_@D9Lj-+TDVOrva>(#*PVbaHO>A)mhl z07OJWCqYC60518$!&c`eNBcBW%GnfaQ*$eazV^2_AW?j)h;J1nUjN(I9=0+!RVx~% z3@Tf!P0TE+98jA?WceK-}A1% zW!K)lyKcGqy#M~})315-A#2NXQ`?6NR#Apo=S!oF=JfpX>iR*49ec{7AN$xxpK{D$ z2d%Fz&rdfSqourN$~Y^NFIMV1CZ?J*bMx~H3k&meGtH@q9ra2vZxmA$S(#jaaj-g4 ztJmxG+DLV<*q<|sDXPp$X>E)#S}Vm&sRaO5P&goh2><}FEdZSXDqsL$06sAkh(e+v zAsBhKSRexgwg6tIy~GFJzaTxXD(}|+0eOwFDA%rn`X;MVwDHT9=4=g%OaJ9s%3b9>9EUTnnp0t;2Zpa{*>mk~hZqItE_!dQ zOtC>8`$l|mV43Jbudf0N6&&X;{=z}Zi}d1`2qmJ}i|0*GsulD3>GgQXHN)pkR6sf1 z?5ZU%&xtL}oH;YiAA)d*^Ndw2T$+Mjuzyzz@-SM`9df7LqTxLuIwC~S0092~+=qYv z@*ja;?Wt!T!{U?c*Z0YtGe)XbI&y-?B&G2$`JDM)(dIV9G`Sc#6?sI60de6kv+)Qb zUW~2|WjvJq3TA8`0+sWA3zRhY9a~ow)O~&StBkG2{*{TGiY~S8ep{V&Vo2l<6LWsu z^#p0-v*t2?3&aA1)ozu|%efSR=XnpX$lvTeRdKlvM!@|pM5p2w3u-6 zU>}t2xiYLS+{|%C65AzX+23Mtlq?BS&YdYcYsVjoiE&rT>;Necn6l^K)T^lmE`5u{ zm1i+-a-gc;Z&v-{;8r)z6NYfBUv+=_L}ef}qa9FX01)+Aaf+;xj(mL6|JUzGJR1|fnanb%?BPPIp>SCjP|8qE5qJ{=n5ZGw?81z3(k;pzH%1CtlX50{E7h)$h{qGKfzC`e2o`*IqA#tjA z`Fz&^%$b9F*N`)U-#6>a)Z`55`$Dd0cfcs0$d13^ONrdCu9xcv_=n#WQo8stcz3jP9|2EvdI-RhJM3%Q%oM&!OlShM|0 z?gz?wHZSnm45njLtsz8PVT1S&jAlbKg5kVam$p16=EK@Sj4EP0OtH zmJDmdc^v)x>56Qg_wmYHz6h)>kl_h$>0@J!ypv%APmjZTAQVLy6Fu50RGY&JAVNhx zrF_qG6`x9MkT;1SFWo$)l{M$;3qUDn9JwE}z zRl#E_bDRJFii61kPgBybIgp8dNW!Cc1b*^YYk-#oWLJvtM_v^hQx~9?8LD4VFFxBF z3MlrsSC%f9Oupn*ctPL0U1fwfX?`tRhPD{PSLFPQOmIt$mDy0SgpNVvHS+f#Do>h1Gn?LZU9(KaN>Q_=Y*_T zvtD7%_u^^+{g`0VGzg(VZrpVQ6Ub5M=tI_p7T93R8@3Zulu3|#{iNcu!oiHxZ4Rf*( zfmiN$$ru(*_Zqn=`Gq#OuHRTSwp7uH_SokR&|)RuW5yo=Z|_4?qU-JU+tpt>!B&Is z@N(=SG;bpVc;AO@zbmMM zScqq1)b-ZQIrs={oD}|?6y{$HNB1U0^LsBh8JI&3!GBZxOXI<}&5-$lgkAaYqhOTb z?2vEnZ$-kk;*M_17(upJF3%+iH*s0-r{vttXVB2OUwI1s^+G(Ft(U8gYFXC}#P&E^ z>T@C^tS`Z7{6HT4_nF~n>JlZtk5&qDBl6r|^kzQYe`wq!C)n@$c>WOPA61NDFj<<6 zGW71NMMhwAl!U-yqrq2xrSFqRCI8acw7?}3j;ynxo*-b7Co;g5r%^j=H@9({PXXBf z@r>U>>N;E)81wx`B4f%{PB~MHka_);%kBCb(d|Jy5!MqJ%2p`t&@L)4$T2j&-WHvG zv3(uyA_gwqNu(k?jQTtv3dgPKRZoH8prxe7>pQBW5L&dpumS&5Ld2?(sCpJjvc4L5 zEnh&?91WVm)ZdTj=fjJ$pPDdgAttLXuke+?KdKxu*;kTC(r!tQk6;gxj4h%FdHAt(^M3YvYj(!tOeN)+Hvj6+< zzyJRG?^lZfWuR#t!tUKP&(?%3v&Zd$R2YN>lB(Lq`OInY48%4%yTv2 zYe1{G`3)(PDEio5Y@-I5tUf`c%%OCJMtSW56g3iEg%3`$7XSJJHyA z<|7&N)5Xrlgv~%BO24eFd;Hd;uiK%D`EdK|quUeRZDqbh9l)%j%J#0lfrZumvA<_w zu&=AVvdChf6}eqh(bUz`(`Ue*p01{fBAcTgKyDYLs_I+YyJEk+rM@avU~>fB$n)HS zM7pfJydu`i%gfS<{PF94kZDv$t>06sAkheDzu40NJ$5CMW%n^Lls?8^p^QGWURbKu3ZduZQZ((s2? zzE`}<{;Zt7<$C|9R8A~DJ~@%x>TfP zF>TX8)@v|t)q4GjRt<}5s6hLHwRel7>V@&r-O|Av(yh;Q1A{E>Ir>p+%dHD|=l+lT zpr(Dg&>#Nu=!)6bCLr-ZS%|;h)Ij$+e@r8_{qO19QvDe=&1tmpY*0lcA^Cc-#{9fQ z<~$*<&P$Q<_jy#<$40PMofM7aQ}C=jphI`4kLg}Z7CIN#26D{-4v-_CA-LiE@(%{y!BzsU%gG`Q?sjLUf%qFSl0y)2#ae*+EI>s|i`d^V$Dn)qmzqRq6VJRY|{4ujsIU%#bnqU6MR&-1I_43=|5(6Jr;Jvert) zE?S|Tmn}Tv<-??sxV5@9t}3D=>YZ0JrQe$CO~|EY=Lj9RM&4svQHPQL6%pV5fPFiH zfXDx;l@~et{*{U*#c#Dvzu)|znDO7$#CRx)Z&yp-}SrD{&|(MQtfUz~n35@RLfUy=aqrhCX0M}J_r5QsK~NmRCR|Nm&L z41UdsLjWxSUlL41r^0K&nCCK>fdR-!MYjFg(z9_mF^C|#ZQw?`)f6uVzF^`bRnVY& zo}@M06J&_+>w9@jpaO4snmU;0t-(zYW1qVBHtuD!d?%?AtN7Plp><-1Y8Rqb20ZaP zTCgn*-Sri4Q8Xn>=gNaWQ57%!D35UkA@ksOlPB*Dvw}t02ENAqw|kFhn%ZyyW%+t{ zNdM!uqEM^;2}f+tECHbwLmH*!nZVrb$-az%t50Y2pg(HqhvY-^-lb}>^6l{$jOI6} zo_kBzj%8aX|6H5M0Y<)7pzz_wLkIpRm!;PzY)9+24wk2&TT{w--phDGDCOz{cN_ca zpnm7`$oDy=HX%0i-`769*0M6(e5j-?(?24%)<)&46y0e&6@HCDZAm9W6Ib#Y#BF6- z=30crHGg+RRTe%VBC>T00OV6F+gQDAK38Ne3N9bm|62tPccBJi)5{B z4zc^Db72XiBd}v$CF|yU{Z=M|DZ%-(XarYNclODlb1Kz1_EKLy(NSLCN`eUl(rBCL zT*jx@wNvze0|TSqgE(QArOZU)_?qH(sj#TwzElLs9q)(0u!_P|R%Cy_0JFQxgGV>1 zz4?_uq<8_gM0`c*Hh|;UMz~vrg1gQXp{ufg`hM_qU;U>+zmvc5blCLSq@PrEBSGR# z&8=2Z4uXN`F3p73ueD1l{s{k$WipAvSh5W7ABe?4)t;r@V?y`bNB5FvBuE|0VRTb< zM1Hn^?DSsJY+sX@T5xW=#>T9VEV|?<(=6|ge$X6Sb05!LFdjDcoq*gM(Zq=t;_)Le&jyt(&9jzR73noru`a# zN*<`KwGa^gZU3-)MSLF0aFag#f0<>E(bYTeHmtdbns#|I)-$)mJ`q9ctQ8g0=ET?| zdO}eZ*b_p>ygRTtR^5Ggdam=Zb5wmd{}np+Jn1d_=M`~P=M67jj})fH4ztb5yQqQW z^C|C&^LHAK-u+ooIK)yM)QM?t;|<{P;;{`p=BclzAN#JzL4jCwXkQB1Dy{=^KR`=~ zTrr)y7eiYBzSNs_DvO=4A6#EgGS-zY%Vi)N*Yb`U;6o}KR}dq{r9pT5wqZ@3NOE8- z9-(}D|Nc5732CSYQbL)!gPQ#RbD8BhK3dl{sUuPvei0tkvnJBxDEAYTesU8H$)g(Plra{VH(v3u^CO1~(+ zU0O7#)jaS4{NcwA+LuSm&VBcX2#Im3xg)W}ySNw%->orn1taZ&+d)}8gJTqA!u|5P z{yv?zol_3|(1(%M(EVU=cp?L`{Pi|ixk{U)*guFML3P!OSlz;zGA#T+E@8@cgQ_mv1o7RSU=Zo_82F?&&2r;WE z@wk}JHYEZ9nYUc(Vv~iTCa3u8e4q(yq<29VoNbKk|`mq%I6u)My=gPIDuUb&lzf4`MEA9^g8u z)vp8|$$HE9m_BTV?lOosIGa4jud=jIbw)O2eCMfyw2*S8?hjWw^nqws$O*M$3I1)x zR0PWFb3$ySOcGTe1dz%N0l;RPc`x%05FtT^f^j{YCP}*Q=lvp4$ZXrTZQHhO+w%wJn3c8j%+5C3UAFD&%8dBl_qi9D5g8fry}6Ev z2_Q~)5^N$!IU`BPh1O|=BxQ#*C5*}`lluC515$lxc-vNC)IgW=K|=z7o%cWFpndn= zX}f{`!VK02_kU+Q5a3m37J;c} zTzbxteE{GNf?yLt5X=Bzc-mio^Up0nunMCgp*ZJ;%MJvPM3QK)BryP(_v@ei4UvHr z6+sbCifQaOkL6-;5fL8$W($zZ_;CZp305C;~$hhRquZr-r)jjd1z z31%ZK{-(`P#|Um_Sivn@p$-vz46uqT>QG0B1w9znfS9A8PB2LaHdzA|_)yjXVR*l{ zkcu3@vEf7bxH0nkh`q?8FmoO_Ucui*>_a~P?qQrlZ9@+D7%MTpSnztpylXrt5!-k8_QPB?YL8Kx_On8WD zgT+111d(Op$^$&KLAN5+@?>f7F4~wFi(8TL8+szgVmcMDTp5l&k6~=rA{Dt}!gb^r zSWY<)M7D|Z2P0cEodj6E42PV>&>DFmQpgt)E-|#sSUU@uKed+F680H@<;-x{p|nuH4!_mn85rx>wz;0mPi2ZkL#k6;sznu?cXh!T0S>{w6 zL^gvR05NY64l*<+_L>On$rjx9!US;l;LX6@z}yi#2XHh)F@Oo+l)h%fq$v}DNmF2> zfs^_t0)3N-W<9-N?uedVv{)-J0W5mh#29QM5R5h&KuiRM=0Zvnf#lF=K#WlCgc#9c zS;qvh(P$!_a8JwyhI^ZJV2k+B6Z^64?w|1?5gyo6y{}923CRZfYVe1#?F% z7h2SUiNO3;T#JUOyovSs@@C1GtwipycA=*x5{BpIZ_#GCMuV8XK=x;qCNy{d7?wA~ zC+=vjls;ci&zW=6$H~4^K%v{p}Ab?U%C6Z4p%eC<3ExqU$XR<}LLF67A$Sr20DR_pJ3yeBa~ z^sw{V0FI5;UpwXsScYuhbqGQ`YQ25;6p6W^+tgL&;Ml;>S3CGpSZ>VrTn0m1$y$HU z&65)I!c?oREz};c=nLCliriqQX->4uivHTgd${GqeAlf*!P^B|jkU|*IdNP(&6C>4 zqOW$)Nw9nvjy^&`?E|gotDV{JmJ9Q~vuhy<`^C4XIUDt|j4o6rK^e8_(=YqC zuaR6TRVf@tUFHB079o4MBIh{M~4>WwnGgesQH*3?w(RA%hCZ*7)b!aNV=yOQ%o_Y=Lt0Sl*(9^jfRnC210Om$=y>*o|3z} zAR&vAdrB#mWoaB0fJSw9xw|Am$fzK>rx-~R#7IFSAwdu_EI|SRfB*yl0w8oX09H^q zAjl2?0I)v*odGJ40FVGaF&2qJq9Gv`>V>2r0|c`GX8h>CX8eHcOy>S0@<;M3<_6UM z7yCEpug5NZL!H_0>Hg_HasQGxR`rY&Z{geOy?N92Z z{lER^um|$*?*G63*njwc(R?NT)Bei*3jVzR>FWUDb^gKhtL4A=kE_1p-%Fo2`!8M} z(0AjuCiS;G{?*^1tB-uY%=)SRx&D)pK4u@>f6@KPe3}2j_har$>HqzH;UCR^ssFD0 z7h+VLO4o@_Yt>>AeaZKUxqyvxWCAjKB>qjQ30UA)#w z&=RmdwlT`7a8J8Yae=7*c8XL|{@%wA8uvCqfsNX^?UZsS>wX}QD{K}ad4y~iO*p%4 z_cS{u7Ek%?WV6em2(U9#d8(&JDirb^u~7wK4+xP$iiI6IlD|a&S)6o=kG;59N|>K1 zn(0mUqbG3YIY7dQd+*4~)`!S9m7H6HP6YcKHhBc#b%1L}VIisp%;TckEkcu0>lo@u995$<*Em;XNodjTiCdC%R+TX|_ZR#|1`RR|`^@Teh zl#w@8fI1FTx2Dy+{blUT{`^kY*V-AZUd?ZZqCS4gW(kY5?retkLbF=>p=59Nl|=sf zo1Pc|{{N4>5nt#627ylGF`3n>X%`w%bw-Y~zWM_{Si$dc82|=YhISal{N7OY?O`C4 zD|qb}6nLWJ`hUyL+E>-;ricg9J@ZNYP(x(Sct&OI$Y!QWr*=^VN;G3#i>^1n4e#Je zOVhbFbLpXVu*16enDM+ic;97@R~u&kh__kgP#!R`*rQEnA+_dLkNP~L`0alC|J;c; zeiK=s8;BsLE)KbG3BD&Br@(Ha@SBT&$?xX`=$;eeel=|R_dIr6-Ro?=HEjnsJ_b`1 zK6Yg^-6;^2aW!xeTK)A~3Rm|L^FCHB_I>jIju7ZGo&N_1*QHkxH2!!%@o4iZ?vntS;&zJdPe1dH#04YD93A44o-MpfD zP{rn_aq>U%RDvC2+bp;xPlsOzauIi3*Lf42`jVKKZCRuKdYhi>FDuL2l=v{$BCN#Q6796s%r-AG$Q^t(3c@ zD?w0UhYr11@feiyl9kY_@H8~|xlmO<8PfQmj1!$@WieW@VxR@Psxfe-v9WCi1+f>F4VL?0O~K7T?m4-u|pSkBpUJZZe*16_wAp zSYZ@;k`3;W3UHKUWc8QeI}0jH5Ly=cGWQPw(Kr2fm=-5L(d`lcXofy8tJY3@Tuadz zYWXR{mW7XT!RF#RVCe%}=tM*O6!AD3^(!8un~opNI%Uko7$5t@<8+?; zTxDys(MyyGsUjtSu9$+|_-t!U3fVb1dkK?l`17<+jfl=hrBHnDSV>^R1=TnQeyqbW z>ov#l%!1|S!1>8UUxIdhQq`_klcHVx0{?#>K3#$4GlXncwldt!g17TcvKq-jo_996 z>oA=tH9CqRl6Yw?Uc`am!V?lHJbizOJaVaScf1UP5e7Dbgabq=b!B~T&_F6?ooU>w%x0A zH~&MHJ=q`fCH{U<7MDXE4SD32cDZA)WJeWkllJ`UspWaS#eDe^kg^oU_A14UE9zG-a^g{xaXf$})Wik>gT zl#dkzGr(;h0JZDuFn(+k8wNq?PZ5grQ<+sM?wBGt@JnH6v0#or-5wBQWKU~(S_> zkE!tc*ZJ1Y&*p(xX84POb3cClRMd!^qJ#CAZfIepEj-<`VURS_yCz0(?*Ixcj4 z-!zV1_QZhpm=0<;*(nm+F>T=)o?ep@CK5I%g^VAA+RB25ab?7)A~z~egru=I1S|@v zH7tXV!0wmGS^qj#e+MY;C5eUjEAp$Y?LDkS^QPZ}8WN85?r$u<-Epi;yZ1|J2J`se z$D6DpH~2F=eI0B&=UFAUnJvZAmClJlK)sutJ?M>xpZiWV&0=G4MZP+x+p>EX=HbCz zxls%Mw?*u^;LbHWIWCyq+yi)`GmFn9J112CZda_u@YIP%i;srFg_paU02Ifij*7}l z&CF-(3|>*a|+vbNR`^RP=9G?ymEJ0Z~)d&c*UE$UMepZ zcITr{0WqhxkjUnM15js_gW=e3Uh|y6ZReaXHIz-=p`x5VvB&rH9y>Amv@^WmXFEw) zQXYrk3feir=a{jMQ+wDIkkFnZ$k{sJakHn*?u za%4b!00ev8NVLM1TY=cl?KB&55BY_MU-sg?c>=Dbz_W{(Z~c?HJi*XpYL)C6Bd8WH zt+v-#0&o~@t4qESi*)+eW%@VD0|o^yF)n0hME$UtXF$*Lvh}7sso{`|pn*JDIy5^Fm3s$5*zEE=?u5<=l8FJc3r%+H} zdfoNl2J0^~!-*mOL5o-x32|e0Im*E!yY7F7E5N)W3>+v_LBydlEx?4$RL5f2oYRD# zaR0wv(-p~wO0eLDl3K=%`{5+0Gd$ktO=W)gWlGZJ0`K z$_RNA=ckrfa;H0KA~dR^p�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} zs4%E?srLW`CJh0GCIK@hTkrW7A15Iu%N&?Q^$0+!{Tv&|t^Y@u%!L zglTg&?Q5q#ijZ;&HBQ?FNPp;k3J5!&{^+SGq?AX~SiOM9jJMRpyP?RCr@z38AQyy&WRMaC;n4una$~nJKSp?q|s8F00c9?Q! zY_ovvjTFm+DeQM^LXJ#v0}6HRt3R1%5PT*}W!k8BEM;Jrj8dIceFo2fhzTqaB3KKk zGlCLI)gU25(#u6ch6GeB1k@eHq7l{EHXv0n6xE#ws#ri}08kkCf8hUt{|Ejb`2YW* zvg}0nSSX1m=76s?sZhRY$K=3dpJ+y*eDULGnL2}4>4nvW^7_<~wIM_5fjvwt4h1|g z)g0Z6ZFq9j<~9~b8((~TN{Z?ZQfw|is&Xp~AC61sj;xItKyCHdI|tCMC_LbXF>~vR z=w6V3^H=W4CbAgR4#xw}ETTwu2guW~=Crl@SMXv85jQ=%y!s^?m4PI0My7MWICO;- z175jm%&PcPWh8QdOU(#8bp4!N7ET-+)N}N2zk2)8ch|4Q&lPFNQgT-thu053`r*h3 z_8dI@G;`zn;lH$zX3RzIk`E8~`J=BBdR}qD%n@vVG1834)!pS1Y?zVkJGtsa(sB~y zNfMYKsOJb%5J(0ivK8d+l2D2y&5X!cg3BG!AJ}910|_${nF}sC1QF^nLIhzXk-Y#x z0)&1iK!O;Og0Ky!;`b~v%b$`S4E&fB)1NB4v@8wr( z&+NX4e^&o)ecb=)dd~C!{(1e6t?&9j{l8%U*k4)?`(L3;Qjw z#w7FS+U(94MaJKS!J9O8^$)36_J8;thW#2$y9i{bB{?M{QS_inZIJ!jwqAbfXYVd$ zQ5fC$6Nc9hFi8m^;oI-%C#BS|c8vy+@{jx6hFcf^_;2VRgkoN(0h!_VSGmgNPRsxI z8$rTo0LaYq-H5i&gtj81=&xU?H-Y2==G@uQV7E`@+2E9XQW@{&j`?EOktk|Ho{HU>ZqDzvgjwBmdex z&uZNd2C1h{{}2k6Ys9$*nFP3;K%u!MhW`uZy7Sn`1M1zs@Es&;z*Z>Gsh@-3Fe6pE zQD2@cqF((NrRevgvLsvM_8;;iNyJ5nyPyy?e!kvKjGj`6diRFBEe49Oa7wwkJFV7Z z$YT&DWloYu-H?3<0BKn9L&JYDT-SK~*6c5pi18P26$JESKRYj{T7Zk6KiRJcbvOO*{P56Q6s8msbeI3>|j>K9}Q9UBeq*inXKemCm`-<5|-$ZyN4u$(3 z&HcvqehFD%5Yrmykg-^d`=BSa8(i=>ZoC77^mWY{evp(km@aHqhUECBz76YiR+VYK zY_avFC~V3$=`6C4JhfHAQ@DZtUOwH`L;oYX6zK0-uI^?hS$ALfq}A7evR;ohJHij} zHSZdW?EKv9U1s4oD*<(0oQ*;MaQ6@cvGL zuHCPgm_NhVsgp^sfr*ia^Db}swo1?O(_Q2)y+S$CBm+g=9wCOUPbz(x)_GbaKa@A7 zuI&!ynLiZRT#V%_y_-D`0Z5lT*auoe{(U5NylTzFSJW()W-#F6*&A`LNO1bV#Y;QJ zSbLBnp|B^dtK|KIWC|No>JjWBWE@n7O)x{&^E(WMeMvp57#qA8m* zeTow*U@_86B#Fm*rxyYu5PRWaWHx8y> z*qmHEp(AMDl0v)ij(AY8fnH=~ZwwjVAbu*m5;xPfidh@ov6d8g zfJsi&!QyK53Es%sC39ts;54V68koALD4b|%tNHW0bIkZAJKa=W&FomJSEDT>W1xIX z1x%Z>AvNIsSPLcn3RTcHXb@KB?cuM)=x6fcIx>&(GxqZ8w3p#jJ(GVgc*`c0HG}dv zIop&Qim!K1NFwic%07KcjWgHBPUkq7f~lj;TPqVGTiT#cUeim>;nY`>h@a*S{qQex zQ`z62WK|Mj)Y{tfF{;T4P;c8$Q|KU?Joh zIkA^z%X7z|r>4aTh@|StTi!-r1D!g=zb#3d#{{&K3CqE$Iz-UH<%37c zRfkO`&uM%#AD3PHv`g5t0e^O%nVL0d{Xlx^EjEC3#skF@`zl-7PF^0oxW)1!C!JxR zWvuAHH?)61FKA1QeT*_sY7;_Id#!GmV4n`MO{~sv}VLSK` zXRw=Y=Clz*00B(5y^K;gCZMAzjT5+c3IC=)l(9VIDdatpxj3y89WwI|bH&$!ZEvp` zPR!T@#!(|KfI-w?!&+7$N3F6>tD{YO4Qg$d_`nNEdfVCha9vaPn0jI0`)`@*72hq! zpU5ND^P*RoEkbD5o#az(-g=Y)L>HH>Oc%}$ zT3Rs_ih0;4+Lv4Y;@Iv(;fUbQ=i-G(#>vghec~*j(I#r|5mqFiJBpzi&hzEcD{u$< zRsm0BVYn=pT;0>R(itW|*D&;O%bOc7et9ACaH#J>z3A1A~6fdP>pmbM%xzm4>|;c_?B+%sl;Qs2{t!60$^u zH1t@9^6>;?!FuusnISi$f5CL&;z?EqJN$FBuWDA#D5`cy_UvCFIVvf{c?4N0teh;d zET$7aVbj08KTQS!x?Nd1Is8q8qFzs}a=!@nJ;7FSfCY^T@D-gpw`w<6e#X3+;O}1h z$%I!M)0bg|EKUA04Qjn@+x{Rj8vt6Wn!R|3A92z}^$KfF5(#CWr4y#~re1CN4i4w0 z#GsypBR{xA3Er7sgAi(|}1-W?s~n$7?K|9WL8kpVfw-;#b9 z+mn;=ep!162U5R>_t}fOt~tE?s#m( zO-S$7>Ay6*hHdZ)7_oU915WYYCIX;hFI-U2EWYX!pllONr@Q--2o~`!isi6vTPLJ4@(|o=%NHYjo0_S&q*UQIROw@*N-By@PaQ&;YxFZ0aR zX&}LeOEz);#m~Hwm^VAY8DK}b$F4bo{jMN?d!lxKPhNklzr^Cd`0f4oJr^z=I|l`* zm8AHm*fPV`0=lF3Pnnp}&J0N1X@}-D94YvmUabFrLGSnTz7Mu^21F#O5tN#CuY9Vh zUZBH=ez%h*wkf0hBtXJh1SN3d+IF{gzT7lp)j}n?03lt;XSQRAh7qd&v;RwTYDuQ# zbI2*r<>?x-G0@hM{;%{VBD7nLKt~D`T~-HAt5;h%i0_=Ifs=yHma5dhJ+QMG?Ux(a z|E?1CMy1!~oA`FP!k~iG=t&5#>bVdz=peT8HMB6Y)#7PpETtNryT^+Rv3vpJaF^zP z{H}0-LyV9Fu21ID%wO9f1IKlFr1p4c{o-?03vyB-tr5duk^&L$;m_|f$vs`^Sl{j2 z95}oY{LlY+=ZS%J+tZoXCd0*sSU7w^gjovXn+g7uyra5{cU49@yHf#Z^Jl-$9cIfo z+AJuxH$VLb=#+uBbVmUjnx zxb1pZ@-O9=AIk4@S)m6fJ2?{HrNYwwnL3a45muuNjr;6$O`bGEM0T4A2_S$t=86*- zcO+0mywg*j#A4mU}enR_!cGmIYQ;qwfchWtFEXL)AK%*;=j znYne+hS4EMy3S)C*mZ1KI>!+)0V@9!N6H$Y}~MJ{rYuf zz^KljIWvFi-?#?V@LPR&c6Nn{!=XM z>}-h$S76;$H{E{Y%@^zlmOl^efBwa%UU+jJD9UVukQ3ti_kH-?H*RC0?M1W%FCvMB zM_+v6fk$6X2sx)-p~B3&Kl{nscK}pNLM*qjtpaf9>AU{-iPKQZR8yCg!TY}Qg*(;) z)gdvCcB%kppZc$VdvsK@)3l1{&DG!d_6OHOS`y=ITLEVu`unSKA2E%JD*DVX{LJ}K z9l>hMRDqxQh0lnpGHpVYneX}eA3Pt|2v%=q;rt)``R|#bDyB)OXY&vI_@|*}h}G?^ z@aZ4_!7cQPX`!fW_?{oT1NTwHs#l5L-0`E|y@48<3Q^HFf8=Idi zpJYD%1MkII!~|7I^WGo)IF=?{>ACnjJ_WUi39C}!Q{QnheVJqeKKqq5^o5CBde(g9 zvw$X6^jz_^E2$wSw4!q5*RG(C2_^XO$HBn_55vbl44OnTTRwRaePP0vo{K)U1#99& z<>rq7V&V(<&@I%MFoN5zrY}sz=(*-L&}1QQ*a%`u25h{cFj===17eB_uGuzG&byQ< zrm8BJZl4r_E$3k|Wo6FW0-6M7>qac5uFQsQcmkLWGfeH74S3Z_rJ!jgN++!@i=HW8 zkyjI(oPH-+-N#Qc^-mpNO`bc6r=2-<%&Wy5K1vfFJB(L_IkpS6fY^NmuL8qsgj>MD zn~BHH9WM~32_3vd=W&B)k7F9q%stJx+b_L_X-4zr^LVUMCmyCTA3sWtkvsmME?Xiy z?xOSfB=_$oY06~J-HcCq&)qcW{j;uP;?Dm}=hkq?zh&n!;m((-G-u_t|6x399Q;>A zgNpxoJNj{u|MFDH7Rhq@FCAl0dE|ddnl!oh9{Lq?@JDoR6L;C941IK`ISfdE$4S zE0AUQ8+2|Ncl_q5QkSp#AODp~(^mfP&%Au@@|TBQwoP`UU+V{6u8|)6ZA{~uKmQ*M zmrMTDU8S~8Eqi{^v0Ug&5Upcm#y7Z1(RbgZAG8jB$eRwCspQ)>5;U)oGZ&E5aeR*K z8Yt`Y0$G))Yd(Y3KH}tA4`-_QmNke5hU_|nq=xtyjwW(_o?itz>B>WM&^63bNdQ)k@-IgDHW*RW$Xo9#RzrTrCn7L2H{9Amq|qNg@#eZY=|P zCoI?2s+L)zsM%WX(NbVEY^`C>lFjIBYmJ6@DKJ0ZT4&F&WHW!dwa%QzOG!?jY_2(S zDcEzZbz*2Q!43|z))9yOP9X1Xt%DXzwY(3tl-TR=Qb_MbZYRrooh;dYYmS!U_as1(=YVB?Q_A|tNu5Ut&_q3jbfDM zoFxT^uEuH`nX3*sB%K?GuHUkweYReBwnHqh3P)~`+s3+Tj!rDA1e)8vuBv5J*IsxC zkd^~b(aGzArj08{>cnzOuy04C+C`}gb|Yz-1avxeWzev3NzcHbz_&4W@QCr$z3~w=8Ua- z`;vfG1~BP8CyLb=F7t1am~ph_#|O%$khSJ9%Vtcn)YmpgQxF?xM^_Vb+5fnpB^W0I`f%X8gb9#X{Q-yJG0{Z56aWeI&zPxnf5pdJA38bM`cYnS#x)% z`n1tFf$i)W-hGm(f9mde^=X@NcV_lFb=P`4&CI&H=IArijGwdCk&X@uQ$5xmj!~^? z#$ROCI)V-~t%L%GS#wo@U27ddR`4`3)WoB{R-4snfNrfee|kI8^bu#yDgYqOwas9# zmcb`3!kRJ`Cr=_tq)8aMt{aGtUZsqwVlj6DgCGre>AEt&x8H_in!x@uwgExIh|-mA zjdaC(29~CTVSaaF7HPbql&*9Uo8P@f)>LqCXclr}peS7_1BQ28u9PO8Eq1@`l3q9o zkfKCaO2?T?ZyA6loW<#9_c^O=m<&h}CA!ineAD@=(gbq`vyT|tiJ6#^B1$P;;qax` z55k&Q?wEh#87niLo*+n4L@65J(Nz~=Ya%7^(miLb(E>A3B@|Jjl;FU&D>o|9#7PJH z?|ago!o;WC^h=|T7PVBg(DAB}72cyUS zb(f>Bwbr!F1eTCO5fpj<{PqhY5>143p?~5ZA5H40);=@M#MYvrB6gqHbU_!GSY??i z%s=>-ciA4*zOOZHds0a(kWewZ4h(k8h(ua7HX)Au&mY~H8KY6(_cb$_&fA@QjIW-*heP3%$d!m5^AdnT}`12qA^c@!g3DOwZ5WwE2?)-yU z!)Vx#Mtxt?FzFTwK!77sy7)sMzUd->w4^bxtpM2j!b1pjgyk zGKwWGeb4)^zjy{9Es&PU1}gwg?|J#L$KJB7ett9@4M%-nGtIQr0>Fl@8-yh`-+1ed zS6r}(MeSvgSoFmH*_WPu@i?}!AB~2?;i&IxrkNg~cQ9Som98tcq)k^|eeER|Zl77t za-TVUc;DNvzVXJ%w52+#weN?+;i#{f#!Oc&z?81*N>^e~ltRS%ZI@lR{rs()HmqG! zx*}ZrI-EZ}ckJMiy>A^oofwDfC~IH)z8{VHKGT@#E5I(Ll&+MnMCl>~AV7+>Gi%mF zkU1QlKASdR0B80!YhP<$Ywi0?W2Ux45oPfxv9QolWzJPD^weBfvo4SONxP35106sAmh(e+vAs0GboFD@PvNs)jNPvarhW}0YliZEg{Gazv z+JDIpoojRVPr<*C|BTq<`6ga{5q^8^!|0cxe=rZ!zxH3%f5ZO0cQ*Z<^$Yt2{|Ek0 zyT|*F+CO@K;(owBKtGg!S^xj-Z~rga2m6nxKl9J=fBSuNKW_dLKWhJKeg^-Xe`^1? z`TyJj)8E!#>_3Y?uKrwqq3LJ#SGU>AzUO|6`nR^u&3FNN_jGOc zw)Nw`wr3yIKhgcee6IaN=ws>M{6677%)hPwx&HzC(f&u~&)6@b2kNRzBDQAP0*H73 zq%McOmRk{B3i47qRe=DA*$&odrbEJZ*pV9XXa&p@wlW~@Yfs>V{yiTtplMhgM*-Bz zsSnlq&pG;z0OUN%$~$3=g1UF+G*>+17eRbBf3=y79J}KR8owon@$1Z7MIrvvWWH)34nK2SD)GsrJ{l z1Cl#oVo3A8qY3e=aF)qzms~FG#2$LzT=gs&aVMOj>(%{y<&O0cG!nCiESl~x=^dF{ zKvj8F1K8Ng171wwM5Fh4KoQw`_c6#y$(5cAm7e}~nJ#A*fx+c9;y#&W!#VukR)ugk zKp3=+;Ut+IYn%m+r4d*<`L2h%aDnX5}^!5R|H;(34AoVWjRx(msBZvk;rCI*|~ zdOijqI@9Z{Vu!~jvHW{lBa$rnl4+!s_5sfK3bCGk-B%iDe&@-}+%fOKU|(9?V1 zHE8&@4z)Kx!RAvAs z!Wic9=o#(bg?kc-G68-m(jZ`^=XGUXb)}t(%&~sjFnV^sEX%hSy6UKC4iOhgV=BHV z2w`4g7Y=s#Vu2B_?#VQ|hP39@eArgfX>-0S+dd&^mx0*wp}>)x;c4RUgxz%;oNe?& z-7-lJ@Y^2^C;=qJsxx5|xF)*pTGhch2B&kxtn;f!7=gznk}I3}Dh}(CoMXgA5-p&kS202!l?!fT3t|HG*rIP~mS* z$Wjo}jq3}z$Qq!9yrtd3fM0N629ZM?LU$nv@Tv9b7I;D|;0H2dsA~g7Z7zp1| zB)XmrkMgF6OQr|R)HHD^TE{Y#j!~SR?b`Xt3Qs`B+x<hxexYeAjMUWdZ-*n9%(1)Wb(n2U<><7&9dwGJmrob)4%H? zlQ%z+L-^$dFhhH|@u$%97Qz?*Ynh2VG@q|?8vY&L74&fs&_b&3$x&Oyjl~LQDRRap zJU4U*R+(2Dd!G+lh8!V{pT_UJn+^1Qg6$` zqkNm(a#hWyc6SP+p5=C4HL8-m`pO`5o~`-LI?_h5CsH?F_%?nDodmz&pWR20WTpJE z?N|wSzLjMUK8E)a2tI}Lf;+;*M|h3Y(U#>)g1>zk9|Hd}oZAa2 zLYBWBoSW!Ts!RwXr^8h+U*@{9{zqS^iH)Op<;r`Uw~nc}<^$V~_i%$GFjaG?X1@E|M`h)nekvFKt`Dh-f>@|0-`Xoq)o` zx;JmzDfOV9qCx|EVpogEe0LK~tGS?5$$L_i6P$P6wIsCQaP_;d{{N=iV@+8LI}o#( zvo*Ejy=IIn{rdIQh1&q-{EuohpVOjJ^Q3lD*YTp37$^RRgn8ihpdu5{Ct%5-KO!VL zcNB6dUajXI9jkm-P|i3~GB-A(X`P1Oqqb$tcku)UJw0w3GeUijb__#QT4j%64z%EeB7S?jlWwx_7&+EEvB|6N=kV}DwnyAlX=?j`) zmU#!$*^@NIu#n_d7;WoJV@*Fbv9|yJO4;n|BNF2xy(54RyB>t~8lUOUW$&2%Nwi1y zx6JxW88>U2$#qhl^6KUbtmg9}D0o5vYDT7kWJthLGkpGnN4T>{St^_EU>4;DmLF9o zr|LqsA8_MoNLQ=}w?8u!ziSZ@PC#Y<#9uJFo-ozVo6D;<8j^1$c|qAE3ZTE5i~zmE z$BU5lw6l=EWsg^y^;8>r9qH{xfL|~PZYK#md$zZ0?o11gV<*WSW~cgy2GYGQir%wf zt4iW8D+;s*;RGrmd(-T<@2&j(Cb9xhV*l-x`TpK`xq|7p?5R%5*s!69?2c!cC*VY* z2DE^9pvOPLU!1e}wA8S8opcTJ3`NB>hY=JQnL~QFXR4K8A$BqJnoEB$wn-%u@E6Mh zCfMF4kusv3N!(aHC}4)Xs^xoOwXd%e^6pi5|DZo=Q25j+6HlJ^7FodH6y1bMROR^q zGu6)fopS`h%Sw<;ZH%TEPf+#81-#_v+@8nlR0jLcIDKQtLleOC)6yLZgC!D9X3GgS zohwU{v$jl=quD#Go^hB{`@Qw*a%`(^jyT~=q^bWgGzRj;|12J55HWdCWV}EB|K=%N z3Nq-qxJJ`>^|1MNN+q}zTB&ooE3j==AgK@^UW<^oSbeALa2peF)Th6{@sj0KyMNHZ zksk1+MXN2tv+22A%cQOGpS9)77(uP9mh+!5T5ERLvF@b}$+WvXM45Z?-kCa)fb~f1 znVbTD$Gx-0Zxc`0D@YgHakge6SL0H`-vN_x?AP0>iGH0_EE&=v83hMJgaKAI0jJXm zVxVz;X<$v6WW7}fxROO7vr#YLP;;lij5VrX{;>7kK6TtOH&6|Ar^xo>00%+u$C4@# z>!jOt6*3><171+WxoZnKDTzJtDRw+T030;yI}~uV@9fCnei^I*j>Bp&mzP2d=FPb_ zCM*l_+$LDR3B*a!A$g#>xsrZvw0lckxmMg>0aQd7tPyN=t{dgXb;Ie+T8{fZH=gdu zM7Rg9c(kg(Jg0?ARRRl=AONFKrvFj)lTY$KfT%6^6s`mk*ABGhsce*LsoD>K{z_M2 ziPpnu+lw22PfF!CoId^6n*G4H(Ix+#+N{C(da7t1BYMGEaE#PdpOLxsVD5riQXHp@OX;`S`8VnpM~)I920w~<3|mo0 zf8~Az`*?2?H&gZ&*K&bRkV@qzvMlRHXys8*Ze2+1c?5o!^+$&MHxB@4Ee5cke52R! zmn7AZtY6ST%ixgU5)%$%QcwHj7Es-Qu^kLAPwy%7pGBw_4Q9#da^W2$}axNHr03)_nw z5?yuNmXrI5HgS46)c5&}B)Tts49oU92>3xBLLy}FMUW=84DQbVq^;7_e7|(Sdz|&J z73N+M`rc2rt*oSWu#7S{*s~nH6HRHJS1SmzeXk|;CA)FI4bat3<%}nkB%;;?=F>B7ms9QSxv#@+69;@>QaR?REYX4&)=itG>rM{<{A79Rmk)`5ON#GL`*KX%}Ihk3w(RtM-WLt z?f&FLF}4N^yE!(pZ&Yj&Bc`~K0@4_}*0Om?wN|}4WJ>WL;G^H2*QpgEkGA~OET-Km zkwz|5{6dnz1U<2Pe9DNL>3g5FEIvp1jzP&2K#z~j%g6!7B;^zF+o95?fV{3mnB8*RMhCDNp>Am-3e@jNfMj?jHV$MWjk!DDKP zkAz$Y?Sr)!GUOX}qTQ5aMh|wq1uq}~joWyKl=b_LboM#wi{CMuz5x6BKlA-qy++cM01D3b7`uD z#l6M4pI;JCypO8JZ6?U&wNxR!{4oB_ zlV!x9+-&Qy6{%MQ{~yoZGkKiTSC`YS_j22~G;xUV855g2&C(zm^V!(wpcm@zn{%!g z4}JGo(sGZ1O~to-}le

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/composeapp/src/main/res/values/colors.xml b/composeapp/src/main/res/values/colors.xml new file mode 100644 index 000000000..f8c6127d3 --- /dev/null +++ b/composeapp/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/composeapp/src/main/res/values/strings.xml b/composeapp/src/main/res/values/strings.xml new file mode 100644 index 000000000..c226d8f8f --- /dev/null +++ b/composeapp/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + ComposeApp + \ No newline at end of file diff --git a/composeapp/src/main/res/values/themes.xml b/composeapp/src/main/res/values/themes.xml new file mode 100644 index 000000000..1f225670b --- /dev/null +++ b/composeapp/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +