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/build.gradle.kts b/auth/build.gradle.kts index 66df263a8..b26051d1c 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,7 +113,9 @@ 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) + testImplementation(libs.androidx.ui.test.junit4) debugImplementation(project(":internal:lintchecks")) } 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/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 new file mode 100644 index 000000000..fe1f6cf80 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt @@ -0,0 +1,462 @@ +/* + * 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.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 + +/** + * 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 +) { + + 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 + } + + /** + * 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() + + /** 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/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..87008cd28 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt @@ -0,0 +1,480 @@ +/* + * 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.compose.ui.graphics.Color +import android.util.Log +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 +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"); + + companion object { + fun fromId(id: String): Provider? { + return entries.find { it.id == id } + } + } +} + +/** + * 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, + + /** + * 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. + */ + 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 = 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 phone number in international format. + */ + val defaultNumber: String?, + + /** + * 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) { + 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. + */ + 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 + ) { + 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. + */ + 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 + ) { + 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. + */ + 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) { + 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. + */ + 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: AuthUIAsset?, + + /** + * An optional background color for the provider button. + */ + val buttonColor: Color?, + + /** + * An optional content color for the provider button. + */ + val contentColor: Color? + ) : OAuthProvider( + 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 new file mode 100644 index 000000000..6fb0202de --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.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 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.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() + +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 locale: Locale? = null + var stringProvider: AuthUIStringProvider? = 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) = + providers.addAll(AuthProvidersBuilder().apply(block).build()) + + internal fun build(): AuthUIConfiguration { + val context = requireNotNull(context) { + "Application context is required" + } + + require(providers.isNotEmpty()) { + "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 + AuthProvider.Anonymous.validate(providers) + + // 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() + + 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 + } + } + + 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 + ) + } +} + +/** + * Configuration object for the authentication flow. + */ +class AuthUIConfiguration( + /** + * Application context + */ + val context: Context, + + /** + * 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, + + /** + * The locale for internationalization. + */ + val locale: Locale? = null, + + /** + * A custom provider for localized strings. + */ + val stringProvider: AuthUIStringProvider = DefaultAuthUIStringProvider(context, locale), + + /** + * 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/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt new file mode 100644 index 000000000..7073fbe6e --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt @@ -0,0 +1,123 @@ +/* + * 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.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, + * 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() { + 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() { + 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() { + 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() { + 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() { + 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 + ) : 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/string_provider/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProvider.kt new file mode 100644 index 000000000..f81ead323 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProvider.kt @@ -0,0 +1,227 @@ +/* + * 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.string_provider + +/** + * 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 + + // 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/string_provider/AuthUIStringProviderSample.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProviderSample.kt new file mode 100644 index 000000000..af0c830cc --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/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.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 + +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/string_provider/DefaultAuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt new file mode 100644 index 000000000..5eba036af --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -0,0 +1,212 @@ +/* + * 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.string_provider + +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) + + /** + * 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/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 new file mode 100644 index 000000000..4af62ffc8 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt @@ -0,0 +1,146 @@ +/* + * 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 provider's icon. + */ + val icon: AuthUIAsset?, + + /** + * 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 + ) { + 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 { + /** + * 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..7f053fbd3 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt @@ -0,0 +1,116 @@ +/* + * 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.R +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( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_googleg_color_24dp), + backgroundColor = Color.White, + contentColor = Color(0xFF757575) + ) + } + + Provider.FACEBOOK -> { + provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_facebook_white_22dp), + backgroundColor = Color(0xFF3B5998), + contentColor = Color.White + ) + } + + Provider.TWITTER -> { + provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_twitter_bird_white_24dp), + backgroundColor = Color(0xFF5BAAF4), + contentColor = Color.White + ) + } + + Provider.GITHUB -> { + provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_github_white_24dp), + backgroundColor = Color(0xFF24292E), + contentColor = Color.White + ) + } + + Provider.EMAIL -> { + provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_mail_white_24dp), + backgroundColor = Color(0xFFD0021B), + contentColor = Color.White + ) + } + + Provider.PHONE -> { + provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_phone_white_24dp), + backgroundColor = Color(0xFF43C5A5), + contentColor = Color.White + ) + } + + Provider.ANONYMOUS -> { + provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_anonymous_white_24dp), + backgroundColor = Color(0xFFF4B400), + contentColor = Color.White + ) + } + + Provider.MICROSOFT -> { + provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_microsoft_24dp), + backgroundColor = Color(0xFF2F2F2F), + contentColor = Color.White + ) + } + + Provider.YAHOO -> { + provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_yahoo_24dp), + backgroundColor = Color(0xFF720E9E), + contentColor = Color.White + ) + } + + Provider.APPLE -> { + provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_apple_white_24dp), + backgroundColor = Color.Black, + contentColor = Color.White + ) + } + } + } +} \ 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..30582a309 --- /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.string_provider.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..88cf98875 --- /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.string_provider.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..2d9efafc1 --- /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.string_provider.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/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt new file mode 100644 index 000000000..255d6c59e --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt @@ -0,0 +1,287 @@ +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.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.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 + +/** + * 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 providerLabel = resolveProviderLabel(provider, stringProvider) + + Button( + modifier = modifier, + contentPadding = PaddingValues(horizontal = 12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = providerStyle.backgroundColor, + contentColor = providerStyle.contentColor, + ), + shape = providerStyle.shape, + elevation = ButtonDefaults.buttonElevation( + defaultElevation = providerStyle.elevation + ), + onClick = onClick, + enabled = enabled, + ) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val providerIcon = providerStyle.icon + if (providerIcon != null) { + val iconTint = providerStyle.iconTint + if (iconTint != null) { + Icon( + painter = providerIcon.painter, + contentDescription = providerLabel, + tint = iconTint + ) + } else { + Image( + painter = providerIcon.painter, + contentDescription = providerLabel + ) + } + Spacer(modifier = Modifier.width(12.dp)) + } + Text( + text = providerLabel, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + } +} + +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/ui/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt new file mode 100644 index 000000000..732a48662 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.components + +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.AuthException +import com.firebase.ui.auth.compose.configuration.string_provider.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/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-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 52314a505..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 @@ -93,6 +94,14 @@ An unknown error occurred. 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 + Password must contain at least one number + Password must contain at least one special character + App logo @@ -131,5 +140,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/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/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 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 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..5fd0d201c --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt @@ -0,0 +1,525 @@ +/* + * 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.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 +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 + +/** + * 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) + } + + // ============================================================================================= + // 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 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..c473867c4 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt @@ -0,0 +1,402 @@ +/* + * 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, + contentColor = 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, + contentColor = 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, + contentColor = 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 new file mode 100644 index 000000000..f08be227f --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -0,0 +1,428 @@ +/* + * 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 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.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 +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 + +/** + * Unit tests for [AuthUIConfiguration] covering configuration builder behavior, + * validation rules, provider setup, and immutability guarantees. + * + * @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 + // ============================================================================================= + + @Test + fun `authUIConfiguration with minimal setup uses correct defaults`() { + val config = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "test_client_id" + ) + ) + } + } + + assertThat(config.context).isEqualTo(applicationContext) + assertThat(config.providers).hasSize(1) + assertThat(config.theme).isEqualTo(AuthUITheme.Default) + assertThat(config.stringProvider).isInstanceOf(DefaultAuthUIStringProvider::class.java) + 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 = mock(AuthUIStringProvider::class.java) + val customLocale = Locale.US + val customActionCodeSettings = actionCodeSettings { + url = "https://example.com/verify" + handleCodeInApp = true + } + + val config = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "test_client_id" + ) + ) + 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.context).isEqualTo(applicationContext) + 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() + } + + @Test + fun `providers block can be called multiple times and accumulates providers`() { + val config = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "test_client_id" + ) + ) + } + + 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 = "test_client_id" + ) + ) + } + 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 = "test_client_id" + ) + ) + } + 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 = "test_client_id" + ) + ) + } + 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 + fun `authUIConfiguration throws when no context configured`() { + try { + authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "test_client_id" + ) + ) + } + } + } catch (e: Exception) { + assertThat(e.message).isEqualTo("Application context is required") + } + } + + @Test + fun `authUIConfiguration throws when no providers configured`() { + 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 = "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( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + provider( + AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = listOf() + ) + ) + } + } + assertThat(config.providers).hasSize(9) + } + + @Test + fun `validation throws for unsupported provider`() { + val mockProvider = AuthProvider.GenericOAuth( + providerId = "unsupported.provider", + scopes = listOf(), + customParameters = mapOf(), + buttonLabel = "Test", + buttonIcon = null, + buttonColor = null, + contentColor = null, + ) + + 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 + fun `validate throws for duplicate providers`() { + 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" + ) + } + } + + // ============================================================================================= + // Builder Immutability Tests + // ============================================================================================= + + @Test + fun `authUIConfiguration providers list is immutable`() { + val config = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "test_client_id" + ) + ) + } + } + + 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( + "context", + "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/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..dbaccbcfb --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt @@ -0,0 +1,338 @@ +/* + * 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.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 +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: AuthUIStringProvider + + @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/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/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..3715d63ec --- /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.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 +import org.junit.runner.RunWith +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: AuthUIStringProvider + + 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..27d34b6a6 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.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.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.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 +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: AuthUIStringProvider + 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 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 new file mode 100644 index 000000000..faae2cf48 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/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.ui.components + +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.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.google.common.truth.Truth.assertThat +import 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/ui/components/ErrorRecoveryDialogLogicTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialogLogicTest.kt new file mode 100644 index 000000000..6a4f5df2f --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialogLogicTest.kt @@ -0,0 +1,302 @@ +package com.firebase.ui.auth.compose.ui.components + +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 +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 = 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.") + } + + // ============================================================================================= + // 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 + Truth.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 + Truth.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 + Truth.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 + 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 + 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 + Truth.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 + Truth.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 + Truth.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 + Truth.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 + Truth.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 + Truth.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 + Truth.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 + Truth.assertThat(actionText).isEqualTo("Continue") + } + + // ============================================================================================= + // Recoverable Tests + // ============================================================================================= + + @Test + fun `isRecoverable returns true for NetworkException`() { + // Arrange + val error = AuthException.NetworkException("Network error") + + // Act & Assert + Truth.assertThat(isRecoverable(error)).isTrue() + } + + @Test + fun `isRecoverable returns true for InvalidCredentialsException`() { + // Arrange + val error = AuthException.InvalidCredentialsException("Invalid credentials") + + // Act & Assert + Truth.assertThat(isRecoverable(error)).isTrue() + } + + @Test + fun `isRecoverable returns false for TooManyRequestsException`() { + // Arrange + val error = AuthException.TooManyRequestsException("Too many requests") + + // Act & Assert + Truth.assertThat(isRecoverable(error)).isFalse() + } + + @Test + fun `isRecoverable returns true for MfaRequiredException`() { + // Arrange + val error = AuthException.MfaRequiredException("MFA required") + + // Act & Assert + Truth.assertThat(isRecoverable(error)).isTrue() + } + + @Test + fun `isRecoverable returns true for UnknownException`() { + // Arrange + val error = AuthException.UnknownException("Unknown error") + + // Act & Assert + Truth.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 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 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 e4aed3d45..8b2317698 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -1,17 +1,17 @@ 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" + 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 } 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" @@ -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.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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..44eb15432 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,9 @@ +[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 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