diff --git a/.firebase/hosting.cHVibGlj.cache b/.firebase/hosting.cHVibGlj.cache new file mode 100644 index 000000000..ef01935eb --- /dev/null +++ b/.firebase/hosting.cHVibGlj.cache @@ -0,0 +1,2 @@ +index.html,1760725054923,96d3ff69603ba92f085431c7b56242a873ddcdd5a1c9691f7836b093f8114a5a +.well-known/assetlinks.json,1760725039101,cbfe2437a47d2f4a2bca9bb7c1c789b4684d6a13694821e46e4177ccce023f4b diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 2eddf8176..0e948843f 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -7,14 +7,27 @@ on: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 45 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 21 + uses: actions/setup-java@v4 with: - java-version: 17 - - name: Build with Gradle + java-version: '21' + distribution: 'temurin' + + - name: Build and Test run: ./scripts/build.sh + - name: Print Logs if: failure() run: ./scripts/print_build_logs.sh diff --git a/.github/workflows/e2e_test.yml b/.github/workflows/e2e_test.yml new file mode 100644 index 000000000..ce4bd9e02 --- /dev/null +++ b/.github/workflows/e2e_test.yml @@ -0,0 +1,52 @@ +name: E2E Tests (Firebase Emulator) + +on: + - pull_request + - push + +jobs: + e2e-tests: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - 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: Firebase Emulator Cache + uses: actions/cache@v4 + with: + path: ~/.cache/firebase/emulators + key: firebase-emulators-v3-${{ runner.os }} + + - name: Install Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Install Firebase Tools + run: | + npm i -g firebase-tools + + - name: Start Firebase Auth Emulator + run: ./scripts/start-firebase-emulator.sh + + - name: Run E2E Tests + run: | + ./gradlew e2eTest + + - name: Print Logs + if: failure() + run: ./scripts/print_build_logs.sh diff --git a/.gitignore b/.gitignore index ca0f434ff..9f97719ae 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ google-services.json crashlytics-build.properties auth/src/main/res/values/com_crashlytics_export_strings.xml *.log +composeapp/.firebaserc +composeapp/firebase.json diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index 66df263a8..331e99c0e 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -1,9 +1,8 @@ -import com.android.build.gradle.internal.dsl.TestOptions - plugins { id("com.android.library") id("com.vanniktech.maven.publish") id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") version Config.kotlinVersion } android { @@ -12,8 +11,9 @@ android { defaultConfig { minSdk = Config.SdkVersions.min - targetSdk =Config.SdkVersions.target + targetSdk = Config.SdkVersions.target + buildConfigField("String", "LIBRARY_NAME", "\"Firebase-UI-Android\"") buildConfigField("String", "VERSION_NAME", "\"${Config.version}\"") resourcePrefix("fui_") @@ -26,8 +26,8 @@ android { consumerProguardFiles("auth-proguard.pro") } } - - compileOptions { + + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -67,30 +67,52 @@ android { kotlinOptions { jvmTarget = "17" } + buildFeatures { + compose = true + buildConfig = true + } } dependencies { - implementation(Config.Libs.Androidx.materialDesign) + 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.activity) + implementation(Config.Libs.Androidx.materialDesign) + implementation(Config.Libs.Androidx.Compose.materialIconsExtended) + implementation(Config.Libs.Androidx.datastorePreferences) // The new activity result APIs force us to include Fragment 1.3.0 // See https://issuetracker.google.com/issues/152554847 implementation(Config.Libs.Androidx.fragment) implementation(Config.Libs.Androidx.customTabs) implementation(Config.Libs.Androidx.constraint) - implementation("androidx.credentials:credentials:1.3.0") - implementation("androidx.credentials:credentials-play-services-auth:1.3.0") + + // Google Authentication + implementation(Config.Libs.Androidx.credentials) + implementation(Config.Libs.Androidx.credentialsPlayServices) + implementation(Config.Libs.Misc.googleid) + implementation(Config.Libs.PlayServices.auth) + //api(Config.Libs.PlayServices.auth) implementation(Config.Libs.Androidx.lifecycleExtensions) implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") - implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1") + implementation("androidx.navigation:navigation-compose:2.8.3") + implementation("com.google.zxing:core:3.5.3") annotationProcessor(Config.Libs.Androidx.lifecycleCompiler) implementation(platform(Config.Libs.Firebase.bom)) api(Config.Libs.Firebase.auth) - api(Config.Libs.PlayServices.auth) + + // Phone number validation + implementation(Config.Libs.Misc.libphonenumber) compileOnly(Config.Libs.Provider.facebook) implementation(Config.Libs.Androidx.legacySupportv4) // Needed to override deps @@ -98,10 +120,27 @@ dependencies { testImplementation(Config.Libs.Test.junit) testImplementation(Config.Libs.Test.truth) - testImplementation(Config.Libs.Test.mockito) testImplementation(Config.Libs.Test.core) testImplementation(Config.Libs.Test.robolectric) + testImplementation(Config.Libs.Test.kotlinReflect) testImplementation(Config.Libs.Provider.facebook) + testImplementation(Config.Libs.Test.mockitoCore) + testImplementation(Config.Libs.Test.mockitoInline) + testImplementation(Config.Libs.Test.mockitoKotlin) + testImplementation(Config.Libs.Androidx.credentials) + testImplementation(Config.Libs.Test.composeUiTestJunit4) debugImplementation(project(":internal:lintchecks")) } + +val mockitoAgent by configurations.creating + +dependencies { + mockitoAgent(Config.Libs.Test.mockitoCore) { + isTransitive = false + } +} + +tasks.withType().configureEach { + jvmArgs("-javaagent:${mockitoAgent.asPath}") +} diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml index bb1a19204..bcd60c4ca 100644 --- a/auth/src/main/AndroidManifest.xml +++ b/auth/src/main/AndroidManifest.xml @@ -19,6 +19,12 @@ + + + + @@ -27,6 +33,10 @@ android:name="com.facebook.sdk.ApplicationId" android:value="@string/facebook_application_id" /> + + @@ -99,6 +109,11 @@ android:label="@string/fui_default_toolbar_title" android:exported="false" /> + + { + * // 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. For example, when a user + * tries to sign in with Facebook but an account already exists with that + * email using a different provider (like email/password). + * + * @property message The detailed error message + * @property email The email address that already has an account (optional) + * @property credential The credential that should be linked after signing in (optional) + * @property cause The underlying [Throwable] that caused this exception + */ + class AccountLinkingRequiredException( + message: String, + val email: String? = null, + val credential: AuthCredential? = null, + 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) + + /** + * The email link used for sign-in is invalid or malformed. + * + * This exception is thrown when the link is not a valid Firebase email link, + * has incorrect format, or is missing required parameters. + * + * @property cause The underlying [Throwable] that caused this exception + */ + class InvalidEmailLinkException( + cause: Throwable? = null + ) : AuthException("You are are attempting to sign in with an invalid email link", cause) + + /** + * The email link is being used on a different device than where it was requested. + * + * This exception is thrown when `forceSameDevice = true` and the user opens + * the link on a different device than the one used to request it. + * + * @property cause The underlying [Throwable] that caused this exception + */ + class EmailLinkWrongDeviceException( + cause: Throwable? = null + ) : AuthException("You must open the email link on the same device.", cause) + + /** + * Cross-device account linking is required to complete email link sign-in. + * + * This exception is thrown when the email link matches an existing account with + * a social provider (Google/Facebook), and the user needs to sign in with that + * provider to link accounts. + * + * @property providerName The name of the social provider that needs to be linked + * @property emailLink The email link being processed + * @property cause The underlying [Throwable] that caused this exception + */ + class EmailLinkCrossDeviceLinkingException( + val providerName: String? = null, + val emailLink: String? = null, + cause: Throwable? = null + ) : AuthException("You must determine if you want to continue linking or " + + "complete the sign in", cause) + + /** + * User needs to provide their email address to complete email link sign-in. + * + * This exception is thrown when the email link is opened on a different device + * and the email address cannot be determined from stored session data. + * + * @property emailLink The email link to be used after email is provided + * @property cause The underlying [Throwable] that caused this exception + */ + class EmailLinkPromptForEmailException( + cause: Throwable? = null, + val emailLink: String? = null, + ) : AuthException("Please enter your email to continue signing in", cause) + + /** + * Email link sign-in attempted with a different anonymous user than expected. + * + * This exception is thrown when an email link for anonymous account upgrade is + * opened on a device with a different anonymous user session. + * + * @property cause The underlying [Throwable] that caused this exception + */ + class EmailLinkDifferentAnonymousUserException( + cause: Throwable? = null + ) : AuthException("The session associated with this sign-in request has either " + + "expired or was cleared", cause) + + /** + * The email address provided does not match the email link. + * + * This exception is thrown when the user enters an email address that doesn't + * match the email to which the sign-in link was sent. + * + * @property cause The underlying [Throwable] that caused this exception + */ + class EmailMismatchException( + cause: Throwable? = null + ) : AuthException("You are are attempting to sign in a different email " + + "than previously provided", cause) + + companion object { + /** + * Creates an appropriate [AuthException] instance from a Firebase authentication exception. + * + * 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) { + // If already an AuthException, return it directly + is AuthException -> 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 + ) + } + } + } + } + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthFlowController.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthFlowController.kt new file mode 100644 index 000000000..6d508e7df --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthFlowController.kt @@ -0,0 +1,274 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Controller for managing the Firebase authentication flow lifecycle. + * + * This controller provides a lifecycle-safe way to start, monitor, and cancel + * the authentication flow. It handles coroutine lifecycle, state listeners, + * and resource cleanup automatically. + * + * **Usage Pattern:** + * ```kotlin + * class MyActivity : ComponentActivity() { + * private lateinit var authController: AuthFlowController + * + * private val authLauncher = registerForActivityResult( + * ActivityResultContracts.StartActivityForResult() + * ) { result -> + * // Auth flow completed + * } + * + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * + * val authUI = FirebaseAuthUI.getInstance() + * val configuration = authUIConfiguration { + * providers = listOf( + * AuthProvider.Email(), + * AuthProvider.Google(...) + * ) + * } + * + * authController = authUI.createAuthFlow(configuration) + * + * // Observe auth state + * lifecycleScope.launch { + * authController.authStateFlow.collect { state -> + * when (state) { + * is AuthState.Success -> { + * // User signed in successfully + * val user = state.user + * } + * is AuthState.Error -> { + * // Handle error + * } + * is AuthState.Cancelled -> { + * // User cancelled + * } + * else -> {} + * } + * } + * } + * + * // Start auth flow + * val intent = authController.createIntent(this) + * authLauncher.launch(intent) + * } + * + * override fun onDestroy() { + * super.onDestroy() + * authController.dispose() + * } + * } + * ``` + * + * **Lifecycle Management:** + * - [createIntent] - Generate Intent to start the auth flow Activity + * - [start] - Alternative to launch the flow (for Activity context) + * - [cancel] - Cancel the ongoing auth flow, transitions to [AuthState.Cancelled] + * - [dispose] - Release all resources (coroutines, listeners). Call in onDestroy() + * + * @property authUI The [FirebaseAuthUI] instance managing authentication + * @property configuration The [AuthUIConfiguration] defining the auth flow behavior + * + * @since 10.0.0 + */ +class AuthFlowController internal constructor( + private val authUI: FirebaseAuthUI, + private val configuration: AuthUIConfiguration +) { + + private val coroutineScope = CoroutineScope(Dispatchers.Main + Job()) + private val isDisposed = AtomicBoolean(false) + private var stateCollectionJob: Job? = null + + /** + * Flow of [AuthState] changes during the authentication flow. + * + * Subscribe to this flow to observe authentication state changes. + * The flow is backed by the [FirebaseAuthUI.authStateFlow] and will + * emit states like: + * - [AuthState.Idle] - No active authentication + * - [AuthState.Loading] - Authentication in progress + * - [AuthState.Success] - User signed in successfully + * - [AuthState.Error] - Authentication error occurred + * - [AuthState.Cancelled] - User cancelled the flow + * - [AuthState.RequiresMfa] - Multi-factor authentication required + * - [AuthState.RequiresEmailVerification] - Email verification required + */ + val authStateFlow: Flow + get() { + checkNotDisposed() + return authUI.authStateFlow() + } + + /** + * Creates an Intent to launch the Firebase authentication flow. + * + * Use this method with [ActivityResultLauncher] to start the auth flow + * and handle the result in a lifecycle-aware manner. + * + * **Example:** + * ```kotlin + * val authLauncher = registerForActivityResult( + * ActivityResultContracts.StartActivityForResult() + * ) { result -> + * if (result.resultCode == Activity.RESULT_OK) { + * // Auth flow completed successfully + * } else { + * // Auth flow cancelled or error + * } + * } + * + * val intent = authController.createIntent(this) + * authLauncher.launch(intent) + * ``` + * + * @param context Android [Context] to create the Intent + * @return [Intent] configured to launch the auth flow Activity + * @throws IllegalStateException if the controller has been disposed + */ + fun createIntent(context: Context): Intent { + checkNotDisposed() + return FirebaseAuthActivity.createIntent(context, configuration) + } + + /** + * Starts the Firebase authentication flow. + * + * This method launches the auth flow Activity from the provided [Activity] context. + * For better lifecycle management, prefer using [createIntent] with + * [ActivityResultLauncher] instead. + * + * **Note:** This method uses [Activity.startActivityForResult] which is deprecated. + * Consider using [createIntent] with the Activity Result API instead. + * + * @param activity The [Activity] to launch from + * @param requestCode Request code for [Activity.onActivityResult] + * @throws IllegalStateException if the controller has been disposed + * + * @see createIntent + */ + @Deprecated( + message = "Use createIntent() with ActivityResultLauncher instead", + replaceWith = ReplaceWith("createIntent(activity)"), + level = DeprecationLevel.WARNING + ) + fun start(activity: Activity, requestCode: Int = RC_SIGN_IN) { + checkNotDisposed() + val intent = createIntent(activity) + activity.startActivityForResult(intent, requestCode) + } + + /** + * Cancels the ongoing authentication flow. + * + * This method transitions the auth state to [AuthState.Cancelled] and + * signals the auth flow to terminate. The auth flow Activity will finish + * and return [Activity.RESULT_CANCELED]. + * + * **Example:** + * ```kotlin + * // User clicked a "Cancel" button + * cancelButton.setOnClickListener { + * authController.cancel() + * } + * ``` + * + * @throws IllegalStateException if the controller has been disposed + */ + fun cancel() { + checkNotDisposed() + authUI.updateAuthState(AuthState.Cancelled) + } + + /** + * Disposes the controller and releases all resources. + * + * This method: + * - Cancels all coroutines in the controller scope + * - Stops listening to auth state changes + * - Marks the controller as disposed + * + * Call this method in your Activity's `onDestroy()` to prevent memory leaks. + * + * **Important:** Once disposed, this controller cannot be reused. Create a new + * controller if you need to start another auth flow. + * + * **Example:** + * ```kotlin + * override fun onDestroy() { + * super.onDestroy() + * authController.dispose() + * } + * ``` + * + * @throws IllegalStateException if already disposed (when called multiple times) + */ + fun dispose() { + if (isDisposed.compareAndSet(false, true)) { + stateCollectionJob?.cancel() + coroutineScope.cancel() + } + } + + /** + * Checks if the controller has been disposed. + * + * @return `true` if disposed, `false` otherwise + */ + fun isDisposed(): Boolean = isDisposed.get() + + private fun checkNotDisposed() { + check(!isDisposed.get()) { + "AuthFlowController has been disposed. Create a new controller to start another auth flow." + } + } + + internal fun startStateCollection() { + if (stateCollectionJob == null || stateCollectionJob?.isActive == false) { + stateCollectionJob = authUI.authStateFlow() + .onEach { state -> + // Optional: Add logging or side effects here + } + .launchIn(coroutineScope) + } + } + + companion object { + /** + * Request code for the sign-in activity result. + * + * Use this constant when calling [start] with `startActivityForResult`. + */ + const val RC_SIGN_IN = 9001 + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt new file mode 100644 index 000000000..a6e57deed --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt @@ -0,0 +1,313 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose + +import com.firebase.ui.auth.compose.AuthState.Companion.Cancelled +import com.firebase.ui.auth.compose.AuthState.Companion.Idle +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorResolver +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthProvider + +/** + * 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)" + } + + /** + * Password reset link has been sent to the user's email. + */ + class PasswordResetLinkSent : AuthState() { + override fun equals(other: Any?): Boolean = other is PasswordResetLinkSent + override fun hashCode(): Int = javaClass.hashCode() + override fun toString(): String = "AuthState.PasswordResetLinkSent" + } + + /** + * Email sign in link has been sent to the user's email. + */ + class EmailSignInLinkSent : AuthState() { + override fun equals(other: Any?): Boolean = other is EmailSignInLinkSent + override fun hashCode(): Int = javaClass.hashCode() + override fun toString(): String = "AuthState.EmailSignInLinkSent" + } + + /** + * Phone number was automatically verified via SMS instant verification. + * + * This state is emitted when Firebase Phone Authentication successfully retrieves + * and verifies the SMS code automatically without user interaction. This happens + * when Google Play services can detect the incoming SMS message. + * + * @property credential The [PhoneAuthCredential] that can be used to sign in the user + * + * @see PhoneNumberVerificationRequired for the manual verification flow + */ + class SMSAutoVerified(val credential: PhoneAuthCredential) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SMSAutoVerified) return false + return credential == other.credential + } + + override fun hashCode(): Int { + var result = credential.hashCode() + result = 31 * result + credential.hashCode() + return result + } + + override fun toString(): String = + "AuthState.SMSAutoVerified(credential=$credential)" + } + + /** + * Phone number verification requires manual code entry. + * + * This state is emitted when Firebase Phone Authentication cannot instantly verify + * the phone number and sends an SMS code that the user must manually enter. This is + * the normal flow when automatic SMS retrieval is not available or fails. + * + * **Resending codes:** + * To allow users to resend the verification code (if they didn't receive it), + * call [FirebaseAuthUI.verifyPhoneNumber] again with: + * - `isForceResendingTokenEnabled = true` + * - `forceResendingToken` from this state + * + * @property verificationId The verification ID to use when submitting the code. + * This must be passed to [FirebaseAuthUI.submitVerificationCode]. + * @property forceResendingToken Token that can be used to resend the SMS code if needed + * + */ + class PhoneNumberVerificationRequired( + val verificationId: String, + val forceResendingToken: PhoneAuthProvider.ForceResendingToken, + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PhoneNumberVerificationRequired) return false + return verificationId == other.verificationId && + forceResendingToken == other.forceResendingToken + } + + override fun hashCode(): Int { + var result = verificationId.hashCode() + result = 31 * result + forceResendingToken.hashCode() + return result + } + + override fun toString(): String = + "AuthState.PhoneNumberVerificationRequired(verificationId=$verificationId, " + + "forceResendingToken=$forceResendingToken)" + } + + 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() + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthActivity.kt b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthActivity.kt new file mode 100644 index 000000000..d98779777 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthActivity.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.lifecycleScope +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.ui.screens.FirebaseAuthScreen +import com.google.firebase.auth.FirebaseUser +import kotlinx.coroutines.launch +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Activity that hosts the Firebase authentication flow UI. + * + * This activity displays the [FirebaseAuthScreen] composable and manages + * the authentication flow lifecycle. It automatically finishes when the user + * signs in successfully or cancels the flow. + * + * **Do not launch this Activity directly.** + * Use [AuthFlowController] to start the auth flow: + * + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * val configuration = authUIConfiguration { + * providers = listOf(AuthProvider.Email(), AuthProvider.Google(...)) + * } + * val controller = authUI.createAuthFlow(configuration) + * val intent = controller.createIntent(context) + * launcher.launch(intent) + * ``` + * + * **Result Codes:** + * - [Activity.RESULT_OK] - User signed in successfully + * - [Activity.RESULT_CANCELED] - User cancelled or error occurred + * + * **Result Data:** + * - [EXTRA_USER_ID] - User ID string (when RESULT_OK) + * - [EXTRA_IS_NEW_USER] - Boolean indicating if user is new (when RESULT_OK) + * - [EXTRA_ERROR] - [AuthException] when an error occurs + * + * **Note:** To get the full user object after successful sign-in, use: + * ```kotlin + * FirebaseAuth.getInstance().currentUser + * ``` + * + * @see AuthFlowController + * @see FirebaseAuthScreen + * @since 10.0.0 + */ +class FirebaseAuthActivity : ComponentActivity() { + + private lateinit var authUI: FirebaseAuthUI + private lateinit var configuration: AuthUIConfiguration + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Extract configuration from cache using UUID key + val configKey = intent.getStringExtra(EXTRA_CONFIGURATION_KEY) + configuration = if (configKey != null) { + configurationCache.remove(configKey) + } else { + null + } ?: run { + // Missing configuration, finish with error + setResult(RESULT_CANCELED) + finish() + return + } + + authUI = FirebaseAuthUI.getInstance() + + // Observe auth state to automatically finish when done + lifecycleScope.launch { + authUI.authStateFlow().collect { state -> + when (state) { + is AuthState.Success -> { + // User signed in successfully + val resultIntent = Intent().apply { + putExtra(EXTRA_USER_ID, state.user.uid) + putExtra(EXTRA_IS_NEW_USER, state.isNewUser) + } + setResult(RESULT_OK, resultIntent) + finish() + } + is AuthState.Cancelled -> { + // User cancelled the flow + setResult(RESULT_CANCELED) + finish() + } + is AuthState.Error -> { + // Error occurred, finish with error info + val resultIntent = Intent().apply { + putExtra(EXTRA_ERROR, state.exception) + } + setResult(RESULT_CANCELED, resultIntent) + // Don't finish on error, let user see error and retry + } + else -> { + // Other states, keep showing UI + } + } + } + } + + // Set up Compose UI + setContent { + AuthUITheme(theme = configuration.theme) { + FirebaseAuthScreen( + authUI = authUI, + configuration = configuration, + onSignInSuccess = { authResult -> + // State flow will handle finishing + }, + onSignInFailure = { exception -> + // State flow will handle error + }, + onSignInCancelled = { + authUI.updateAuthState(AuthState.Cancelled) + } + ) + } + } + } + + override fun onDestroy() { + super.onDestroy() + // Reset auth state when activity is destroyed + if (!isFinishing) { + authUI.updateAuthState(AuthState.Idle) + } + } + + companion object { + private const val EXTRA_CONFIGURATION_KEY = "com.firebase.ui.auth.compose.CONFIGURATION_KEY" + + /** + * Intent extra key for user ID on successful sign-in. + * Use [com.google.firebase.auth.FirebaseAuth.getInstance().currentUser] to get the full user object. + */ + const val EXTRA_USER_ID = "com.firebase.ui.auth.compose.USER_ID" + + /** + * Intent extra key for isNewUser flag on successful sign-in. + */ + const val EXTRA_IS_NEW_USER = "com.firebase.ui.auth.compose.IS_NEW_USER" + + /** + * Intent extra key for [AuthException] on error. + */ + const val EXTRA_ERROR = "com.firebase.ui.auth.compose.ERROR" + + /** + * Cache for configurations passed through Intents. + * Uses UUID keys to avoid serialization issues with Context references. + */ + private val configurationCache = ConcurrentHashMap() + + /** + * Creates an Intent to launch the Firebase authentication flow. + * + * @param context Android [Context] + * @param configuration [AuthUIConfiguration] defining the auth flow + * @return Configured [Intent] to start [FirebaseAuthActivity] + */ + internal fun createIntent( + context: Context, + configuration: AuthUIConfiguration + ): Intent { + val configKey = UUID.randomUUID().toString() + configurationCache[configKey] = configuration + + return Intent(context, FirebaseAuthActivity::class.java).apply { + putExtra(EXTRA_CONFIGURATION_KEY, configKey) + } + } + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt new file mode 100644 index 000000000..7f952212d --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt @@ -0,0 +1,577 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose + +import android.content.Context +import android.content.Intent +import androidx.annotation.RestrictTo +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.signOutFromGoogle +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuth.AuthStateListener +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.ktx.auth +import com.google.firebase.ktx.Firebase +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +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) + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + var testCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null + + /** + * 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 true if this instance can handle the provided [Intent]. + * + * This mirrors the classic `AuthUI.canHandleIntent` API but uses the [FirebaseAuth] instance + * backing this [FirebaseAuthUI], ensuring custom app/auth configurations are respected. + */ + fun canHandleIntent(intent: Intent?): Boolean { + val link = intent?.data ?: return false + return auth.isSignInWithEmailLink(link.toString()) + } + + /** + * Creates a new authentication flow controller with the specified configuration. + * + * This method returns an [AuthFlowController] that manages the authentication flow + * lifecycle. The controller provides methods to start the flow, monitor its state, + * and clean up resources when done. + * + * **Example with ActivityResultLauncher:** + * ```kotlin + * class MyActivity : ComponentActivity() { + * private lateinit var authController: AuthFlowController + * + * private val authLauncher = registerForActivityResult( + * ActivityResultContracts.StartActivityForResult() + * ) { result -> + * if (result.resultCode == Activity.RESULT_OK) { + * val userId = result.data?.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID) + * val isNewUser = result.data?.getBooleanExtra( + * FirebaseAuthActivity.EXTRA_IS_NEW_USER, + * false + * ) ?: false + * // Get the full user object + * val user = FirebaseAuth.getInstance().currentUser + * } + * } + * + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * + * val authUI = FirebaseAuthUI.getInstance() + * val configuration = authUIConfiguration { + * providers = listOf( + * AuthProvider.Email(), + * AuthProvider.Google(...) + * ) + * } + * + * authController = authUI.createAuthFlow(configuration) + * + * // Observe auth state + * lifecycleScope.launch { + * authController.authStateFlow.collect { state -> + * when (state) { + * is AuthState.Success -> { + * // User signed in successfully + * } + * is AuthState.Error -> { + * // Handle error + * } + * else -> {} + * } + * } + * } + * + * // Start auth flow + * val intent = authController.createIntent(this) + * authLauncher.launch(intent) + * } + * + * override fun onDestroy() { + * super.onDestroy() + * authController.dispose() + * } + * } + * ``` + * + * @param configuration The [AuthUIConfiguration] defining the auth flow behavior + * @return A new [AuthFlowController] instance + * @see AuthFlowController + * @since 10.0.0 + */ + fun createAuthFlow(configuration: AuthUIConfiguration): AuthFlowController { + return AuthFlowController(this, configuration) + } + + /** + * Returns a [Flow] that emits [AuthState] changes. + * + * 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 { + // Create a flow from FirebaseAuth state listener + val firebaseAuthFlow = callbackFlow { + // Set initial state based on current auth state + val initialState = auth.currentUser?.let { user -> + // Check if email verification is required + if (!user.isEmailVerified && + user.email != null && + user.providerData.any { it.providerId == "password" } + ) { + AuthState.RequiresEmailVerification( + user = user, + email = user.email!! + ) + } else { + 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) + + // Remove listener when flow collection is cancelled + awaitClose { + auth.removeAuthStateListener(authStateListener) + } + } + + // Also observe internal state changes + return combine( + firebaseAuthFlow, + _authStateFlow + ) { firebaseState, internalState -> + // Prefer non-idle internal states (like PasswordResetLinkSent, Error, etc.) + if (internalState !is AuthState.Idle) internalState else firebaseState + }.distinctUntilChanged() + } + + /** + * Updates the internal authentication state. + * This method can be used to manually trigger state updates when the Firebase Auth state + * listener doesn't automatically detect changes (e.g., after reloading user properties). + * + * @param state The new [AuthState] to emit + */ + 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() + .also { + signOutFromGoogle(context) + } + + // 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) + 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/FirebaseUIComposeRegistrar.kt b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseUIComposeRegistrar.kt new file mode 100644 index 000000000..62da63759 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseUIComposeRegistrar.kt @@ -0,0 +1,34 @@ +// Copyright 2025 Google LLC +// +// 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.Keep +import com.firebase.ui.auth.BuildConfig +import com.google.firebase.components.Component +import com.google.firebase.components.ComponentRegistrar +import com.google.firebase.platforminfo.LibraryVersionComponent + +/** + * Registers the FirebaseUI-Android Compose library with Firebase Analytics. + * This enables Firebase to track which versions of FirebaseUI are being used. + */ +@Keep +class FirebaseUIComposeRegistrar : ComponentRegistrar { + override fun getComponents(): List> { + return listOf( + LibraryVersionComponent.create(BuildConfig.LIBRARY_NAME, BuildConfig.VERSION_NAME) + ) + } +} 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..d0a808fec --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt @@ -0,0 +1,198 @@ +/* + * 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.vector.ImageVector +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvidersBuilder +import com.firebase.ui.auth.compose.configuration.auth_provider.Provider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.google.firebase.auth.ActionCodeSettings +import java.util.Locale + +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: AuthUIAsset? = null + var passwordResetActionCodeSettings: 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 (allow predefined providers and custom OIDC providers starting with "oidc.") + val supportedProviderIds = Provider.entries.map { it.id }.toSet() + val unknownProviders = providers.filter { provider -> + provider.providerId !in supportedProviderIds && !provider.providerId.startsWith("oidc.") + } + 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(isAnonymousUpgradeEnabled) + 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, + passwordResetActionCodeSettings = passwordResetActionCodeSettings, + 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: AuthUIAsset? = null, + + /** + * Configuration for sending email reset link. + */ + val passwordResetActionCodeSettings: 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/MfaConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/MfaConfiguration.kt new file mode 100644 index 000000000..393ef0f26 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/MfaConfiguration.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration + +/** + * Configuration class for Multi-Factor Authentication (MFA) enrollment and verification behavior. + * + * This class controls which MFA factors are available to users, whether enrollment is mandatory, + * and whether recovery codes are generated. + * + * @property allowedFactors List of MFA factors that users are permitted to enroll in. + * Defaults to [MfaFactor.Sms, MfaFactor.Totp]. + * @property requireEnrollment Whether MFA enrollment is mandatory for all users. + * When true, users must enroll in at least one MFA factor. + * Defaults to false. + * @property enableRecoveryCodes Whether to generate and provide recovery codes to users + * after successful MFA enrollment. These codes can be used + * as a backup authentication method. Defaults to true. + */ +class MfaConfiguration( + val allowedFactors: List = listOf(MfaFactor.Sms, MfaFactor.Totp), + val requireEnrollment: Boolean = false, + val enableRecoveryCodes: Boolean = true +) { + init { + require(allowedFactors.isNotEmpty()) { + "At least one MFA factor must be allowed" + } + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/MfaFactor.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/MfaFactor.kt new file mode 100644 index 000000000..78926ace4 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/MfaFactor.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration + +/** + * Represents the different Multi-Factor Authentication (MFA) factors that can be used + * for enrollment and verification. + */ +enum class MfaFactor { + /** + * SMS-based authentication factor. + * Users receive a verification code via text message to their registered phone number. + */ + Sms, + + /** + * Time-based One-Time Password (TOTP) authentication factor. + * Users generate verification codes using an authenticator app. + */ + Totp +} diff --git a/auth/src/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..5187f96a5 --- /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 [com.firebase.ui.auth.compose.configuration.auth_provider.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(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/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 000000000..b82cba9b4 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,129 @@ +package com.firebase.ui.auth.compose.configuration.auth_provider + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +/** + * Creates a remembered launcher function for anonymous sign-in. + * + * @return A launcher function that starts the anonymous sign-in flow when invoked + * + * @see signInAnonymously + * @see createOrLinkUserWithEmailAndPassword for upgrading anonymous accounts + */ +@Composable +internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(): () -> Unit { + val coroutineScope = rememberCoroutineScope() + return remember(this) { + { + coroutineScope.launch { + try { + signInAnonymously() + } catch (e: AuthException) { + // Already an AuthException, don't re-wrap it + updateAuthState(AuthState.Error(e)) + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + } + } + } + } +} + +/** + * Signs in a user anonymously with Firebase Authentication. + * + * This method creates a temporary anonymous user account that can be used for testing + * or as a starting point for users who want to try the app before creating a permanent + * account. Anonymous users can later be upgraded to permanent accounts by linking + * credentials (email/password, social providers, phone, etc.). + * + * **Flow:** + * 1. Updates auth state to loading with "Signing in anonymously..." message + * 2. Calls Firebase Auth's `signInAnonymously()` method + * 3. Updates auth state to idle on success + * 4. Handles cancellation and converts exceptions to [AuthException] types + * + * **Anonymous Account Benefits:** + * - No user data collection required + * - Immediate access to app features + * - Can be upgraded to permanent account later + * - Useful for guest users and app trials + * + * **Account Upgrade:** + * Anonymous accounts can be upgraded to permanent accounts by calling methods like: + * - [signInAndLinkWithCredential] with email/password or social credentials + * - [createOrLinkUserWithEmailAndPassword] for email/password accounts + * - [signInWithPhoneAuthCredential] for phone authentication + * + * **Example: Basic anonymous sign-in** + * ```kotlin + * try { + * firebaseAuthUI.signInAnonymously() + * // User is now signed in anonymously + * // Show app content or prompt for account creation + * } catch (e: AuthException.AuthCancelledException) { + * // User cancelled the sign-in process + * } catch (e: AuthException.NetworkException) { + * // Network error occurred + * } + * ``` + * + * **Example: Anonymous sign-in with upgrade flow** + * ```kotlin + * // Step 1: Sign in anonymously + * firebaseAuthUI.signInAnonymously() + * + * // Step 2: Later, upgrade to permanent account + * try { + * firebaseAuthUI.createOrLinkUserWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * name = "John Doe", + * email = "john@example.com", + * password = "SecurePass123!" + * ) + * // Anonymous account upgraded to permanent email/password account + * } catch (e: AuthException.AccountLinkingRequiredException) { + * // Email already exists - show account linking UI + * } + * ``` + * + * @throws AuthException.AuthCancelledException if the coroutine is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.UnknownException for other authentication errors + * + * @see signInAndLinkWithCredential for upgrading anonymous accounts + * @see createOrLinkUserWithEmailAndPassword for email/password upgrade + * @see signInWithPhoneAuthCredential for phone authentication upgrade + */ +internal suspend fun FirebaseAuthUI.signInAnonymously() { + try { + updateAuthState(AuthState.Loading("Signing in anonymously...")) + auth.signInAnonymously().await() + updateAuthState(AuthState.Idle) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in anonymously was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt new file mode 100644 index 000000000..097cf9c0a --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt @@ -0,0 +1,1024 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.app.Activity +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.annotation.RestrictTo +import androidx.compose.ui.graphics.Color +import androidx.core.net.toUri +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.datastore.preferences.core.stringPreferencesKey +import com.facebook.AccessToken +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.AuthUIConfigurationDsl +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.util.Preconditions +import com.firebase.ui.auth.util.data.ContinueUrlBuilder +import com.firebase.ui.auth.util.data.PhoneNumberUtils +import com.firebase.ui.auth.util.data.ProviderAvailability +import com.google.android.gms.auth.api.identity.AuthorizationRequest +import com.google.android.gms.auth.api.identity.Identity +import com.google.android.gms.common.api.Scope +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.firebase.FirebaseException +import com.google.firebase.auth.ActionCodeSettings +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FacebookAuthProvider +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.GithubAuthProvider +import com.google.firebase.auth.GoogleAuthProvider +import com.google.firebase.auth.MultiFactorSession +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthOptions +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.TwitterAuthProvider +import com.google.firebase.auth.UserProfileChangeRequest +import com.google.firebase.auth.actionCodeSettings +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.tasks.await +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +@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, + val providerName: String, + val isSocialProvider: Boolean = false, +) { + GOOGLE(GoogleAuthProvider.PROVIDER_ID, providerName = "Google", isSocialProvider = true), + FACEBOOK(FacebookAuthProvider.PROVIDER_ID, providerName = "Facebook", isSocialProvider = true), + TWITTER(TwitterAuthProvider.PROVIDER_ID, providerName = "Twitter", isSocialProvider = true), + GITHUB(GithubAuthProvider.PROVIDER_ID, providerName = "Github", isSocialProvider = true), + EMAIL(EmailAuthProvider.PROVIDER_ID, providerName = "Email"), + PHONE(PhoneAuthProvider.PROVIDER_ID, providerName = "Phone"), + ANONYMOUS("anonymous", providerName = "Anonymous"), + MICROSOFT("microsoft.com", providerName = "Microsoft", isSocialProvider = true), + YAHOO("yahoo.com", providerName = "Yahoo", isSocialProvider = true), + APPLE("apple.com", providerName = "Apple", isSocialProvider = true); + + companion object { + fun fromId(id: String?): Provider? { + return entries.find { it.id == id } + } + } +} + +/** + * Base abstract class for authentication providers. + */ +abstract class AuthProvider(open val providerId: String, open val providerName: String) { + /** + * Base abstract class for OAuth authentication providers with common properties. + */ + abstract class OAuth( + override val providerId: String, + + override val providerName: String, + open val scopes: List = emptyList(), + open val customParameters: Map = emptyMap(), + ) : AuthProvider(providerId = providerId, providerName = providerName) + + /** + * 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 emailLinkActionCodeSettings: 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, providerName = Provider.EMAIL.providerName) { + companion object { + const val SESSION_ID_LENGTH = 10 + val KEY_EMAIL = stringPreferencesKey("com.firebase.ui.auth.data.client.email") + val KEY_PROVIDER = stringPreferencesKey("com.firebase.ui.auth.data.client.provider") + val KEY_ANONYMOUS_USER_ID = + stringPreferencesKey("com.firebase.ui.auth.data.client.auid") + val KEY_SESSION_ID = stringPreferencesKey("com.firebase.ui.auth.data.client.sid") + val KEY_IDP_TOKEN = stringPreferencesKey("com.firebase.ui.auth.data.client.idpToken") + val KEY_IDP_SECRET = stringPreferencesKey("com.firebase.ui.auth.data.client.idpSecret") + } + + internal fun validate(isAnonymousUpgradeEnabled: Boolean = false) { + if (isEmailLinkSignInEnabled) { + val actionCodeSettings = requireNotNull(emailLinkActionCodeSettings) { + "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." + } + + if (isAnonymousUpgradeEnabled) { + check(isEmailLinkForceSameDeviceEnabled) { + "You must force the same device flow when using email link sign in " + + "with anonymous user upgrade" + } + } + } + } + + // For Send Email Link + internal fun addSessionInfoToActionCodeSettings( + sessionId: String, + anonymousUserId: String, + credentialForLinking: AuthCredential? = null, + ): ActionCodeSettings { + requireNotNull(emailLinkActionCodeSettings) { + "ActionCodeSettings is required for email link sign in" + } + + val continueUrl = continueUrl(emailLinkActionCodeSettings.url) { + appendSessionId(sessionId) + appendAnonymousUserId(anonymousUserId) + appendForceSameDeviceBit(isEmailLinkForceSameDeviceEnabled) + // Only append providerId for linking flows (when credentialForLinking is not null) + if (credentialForLinking != null) { + appendProviderId(credentialForLinking.provider) + } + } + + return actionCodeSettings { + url = continueUrl + handleCodeInApp = emailLinkActionCodeSettings.canHandleCodeInApp() + iosBundleId = emailLinkActionCodeSettings.iosBundle + setAndroidPackageName( + emailLinkActionCodeSettings.androidPackageName ?: "", + emailLinkActionCodeSettings.androidInstallApp, + emailLinkActionCodeSettings.androidMinimumVersion + ) + } + } + + // For Sign In With Email Link + internal fun isDifferentDevice( + sessionIdFromLocal: String?, + sessionIdFromLink: String, + ): Boolean { + return sessionIdFromLocal == null || sessionIdFromLocal.isEmpty() + || sessionIdFromLink.isEmpty() + || (sessionIdFromLink != sessionIdFromLocal) + } + + private fun continueUrl(continueUrl: String, block: ContinueUrlBuilder.() -> Unit) = + ContinueUrlBuilder(continueUrl).apply(block).build() + + /** + * An interface to wrap the static `EmailAuthProvider.getCredential` method to make it testable. + * @suppress + */ + internal interface CredentialProvider { + fun getCredential(email: String, password: String): AuthCredential + } + + /** + * The default implementation of [CredentialProvider] that calls the static method. + * @suppress + */ + internal class DefaultCredentialProvider : CredentialProvider { + override fun getCredential(email: String, password: String): AuthCredential { + return EmailAuthProvider.getCredential(email, password) + } + } + } + + /** + * 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, + ) : AuthProvider(providerId = Provider.PHONE.id, providerName = Provider.PHONE.providerName) { + /** + * Sealed class representing the result of phone number verification. + * + * Phone verification can complete in two ways: + * - [AutoVerified]: SMS was instantly retrieved and verified by the Firebase SDK + * - [NeedsManualVerification]: SMS code was sent, user must manually enter it + */ + internal sealed class VerifyPhoneNumberResult { + /** + * Instant verification succeeded via SMS auto-retrieval. + * + * @property credential The [PhoneAuthCredential] that can be used to sign in + */ + class AutoVerified(val credential: PhoneAuthCredential) : VerifyPhoneNumberResult() + + /** + * Instant verification failed, manual code entry required. + * + * @property verificationId The verification ID to use when submitting the code + * @property token Token for resending the verification code + */ + class NeedsManualVerification( + val verificationId: String, + val token: PhoneAuthProvider.ForceResendingToken, + ) : VerifyPhoneNumberResult() + } + + internal 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" + } + } + } + + /** + * Internal coroutine-based wrapper for Firebase Phone Authentication verification. + * + * This method wraps the callback-based Firebase Phone Auth API into a suspending function + * using Kotlin coroutines. It handles the Firebase [PhoneAuthProvider.OnVerificationStateChangedCallbacks] + * and converts them into a [VerifyPhoneNumberResult]. + * + * **Callback mapping:** + * - `onVerificationCompleted` → [VerifyPhoneNumberResult.AutoVerified] + * - `onCodeSent` → [VerifyPhoneNumberResult.NeedsManualVerification] + * - `onVerificationFailed` → throws the exception + * + * This is a private helper method used by [verifyPhoneNumber]. Callers should use + * [verifyPhoneNumber] instead as it handles state management and error handling. + * + * @param auth The [FirebaseAuth] instance to use for verification + * @param phoneNumber The phone number to verify in E.164 format + * @param multiFactorSession Optional [MultiFactorSession] for MFA enrollment. When provided, + * Firebase verifies the phone number for enrolling as a second authentication factor + * instead of primary sign-in. Pass null for standard phone authentication. + * @param forceResendingToken Optional token from previous verification for resending + * + * @return [VerifyPhoneNumberResult] indicating auto-verified or manual verification needed + * @throws FirebaseException if verification fails + */ + internal suspend fun verifyPhoneNumberAwait( + auth: FirebaseAuth, + activity: Activity?, + phoneNumber: String, + multiFactorSession: MultiFactorSession? = null, + forceResendingToken: PhoneAuthProvider.ForceResendingToken?, + verifier: Verifier = DefaultVerifier(), + ): VerifyPhoneNumberResult { + return verifier.verifyPhoneNumber( + auth, + activity, + phoneNumber, + timeout, + forceResendingToken, + multiFactorSession, + isInstantVerificationEnabled + ) + } + + /** + * @suppress + */ + internal interface Verifier { + suspend fun verifyPhoneNumber( + auth: FirebaseAuth, + activity: Activity?, + phoneNumber: String, + timeout: Long, + forceResendingToken: PhoneAuthProvider.ForceResendingToken?, + multiFactorSession: MultiFactorSession?, + isInstantVerificationEnabled: Boolean, + ): VerifyPhoneNumberResult + } + + /** + * @suppress + */ + internal class DefaultVerifier : Verifier { + override suspend fun verifyPhoneNumber( + auth: FirebaseAuth, + activity: Activity?, + phoneNumber: String, + timeout: Long, + forceResendingToken: PhoneAuthProvider.ForceResendingToken?, + multiFactorSession: MultiFactorSession?, + isInstantVerificationEnabled: Boolean, + ): VerifyPhoneNumberResult { + return suspendCoroutine { continuation -> + val options = PhoneAuthOptions.newBuilder(auth) + .setPhoneNumber(phoneNumber) + .requireSmsValidation(!isInstantVerificationEnabled) + .setTimeout(timeout, TimeUnit.SECONDS) + .setCallbacks(object : + PhoneAuthProvider.OnVerificationStateChangedCallbacks() { + override fun onVerificationCompleted(credential: PhoneAuthCredential) { + continuation.resume(VerifyPhoneNumberResult.AutoVerified(credential)) + } + + override fun onVerificationFailed(e: FirebaseException) { + continuation.resumeWithException(e) + } + + override fun onCodeSent( + verificationId: String, + token: PhoneAuthProvider.ForceResendingToken, + ) { + continuation.resume( + VerifyPhoneNumberResult.NeedsManualVerification( + verificationId, + token + ) + ) + } + }) + .apply { + activity?.let { + setActivity(it) + } + forceResendingToken?.let { + setForceResendingToken(it) + } + multiFactorSession?.let { + setMultiFactorSession(it) + } + } + .build() + PhoneAuthProvider.verifyPhoneNumber(options) + } + } + } + + /** + * An interface to wrap the static `PhoneAuthProvider.getCredential` method to make it testable. + * @suppress + */ + internal interface CredentialProvider { + fun getCredential(verificationId: String, smsCode: String): PhoneAuthCredential + } + + /** + * The default implementation of [CredentialProvider] that calls the static method. + * @suppress + */ + internal class DefaultCredentialProvider : CredentialProvider { + override fun getCredential( + verificationId: String, + smsCode: String, + ): PhoneAuthCredential { + return PhoneAuthProvider.getCredential(verificationId, smsCode) + } + } + + } + + /** + * 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(), + ) : OAuth( + providerId = Provider.GOOGLE.id, + providerName = Provider.GOOGLE.providerName, + scopes = scopes, + customParameters = customParameters + ) { + internal 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!" + ) + } + } + + /** + * Result container for Google Sign-In credential flow. + * @suppress + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + data class GoogleSignInResult( + val credential: AuthCredential, + val idToken: String, + val displayName: String?, + val photoUrl: Uri? + ) + + /** + * An interface to wrap the Authorization API for requesting OAuth scopes. + * @suppress + */ + internal interface AuthorizationProvider { + suspend fun authorize(context: Context, scopes: List) + } + + /** + * The default implementation of [AuthorizationProvider]. + * @suppress + */ + internal class DefaultAuthorizationProvider : AuthorizationProvider { + override suspend fun authorize(context: Context, scopes: List) { + val authorizationRequest = AuthorizationRequest.builder() + .setRequestedScopes(scopes) + .build() + + Identity.getAuthorizationClient(context) + .authorize(authorizationRequest) + .await() + } + } + + /** + * An interface to wrap the Credential Manager flow for Google Sign-In. + * @suppress + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + interface CredentialManagerProvider { + suspend fun getGoogleCredential( + context: Context, + credentialManager: CredentialManager, + serverClientId: String, + filterByAuthorizedAccounts: Boolean, + autoSelectEnabled: Boolean + ): GoogleSignInResult + } + + /** + * The default implementation of [CredentialManagerProvider]. + * @suppress + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + class DefaultCredentialManagerProvider : CredentialManagerProvider { + override suspend fun getGoogleCredential( + context: Context, + credentialManager: CredentialManager, + serverClientId: String, + filterByAuthorizedAccounts: Boolean, + autoSelectEnabled: Boolean, + ): GoogleSignInResult { + val googleIdOption = GetGoogleIdOption.Builder() + .setServerClientId(serverClientId) + .setFilterByAuthorizedAccounts(filterByAuthorizedAccounts) + .setAutoSelectEnabled(autoSelectEnabled) + .build() + + val request = GetCredentialRequest.Builder() + .addCredentialOption(googleIdOption) + .build() + + val result = credentialManager.getCredential(context, request) + val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(result.credential.data) + val credential = GoogleAuthProvider.getCredential(googleIdTokenCredential.idToken, null) + + return GoogleSignInResult( + credential = credential, + idToken = googleIdTokenCredential.idToken, + displayName = googleIdTokenCredential.displayName, + photoUrl = googleIdTokenCredential.profilePictureUri, + ) + } + } + } + + /** + * 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"), + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map = emptyMap(), + ) : OAuth( + providerId = Provider.FACEBOOK.id, + providerName = Provider.FACEBOOK.providerName, + scopes = scopes, + customParameters = customParameters + ) { + internal 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" + } + } + } + + /** + * An interface to wrap the static `FacebookAuthProvider.getCredential` method to make it testable. + * @suppress + */ + internal interface CredentialProvider { + fun getCredential(token: String): AuthCredential + } + + /** + * The default implementation of [CredentialProvider] that calls the static method. + * @suppress + */ + internal class DefaultCredentialProvider : CredentialProvider { + override fun getCredential(token: String): AuthCredential { + return FacebookAuthProvider.getCredential(token) + } + } + + /** + * Internal data class to hold Facebook profile information. + */ + internal class FacebookProfileData( + val displayName: String?, + val email: String?, + val photoUrl: Uri?, + ) + + /** + * Fetches user profile data from Facebook Graph API. + * + * @param accessToken The Facebook access token + * @return FacebookProfileData containing user's display name, email, and photo URL + */ + internal suspend fun fetchFacebookProfile(accessToken: AccessToken): FacebookProfileData? { + return suspendCancellableCoroutine { continuation -> + val request = + com.facebook.GraphRequest.newMeRequest(accessToken) { jsonObject, response -> + try { + val error = response?.error + if (error != null) { + Log.e( + "FirebaseAuthUI.signInWithFacebook", + "Graph API error: ${error.errorMessage}" + ) + continuation.resume(null) + return@newMeRequest + } + + if (jsonObject == null) { + Log.e( + "FirebaseAuthUI.signInWithFacebook", + "Graph API returned null response" + ) + continuation.resume(null) + return@newMeRequest + } + + val name = jsonObject.optString("name") + val email = jsonObject.optString("email") + + // Extract photo URL from picture object + val photoUrl = try { + jsonObject.optJSONObject("picture") + ?.optJSONObject("data") + ?.optString("url") + ?.takeIf { it.isNotEmpty() }?.toUri() + } catch (e: Exception) { + Log.w( + "FirebaseAuthUI.signInWithFacebook", + "Error parsing photo URL", + e + ) + null + } + + Log.d( + "FirebaseAuthUI.signInWithFacebook", + "Profile fetched: name=$name, email=$email, hasPhoto=${photoUrl != null}" + ) + + continuation.resume( + FacebookProfileData( + displayName = name, + email = email, + photoUrl = photoUrl + ) + ) + } catch (e: Exception) { + Log.e( + "FirebaseAuthUI.signInWithFacebook", + "Error processing Graph API response", + e + ) + continuation.resume(null) + } + } + + // Request specific fields: id, name, email, and picture + val parameters = android.os.Bundle().apply { + putString("fields", "id,name,email,picture") + } + request.parameters = parameters + request.executeAsync() + } + } + } + + /** + * Twitter/X authentication provider configuration. + */ + class Twitter( + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map, + ) : OAuth( + providerId = Provider.TWITTER.id, + providerName = Provider.TWITTER.providerName, + 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, + ) : OAuth( + providerId = Provider.GITHUB.id, + providerName = Provider.GITHUB.providerName, + 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, + ) : OAuth( + providerId = Provider.MICROSOFT.id, + providerName = Provider.MICROSOFT.providerName, + 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, + ) : OAuth( + providerId = Provider.YAHOO.id, + providerName = Provider.YAHOO.providerName, + 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, + ) : OAuth( + providerId = Provider.APPLE.id, + providerName = Provider.APPLE.providerName, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Anonymous authentication provider. It has no configurable properties. + */ + object Anonymous : AuthProvider( + providerId = Provider.ANONYMOUS.id, + providerName = Provider.ANONYMOUS.providerName + ) { + internal fun validate(providers: List) { + if (providers.size == 1 && providers.first() is Anonymous) { + throw IllegalStateException( + "Sign in as guest cannot be the only sign in method. " + + "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 name. + */ + override val providerName: String, + + /** + * 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?, + ) : OAuth( + providerId = providerId, + providerName = providerName, + scopes = scopes, + customParameters = customParameters + ) { + internal fun validate() { + require(providerId.isNotBlank()) { + "Provider ID cannot be null or empty" + } + + require(buttonLabel.isNotBlank()) { + "Button label cannot be null or empty" + } + } + } + + companion object { + internal fun canUpgradeAnonymous(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean { + val currentUser = auth.currentUser + return config.isAnonymousUpgradeEnabled + && currentUser != null + && currentUser.isAnonymous + } + + /** + * Merges profile information (display name and photo URL) with the current user's profile. + * + * This method updates the user's profile only if the current profile is incomplete + * (missing display name or photo URL). This prevents overwriting existing profile data. + * + * **Use case:** + * After creating a new user account or linking credentials, update the profile with + * information from the sign-up form or social provider. + * + * @param auth The [FirebaseAuth] instance + * @param displayName The display name to set (if current is empty) + * @param photoUri The photo URL to set (if current is null) + * + * **Note:** This operation always succeeds to minimize login interruptions. + * Failures are logged but don't prevent sign-in completion. + */ + internal suspend fun mergeProfile( + auth: FirebaseAuth, + displayName: String?, + photoUri: Uri?, + ) { + try { + val currentUser = auth.currentUser ?: return + + // Only update if current profile is incomplete + val currentDisplayName = currentUser.displayName + val currentPhotoUrl = currentUser.photoUrl + + if (!currentDisplayName.isNullOrEmpty() && currentPhotoUrl != null) { + // Profile is complete, no need to update + return + } + + // Build profile update with provided values + val nameToSet = + if (currentDisplayName.isNullOrEmpty()) displayName else currentDisplayName + val photoToSet = currentPhotoUrl ?: photoUri + + if (nameToSet != null || photoToSet != null) { + val profileUpdates = UserProfileChangeRequest.Builder() + .setDisplayName(nameToSet) + .setPhotoUri(photoToSet) + .build() + + currentUser.updateProfile(profileUpdates).await() + } + } catch (e: Exception) { + // Log error but don't throw - profile update failure shouldn't prevent sign-in + Log.e("AuthProvider.Email", "Error updating profile", e) + } + } + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 000000000..4e9206b93 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,1107 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.content.Context +import android.net.Uri +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider.Companion.mergeProfile +import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager +import com.firebase.ui.auth.util.data.EmailLinkParser +import com.firebase.ui.auth.util.data.SessionUtils +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.ActionCodeSettings +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthMultiFactorException +import com.google.firebase.auth.FirebaseAuthUserCollisionException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.tasks.await + + +/** + * Creates an email/password account or links the credential to an anonymous user. + * + * Mirrors the legacy email sign-up handler: validates password strength, validates custom + * password rules, checks if new accounts are allowed, chooses between + * `createUserWithEmailAndPassword` and `linkWithCredential`, merges the supplied display name + * into the Firebase profile, and throws [AuthException.AccountLinkingRequiredException] when + * anonymous upgrade encounters an existing account for the email. + * + * **Flow:** + * 1. Check if new accounts are allowed (for non-upgrade flows) + * 2. Validate password length against [AuthProvider.Email.minimumPasswordLength] + * 3. Validate password against custom [AuthProvider.Email.passwordValidationRules] + * 4. If upgrading anonymous user: link credential to existing anonymous account + * 5. Otherwise: create new account with `createUserWithEmailAndPassword` + * 6. Merge display name into user profile + * + * @param context Android [Context] for localized strings + * @param config Auth UI configuration describing provider settings + * @param provider Email provider configuration + * @param name Optional display name collected during sign-up + * @param email Email address for the new account + * @param password Password for the new account + * + * @return [AuthResult] containing the newly created or linked user, or null if failed + * + * @throws AuthException.UserNotFoundException if new accounts are not allowed + * @throws AuthException.WeakPasswordException if the password fails validation rules + * @throws AuthException.InvalidCredentialsException if the email or password is invalid + * @throws AuthException.EmailAlreadyInUseException if the email already exists + * @throws AuthException.AuthCancelledException if the coroutine is cancelled + * @throws AuthException.NetworkException for network-related failures + * + * **Example: Normal sign-up** + * ```kotlin + * try { + * val result = firebaseAuthUI.createOrLinkUserWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * name = "John Doe", + * email = "john@example.com", + * password = "SecurePass123!" + * ) + * // User account created successfully + * } catch (e: AuthException.WeakPasswordException) { + * // Password doesn't meet validation rules + * } catch (e: AuthException.EmailAlreadyInUseException) { + * // Email already exists - redirect to sign-in + * } + * ``` + * + * **Example: Anonymous user upgrade** + * ```kotlin + * // User is currently signed in anonymously + * try { + * val result = firebaseAuthUI.createOrLinkUserWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * name = "Jane Smith", + * email = "jane@example.com", + * password = "MyPassword456" + * ) + * // Anonymous account upgraded to permanent email/password account + * } catch (e: AuthException.AccountLinkingRequiredException) { + * // Email already exists - show account linking UI + * // User needs to sign in with existing account to link + * } + * ``` + */ +internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Email, + name: String?, + email: String, + password: String, + credentialProvider: AuthProvider.Email.CredentialProvider = AuthProvider.Email.DefaultCredentialProvider(), +): AuthResult? { + val canUpgrade = canUpgradeAnonymous(config, auth) + val pendingCredential = + if (canUpgrade) credentialProvider.getCredential(email, password) else null + + try { + // Check if new accounts are allowed (only for non-upgrade flows) + if (!canUpgrade && !provider.isNewAccountsAllowed) { + throw AuthException.UserNotFoundException( + message = context.getString(R.string.fui_error_email_does_not_exist) + ) + } + + // Validate minimum password length + if (password.length < provider.minimumPasswordLength) { + throw AuthException.InvalidCredentialsException( + message = context.getString(R.string.fui_error_password_too_short) + .format(provider.minimumPasswordLength) + ) + } + + // Validate password against custom rules + for (rule in provider.passwordValidationRules) { + if (!rule.isValid(password)) { + throw AuthException.WeakPasswordException( + message = rule.getErrorMessage(config.stringProvider), + reason = "Password does not meet custom validation rules" + ) + } + } + + updateAuthState(AuthState.Loading("Creating user...")) + val result = if (canUpgrade) { + auth.currentUser?.linkWithCredential(requireNotNull(pendingCredential))?.await() + } else { + auth.createUserWithEmailAndPassword(email, password).await() + }.also { authResult -> + authResult?.user?.let { + // Merge display name into profile (photoUri is always null for email/password) + mergeProfile(auth, name, null) + } + } + updateAuthState(AuthState.Idle) + return result + } catch (e: FirebaseAuthUserCollisionException) { + // Account collision: email already exists + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with this email. " + + "Please sign in with your existing account.", + email = e.email ?: email, + credential = if (canUpgrade) { + e.updatedCredential ?: pendingCredential + } else { + null + }, + cause = e + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Create or link user with email and password was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs in a user with email and password, optionally linking a social credential. + * + * This method handles both normal sign-in and anonymous upgrade flows. In anonymous upgrade + * scenarios, it validates credentials in a scratch auth instance before throwing + * [AuthException.AccountLinkingRequiredException]. + * + * **Flow:** + * 1. If anonymous upgrade: + * - Create scratch auth instance to validate credential + * - If linking social provider: sign in with email, then link social credential (safe link) + * - Otherwise: just validate email credential + * - Throw [AuthException.AccountLinkingRequiredException] after successful validation + * 2. If normal sign-in: + * - Sign in with email/password + * - If credential provided: link it and merge profile + * + * @param context Android [Context] for creating scratch auth instance + * @param config Auth UI configuration describing provider settings + * @param email Email address for sign-in + * @param password Password for sign-in + * @param credentialForLinking Optional social provider credential to link after sign-in + * + * @return [AuthResult] containing the signed-in user, or null if validation-only (anonymous upgrade) + * + * @throws AuthException.InvalidCredentialsException if email or password is incorrect + * @throws AuthException.UserNotFoundException if the user doesn't exist + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException for network-related failures + * + * **Example: Normal sign-in** + * ```kotlin + * try { + * val result = firebaseAuthUI.signInWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com", + * password = "password123" + * ) + * // User signed in successfully + * } catch (e: AuthException.InvalidCredentialsException) { + * // Wrong password + * } + * ``` + * + * **Example: Sign-in with social credential linking** + * ```kotlin + * // User tried to sign in with Google, but account exists with email/password + * // Prompt for password, then link Google credential + * val googleCredential = GoogleAuthProvider.getCredential(idToken, null) + * + * val result = firebaseAuthUI.signInWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com", + * password = "password123", + * credentialForLinking = googleCredential + * ) + * // User signed in with email/password AND Google is now linked + * // Profile updated with Google display name and photo + * ``` + * + * **Example: Anonymous upgrade validation** + * ```kotlin + * // User is anonymous, wants to upgrade with existing email/password account + * try { + * firebaseAuthUI.signInWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "existing@example.com", + * password = "password123" + * ) + * } catch (e: AuthException.AccountLinkingRequiredException) { + * // Account linking required - UI shows account linking screen + * // User needs to sign in with existing account to link anonymous account + * } + * ``` + */ +internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( + context: Context, + config: AuthUIConfiguration, + email: String, + password: String, + credentialForLinking: AuthCredential? = null, +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Signing in...")) + return if (canUpgradeAnonymous(config, auth)) { + // Anonymous upgrade flow: validate credential in scratch auth + val credentialToValidate = EmailAuthProvider.getCredential(email, password) + + // Check if we're linking a social provider credential + val isSocialProvider = credentialForLinking != null && + (Provider.fromId(credentialForLinking.provider)?.isSocialProvider ?: false) + + // Create scratch auth instance to avoid losing anonymous user state + val appExplicitlyForValidation = FirebaseApp.initializeApp( + context, + auth.app.options, + "FUIAuthScratchApp_${System.currentTimeMillis()}" + ) + val authExplicitlyForValidation = FirebaseAuth + .getInstance(appExplicitlyForValidation) + + if (isSocialProvider) { + // Safe link: sign in with email, then link social credential + authExplicitlyForValidation + .signInWithCredential(credentialToValidate).await() + .user?.linkWithCredential(credentialForLinking)?.await() + .also { + // Throw AccountLinkingRequiredException after successful validation + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with this email. " + + "Please sign in with your existing account to upgrade your anonymous account.", + email = email, + credential = credentialToValidate, + cause = null + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException + } + } else { + // Just validate the email credential + // No linking for non-federated IDPs + authExplicitlyForValidation + .signInWithCredential(credentialToValidate).await() + .also { + // Throw AccountLinkingRequiredException after successful validation + // Account exists and user is anonymous - needs to link accounts + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with this email. " + + "Please sign in with your existing account to upgrade your anonymous account.", + email = email, + credential = credentialToValidate, + cause = null + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException + } + } + } else { + // Normal sign-in + auth.signInWithEmailAndPassword(email, password).await() + .let { result -> + // If there's a credential to link, link it after sign-in + if (credentialForLinking != null) { + val linkResult = result.user + ?.linkWithCredential(credentialForLinking) + ?.await() + + // Merge profile from social provider + linkResult?.user?.let { user -> + mergeProfile( + auth, + user.displayName, + user.photoUrl + ) + } + + linkResult ?: result + } else { + result + } + } + }.also { + updateAuthState(AuthState.Idle) + } + } catch (e: FirebaseAuthMultiFactorException) { + // MFA required - extract resolver and update state + val resolver = e.resolver + val hint = resolver.hints.firstOrNull()?.displayName + updateAuthState(AuthState.RequiresMfa(resolver, hint)) + return null + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with email and password was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs in with a credential or links it to an existing anonymous user. + * + * This method handles both normal sign-in and anonymous upgrade flows. After successful + * authentication, it merges profile information (display name and photo URL) into the + * Firebase user profile if provided. + * + * **Flow:** + * 1. Check if user is anonymous and upgrade is enabled + * 2. If yes: Link credential to anonymous user + * 3. If no: Sign in with credential + * 4. Merge profile information (name, photo) into Firebase user + * 5. Handle collision exceptions by throwing [AuthException.AccountLinkingRequiredException] + * + * @param config The [AuthUIConfiguration] containing authentication settings + * @param credential The [AuthCredential] to use for authentication. Can be from any provider. + * @param displayName Optional display name from the provider to merge into the user profile + * @param photoUrl Optional photo URL from the provider to merge into the user profile + * + * @return [AuthResult] containing the authenticated user + * + * @throws AuthException.InvalidCredentialsException if credential is invalid or expired + * @throws AuthException.EmailAlreadyInUseException if linking and email is already in use + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * + * **Example: Google Sign-In** + * ```kotlin + * val googleCredential = GoogleAuthProvider.getCredential(idToken, null) + * val displayName = "John Doe" // From Google profile + * val photoUrl = Uri.parse("https://...") // From Google profile + * + * val result = firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = googleCredential, + * displayName = displayName, + * photoUrl = photoUrl + * ) + * // User signed in with Google AND profile updated with Google data + * ``` + * + * **Example: Phone Auth** + * ```kotlin + * val phoneCredential = PhoneAuthProvider.getCredential(verificationId, code) + * + * val result = firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = phoneCredential + * ) + * // User signed in with phone number + * ``` + * + * **Example: Phone Auth with Collision (Anonymous Upgrade)** + * ```kotlin + * // User is currently anonymous, trying to link a phone number + * val phoneCredential = PhoneAuthProvider.getCredential(verificationId, code) + * + * try { + * firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = phoneCredential + * ) + * } catch (e: AuthException.AccountLinkingRequiredException) { + * // Phone number already exists on another account + * // Account linking required - UI can show account linking screen + * // User needs to sign in with existing account to link + * } + * ``` + * + * **Example: Email Link Sign-In** + * ```kotlin + * val emailLinkCredential = EmailAuthProvider.getCredentialWithLink( + * email = "user@example.com", + * emailLink = emailLink + * ) + * + * val result = firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = emailLinkCredential + * ) + * // User signed in with email link (passwordless) + * ``` + */ +internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( + config: AuthUIConfiguration, + credential: AuthCredential, + provider: AuthProvider? = null, + displayName: String? = null, + photoUrl: Uri? = null, +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Signing in user...")) + return if (canUpgradeAnonymous(config, auth)) { + auth.currentUser?.linkWithCredential(credential)?.await() + } else { + auth.signInWithCredential(credential).await() + }.also { result -> + // Merge profile information from the provider + result?.user?.let { + mergeProfile(auth, displayName, photoUrl) + } + updateAuthState(AuthState.Idle) + } + } catch (e: FirebaseAuthMultiFactorException) { + // MFA required - extract resolver and update state + val resolver = e.resolver + val hint = resolver.hints.firstOrNull()?.displayName + updateAuthState(AuthState.RequiresMfa(resolver, hint)) + return null + } catch (e: FirebaseAuthUserCollisionException) { + // Account collision: account already exists with different sign-in method + // Create AccountLinkingRequiredException with credential for linking + val email = e.email + val credentialForException = if (canUpgradeAnonymous(config, auth)) { + // For anonymous upgrade, use the updated credential from the exception + e.updatedCredential ?: credential + } else { + // For non-anonymous, use the original credential + credential + } + + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with the email ${email ?: ""}. " + + "Please sign in with your existing account to link " + + "your ${provider?.providerName ?: "this provider"} account.", + email = email, + credential = credentialForException, + cause = e + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in and link with credential was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Sends a passwordless sign-in link to the specified email address. + * + * This method initiates the email-link (passwordless) authentication flow by sending + * an email containing a magic link. The link includes session information for validation + * and security. + * + * **How it works:** + * 1. Generates a unique session ID for same-device validation + * 2. Retrieves anonymous user ID if upgrading anonymous account + * 3. Enriches the [ActionCodeSettings] URL with session data (session ID, anonymous user ID, force same-device flag) + * 4. Sends the email via [com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail] + * 5. Saves session data to DataStore for validation when the user clicks the link + * 6. User receives email with a magic link containing the session information + * 7. When user clicks link, app opens via deep link and calls [signInWithEmailLink] to complete authentication + * + * **Account Linking Support:** + * If a user tries to sign in with a social provider (Google, Facebook) but an email link + * account already exists with that email, the social provider implementation should: + * 1. Catch the [FirebaseAuthUserCollisionException] from the sign-in attempt + * 2. Call [EmailLinkPersistenceManager.default.saveCredentialForLinking] with the provider tokens + * 3. Call this method to send the email link + * 4. When [signInWithEmailLink] completes, it automatically retrieves and links the saved credential + * + * **Session Security:** + * - **Session ID**: Random 10-character string for same-device validation + * - **Anonymous User ID**: Stored if upgrading anonymous account to prevent account hijacking + * - **Force Same Device**: Can be configured via [AuthProvider.Email.isEmailLinkForceSameDeviceEnabled] + * - All session data is validated in [signInWithEmailLink] before completing authentication + * + * @param context Android [Context] for DataStore access + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider The [AuthProvider.Email] configuration with [ActionCodeSettings] + * @param email The email address to send the sign-in link to + * @param credentialForLinking Optional [AuthCredential] from a social provider to link after email sign-in. + * If provided, the credential is saved to DataStore and automatically linked + * when [signInWithEmailLink] completes. Used for account linking flows. + * + * @throws AuthException.InvalidCredentialsException if email is invalid + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws IllegalStateException if ActionCodeSettings is not configured + * + * **Example 1: Basic email link sign-in** + * ```kotlin + * // Send the email link + * firebaseAuthUI.sendSignInLinkToEmail( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com" + * ) + * // Show "Check your email" UI to user + * + * // Later, when user clicks the link in their email: + * // (In your deep link handling Activity) + * val emailLink = intent.data.toString() + * firebaseAuthUI.signInWithEmailLink( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com", + * emailLink = emailLink + * ) + * // User is now signed in + * ``` + * + * **Example 2: Anonymous user upgrade** + * ```kotlin + * // User is currently signed in anonymously + * // Send email link to upgrade anonymous account to permanent email account + * firebaseAuthUI.sendSignInLinkToEmail( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com" + * ) + * // Session includes anonymous user ID for validation + * // When user clicks link, anonymous account is upgraded to permanent account + * ``` + * + * **Example 3: Social provider linking** + * ```kotlin + * try { + * // Try to sign in with Google + * authUI.signInWithGoogle(...) + * } catch (e: FirebaseAuthUserCollisionException) { + * // Email already exists with email-link provider + * val googleCredential = e.updatedCredential + * + * // Save credential for linking + * EmailLinkPersistenceManager.default.saveCredentialForLinking( + * context = context, + * providerType = "google.com", + * idToken = (googleCredential as GoogleAuthCredential).idToken, + * accessToken = null + * ) + * + * // Send email link with credential + * firebaseAuthUI.sendSignInLinkToEmail( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = e.email!!, + * credentialForLinking = googleCredential + * ) + * // When user clicks link and signs in, Google is automatically linked + * } + * ``` + * + * @see signInWithEmailLink + * @see EmailLinkPersistenceManager + * @see com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail + */ +internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Email, + email: String, + credentialForLinking: AuthCredential?, + persistenceManager: com.firebase.ui.auth.compose.util.PersistenceManager = EmailLinkPersistenceManager.default, +) { + try { + updateAuthState(AuthState.Loading("Sending sign in email link...")) + + // Get anonymousUserId if can upgrade anonymously else default to empty string. + // NOTE: check for empty string instead of null to validate anonymous user ID matches + // when sign in from email link + val anonymousUserId = + if (canUpgradeAnonymous(config, auth)) (auth.currentUser?.uid + ?: "") else "" + + // Generate sessionId + val sessionId = + SessionUtils.generateRandomAlphaNumericString(AuthProvider.Email.SESSION_ID_LENGTH) + + // Modify actionCodeSettings Url to include sessionId, anonymousUserId, force same + // device flag + val updatedActionCodeSettings = + provider.addSessionInfoToActionCodeSettings( + sessionId = sessionId, + anonymousUserId = anonymousUserId, + credentialForLinking = credentialForLinking + ) + + auth.sendSignInLinkToEmail(email, updatedActionCodeSettings).await() + + // Save Email to dataStore for use in signInWithEmailLink + persistenceManager.saveEmail(context, email, sessionId, anonymousUserId) + + updateAuthState(AuthState.EmailSignInLinkSent()) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Send sign in link to email was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs in a user using an email link (passwordless authentication). + * + * This method completes the email link sign-in flow after the user clicks the magic link + * sent to their email. It validates the link, extracts session information, and either + * signs in the user normally or upgrades an anonymous account based on configuration. + * + * **Flow:** + * 1. User receives email with magic link + * 2. User clicks link, app opens via deep link + * 3. Activity extracts emailLink from Intent.data + * 4. This method validates and completes sign-in + * + * **Same-Device Flow:** + * - Email is retrieved from DataStore automatically + * - Session ID from link matches stored session ID + * - User is signed in immediately without additional input + * + * **Cross-Device Flow:** + * - Session ID from link doesn't match (or no local session exists) + * - If [email] is empty: throws [AuthException.EmailLinkPromptForEmailException] + * - User must provide their email address + * - Call this method again with user-provided email to complete sign-in + * + * @param context Android [Context] for DataStore access + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider The [AuthProvider.Email] configuration with email-link settings + * @param email The email address of the user. On same-device, retrieved from DataStore. + * On cross-device first call, pass empty string to trigger validation. + * On cross-device second call, pass user-provided email. + * @param emailLink The complete deep link URL received from the Intent. + * @param persistenceManager Optional [PersistenceManager] for testing. Defaults to [EmailLinkPersistenceManager.default] + * + * This URL contains: + * - Firebase action code (oobCode) for authentication + * - Session ID (ui_sid) for same-device validation + * - Anonymous user ID (ui_auid) if upgrading anonymous account + * - Force same-device flag (ui_sd) for security enforcement + * - Provider ID (ui_pid) if linking social provider credential + * + * Example: + * `https://yourapp.page.link/__/auth/action?oobCode=ABC123&continueUrl=https://yourapp.com?ui_sid=123456&ui_auid=anon-uid` + * + * @return [AuthResult] containing the signed-in user, or null if cross-device validation is required + * + * @throws AuthException.InvalidEmailLinkException if the email link is invalid or expired + * @throws AuthException.EmailLinkPromptForEmailException if cross-device and email is empty + * @throws AuthException.EmailLinkWrongDeviceException if force same-device is enabled on different device + * @throws AuthException.EmailLinkCrossDeviceLinkingException if trying to link provider on different device + * @throws AuthException.EmailLinkDifferentAnonymousUserException if anonymous user ID doesn't match + * @throws AuthException.EmailMismatchException if email is empty on same-device flow + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.UnknownException for other errors + * + * **Example 1: Same-device sign-in (automatic)** + * ```kotlin + * // In your deep link handler Activity: + * val emailLink = intent.data.toString() + * val savedEmail = EmailLinkPersistenceManager.default.retrieveSessionRecord(context)?.email + * + * if (savedEmail != null) { + * // Same device - email and session are stored + * val result = firebaseAuthUI.signInWithEmailLink( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = savedEmail, + * emailLink = emailLink + * ) + * // User is signed in automatically + * } + * ``` + * + * **Example 2: Cross-device sign-in (with email prompt)** + * ```kotlin + * // First call with empty email to validate link + * try { + * firebaseAuthUI.signInWithEmailLink( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "", // Empty email on different device + * emailLink = emailLink + * ) + * } catch (e: AuthException.EmailLinkPromptForEmailException) { + * // Show dialog asking user to enter their email + * val userEmail = showEmailInputDialog() + * + * // Second call with user-provided email + * val result = firebaseAuthUI.signInWithEmailLink( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = userEmail, // User provided email + * emailLink = emailLink + * ) + * // User is now signed in + * } + * ``` + * + * @see sendSignInLinkToEmail for sending the initial email link + * @see EmailLinkPersistenceManager for session data management + */ +internal suspend fun FirebaseAuthUI.signInWithEmailLink( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Email, + email: String, + emailLink: String, + persistenceManager: com.firebase.ui.auth.compose.util.PersistenceManager = EmailLinkPersistenceManager.default, +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Signing in with email link...")) + + // Validate link format + if (!auth.isSignInWithEmailLink(emailLink)) { + throw AuthException.InvalidEmailLinkException() + } + + // Parse email link for session data + val parser = EmailLinkParser(emailLink) + val sessionIdFromLink = parser.sessionId + val anonymousUserIdFromLink = parser.anonymousUserId + val oobCode = parser.oobCode + val providerIdFromLink = parser.providerId + val isEmailLinkForceSameDeviceEnabled = parser.forceSameDeviceBit + + // Retrieve stored session record from DataStore + val sessionRecord = persistenceManager.retrieveSessionRecord(context) + val storedSessionId = sessionRecord?.sessionId + + // Check if this is a different device flow + val isDifferentDevice = provider.isDifferentDevice( + sessionIdFromLocal = storedSessionId, + sessionIdFromLink = sessionIdFromLink ?: "" // Convert null to empty string to match legacy behavior + ) + + if (isDifferentDevice) { + // Handle cross-device flow + // Session ID must always be present in the link + if (sessionIdFromLink.isNullOrEmpty()) { + val exception = AuthException.InvalidEmailLinkException() + updateAuthState(AuthState.Error(exception)) + throw exception + } + + // These scenarios require same-device flow + if (isEmailLinkForceSameDeviceEnabled || !anonymousUserIdFromLink.isNullOrEmpty()) { + val exception = AuthException.EmailLinkWrongDeviceException() + updateAuthState(AuthState.Error(exception)) + throw exception + } + + // If we have no SessionRecord/there is a session ID mismatch, this means that we were + // not the ones to send the link. The only way forward is to prompt the user for their + // email before continuing the flow. We should only do that after validating the link. + // However, if email is already provided (cross-device with user input), skip validation + if (email.isEmpty()) { + handleDifferentDeviceErrorFlow(oobCode, providerIdFromLink, emailLink) + return null + } + // Email provided - validate it and continue with normal flow + } + + // Validate email is not empty (same-device flow only) + if (email.isEmpty()) { + throw AuthException.EmailMismatchException() + } + + // Validate anonymous user ID matches (same-device flow) + if (!anonymousUserIdFromLink.isNullOrEmpty()) { + val currentUser = auth.currentUser + if (currentUser == null + || !currentUser.isAnonymous + || currentUser.uid != anonymousUserIdFromLink + ) { + val exception = AuthException.EmailLinkDifferentAnonymousUserException() + updateAuthState(AuthState.Error(exception)) + throw exception + } + } + + // Get credential for linking from session record + val storedCredentialForLink = sessionRecord?.credentialForLinking + val emailLinkCredential = EmailAuthProvider.getCredentialWithLink(email, emailLink) + + val result = if (storedCredentialForLink == null) { + // Normal Flow: Just sign in with email link + handleEmailLinkNormalFlow(config, emailLinkCredential) + } else { + // Linking Flow: Sign in with email link, then link the social credential + handleEmailLinkCredentialLinkingFlow( + context = context, + config = config, + email = email, + emailLinkCredential = emailLinkCredential, + storedCredentialForLink = storedCredentialForLink, + ) + } + // Clear DataStore after success + persistenceManager.clear(context) + updateAuthState(AuthState.Idle) + return result + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with email link was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +private suspend fun FirebaseAuthUI.handleDifferentDeviceErrorFlow( + oobCode: String, + providerIdFromLink: String?, + emailLink: String +) { + // Validate the action code + try { + auth.checkActionCode(oobCode).await() + } catch (e: Exception) { + // Invalid action code + val exception = AuthException.InvalidEmailLinkException(cause = e) + updateAuthState(AuthState.Error(exception)) + throw exception + } + + // If there's a provider ID, this is a linking flow which can't be done cross-device + if (!providerIdFromLink.isNullOrEmpty()) { + val providerNameForMessage = + Provider.fromId(providerIdFromLink)?.providerName ?: providerIdFromLink + val exception = AuthException.EmailLinkCrossDeviceLinkingException( + providerName = providerNameForMessage, + emailLink = emailLink + ) + updateAuthState(AuthState.Error(exception)) + throw exception + } + + // Link is valid but we need the user to provide their email + val exception = AuthException.EmailLinkPromptForEmailException( + cause = null, + emailLink = emailLink + ) + updateAuthState(AuthState.Error(exception)) + throw exception +} + +private suspend fun FirebaseAuthUI.handleEmailLinkNormalFlow( + config: AuthUIConfiguration, + emailLinkCredential: AuthCredential, +): AuthResult? { + return signInAndLinkWithCredential(config, emailLinkCredential) +} + +private suspend fun FirebaseAuthUI.handleEmailLinkCredentialLinkingFlow( + context: Context, + config: AuthUIConfiguration, + email: String, + emailLinkCredential: AuthCredential, + storedCredentialForLink: AuthCredential, +): AuthResult? { + return if (canUpgradeAnonymous(config, auth)) { + // Anonymous upgrade: Use safe link pattern with scratch auth + val appExplicitlyForValidation = FirebaseApp.initializeApp( + context, + auth.app.options, + "FUIAuthScratchApp_${System.currentTimeMillis()}" + ) + val authExplicitlyForValidation = FirebaseAuth + .getInstance(appExplicitlyForValidation) + + // Safe link: Validate that both credentials can be linked + authExplicitlyForValidation + .signInWithCredential(emailLinkCredential).await() + .user?.linkWithCredential(storedCredentialForLink)?.await() + .also { result -> + // If safe link succeeds, throw AccountLinkingRequiredException for UI to handle + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with this email. " + + "Please sign in with your existing account to upgrade your anonymous account.", + email = email, + credential = storedCredentialForLink, + cause = null + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException + } + } else { + // Non-upgrade: Sign in with email link, then link social credential + auth.signInWithCredential(emailLinkCredential).await() + // Link the social credential + .user?.linkWithCredential(storedCredentialForLink)?.await() + .also { result -> + result?.user?.let { user -> + // Merge profile from the linked social credential + mergeProfile( + auth, + user.displayName, + user.photoUrl + ) + } + } + } +} + +/** + * Sends a password reset email to the specified email address. + * + * This method initiates the "forgot password" flow by sending an email to the user + * with a link to reset their password. The user will receive an email from Firebase + * containing a link that allows them to set a new password for their account. + * + * **Flow:** + * 1. Validate the email address exists in Firebase Auth + * 2. Send password reset email to the user + * 3. Emit [AuthState.PasswordResetLinkSent] state + * 4. User clicks link in email to reset password + * 5. User is redirected to Firebase-hosted password reset page (or custom URL if configured) + * + * **Error Handling:** + * - If the email doesn't exist: throws [AuthException.UserNotFoundException] + * - If the email is invalid: throws [AuthException.InvalidCredentialsException] + * - If network error occurs: throws [AuthException.NetworkException] + * + * @param email The email address to send the password reset email to + * @param actionCodeSettings Optional [ActionCodeSettings] to configure the password reset link. + * Use this to customize the continue URL, dynamic link domain, and other settings. + * + * @throws AuthException.UserNotFoundException if no account exists with this email + * @throws AuthException.InvalidCredentialsException if the email format is invalid + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.UnknownException for other errors + * + * **Example 1: Basic password reset** + * ```kotlin + * try { + * firebaseAuthUI.sendPasswordResetEmail( + * email = "user@example.com" + * ) + * // Show success message: "Password reset email sent to $email" + * } catch (e: AuthException.UserNotFoundException) { + * // Show error: "No account exists with this email" + * } catch (e: AuthException.InvalidCredentialsException) { + * // Show error: "Invalid email address" + * } + * ``` + * + * **Example 2: Custom password reset with ActionCodeSettings** + * ```kotlin + * val actionCodeSettings = ActionCodeSettings.newBuilder() + * .setUrl("https://myapp.com/resetPassword") // Continue URL after reset + * .setHandleCodeInApp(false) // Use Firebase-hosted reset page + * .setAndroidPackageName( + * "com.myapp", + * true, // Install if not available + * null // Minimum version + * ) + * .build() + * + * firebaseAuthUI.sendPasswordResetEmail( + * email = "user@example.com", + * actionCodeSettings = actionCodeSettings + * ) + * // User receives email with custom continue URL + * ``` + * + * @see com.google.firebase.auth.ActionCodeSettings + */ +internal suspend fun FirebaseAuthUI.sendPasswordResetEmail( + email: String, + actionCodeSettings: ActionCodeSettings? = null, +) { + try { + updateAuthState(AuthState.Loading("Sending password reset email...")) + auth.sendPasswordResetEmail(email, actionCodeSettings).await() + updateAuthState(AuthState.PasswordResetLinkSent()) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Send password reset email was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 000000000..41b7c0b6f --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,194 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.content.Context +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.facebook.AccessToken +import com.facebook.CallbackManager +import com.facebook.FacebookCallback +import com.facebook.FacebookException +import com.facebook.login.LoginManager +import com.facebook.login.LoginResult +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +/** + * Creates a remembered launcher function for Facebook sign-in. + * + * Returns a launcher function that initiates the Facebook sign-in flow. Automatically handles + * profile data fetching, Firebase credential creation, anonymous account upgrades, and account + * linking when an email collision occurs. + * + * @param context Android context for DataStore access when saving credentials for linking + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider The [AuthProvider.Facebook] configuration with scopes and credential provider + * + * @return A launcher function that starts the Facebook sign-in flow when invoked + * + * @see signInWithFacebook + */ +@Composable +internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Facebook, +): () -> Unit { + val coroutineScope = rememberCoroutineScope() + val callbackManager = remember { CallbackManager.Factory.create() } + val loginManager = LoginManager.getInstance() + + val launcher = rememberLauncherForActivityResult( + loginManager.createLogInActivityResultContract( + callbackManager, + null + ), + onResult = {}, + ) + + DisposableEffect(Unit) { + loginManager.registerCallback( + callbackManager, + object : FacebookCallback { + override fun onSuccess(result: LoginResult) { + coroutineScope.launch { + try { + signInWithFacebook( + context = context, + config = config, + provider = provider, + accessToken = result.accessToken, + ) + } catch (e: AuthException) { + // Already an AuthException, don't re-wrap it + updateAuthState(AuthState.Error(e)) + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + } + } + } + + override fun onCancel() { + updateAuthState(AuthState.Idle) + } + + override fun onError(error: FacebookException) { + Log.e("FacebookAuthProvider", "Error during Facebook sign in", error) + val authException = AuthException.from(error) + updateAuthState( + AuthState.Error( + authException + ) + ) + } + }) + + onDispose { loginManager.unregisterCallback(callbackManager) } + } + + return { + updateAuthState( + AuthState.Loading("Signing in with facebook...") + ) + launcher.launch(provider.scopes) + } +} + +/** + * Signs in a user with Facebook by converting a Facebook access token to a Firebase credential. + * + * Fetches user profile data from Facebook Graph API, creates a Firebase credential, and signs in + * or upgrades an anonymous account. Handles account collisions by saving the Facebook credential + * for linking and throwing [AuthException.AccountLinkingRequiredException]. + * + * @param context Android context for DataStore access when saving credentials for linking + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider The [AuthProvider.Facebook] configuration + * @param accessToken The Facebook [AccessToken] from successful login + * @param credentialProvider Creates Firebase credentials from Facebook tokens + * + * @throws AuthException.AccountLinkingRequiredException if an account exists with the same email + * @throws AuthException.AuthCancelledException if the coroutine is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.InvalidCredentialsException if the Facebook token is invalid + * + * @see rememberSignInWithFacebookLauncher + * @see signInAndLinkWithCredential + */ +internal suspend fun FirebaseAuthUI.signInWithFacebook( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Facebook, + accessToken: AccessToken, + credentialProvider: AuthProvider.Facebook.CredentialProvider = AuthProvider.Facebook.DefaultCredentialProvider(), +) { + try { + updateAuthState( + AuthState.Loading("Signing in with facebook...") + ) + val profileData = provider.fetchFacebookProfile(accessToken) + val credential = credentialProvider.getCredential(accessToken.token) + signInAndLinkWithCredential( + config = config, + credential = credential, + provider = provider, + displayName = profileData?.displayName, + photoUrl = profileData?.photoUrl, + ) + } catch (e: AuthException.AccountLinkingRequiredException) { + // Account collision occurred - save Facebook credential for linking after email link sign-in + // This happens when a user tries to sign in with Facebook but an email link account exists + EmailLinkPersistenceManager.default.saveCredentialForLinking( + context = context, + providerType = provider.providerId, + idToken = null, + accessToken = accessToken.token + ) + + // Re-throw to let UI handle the account linking flow + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: FacebookException) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with facebook was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 000000000..8f8cb6b69 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,210 @@ +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.credentials.ClearCredentialStateRequest +import androidx.credentials.CredentialManager +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.NoCredentialException +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager +import com.google.android.gms.common.api.Scope +import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +/** + * Creates a remembered callback for Google Sign-In that can be invoked from UI components. + * + * This Composable function returns a lambda that, when invoked, initiates the Google Sign-In + * flow using [signInWithGoogle]. The callback is stable across recompositions and automatically + * handles coroutine scoping and error state management. + * + * **Usage:** + * ```kotlin + * val onSignInWithGoogle = authUI.rememberGoogleSignInHandler( + * context = context, + * config = configuration, + * provider = googleProvider + * ) + * + * Button(onClick = onSignInWithGoogle) { + * Text("Sign in with Google") + * } + * ``` + * + * **Error Handling:** + * - Catches all exceptions and converts them to [AuthException] + * - Automatically updates [AuthState.Error] on failures + * - Logs errors for debugging purposes + * + * @param context Android context for Credential Manager + * @param config Authentication UI configuration + * @param provider Google provider configuration with server client ID and optional scopes + * @return A callback function that initiates Google Sign-In when invoked + * + * @see signInWithGoogle + * @see AuthProvider.Google + */ +@Composable +internal fun FirebaseAuthUI.rememberGoogleSignInHandler( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Google, +): () -> Unit { + val coroutineScope = rememberCoroutineScope() + return remember(this) { + { + coroutineScope.launch { + try { + signInWithGoogle(context, config, provider) + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + } + } + } + } +} + +/** + * Signs in with Google using Credential Manager and optionally requests OAuth scopes. + * + * This function implements Google Sign-In using Android's Credential Manager API with + * comprehensive error handling. + * + * **Flow:** + * 1. If [AuthProvider.Google.scopes] are specified, requests OAuth authorization first + * 2. Attempts sign-in using Credential Manager + * 3. Creates Firebase credential and calls [signInAndLinkWithCredential] + * + * **Scopes Behavior:** + * - If [AuthProvider.Google.scopes] is not empty, requests OAuth authorization before sign-in + * - Basic profile, email, and ID token are always included automatically + * - Scopes are requested using the AuthorizationClient API + * + * **Error Handling:** + * - [GoogleIdTokenParsingException]: Library version mismatch + * - [NoCredentialException]: No Google accounts on device + * - [GetCredentialException]: User cancellation, configuration errors, or no credentials + * - Configuration errors trigger detailed developer guidance logs + * + * @param context Android context for Credential Manager + * @param config Authentication UI configuration + * @param provider Google provider configuration with optional scopes + * @param authorizationProvider Provider for OAuth scopes authorization (for testing) + * @param credentialManagerProvider Provider for Credential Manager flow (for testing) + * + * @throws AuthException.InvalidCredentialsException if token parsing fails + * @throws AuthException.AuthCancelledException if user cancels or no accounts found + * @throws AuthException if sign-in or linking fails + * + * @see AuthProvider.Google + * @see signInAndLinkWithCredential + */ +internal suspend fun FirebaseAuthUI.signInWithGoogle( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Google, + authorizationProvider: AuthProvider.Google.AuthorizationProvider = AuthProvider.Google.DefaultAuthorizationProvider(), + credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider = AuthProvider.Google.DefaultCredentialManagerProvider(), +) { + var idTokenFromResult: String? = null + try { + updateAuthState(AuthState.Loading("Signing in with google...")) + + // Request OAuth scopes if specified (before sign-in) + if (provider.scopes.isNotEmpty()) { + try { + val requestedScopes = provider.scopes.map { Scope(it) } + authorizationProvider.authorize(context, requestedScopes) + } catch (e: Exception) { + // Continue with sign-in even if scope authorization fails + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + } + } + + val result = + (testCredentialManagerProvider ?: credentialManagerProvider).getGoogleCredential( + context = context, + credentialManager = CredentialManager.create(context), + serverClientId = provider.serverClientId!!, + filterByAuthorizedAccounts = true, + autoSelectEnabled = false + ) + idTokenFromResult = result.idToken + + signInAndLinkWithCredential( + config = config, + credential = result.credential, + provider = provider, + displayName = result.displayName, + photoUrl = result.photoUrl, + ) + } catch (e: AuthException.AccountLinkingRequiredException) { + // Account collision occurred - save Facebook credential for linking after email link sign-in + // This happens when a user tries to sign in with Facebook but an email link account exists + EmailLinkPersistenceManager.default.saveCredentialForLinking( + context = context, + providerType = provider.providerId, + idToken = idTokenFromResult, + accessToken = null + ) + + // Re-throw to let UI handle the account linking flow + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with google was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs out from Google and clears credential state. + * + * This function clears the cached Google credentials, ensuring that the account picker + * will be shown on the next sign-in attempt instead of automatically signing in with + * the previously used account. + * + * **When to call:** + * - After user explicitly signs out + * - Before allowing user to select a different Google account + * - When switching between accounts + * + * **Note:** This does not sign out from Firebase Auth itself. Call [FirebaseAuthUI.signOut] + * separately if you need to sign out from Firebase. + * + * @param context Android context for Credential Manager + */ +internal suspend fun signOutFromGoogle(context: Context) { + try { + val credentialManager = CredentialManager.create(context) + credentialManager.clearCredentialState( + ClearCredentialStateRequest() + ) + } catch (_: Exception) { + + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 000000000..1c8eb48d2 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,215 @@ +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous +import com.google.firebase.auth.FirebaseAuthUserCollisionException +import com.google.firebase.auth.OAuthCredential +import com.google.firebase.auth.OAuthProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +/** + * Creates a Composable handler for OAuth provider sign-in. + * + * This function creates a remember-scoped sign-in handler that can be invoked + * from button clicks or other UI events. It automatically handles: + * - Activity retrieval from LocalActivity + * - Coroutine scope management + * - Error handling and state updates + * + * **Usage:** + * ```kotlin + * val onSignInWithGitHub = authUI.rememberOAuthSignInHandler( + * config = configuration, + * provider = githubProvider + * ) + * + * Button(onClick = onSignInWithGitHub) { + * Text("Sign in with GitHub") + * } + * ``` + * + * @param config Authentication UI configuration + * @param provider OAuth provider configuration + * + * @return Lambda that triggers OAuth sign-in when invoked + * + * @throws IllegalStateException if LocalActivity.current is null + * + * @see signInWithProvider + */ +@Composable +internal fun FirebaseAuthUI.rememberOAuthSignInHandler( + activity: Activity?, + config: AuthUIConfiguration, + provider: AuthProvider.OAuth, +): () -> Unit { + val coroutineScope = rememberCoroutineScope() + activity ?: throw IllegalStateException( + "OAuth sign-in requires an Activity. " + + "Ensure FirebaseAuthScreen is used within an Activity." + ) + + return remember(this, provider.providerId) { + { + coroutineScope.launch { + try { + signInWithProvider( + config = config, + activity = activity, + provider = provider + ) + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + } + } + } + } +} + +/** + * Signs in with an OAuth provider (GitHub, Microsoft, Yahoo, Apple, Twitter). + * + * This function implements OAuth provider authentication using Firebase's native OAuthProvider. + * It handles both normal sign-in flow and anonymous user upgrade flow. + * + * **Supported Providers:** + * - GitHub (github.com) + * - Microsoft (microsoft.com) + * - Yahoo (yahoo.com) + * - Apple (apple.com) + * - Twitter (twitter.com) + * + * **Flow:** + * 1. Checks for pending auth results (e.g., from app restart during OAuth flow) + * 2. If anonymous upgrade is enabled and user is anonymous, links credential to anonymous account + * 3. Otherwise, performs normal sign-in + * 4. Updates auth state to Idle on success + * + * **Anonymous Upgrade:** + * If [AuthUIConfiguration.isAnonymousUpgradeEnabled] is true and a user is currently signed in + * anonymously, this will attempt to link the OAuth credential to the anonymous account instead + * of creating a new account. + * + * **Error Handling:** + * - [AuthException.AuthCancelledException]: User cancelled OAuth flow + * - [AuthException.AccountLinkingRequiredException]: Account collision (email already exists) + * - [AuthException]: Other authentication errors + * + * @param config Authentication UI configuration + * @param activity Activity for OAuth flow + * @param provider OAuth provider configuration with scopes and custom parameters + * + * @throws AuthException.AuthCancelledException if user cancels + * @throws AuthException.AccountLinkingRequiredException if account collision occurs + * @throws AuthException if OAuth flow or sign-in fails + * + * @see AuthProvider.OAuth + * @see signInAndLinkWithCredential + */ +internal suspend fun FirebaseAuthUI.signInWithProvider( + config: AuthUIConfiguration, + activity: Activity, + provider: AuthProvider.OAuth, +) { + try { + updateAuthState(AuthState.Loading("Signing in with ${provider.providerName}...")) + + // Build OAuth provider with scopes and custom parameters + val oauthProvider = OAuthProvider + .newBuilder(provider.providerId) + .apply { + // Add scopes if provided + if (provider.scopes.isNotEmpty()) { + scopes = provider.scopes + } + // Add custom parameters if provided + provider.customParameters.forEach { (key, value) -> + addCustomParameter(key, value) + } + } + .build() + + // Check for pending auth result (e.g., app was killed during OAuth flow) + val pendingResult = auth.pendingAuthResult + if (pendingResult != null) { + val authResult = pendingResult.await() + val credential = authResult.credential as? OAuthCredential + + if (credential != null) { + // Complete the pending sign-in/link flow + signInAndLinkWithCredential( + config = config, + credential = credential, + provider = provider, + displayName = authResult.user?.displayName, + photoUrl = authResult.user?.photoUrl, + ) + } + updateAuthState(AuthState.Idle) + return + } + + // Determine if we should upgrade anonymous user or do normal sign-in + val authResult = if (canUpgradeAnonymous(config, auth)) { + auth.currentUser?.startActivityForLinkWithProvider(activity, oauthProvider)?.await() + } else { + auth.startActivityForSignInWithProvider(activity, oauthProvider).await() + } + + // Extract OAuth credential and complete sign-in + val credential = authResult?.credential as? OAuthCredential + if (credential != null) { + // The user is already signed in via startActivityForSignInWithProvider/startActivityForLinkWithProvider + // Just update state to Idle + updateAuthState(AuthState.Idle) + } else { + throw AuthException.UnknownException( + message = "OAuth sign-in did not return a valid credential" + ) + } + + } catch (e: FirebaseAuthUserCollisionException) { + // Account collision: account already exists with different sign-in method + val email = e.email + val credential = e.updatedCredential + + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with the email ${email ?: ""}. " + + "Please sign in with your existing account to link " + + "your ${provider.providerName} account.", + email = email, + credential = credential, + cause = e + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Signing in with ${provider.providerName} was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 000000000..af381e78c --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,315 @@ +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.app.Activity +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.MultiFactorSession +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthProvider +import kotlinx.coroutines.CancellationException + +/** + * Initiates phone number verification with Firebase Phone Authentication. + * + * This method starts the phone verification flow, which can complete in two ways: + * 1. **Instant verification** (auto): Firebase SDK automatically retrieves and verifies + * the SMS code without user interaction. This happens when Google Play services can + * detect the incoming SMS automatically. + * 2. **Manual verification**: SMS code is sent to the user's device, and the user must + * manually enter the code via [submitVerificationCode]. + * + * **Flow:** + * - Call this method with the phone number + * - Firebase SDK attempts instant verification + * - If instant verification succeeds: + * - Emits [AuthState.SMSAutoVerified] with the credential + * - UI should observe this state and call [signInWithPhoneAuthCredential] + * - If instant verification fails: + * - Emits [AuthState.PhoneNumberVerificationRequired] with verification details + * - UI should show code entry screen + * - User enters code → call [submitVerificationCode] + * + * **Resending codes:** + * To resend a verification code, call this method again with: + * - `forceResendingToken` = the token from [AuthState.PhoneNumberVerificationRequired] + * + * **Example: Basic phone verification** + * ```kotlin + * // Step 1: Start verification + * firebaseAuthUI.verifyPhoneNumber( + * provider = phoneProvider, + * phoneNumber = "+1234567890", + * ) + * + * // Step 2: Observe AuthState + * authUI.authStateFlow().collect { state -> + * when (state) { + * is AuthState.SMSAutoVerified -> { + * // Instant verification succeeded! + * showToast("Phone number verified automatically") + * // Now sign in with the credential + * firebaseAuthUI.signInWithPhoneAuthCredential( + * config = authUIConfig, + * credential = state.credential + * ) + * } + * is AuthState.PhoneNumberVerificationRequired -> { + * // Show code entry screen + * showCodeEntryScreen( + * verificationId = state.verificationId, + * forceResendingToken = state.forceResendingToken + * ) + * } + * is AuthState.Error -> { + * // Handle error + * showError(state.exception.message) + * } + * } + * } + * + * // Step 3: When user enters code + * firebaseAuthUI.submitVerificationCode( + * config = authUIConfig, + * verificationId = verificationId, + * code = userEnteredCode + * ) + * ``` + * + * **Example: Resending verification code** + * ```kotlin + * // User didn't receive the code, wants to resend + * firebaseAuthUI.verifyPhoneNumber( + * provider = phoneProvider, + * phoneNumber = "+1234567890", + * forceResendingToken = savedToken // From PhoneNumberVerificationRequired state + * ) + * ``` + * + * @param provider The [AuthProvider.Phone] configuration containing timeout and other settings + * @param phoneNumber The phone number to verify in E.164 format (e.g., "+1234567890") + * @param multiFactorSession Optional [MultiFactorSession] for MFA enrollment. When provided, + * this initiates phone verification for enrolling a second factor rather than primary sign-in. + * Obtain this from `FirebaseUser.multiFactor.session` when enrolling MFA. + * @param forceResendingToken Optional token from previous verification for resending SMS + * + * @throws AuthException.InvalidCredentialsException if the phone number is invalid + * @throws AuthException.TooManyRequestsException if SMS quota is exceeded + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + */ +internal suspend fun FirebaseAuthUI.verifyPhoneNumber( + provider: AuthProvider.Phone, + activity: Activity?, + phoneNumber: String, + multiFactorSession: MultiFactorSession? = null, + forceResendingToken: PhoneAuthProvider.ForceResendingToken? = null, + verifier: AuthProvider.Phone.Verifier = AuthProvider.Phone.DefaultVerifier(), +) { + try { + updateAuthState(AuthState.Loading("Verifying phone number...")) + val result = provider.verifyPhoneNumberAwait( + auth = auth, + activity = activity, + phoneNumber = phoneNumber, + multiFactorSession = multiFactorSession, + forceResendingToken = forceResendingToken, + verifier = verifier + ) + when (result) { + is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { + updateAuthState(AuthState.SMSAutoVerified(credential = result.credential)) + } + + is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { + updateAuthState( + AuthState.PhoneNumberVerificationRequired( + verificationId = result.verificationId, + forceResendingToken = result.token, + ) + ) + } + } + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Verify phone number was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Submits a verification code entered by the user and signs them in. + * + * This method is called after [verifyPhoneNumber] emits [AuthState.PhoneNumberVerificationRequired], + * indicating that manual code entry is needed. It creates a [PhoneAuthCredential] from the + * verification ID and user-entered code, then signs in the user by calling + * [signInWithPhoneAuthCredential]. + * + * **Flow:** + * 1. User receives SMS with 6-digit code + * 2. User enters code in UI + * 3. UI calls this method with the code + * 4. Credential is created and used to sign in + * 5. Returns [AuthResult] with signed-in user + * + * This method handles both normal sign-in and anonymous account upgrade scenarios based + * on the [AuthUIConfiguration] settings. + * + * **Example: Manual code entry flow* + * ``` + * val userEnteredCode = "123456" + * try { + * val result = firebaseAuthUI.submitVerificationCode( + * config = authUIConfig, + * verificationId = savedVerificationId!!, + * code = userEnteredCode + * ) + * // User is now signed in + * } catch (e: AuthException.InvalidCredentialsException) { + * // Wrong code entered + * showError("Invalid verification code") + * } catch (e: AuthException.SessionExpiredException) { + * // Code expired + * showError("Verification code expired. Please request a new one.") + * } + * ``` + * + * @param config The [AuthUIConfiguration] containing authentication settings + * @param verificationId The verification ID from [AuthState.PhoneNumberVerificationRequired] + * @param code The 6-digit verification code entered by the user + * + * @return [AuthResult] containing the signed-in user + * + * @throws AuthException.InvalidCredentialsException if the code is incorrect or expired + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + */ +internal suspend fun FirebaseAuthUI.submitVerificationCode( + config: AuthUIConfiguration, + verificationId: String, + code: String, + credentialProvider: AuthProvider.Phone.CredentialProvider = AuthProvider.Phone.DefaultCredentialProvider(), +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Submitting verification code...")) + val credential = credentialProvider.getCredential(verificationId, code) + return signInWithPhoneAuthCredential( + config = config, + credential = credential + ) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Submit verification code was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs in a user with a phone authentication credential. + * + * This method is the final step in the phone authentication flow. It takes a + * [PhoneAuthCredential] (either from instant verification or manual code entry) and + * signs in the user. The method handles both normal sign-in and anonymous account + * upgrade scenarios by delegating to [signInAndLinkWithCredential]. + * + * **When to call this:** + * - After [verifyPhoneNumber] emits [AuthState.SMSAutoVerified] (instant verification) + * - Called internally by [submitVerificationCode] (manual verification) + * + * The method automatically handles: + * - Normal sign-in for new or returning users + * - Linking phone credential to anonymous accounts (if enabled in config) + * - Throwing [AuthException.AccountLinkingRequiredException] if phone number already exists on another account + * + * **Example: Sign in after instant verification** + * ```kotlin + * authUI.authStateFlow().collect { state -> + * when (state) { + * is AuthState.SMSAutoVerified -> { + * // Phone was instantly verified + * showToast("Phone verified automatically!") + * + * // Now sign in with the credential + * val result = firebaseAuthUI.signInWithPhoneAuthCredential( + * config = authUIConfig, + * credential = state.credential + * ) + * // User is now signed in + * } + * } + * } + * ``` + * + * **Example: Anonymous upgrade with collision** + * ```kotlin + * // User is currently anonymous + * try { + * firebaseAuthUI.signInWithPhoneAuthCredential( + * config = authUIConfig, + * credential = phoneCredential + * ) + * } catch (e: AuthException.AccountLinkingRequiredException) { + * // Phone number already exists on another account + * // Account linking required - show account linking screen + * // User needs to sign in with existing account to link + * } + * ``` + * + * @param config The [AuthUIConfiguration] containing authentication settings + * @param credential The [PhoneAuthCredential] to use for signing in + * + * @return [AuthResult] containing the signed-in user, or null if anonymous upgrade collision occurred + * + * @throws AuthException.InvalidCredentialsException if the credential is invalid or expired + * @throws AuthException.EmailAlreadyInUseException if phone number is linked to another account + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + */ +internal suspend fun FirebaseAuthUI.signInWithPhoneAuthCredential( + config: AuthUIConfiguration, + credential: PhoneAuthCredential, +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Signing in with phone...")) + return signInAndLinkWithCredential( + config = config, + credential = credential, + ) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with phone was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProvider.kt new file mode 100644 index 000000000..e5a6631d0 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/AuthUIStringProvider.kt @@ -0,0 +1,512 @@ +/* + * 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 androidx.compose.runtime.staticCompositionLocalOf + +/** + * CompositionLocal for providing [AuthUIStringProvider] throughout the Compose tree. + * + * This allows accessing localized strings without manually passing the provider through + * every composable. The provider is set at the top level in FirebaseAuthScreen and can + * be accessed anywhere in the auth UI using `LocalAuthUIStringProvider.current`. + * + * **Usage:** + * ```kotlin + * @Composable + * fun MyAuthComponent() { + * val stringProvider = LocalAuthUIStringProvider.current + * Text(stringProvider.signInWithGoogle) + * } + * ``` + * + * @since 10.0.0 + */ +val LocalAuthUIStringProvider = staticCompositionLocalOf { + error("No AuthUIStringProvider provided. Ensure FirebaseAuthScreen is used as the root composable.") +} + +/** + * 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. */ + fun passwordTooShort(minimumLength: Int): 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 signupPageTitle: String + + /** Hint for email input field */ + val emailHint: String + + /** Hint for password input field */ + val passwordHint: String + + /** Hint for confirm password input field */ + val confirmPasswordHint: 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 + + /** Title for recover password page */ + val recoverPasswordPageTitle: String + + /** Button text for reset password */ + val sendButtonText: String + + /** Title for recover password link sent dialog */ + val recoverPasswordLinkSentDialogTitle: String + + /** Body for recover password link sent dialog */ + fun recoverPasswordLinkSentDialogBody(email: String): String + + /** Title for email sign in link sent dialog */ + val emailSignInLinkSentDialogTitle: String + + /** Body for email sign in link sent dialog */ + fun emailSignInLinkSentDialogBody(email: String): String + + /** Divider text for alternate sign-in options */ + val orContinueWith: String + + /** Button text to sign in with email link */ + val signInWithEmailLink: String + + /** Button text to sign in with password */ + val signInWithPassword: String + + /** Title shown when prompting the user to confirm their email for cross-device flows */ + val emailLinkPromptForEmailTitle: String + + /** Message shown when prompting the user to confirm their email for cross-device flows */ + val emailLinkPromptForEmailMessage: String + + /** Title shown when email link must be opened on same device */ + val emailLinkWrongDeviceTitle: String + + /** Message shown when email link must be opened on same device */ + val emailLinkWrongDeviceMessage: String + + /** Title shown when the anonymous session differs */ + val emailLinkDifferentAnonymousUserTitle: String + + /** Message shown when the anonymous session differs */ + val emailLinkDifferentAnonymousUserMessage: String + + /** Message shown for cross-device linking flows with the provider name */ + fun emailLinkCrossDeviceLinkingMessage(providerName: String): String + + /** Title shown when email link is invalid */ + val emailLinkInvalidLinkTitle: String + + /** Message shown when email link is invalid */ + val emailLinkInvalidLinkMessage: String + + /** Message shown when email mismatch occurs */ + val emailMismatchMessage: 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 + + /** Missing phone number error */ + val missingPhoneNumber: 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 + + /** Resend code with timer */ + fun resendCodeTimer(timeFormatted: String): String + + /** Verifying progress text */ + val verifying: String + + /** Wrong verification code error */ + val incorrectCodeDialogBody: String + + /** SMS terms of service warning */ + val smsTermsOfService: String + + /** Enter phone number title */ + val enterPhoneNumberTitle: String + + /** Phone number hint */ + val phoneNumberHint: String + + /** Send verification code button text */ + val sendVerificationCode: String + + /** Enter verification code title with phone number */ + fun enterVerificationCodeTitle(phoneNumber: String): String + + /** Verification code hint */ + val verificationCodeHint: String + + /** Change phone number link text */ + val changePhoneNumber: String + + /** Missing verification code error */ + val missingVerificationCode: String + + /** Invalid verification code error */ + val invalidVerificationCode: String + + /** Select country modal sheet title */ + val countrySelectorModalTitle: String + + /** Select country modal sheet input field hint */ + val searchCountriesHint: 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 + + /** Label shown when the user is signed in. String should contain a single %s placeholder. */ + fun signedInAs(userIdentifier: String): String + + /** Action text for managing multi-factor authentication settings. */ + val manageMfaAction: String + + /** Action text for signing out. */ + val signOutAction: String + + /** Instruction shown when the user must verify their email. Accepts the email value. */ + fun verifyEmailInstruction(email: String): String + + /** Action text for resending the verification email. */ + val resendVerificationEmailAction: String + + /** Action text once the user has verified their email. */ + val verifiedEmailAction: String + + /** Message shown when profile completion is required. */ + val profileCompletionMessage: String + + /** Message listing missing profile fields. Accepts a comma-separated list. */ + fun profileMissingFieldsMessage(fields: String): String + + /** Action text for skipping an optional step. */ + val skipAction: String + + /** Action text for removing an item (for example, an MFA factor). */ + val removeAction: String + + /** Action text for navigating back. */ + val backAction: String + + /** Action text for confirming verification. */ + val verifyAction: String + + /** Action text for choosing a different factor during MFA challenge. */ + val useDifferentMethodAction: String + + /** Action text for confirming recovery codes have been saved. */ + val recoveryCodesSavedAction: String + + /** Label for secret key text displayed during TOTP setup. */ + val secretKeyLabel: String + + /** Label for verification code input fields. */ + val verificationCodeLabel: String + + /** Generic identity verified confirmation message. */ + val identityVerifiedMessage: String + + /** Title for the manage MFA screen. */ + val mfaManageFactorsTitle: String + + /** Helper description for the manage MFA screen. */ + val mfaManageFactorsDescription: String + + /** Header for the list of currently enrolled MFA factors. */ + val mfaActiveMethodsTitle: String + + /** Header for the list of available MFA factors to enroll. */ + val mfaAddNewMethodTitle: String + + /** Message shown when all factors are already enrolled. */ + val mfaAllMethodsEnrolledMessage: String + + /** Label for SMS MFA factor. */ + val smsAuthenticationLabel: String + + /** Label for authenticator-app MFA factor. */ + val totpAuthenticationLabel: String + + /** Label used when the factor type is unknown. */ + val unknownMethodLabel: String + + /** Label describing the enrollment date. Accepts a formatted date string. */ + fun enrolledOnDateLabel(date: String): String + + /** Description displayed during authenticator app setup. */ + val setupAuthenticatorDescription: 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 + + // MFA Enrollment Step Titles + /** Title for MFA factor selection step */ + val mfaStepSelectFactorTitle: String + + /** Title for SMS MFA configuration step */ + val mfaStepConfigureSmsTitle: String + + /** Title for TOTP MFA configuration step */ + val mfaStepConfigureTotpTitle: String + + /** Title for MFA verification step */ + val mfaStepVerifyFactorTitle: String + + /** Title for recovery codes step */ + val mfaStepShowRecoveryCodesTitle: String + + // MFA Enrollment Helper Text + /** Helper text for selecting MFA factor */ + val mfaStepSelectFactorHelper: String + + /** Helper text for SMS configuration */ + val mfaStepConfigureSmsHelper: String + + /** Helper text for TOTP configuration */ + val mfaStepConfigureTotpHelper: String + + /** Helper text for SMS verification */ + val mfaStepVerifyFactorSmsHelper: String + + /** Helper text for TOTP verification */ + val mfaStepVerifyFactorTotpHelper: String + + /** Generic helper text for factor verification */ + val mfaStepVerifyFactorGenericHelper: String + + /** Helper text for recovery codes */ + val mfaStepShowRecoveryCodesHelper: String + + // MFA Enrollment Screen Titles + /** Title for MFA phone number enrollment screen (top app bar) */ + val mfaEnrollmentEnterPhoneNumber: String + + /** Title for MFA SMS verification screen (top app bar) */ + val mfaEnrollmentVerifySmsCode: String + + // MFA Error Messages + /** Error message when MFA enrollment requires recent authentication */ + val mfaErrorRecentLoginRequired: String + + /** Error message when MFA enrollment fails due to invalid verification code */ + val mfaErrorInvalidVerificationCode: String + + /** Error message when MFA enrollment fails due to network issues */ + val mfaErrorNetwork: String + + /** Generic error message for MFA enrollment failures */ + val mfaErrorGeneric: String + + // Re-authentication Dialog + /** Title displayed in the re-authentication dialog. */ + val reauthDialogTitle: String + + /** Descriptive message shown in the re-authentication dialog. */ + val reauthDialogMessage: String + + /** Label showing the account email being re-authenticated. */ + fun reauthAccountLabel(email: String): String + + /** Error message shown when the provided password is incorrect. */ + val incorrectPasswordError: String + + /** General error message for re-authentication failures. */ + val reauthGenericError: String + + // Terms of Service and Privacy Policy + /** Terms of Service link text */ + val termsOfService: String + + /** Privacy Policy link text */ + val privacyPolicy: String + + /** ToS and Privacy Policy combined message with placeholders for links */ + fun tosAndPrivacyPolicy(termsOfServiceLabel: String, privacyPolicyLabel: String): 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..453f28cda --- /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.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider + +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..4f39b0504 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -0,0 +1,469 @@ +/* + * 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( + context: Context, + 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 fun passwordTooShort(minimumLength: Int): String = + localizedContext.getString(R.string.fui_error_password_too_short, minimumLength) + + 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 signupPageTitle: 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 confirmPasswordHint: String + get() = localizedContext.getString(R.string.fui_confirm_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) + + override val recoverPasswordPageTitle: String + get() = localizedContext.getString(R.string.fui_title_recover_password_activity) + + override val sendButtonText: String + get() = localizedContext.getString(R.string.fui_button_text_send) + + override val recoverPasswordLinkSentDialogTitle: String + get() = localizedContext.getString(R.string.fui_title_confirm_recover_password) + + override fun recoverPasswordLinkSentDialogBody(email: String): String = + localizedContext.getString(R.string.fui_confirm_recovery_body, email) + + override val emailSignInLinkSentDialogTitle: String + get() = localizedContext.getString(R.string.fui_email_link_header) + + override fun emailSignInLinkSentDialogBody(email: String): String = + localizedContext.getString(R.string.fui_email_link_email_sent, email) + + override val orContinueWith: String + get() = localizedContext.getString(R.string.fui_or_continue_with) + + override val signInWithEmailLink: String + get() = localizedContext.getString(R.string.fui_sign_in_with_email_link) + + override val signInWithPassword: String + get() = localizedContext.getString(R.string.fui_sign_in_with_password) + + override val emailLinkPromptForEmailTitle: String + get() = localizedContext.getString(R.string.fui_email_link_confirm_email_header) + + override val emailLinkPromptForEmailMessage: String + get() = localizedContext.getString(R.string.fui_email_link_confirm_email_message) + + override val emailLinkWrongDeviceTitle: String + get() = localizedContext.getString(R.string.fui_email_link_wrong_device_header) + + override val emailLinkWrongDeviceMessage: String + get() = localizedContext.getString(R.string.fui_email_link_wrong_device_message) + + override val emailLinkDifferentAnonymousUserTitle: String + get() = localizedContext.getString(R.string.fui_email_link_different_anonymous_user_header) + + override val emailLinkDifferentAnonymousUserMessage: String + get() = localizedContext.getString(R.string.fui_email_link_different_anonymous_user_message) + + override fun emailLinkCrossDeviceLinkingMessage(providerName: String): String = + localizedContext.getString( + R.string.fui_email_link_cross_device_linking_text, + providerName + ) + + override val emailLinkInvalidLinkTitle: String + get() = localizedContext.getString(R.string.fui_email_link_invalid_link_header) + + override val emailLinkInvalidLinkMessage: String + get() = localizedContext.getString(R.string.fui_email_link_invalid_link_message) + + override val emailMismatchMessage: String + get() = localizedContext.getString(R.string.fui_error_unknown) + + /** + * 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 missingPhoneNumber: String + get() = localizedContext.getString(R.string.fui_required_field) + 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 fun resendCodeTimer(timeFormatted: String): String = + localizedContext.getString(R.string.fui_resend_code_in, timeFormatted) + + 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) + + override val enterPhoneNumberTitle: String + get() = localizedContext.getString(R.string.fui_verify_phone_number_title) + + override val phoneNumberHint: String + get() = localizedContext.getString(R.string.fui_phone_hint) + + override val sendVerificationCode: String + get() = localizedContext.getString(R.string.fui_next_default) + + override fun enterVerificationCodeTitle(phoneNumber: String): String = + localizedContext.getString(R.string.fui_enter_confirmation_code) + " " + phoneNumber + + override val verificationCodeHint: String + get() = localizedContext.getString(R.string.fui_enter_confirmation_code) + + override val changePhoneNumber: String + get() = localizedContext.getString(R.string.fui_change_phone_number) + + override val missingVerificationCode: String + get() = localizedContext.getString(R.string.fui_required_field) + + override val invalidVerificationCode: String + get() = localizedContext.getString(R.string.fui_incorrect_code_dialog_body) + + override val countrySelectorModalTitle: String + get() = localizedContext.getString(R.string.fui_country_selector_title) + + override val searchCountriesHint: String + get() = localizedContext.getString(R.string.fui_search_country_field_hint) + + /** + * 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 fun signedInAs(userIdentifier: String): String = + localizedContext.getString(R.string.fui_signed_in_as, userIdentifier) + + override val manageMfaAction: String + get() = localizedContext.getString(R.string.fui_manage_mfa_action) + + override val signOutAction: String + get() = localizedContext.getString(R.string.fui_sign_out_action) + + override fun verifyEmailInstruction(email: String): String = + localizedContext.getString(R.string.fui_verify_email_instruction, email) + + override val resendVerificationEmailAction: String + get() = localizedContext.getString(R.string.fui_resend_verification_email_action) + + override val verifiedEmailAction: String + get() = localizedContext.getString(R.string.fui_verified_email_action) + + override val profileCompletionMessage: String + get() = localizedContext.getString(R.string.fui_profile_completion_message) + + override fun profileMissingFieldsMessage(fields: String): String = + localizedContext.getString(R.string.fui_profile_missing_fields_message, fields) + + override val skipAction: String + get() = localizedContext.getString(R.string.fui_skip_action) + + override val removeAction: String + get() = localizedContext.getString(R.string.fui_remove_action) + + override val backAction: String + get() = localizedContext.getString(R.string.fui_back_action) + + override val verifyAction: String + get() = localizedContext.getString(R.string.fui_verify_action) + + override val useDifferentMethodAction: String + get() = localizedContext.getString(R.string.fui_use_different_method_action) + + override val recoveryCodesSavedAction: String + get() = localizedContext.getString(R.string.fui_recovery_codes_saved_action) + + override val secretKeyLabel: String + get() = localizedContext.getString(R.string.fui_secret_key_label) + + override val verificationCodeLabel: String + get() = localizedContext.getString(R.string.fui_verification_code_label) + + override val identityVerifiedMessage: String + get() = localizedContext.getString(R.string.fui_identity_verified_message) + + override val mfaManageFactorsTitle: String + get() = localizedContext.getString(R.string.fui_mfa_manage_factors_title) + + override val mfaManageFactorsDescription: String + get() = localizedContext.getString(R.string.fui_mfa_manage_factors_description) + + override val mfaActiveMethodsTitle: String + get() = localizedContext.getString(R.string.fui_mfa_active_methods_title) + + override val mfaAddNewMethodTitle: String + get() = localizedContext.getString(R.string.fui_mfa_add_new_method_title) + + override val mfaAllMethodsEnrolledMessage: String + get() = localizedContext.getString(R.string.fui_mfa_all_methods_enrolled_message) + + override val smsAuthenticationLabel: String + get() = localizedContext.getString(R.string.fui_mfa_label_sms_authentication) + + override val totpAuthenticationLabel: String + get() = localizedContext.getString(R.string.fui_mfa_label_totp_authentication) + + override val unknownMethodLabel: String + get() = localizedContext.getString(R.string.fui_mfa_label_unknown_method) + + override fun enrolledOnDateLabel(date: String): String = + localizedContext.getString(R.string.fui_mfa_enrolled_on, date) + + override val setupAuthenticatorDescription: String + get() = localizedContext.getString(R.string.fui_mfa_setup_authenticator_description) + 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) + + /** + * MFA Enrollment Step Titles + */ + override val mfaStepSelectFactorTitle: String + get() = localizedContext.getString(R.string.fui_mfa_step_select_factor_title) + override val mfaStepConfigureSmsTitle: String + get() = localizedContext.getString(R.string.fui_mfa_step_configure_sms_title) + override val mfaStepConfigureTotpTitle: String + get() = localizedContext.getString(R.string.fui_mfa_step_configure_totp_title) + override val mfaStepVerifyFactorTitle: String + get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_title) + override val mfaStepShowRecoveryCodesTitle: String + get() = localizedContext.getString(R.string.fui_mfa_step_show_recovery_codes_title) + + /** + * MFA Enrollment Helper Text + */ + override val mfaStepSelectFactorHelper: String + get() = localizedContext.getString(R.string.fui_mfa_step_select_factor_helper) + override val mfaStepConfigureSmsHelper: String + get() = localizedContext.getString(R.string.fui_mfa_step_configure_sms_helper) + override val mfaStepConfigureTotpHelper: String + get() = localizedContext.getString(R.string.fui_mfa_step_configure_totp_helper) + override val mfaStepVerifyFactorSmsHelper: String + get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_sms_helper) + override val mfaStepVerifyFactorTotpHelper: String + get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_totp_helper) + override val mfaStepVerifyFactorGenericHelper: String + get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_generic_helper) + override val mfaStepShowRecoveryCodesHelper: String + get() = localizedContext.getString(R.string.fui_mfa_step_show_recovery_codes_helper) + + // MFA Enrollment Screen Titles + override val mfaEnrollmentEnterPhoneNumber: String + get() = localizedContext.getString(R.string.fui_mfa_enrollment_enter_phone_number) + override val mfaEnrollmentVerifySmsCode: String + get() = localizedContext.getString(R.string.fui_mfa_enrollment_verify_sms_code) + + // MFA Error Messages + override val mfaErrorRecentLoginRequired: String + get() = localizedContext.getString(R.string.fui_mfa_error_recent_login_required) + override val mfaErrorInvalidVerificationCode: String + get() = localizedContext.getString(R.string.fui_mfa_error_invalid_verification_code) + override val mfaErrorNetwork: String + get() = localizedContext.getString(R.string.fui_mfa_error_network) + override val mfaErrorGeneric: String + get() = localizedContext.getString(R.string.fui_mfa_error_generic) + + override val reauthDialogTitle: String + get() = localizedContext.getString(R.string.fui_reauth_dialog_title) + + override val reauthDialogMessage: String + get() = localizedContext.getString(R.string.fui_reauth_dialog_message) + + override fun reauthAccountLabel(email: String): String = + localizedContext.getString(R.string.fui_reauth_account_label, email) + + override val incorrectPasswordError: String + get() = localizedContext.getString(R.string.fui_incorrect_password_error) + + override val reauthGenericError: String + get() = localizedContext.getString(R.string.fui_reauth_generic_error) + + override val termsOfService: String + get() = localizedContext.getString(R.string.fui_terms_of_service) + + override val privacyPolicy: String + get() = localizedContext.getString(R.string.fui_privacy_policy) + + override fun tosAndPrivacyPolicy(termsOfServiceLabel: String, privacyPolicyLabel: String): String = + localizedContext.getString(R.string.fui_tos_and_pp, termsOfServiceLabel, privacyPolicyLabel) +} 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..4ef809e9c --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt @@ -0,0 +1,165 @@ +/* + * 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.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +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(), + typography = Typography(), + shapes = Shapes(), + providerStyles = ProviderStyleDefaults.default + ) + + val DefaultDark = AuthUITheme( + colorScheme = darkColorScheme(), + 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 + ) + } + + @OptIn(ExperimentalMaterial3Api::class) + @get:Composable + val topAppBarColors + get() = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +@Composable +fun AuthUITheme( + theme: AuthUITheme = if (isSystemInDarkTheme()) + AuthUITheme.DefaultDark else 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..115823a13 --- /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.auth_provider.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(0xFF1877F2), + contentColor = Color.White + ) + } + + Provider.TWITTER -> { + provider.id to AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_twitter_x_white_24dp), + backgroundColor = Color.Black, + 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/GeneralFieldValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/GeneralFieldValidator.kt new file mode 100644 index 000000000..74a08fe68 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/GeneralFieldValidator.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.validators + +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider + +internal class GeneralFieldValidator( + override val stringProvider: AuthUIStringProvider, + val isValid: ((String) -> Boolean)? = null, + val customMessage: String? = null, +) : FieldValidator { + private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + + override val hasError: Boolean + get() = _validationStatus.hasError + + override val errorMessage: String + get() = _validationStatus.errorMessage ?: "" + + override fun validate(value: String): Boolean { + if (value.isEmpty()) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.requiredField + ) + return false + } + + if (isValid != null && !isValid(value)) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = customMessage + ) + return false + } + + _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + return true + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt new file mode 100644 index 000000000..65f163fea --- /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.PasswordRule +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider + +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/configuration/validators/PhoneNumberValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PhoneNumberValidator.kt new file mode 100644 index 000000000..692ee0e44 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PhoneNumberValidator.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.validators + +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.data.CountryData +import com.google.i18n.phonenumbers.NumberParseException +import com.google.i18n.phonenumbers.PhoneNumberUtil + +internal class PhoneNumberValidator( + override val stringProvider: AuthUIStringProvider, + val selectedCountry: CountryData, +) : + FieldValidator { + private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + private val phoneNumberUtil = PhoneNumberUtil.getInstance() + + 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.missingPhoneNumber + ) + return false + } + + try { + val phoneNumber = phoneNumberUtil.parse(value, selectedCountry.countryCode) + val isValid = phoneNumberUtil.isValidNumber(phoneNumber) + + if (!isValid) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.invalidPhoneNumber + ) + return false + } + } catch (_: NumberParseException) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.invalidPhoneNumber + ) + return false + } + + _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + return true + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/VerificationCodeValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/VerificationCodeValidator.kt new file mode 100644 index 000000000..e72f100ca --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/VerificationCodeValidator.kt @@ -0,0 +1,51 @@ +/* + * 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 VerificationCodeValidator(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.missingVerificationCode + ) + return false + } + + // Verification codes are typically 6 digits + val digitsOnly = value.replace(Regex("[^0-9]"), "") + if (digitsOnly.length != 6) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.invalidVerificationCode + ) + return false + } + + _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + return true + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/credentialmanager/PasswordCredential.kt b/auth/src/main/java/com/firebase/ui/auth/compose/credentialmanager/PasswordCredential.kt new file mode 100644 index 000000000..23d2e6080 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/credentialmanager/PasswordCredential.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.credentialmanager + +/** + * Represents a password credential retrieved from the system credential manager. + * + * @property username The username/identifier associated with the credential + * @property password The password associated with the credential + */ +data class PasswordCredential( + val username: String, + val password: String +) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/credentialmanager/PasswordCredentialHandler.kt b/auth/src/main/java/com/firebase/ui/auth/compose/credentialmanager/PasswordCredentialHandler.kt new file mode 100644 index 000000000..491606f5d --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/credentialmanager/PasswordCredentialHandler.kt @@ -0,0 +1,138 @@ +/* + * 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.credentialmanager + +import android.content.Context +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPasswordOption +import androidx.credentials.PasswordCredential as AndroidPasswordCredential +import androidx.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.NoCredentialException + +/** + * Handler for password credential operations using Android's Credential Manager. + * + * This class provides methods to save and retrieve password credentials through + * the system credential manager, which displays native UI prompts to the user. + * + * @property context The Android context used for credential operations + */ +class PasswordCredentialHandler( + private val context: Context +) { + private val credentialManager: CredentialManager = CredentialManager.create(context) + + /** + * Saves a password credential to the system credential manager. + * + * This method displays a system prompt to the user asking if they want to save + * the credential. The operation is performed asynchronously using Kotlin coroutines. + * + * @param username The username/identifier for the credential + * @param password The password to save + * @throws CreateCredentialException if the credential cannot be saved + * @throws CreateCredentialCancellationException if the user cancels the save operation + * @throws IllegalArgumentException if username or password is blank + */ + suspend fun savePassword(username: String, password: String) { + require(username.isNotBlank()) { "Username cannot be blank" } + require(password.isNotBlank()) { "Password cannot be blank" } + + val request = CreatePasswordRequest( + id = username, + password = password + ) + + try { + credentialManager.createCredential(context, request) + } catch (e: CreateCredentialCancellationException) { + // User cancelled the save operation + throw PasswordCredentialCancelledException("User cancelled password save operation", e) + } catch (e: CreateCredentialException) { + // Other credential creation errors + throw PasswordCredentialException("Failed to save password credential", e) + } + } + + /** + * Retrieves a password credential from the system credential manager. + * + * This method displays a system prompt showing available credentials for the user + * to select from. The operation is performed asynchronously using Kotlin coroutines. + * + * @return PasswordCredential containing the username and password + * @throws NoCredentialException if no credentials are available + * @throws GetCredentialCancellationException if the user cancels the retrieval operation + * @throws GetCredentialException if the credential cannot be retrieved + */ + suspend fun getPassword(): PasswordCredential { + val getPasswordOption = GetPasswordOption() + val request = GetCredentialRequest.Builder() + .addCredentialOption(getPasswordOption) + .build() + + try { + val result = credentialManager.getCredential(context, request) + val credential = result.credential + + if (credential is AndroidPasswordCredential) { + return PasswordCredential( + username = credential.id, + password = credential.password + ) + } else { + throw PasswordCredentialException("Retrieved credential is not a password credential") + } + } catch (e: GetCredentialCancellationException) { + // User cancelled the retrieval operation + throw PasswordCredentialCancelledException("User cancelled password retrieval operation", e) + } catch (e: NoCredentialException) { + // No credentials available + throw PasswordCredentialNotFoundException("No password credentials found", e) + } catch (e: GetCredentialException) { + // Other credential retrieval errors + throw PasswordCredentialException("Failed to retrieve password credential", e) + } + } +} + +/** + * Base exception for password credential operations. + */ +open class PasswordCredentialException( + message: String, + cause: Throwable? = null +) : Exception(message, cause) + +/** + * Exception thrown when a password credential operation is cancelled by the user. + */ +class PasswordCredentialCancelledException( + message: String, + cause: Throwable? = null +) : PasswordCredentialException(message, cause) + +/** + * Exception thrown when no password credentials are found. + */ +class PasswordCredentialNotFoundException( + message: String, + cause: Throwable? = null +) : PasswordCredentialException(message, cause) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/data/Countries.kt b/auth/src/main/java/com/firebase/ui/auth/compose/data/Countries.kt new file mode 100644 index 000000000..3ba0dc497 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/data/Countries.kt @@ -0,0 +1,260 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.data + +/** + * Complete list of countries with their dial codes and ISO country codes. + * Auto-generated from ISO 3166-1 standard. + */ +val ALL_COUNTRIES: List = listOf( + CountryData("Afghanistan", "+93", "AF", countryCodeToFlagEmoji("AF")), + CountryData("Albania", "+355", "AL", countryCodeToFlagEmoji("AL")), + CountryData("Algeria", "+213", "DZ", countryCodeToFlagEmoji("DZ")), + CountryData("American Samoa", "+684", "AS", countryCodeToFlagEmoji("AS")), + CountryData("Andorra", "+376", "AD", countryCodeToFlagEmoji("AD")), + CountryData("Angola", "+244", "AO", countryCodeToFlagEmoji("AO")), + CountryData("Anguilla", "+264", "AI", countryCodeToFlagEmoji("AI")), + CountryData("Antigua and Barbuda", "+268", "AG", countryCodeToFlagEmoji("AG")), + CountryData("Argentina", "+54", "AR", countryCodeToFlagEmoji("AR")), + CountryData("Armenia", "+374", "AM", countryCodeToFlagEmoji("AM")), + CountryData("Aruba", "+297", "AW", countryCodeToFlagEmoji("AW")), + CountryData("Australia", "+61", "AU", countryCodeToFlagEmoji("AU")), + CountryData("Austria", "+43", "AT", countryCodeToFlagEmoji("AT")), + CountryData("Azerbaijan", "+994", "AZ", countryCodeToFlagEmoji("AZ")), + CountryData("Bahamas", "+242", "BS", countryCodeToFlagEmoji("BS")), + CountryData("Bahrain", "+973", "BH", countryCodeToFlagEmoji("BH")), + CountryData("Bangladesh", "+880", "BD", countryCodeToFlagEmoji("BD")), + CountryData("Barbados", "+246", "BB", countryCodeToFlagEmoji("BB")), + CountryData("Belarus", "+375", "BY", countryCodeToFlagEmoji("BY")), + CountryData("Belgium", "+32", "BE", countryCodeToFlagEmoji("BE")), + CountryData("Belize", "+501", "BZ", countryCodeToFlagEmoji("BZ")), + CountryData("Benin", "+229", "BJ", countryCodeToFlagEmoji("BJ")), + CountryData("Bermuda", "+441", "BM", countryCodeToFlagEmoji("BM")), + CountryData("Bhutan", "+975", "BT", countryCodeToFlagEmoji("BT")), + CountryData("Bolivia", "+591", "BO", countryCodeToFlagEmoji("BO")), + CountryData("Bosnia and Herzegovina", "+387", "BA", countryCodeToFlagEmoji("BA")), + CountryData("Botswana", "+267", "BW", countryCodeToFlagEmoji("BW")), + CountryData("Brazil", "+55", "BR", countryCodeToFlagEmoji("BR")), + CountryData("British Indian Ocean Territory", "+246", "IO", countryCodeToFlagEmoji("IO")), + CountryData("Brunei", "+673", "BN", countryCodeToFlagEmoji("BN")), + CountryData("Bulgaria", "+359", "BG", countryCodeToFlagEmoji("BG")), + CountryData("Burkina Faso", "+226", "BF", countryCodeToFlagEmoji("BF")), + CountryData("Burundi", "+257", "BI", countryCodeToFlagEmoji("BI")), + CountryData("Cambodia", "+855", "KH", countryCodeToFlagEmoji("KH")), + CountryData("Cameroon", "+237", "CM", countryCodeToFlagEmoji("CM")), + CountryData("Canada", "+1", "CA", countryCodeToFlagEmoji("CA")), + CountryData("Cape Verde", "+238", "CV", countryCodeToFlagEmoji("CV")), + CountryData("Cayman Islands", "+345", "KY", countryCodeToFlagEmoji("KY")), + CountryData("Central African Republic", "+236", "CF", countryCodeToFlagEmoji("CF")), + CountryData("Chad", "+235", "TD", countryCodeToFlagEmoji("TD")), + CountryData("Chile", "+56", "CL", countryCodeToFlagEmoji("CL")), + CountryData("China", "+86", "CN", countryCodeToFlagEmoji("CN")), + CountryData("Colombia", "+57", "CO", countryCodeToFlagEmoji("CO")), + CountryData("Comoros", "+269", "KM", countryCodeToFlagEmoji("KM")), + CountryData("Congo", "+242", "CG", countryCodeToFlagEmoji("CG")), + CountryData("Congo (DRC)", "+243", "CD", countryCodeToFlagEmoji("CD")), + CountryData("Cook Islands", "+682", "CK", countryCodeToFlagEmoji("CK")), + CountryData("Costa Rica", "+506", "CR", countryCodeToFlagEmoji("CR")), + CountryData("Côte d'Ivoire", "+225", "CI", countryCodeToFlagEmoji("CI")), + CountryData("Croatia", "+385", "HR", countryCodeToFlagEmoji("HR")), + CountryData("Cuba", "+53", "CU", countryCodeToFlagEmoji("CU")), + CountryData("Curaçao", "+599", "CW", countryCodeToFlagEmoji("CW")), + CountryData("Cyprus", "+357", "CY", countryCodeToFlagEmoji("CY")), + CountryData("Czech Republic", "+420", "CZ", countryCodeToFlagEmoji("CZ")), + CountryData("Denmark", "+45", "DK", countryCodeToFlagEmoji("DK")), + CountryData("Djibouti", "+253", "DJ", countryCodeToFlagEmoji("DJ")), + CountryData("Dominica", "+767", "DM", countryCodeToFlagEmoji("DM")), + CountryData("Dominican Republic", "+809", "DO", countryCodeToFlagEmoji("DO")), + CountryData("Ecuador", "+593", "EC", countryCodeToFlagEmoji("EC")), + CountryData("Egypt", "+20", "EG", countryCodeToFlagEmoji("EG")), + CountryData("El Salvador", "+503", "SV", countryCodeToFlagEmoji("SV")), + CountryData("Equatorial Guinea", "+240", "GQ", countryCodeToFlagEmoji("GQ")), + CountryData("Eritrea", "+291", "ER", countryCodeToFlagEmoji("ER")), + CountryData("Estonia", "+372", "EE", countryCodeToFlagEmoji("EE")), + CountryData("Ethiopia", "+251", "ET", countryCodeToFlagEmoji("ET")), + CountryData("Falkland Islands", "+500", "FK", countryCodeToFlagEmoji("FK")), + CountryData("Faroe Islands", "+298", "FO", countryCodeToFlagEmoji("FO")), + CountryData("Fiji", "+679", "FJ", countryCodeToFlagEmoji("FJ")), + CountryData("Finland", "+358", "FI", countryCodeToFlagEmoji("FI")), + CountryData("France", "+33", "FR", countryCodeToFlagEmoji("FR")), + CountryData("French Guiana", "+594", "GF", countryCodeToFlagEmoji("GF")), + CountryData("French Polynesia", "+689", "PF", countryCodeToFlagEmoji("PF")), + CountryData("Gabon", "+241", "GA", countryCodeToFlagEmoji("GA")), + CountryData("Gambia", "+220", "GM", countryCodeToFlagEmoji("GM")), + CountryData("Georgia", "+995", "GE", countryCodeToFlagEmoji("GE")), + CountryData("Germany", "+49", "DE", countryCodeToFlagEmoji("DE")), + CountryData("Ghana", "+233", "GH", countryCodeToFlagEmoji("GH")), + CountryData("Gibraltar", "+350", "GI", countryCodeToFlagEmoji("GI")), + CountryData("Greece", "+30", "GR", countryCodeToFlagEmoji("GR")), + CountryData("Greenland", "+299", "GL", countryCodeToFlagEmoji("GL")), + CountryData("Grenada", "+473", "GD", countryCodeToFlagEmoji("GD")), + CountryData("Guadeloupe", "+590", "GP", countryCodeToFlagEmoji("GP")), + CountryData("Guam", "+671", "GU", countryCodeToFlagEmoji("GU")), + CountryData("Guatemala", "+502", "GT", countryCodeToFlagEmoji("GT")), + CountryData("Guernsey", "+1481", "GG", countryCodeToFlagEmoji("GG")), + CountryData("Guinea", "+224", "GN", countryCodeToFlagEmoji("GN")), + CountryData("Guinea-Bissau", "+245", "GW", countryCodeToFlagEmoji("GW")), + CountryData("Guyana", "+592", "GY", countryCodeToFlagEmoji("GY")), + CountryData("Haiti", "+509", "HT", countryCodeToFlagEmoji("HT")), + CountryData("Honduras", "+504", "HN", countryCodeToFlagEmoji("HN")), + CountryData("Hong Kong", "+852", "HK", countryCodeToFlagEmoji("HK")), + CountryData("Hungary", "+36", "HU", countryCodeToFlagEmoji("HU")), + CountryData("Iceland", "+354", "IS", countryCodeToFlagEmoji("IS")), + CountryData("India", "+91", "IN", countryCodeToFlagEmoji("IN")), + CountryData("Indonesia", "+62", "ID", countryCodeToFlagEmoji("ID")), + CountryData("Iran", "+98", "IR", countryCodeToFlagEmoji("IR")), + CountryData("Iraq", "+964", "IQ", countryCodeToFlagEmoji("IQ")), + CountryData("Ireland", "+353", "IE", countryCodeToFlagEmoji("IE")), + CountryData("Isle of Man", "+44", "IM", countryCodeToFlagEmoji("IM")), + CountryData("Israel", "+972", "IL", countryCodeToFlagEmoji("IL")), + CountryData("Italy", "+39", "IT", countryCodeToFlagEmoji("IT")), + CountryData("Jamaica", "+876", "JM", countryCodeToFlagEmoji("JM")), + CountryData("Japan", "+81", "JP", countryCodeToFlagEmoji("JP")), + CountryData("Jersey", "+44", "JE", countryCodeToFlagEmoji("JE")), + CountryData("Jordan", "+962", "JO", countryCodeToFlagEmoji("JO")), + CountryData("Kazakhstan", "+7", "KZ", countryCodeToFlagEmoji("KZ")), + CountryData("Kenya", "+254", "KE", countryCodeToFlagEmoji("KE")), + CountryData("Kiribati", "+686", "KI", countryCodeToFlagEmoji("KI")), + CountryData("Kosovo", "+383", "XK", countryCodeToFlagEmoji("XK")), + CountryData("Kuwait", "+965", "KW", countryCodeToFlagEmoji("KW")), + CountryData("Kyrgyzstan", "+996", "KG", countryCodeToFlagEmoji("KG")), + CountryData("Laos", "+856", "LA", countryCodeToFlagEmoji("LA")), + CountryData("Latvia", "+371", "LV", countryCodeToFlagEmoji("LV")), + CountryData("Lebanon", "+961", "LB", countryCodeToFlagEmoji("LB")), + CountryData("Lesotho", "+266", "LS", countryCodeToFlagEmoji("LS")), + CountryData("Liberia", "+231", "LR", countryCodeToFlagEmoji("LR")), + CountryData("Libya", "+218", "LY", countryCodeToFlagEmoji("LY")), + CountryData("Liechtenstein", "+423", "LI", countryCodeToFlagEmoji("LI")), + CountryData("Lithuania", "+370", "LT", countryCodeToFlagEmoji("LT")), + CountryData("Luxembourg", "+352", "LU", countryCodeToFlagEmoji("LU")), + CountryData("Macao", "+853", "MO", countryCodeToFlagEmoji("MO")), + CountryData("Macedonia", "+389", "MK", countryCodeToFlagEmoji("MK")), + CountryData("Madagascar", "+261", "MG", countryCodeToFlagEmoji("MG")), + CountryData("Malawi", "+265", "MW", countryCodeToFlagEmoji("MW")), + CountryData("Malaysia", "+60", "MY", countryCodeToFlagEmoji("MY")), + CountryData("Maldives", "+960", "MV", countryCodeToFlagEmoji("MV")), + CountryData("Mali", "+223", "ML", countryCodeToFlagEmoji("ML")), + CountryData("Malta", "+356", "MT", countryCodeToFlagEmoji("MT")), + CountryData("Marshall Islands", "+692", "MH", countryCodeToFlagEmoji("MH")), + CountryData("Martinique", "+596", "MQ", countryCodeToFlagEmoji("MQ")), + CountryData("Mauritania", "+222", "MR", countryCodeToFlagEmoji("MR")), + CountryData("Mauritius", "+230", "MU", countryCodeToFlagEmoji("MU")), + CountryData("Mayotte", "+262", "YT", countryCodeToFlagEmoji("YT")), + CountryData("Mexico", "+52", "MX", countryCodeToFlagEmoji("MX")), + CountryData("Micronesia", "+691", "FM", countryCodeToFlagEmoji("FM")), + CountryData("Moldova", "+373", "MD", countryCodeToFlagEmoji("MD")), + CountryData("Monaco", "+377", "MC", countryCodeToFlagEmoji("MC")), + CountryData("Mongolia", "+976", "MN", countryCodeToFlagEmoji("MN")), + CountryData("Montenegro", "+382", "ME", countryCodeToFlagEmoji("ME")), + CountryData("Montserrat", "+664", "MS", countryCodeToFlagEmoji("MS")), + CountryData("Morocco", "+212", "MA", countryCodeToFlagEmoji("MA")), + CountryData("Mozambique", "+258", "MZ", countryCodeToFlagEmoji("MZ")), + CountryData("Myanmar", "+95", "MM", countryCodeToFlagEmoji("MM")), + CountryData("Namibia", "+264", "NA", countryCodeToFlagEmoji("NA")), + CountryData("Nauru", "+674", "NR", countryCodeToFlagEmoji("NR")), + CountryData("Nepal", "+977", "NP", countryCodeToFlagEmoji("NP")), + CountryData("Netherlands", "+31", "NL", countryCodeToFlagEmoji("NL")), + CountryData("New Caledonia", "+687", "NC", countryCodeToFlagEmoji("NC")), + CountryData("New Zealand", "+64", "NZ", countryCodeToFlagEmoji("NZ")), + CountryData("Nicaragua", "+505", "NI", countryCodeToFlagEmoji("NI")), + CountryData("Niger", "+227", "NE", countryCodeToFlagEmoji("NE")), + CountryData("Nigeria", "+234", "NG", countryCodeToFlagEmoji("NG")), + CountryData("Niue", "+683", "NU", countryCodeToFlagEmoji("NU")), + CountryData("Norfolk Island", "+672", "NF", countryCodeToFlagEmoji("NF")), + CountryData("North Korea", "+850", "KP", countryCodeToFlagEmoji("KP")), + CountryData("Northern Mariana Islands", "+670", "MP", countryCodeToFlagEmoji("MP")), + CountryData("Norway", "+47", "NO", countryCodeToFlagEmoji("NO")), + CountryData("Oman", "+968", "OM", countryCodeToFlagEmoji("OM")), + CountryData("Pakistan", "+92", "PK", countryCodeToFlagEmoji("PK")), + CountryData("Palau", "+680", "PW", countryCodeToFlagEmoji("PW")), + CountryData("Palestine", "+970", "PS", countryCodeToFlagEmoji("PS")), + CountryData("Panama", "+507", "PA", countryCodeToFlagEmoji("PA")), + CountryData("Papua New Guinea", "+675", "PG", countryCodeToFlagEmoji("PG")), + CountryData("Paraguay", "+595", "PY", countryCodeToFlagEmoji("PY")), + CountryData("Peru", "+51", "PE", countryCodeToFlagEmoji("PE")), + CountryData("Philippines", "+63", "PH", countryCodeToFlagEmoji("PH")), + CountryData("Poland", "+48", "PL", countryCodeToFlagEmoji("PL")), + CountryData("Portugal", "+351", "PT", countryCodeToFlagEmoji("PT")), + CountryData("Puerto Rico", "+787", "PR", countryCodeToFlagEmoji("PR")), + CountryData("Qatar", "+974", "QA", countryCodeToFlagEmoji("QA")), + CountryData("Réunion", "+262", "RE", countryCodeToFlagEmoji("RE")), + CountryData("Romania", "+40", "RO", countryCodeToFlagEmoji("RO")), + CountryData("Russia", "+7", "RU", countryCodeToFlagEmoji("RU")), + CountryData("Rwanda", "+250", "RW", countryCodeToFlagEmoji("RW")), + CountryData("Saint Barthélemy", "+590", "BL", countryCodeToFlagEmoji("BL")), + CountryData("Saint Helena", "+290", "SH", countryCodeToFlagEmoji("SH")), + CountryData("Saint Kitts and Nevis", "+869", "KN", countryCodeToFlagEmoji("KN")), + CountryData("Saint Lucia", "+758", "LC", countryCodeToFlagEmoji("LC")), + CountryData("Saint Martin", "+590", "MF", countryCodeToFlagEmoji("MF")), + CountryData("Saint Pierre and Miquelon", "+508", "PM", countryCodeToFlagEmoji("PM")), + CountryData("Saint Vincent and the Grenadines", "+784", "VC", countryCodeToFlagEmoji("VC")), + CountryData("Samoa", "+685", "WS", countryCodeToFlagEmoji("WS")), + CountryData("San Marino", "+378", "SM", countryCodeToFlagEmoji("SM")), + CountryData("Sao Tome and Principe", "+239", "ST", countryCodeToFlagEmoji("ST")), + CountryData("Saudi Arabia", "+966", "SA", countryCodeToFlagEmoji("SA")), + CountryData("Senegal", "+221", "SN", countryCodeToFlagEmoji("SN")), + CountryData("Serbia", "+381", "RS", countryCodeToFlagEmoji("RS")), + CountryData("Seychelles", "+248", "SC", countryCodeToFlagEmoji("SC")), + CountryData("Sierra Leone", "+232", "SL", countryCodeToFlagEmoji("SL")), + CountryData("Singapore", "+65", "SG", countryCodeToFlagEmoji("SG")), + CountryData("Sint Maarten", "+599", "SX", countryCodeToFlagEmoji("SX")), + CountryData("Slovakia", "+421", "SK", countryCodeToFlagEmoji("SK")), + CountryData("Slovenia", "+386", "SI", countryCodeToFlagEmoji("SI")), + CountryData("Solomon Islands", "+677", "SB", countryCodeToFlagEmoji("SB")), + CountryData("Somalia", "+252", "SO", countryCodeToFlagEmoji("SO")), + CountryData("South Africa", "+27", "ZA", countryCodeToFlagEmoji("ZA")), + CountryData("South Korea", "+82", "KR", countryCodeToFlagEmoji("KR")), + CountryData("South Sudan", "+211", "SS", countryCodeToFlagEmoji("SS")), + CountryData("Spain", "+34", "ES", countryCodeToFlagEmoji("ES")), + CountryData("Sri Lanka", "+94", "LK", countryCodeToFlagEmoji("LK")), + CountryData("Sudan", "+249", "SD", countryCodeToFlagEmoji("SD")), + CountryData("Suriname", "+597", "SR", countryCodeToFlagEmoji("SR")), + CountryData("Swaziland", "+268", "SZ", countryCodeToFlagEmoji("SZ")), + CountryData("Sweden", "+46", "SE", countryCodeToFlagEmoji("SE")), + CountryData("Switzerland", "+41", "CH", countryCodeToFlagEmoji("CH")), + CountryData("Syria", "+963", "SY", countryCodeToFlagEmoji("SY")), + CountryData("Taiwan", "+886", "TW", countryCodeToFlagEmoji("TW")), + CountryData("Tajikistan", "+992", "TJ", countryCodeToFlagEmoji("TJ")), + CountryData("Tanzania", "+255", "TZ", countryCodeToFlagEmoji("TZ")), + CountryData("Thailand", "+66", "TH", countryCodeToFlagEmoji("TH")), + CountryData("Timor-Leste", "+670", "TL", countryCodeToFlagEmoji("TL")), + CountryData("Togo", "+228", "TG", countryCodeToFlagEmoji("TG")), + CountryData("Tokelau", "+690", "TK", countryCodeToFlagEmoji("TK")), + CountryData("Tonga", "+676", "TO", countryCodeToFlagEmoji("TO")), + CountryData("Trinidad and Tobago", "+868", "TT", countryCodeToFlagEmoji("TT")), + CountryData("Tunisia", "+216", "TN", countryCodeToFlagEmoji("TN")), + CountryData("Turkey", "+90", "TR", countryCodeToFlagEmoji("TR")), + CountryData("Turkmenistan", "+993", "TM", countryCodeToFlagEmoji("TM")), + CountryData("Turks and Caicos Islands", "+649", "TC", countryCodeToFlagEmoji("TC")), + CountryData("Tuvalu", "+688", "TV", countryCodeToFlagEmoji("TV")), + CountryData("Uganda", "+256", "UG", countryCodeToFlagEmoji("UG")), + CountryData("Ukraine", "+380", "UA", countryCodeToFlagEmoji("UA")), + CountryData("United Arab Emirates", "+971", "AE", countryCodeToFlagEmoji("AE")), + CountryData("United Kingdom", "+44", "GB", countryCodeToFlagEmoji("GB")), + CountryData("United States", "+1", "US", countryCodeToFlagEmoji("US")), + CountryData("Uruguay", "+598", "UY", countryCodeToFlagEmoji("UY")), + CountryData("Uzbekistan", "+998", "UZ", countryCodeToFlagEmoji("UZ")), + CountryData("Vanuatu", "+678", "VU", countryCodeToFlagEmoji("VU")), + CountryData("Vatican City", "+379", "VA", countryCodeToFlagEmoji("VA")), + CountryData("Venezuela", "+58", "VE", countryCodeToFlagEmoji("VE")), + CountryData("Vietnam", "+84", "VN", countryCodeToFlagEmoji("VN")), + CountryData("Virgin Islands (British)", "+284", "VG", countryCodeToFlagEmoji("VG")), + CountryData("Virgin Islands (U.S.)", "+340", "VI", countryCodeToFlagEmoji("VI")), + CountryData("Wallis and Futuna", "+681", "WF", countryCodeToFlagEmoji("WF")), + CountryData("Western Sahara", "+212", "EH", countryCodeToFlagEmoji("EH")), + CountryData("Yemen", "+967", "YE", countryCodeToFlagEmoji("YE")), + CountryData("Zambia", "+260", "ZM", countryCodeToFlagEmoji("ZM")), + CountryData("Zimbabwe", "+263", "ZW", countryCodeToFlagEmoji("ZW")) +) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/data/CountryData.kt b/auth/src/main/java/com/firebase/ui/auth/compose/data/CountryData.kt new file mode 100644 index 000000000..7deb276b7 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/data/CountryData.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.data + +/** + * Represents country information for phone number authentication. + * + * @property name The display name of the country (e.g., "United States"). + * @property dialCode The international dialing code (e.g., "+1"). + * @property countryCode The ISO 3166-1 alpha-2 country code (e.g., "US"). + * @property flagEmoji The flag emoji for the country (e.g., "🇺🇸"). + */ +data class CountryData( + val name: String, + val dialCode: String, + val countryCode: String, + val flagEmoji: String +) { + /** + * Returns a formatted display string combining flag emoji and country name. + */ + fun getDisplayName(): String = "$flagEmoji $name" + + /** + * Returns a formatted string with dial code. + */ + fun getDisplayNameWithDialCode(): String = "$flagEmoji $name ($dialCode)" +} + +/** + * Converts an ISO 3166-1 alpha-2 country code to its corresponding flag emoji. + * + * @param countryCode The two-letter country code (e.g., "US", "GB", "FR"). + * @return The flag emoji string, or an empty string if the code is invalid. + */ +fun countryCodeToFlagEmoji(countryCode: String): String { + if (countryCode.length != 2) return "" + + val uppercaseCode = countryCode.uppercase() + val baseCodePoint = 0x1F1E6 // Regional Indicator Symbol Letter A + val charCodeOffset = 'A'.code + + val firstChar = uppercaseCode[0].code + val secondChar = uppercaseCode[1].code + + val firstCodePoint = baseCodePoint + (firstChar - charCodeOffset) + val secondCodePoint = baseCodePoint + (secondChar - charCodeOffset) + + return String(intArrayOf(firstCodePoint, secondCodePoint), 0, 2) +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/data/CountryUtils.kt b/auth/src/main/java/com/firebase/ui/auth/compose/data/CountryUtils.kt new file mode 100644 index 000000000..e71270a33 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/data/CountryUtils.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.data + +import java.text.Normalizer +import java.util.Locale + +/** + * Utility functions for searching and filtering countries. + */ +object CountryUtils { + + // Lazy-initialized maps for fast lookups + private val countryCodeMap: Map by lazy { + ALL_COUNTRIES.associateBy { it.countryCode.uppercase() } + } + + private val dialCodeMap: Map> by lazy { + ALL_COUNTRIES.groupBy { it.dialCode } + } + + /** + * Finds a country by its ISO 3166-1 alpha-2 country code. + * + * @param countryCode The two-letter country code (e.g., "US", "GB"). + * @return The CountryData or null if not found. + */ + fun findByCountryCode(countryCode: String): CountryData? { + return countryCodeMap[countryCode.uppercase()] + } + + /** + * Finds all countries with the given dial code. + * + * @param dialCode The international dialing code (e.g., "+1", "+44"). + * @return List of countries with that dial code, or empty list if none found. + */ + fun findByDialCode(dialCode: String): List { + return dialCodeMap[dialCode] ?: emptyList() + } + + /** + * Searches for countries by name. Supports partial matching and diacritic-insensitive search. + * + * @param query The search query. + * @return List of countries matching the query, or empty list if none found. + */ + fun searchByName(query: String): List { + val trimmedQuery = query.trim() + if (trimmedQuery.isEmpty()) return emptyList() + + val normalizedQuery = normalizeString(trimmedQuery) + + return ALL_COUNTRIES.filter { country -> + normalizeString(country.name).contains(normalizedQuery, ignoreCase = true) + } + } + + /** + * Searches for countries by name, country code, or dial code. + * Supports partial matching and diacritic-insensitive search. + * + * @param query The search query (country name, country code, or dial code). + * @return List of countries matching the query, sorted by relevance. + */ + fun search(query: String): List { + val trimmedQuery = query.trim() + if (trimmedQuery.isEmpty()) return emptyList() + + val normalizedQuery = normalizeString(trimmedQuery) + val uppercaseQuery = trimmedQuery.uppercase() + + return ALL_COUNTRIES.filter { country -> + // Match by country name (partial, case-insensitive, diacritic-insensitive) + normalizeString(country.name).contains(normalizedQuery, ignoreCase = true) || + // Match by country code (partial, case-insensitive) + country.countryCode.uppercase().contains(uppercaseQuery) || + // Match by dial code (partial) + country.dialCode.contains(trimmedQuery) + }.sortedWith( + compareBy( + // Prioritize exact matches first + { country -> + when { + country.countryCode.uppercase() == uppercaseQuery -> 0 + country.dialCode == trimmedQuery -> 1 + normalizeString(country.name) == normalizedQuery -> 2 + else -> 3 + } + }, + // Then sort alphabetically by name + { country -> country.name } + ) + ) + } + + /** + * Filters countries by allowed country codes. + * + * @param allowedCountryCodes Set of allowed ISO 3166-1 alpha-2 country codes. + * @return List of countries that are in the allowed set. + */ + fun filterByAllowedCountries(allowedCountryCodes: Set): List { + if (allowedCountryCodes.isEmpty()) return ALL_COUNTRIES + + val uppercaseAllowed = allowedCountryCodes.map { it.uppercase() }.toSet() + return ALL_COUNTRIES.filter { it.countryCode.uppercase() in uppercaseAllowed } + } + + /** + * Gets the default country based on the device's locale. + * + * @return The CountryData for the device's country, or United States as fallback. + */ + fun getDefaultCountry(): CountryData { + val deviceCountryCode = Locale.getDefault().country + return findByCountryCode(deviceCountryCode) ?: findByCountryCode("US")!! + } + + /** + * Formats a phone number with the country's dial code. + * + * @param dialCode The country dial code (e.g., "+1"). + * @param phoneNumber The local phone number. + * @return The formatted international phone number. + */ + fun formatPhoneNumber(dialCode: String, phoneNumber: String): String { + val cleanNumber = phoneNumber.replace(Regex("[^0-9]"), "") + return "$dialCode$cleanNumber" + } + + /** + * Normalizes a string by removing diacritics and converting to lowercase. + * + * @param value The string to normalize. + * @return The normalized string. + */ + private fun normalizeString(value: String): String { + return Normalizer.normalize(value, Normalizer.Form.NFD) + .replace(Regex("\\p{M}"), "") + .lowercase() + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/mfa/MfaChallengeContentState.kt b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/MfaChallengeContentState.kt new file mode 100644 index 000000000..8e0381830 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/MfaChallengeContentState.kt @@ -0,0 +1,115 @@ +/* + * 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.mfa + +import com.firebase.ui.auth.compose.configuration.MfaFactor + +/** + * State class containing all the necessary information to render a custom UI for the + * Multi-Factor Authentication (MFA) challenge flow during sign-in. + * + * This class is passed to the content slot of the MfaChallengeScreen composable, providing + * access to the current factor, user input values, callbacks for actions, and loading/error states. + * + * The challenge flow is simpler than enrollment as the user has already configured their MFA: + * 1. User enters their verification code (SMS or TOTP) + * 2. System verifies the code and completes sign-in + * + * ```kotlin + * MfaChallengeScreen(resolver, onSuccess, onCancel, onError) { state -> + * Column { + * Text("Enter your ${state.factorType} code") + * TextField( + * value = state.verificationCode, + * onValueChange = state.onVerificationCodeChange + * ) + * if (state.canResend) { + * TextButton(onClick = state.onResendCodeClick) { + * Text("Resend code") + * } + * } + * Button( + * onClick = state.onVerifyClick, + * enabled = !state.isLoading && state.isValid + * ) { + * Text("Verify") + * } + * } + * } + * ``` + * + * @property factorType The type of MFA factor being challenged (SMS or TOTP) + * @property maskedPhoneNumber For SMS factors, the masked phone number (e.g., "+1••••••890") + * @property isLoading `true` when verification is in progress. Use this to show loading indicators. + * @property error An optional error message to display to the user. Will be `null` if there's no error. + * @property verificationCode The current value of the verification code input field. + * @property resendTimer The number of seconds remaining before the "Resend" action is available. Will be 0 when resend is allowed. + * @property onVerificationCodeChange Callback invoked when the verification code input changes. + * @property onVerifyClick Callback to verify the entered code and complete sign-in. + * @property onResendCodeClick For SMS only: Callback to resend the verification code. `null` for TOTP. + * @property onCancelClick Callback to cancel the MFA challenge and return to sign-in. + * + * @since 10.0.0 + */ +data class MfaChallengeContentState( + /** The type of MFA factor being challenged (SMS or TOTP). */ + val factorType: MfaFactor, + + /** For SMS: the masked phone number. For TOTP: null. */ + val maskedPhoneNumber: String? = null, + + /** `true` when verification is in progress. Use to show loading indicators. */ + val isLoading: Boolean = false, + + /** Optional error message to display. `null` if no error. */ + val error: String? = null, + + /** The current value of the verification code input field. */ + val verificationCode: String = "", + + /** The number of seconds remaining before resend is available. 0 when ready. */ + val resendTimer: Int = 0, + + /** Callback invoked when the verification code input changes. */ + val onVerificationCodeChange: (String) -> Unit = {}, + + /** Callback to verify the code and complete sign-in. */ + val onVerifyClick: () -> Unit = {}, + + /** For SMS only: Callback to resend the code. `null` for TOTP. */ + val onResendCodeClick: (() -> Unit)? = null, + + /** Callback to cancel the challenge and return to sign-in. */ + val onCancelClick: () -> Unit = {} +) { + /** + * Returns true if the current state is valid for verification. + * The code must be 6 digits long. + */ + val isValid: Boolean + get() = verificationCode.length == 6 && verificationCode.all { it.isDigit() } + + /** + * Returns true if there is an error in the current state. + */ + val hasError: Boolean + get() = !error.isNullOrBlank() + + /** + * Returns true if the resend action is available (SMS only). + */ + val canResend: Boolean + get() = factorType == MfaFactor.Sms && onResendCodeClick != null +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/mfa/MfaEnrollmentContentState.kt b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/MfaEnrollmentContentState.kt new file mode 100644 index 000000000..42b8fe06e --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/MfaEnrollmentContentState.kt @@ -0,0 +1,172 @@ +/* + * 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.mfa + +import com.firebase.ui.auth.compose.configuration.MfaFactor +import com.firebase.ui.auth.compose.data.CountryData +import com.google.firebase.auth.MultiFactorInfo + +/** + * State class containing all the necessary information to render a custom UI for the + * Multi-Factor Authentication (MFA) enrollment flow. + * + * This class is passed to the content slot of the MfaEnrollmentScreen composable, providing + * access to the current step, user input values, callbacks for actions, and loading/error states. + * + * Use a `when` expression on [step] to determine which UI to render: + * + * ```kotlin + * MfaEnrollmentScreen(user, config, onComplete, onSkip) { state -> + * when (state.step) { + * MfaEnrollmentStep.SelectFactor -> { + * // Render factor selection UI using state.availableFactors + * } + * MfaEnrollmentStep.ConfigureTotp -> { + * // Render TOTP setup UI using state.totpSecret and state.totpQrCodeUrl + * } + * MfaEnrollmentStep.VerifyFactor -> { + * // Render verification UI using state.verificationCode + * } + * // ... other steps + * } + * } + * ``` + * + * @property step The current step in the enrollment flow. Use this to determine which UI to display. + * @property isLoading `true` when an asynchronous operation (like generating a secret or verifying a code) is in progress. Use this to show loading indicators. + * @property error An optional error message to display to the user. Will be `null` if there's no error. + * @property onBackClick Callback to navigate to the previous step in the flow. Invoked when the user clicks a back button. + * + * @property availableFactors (Step: [MfaEnrollmentStep.SelectFactor]) A list of MFA factors the user can choose from (e.g., SMS, TOTP). Determined by [com.firebase.ui.auth.compose.configuration.MfaConfiguration.allowedFactors]. + * @property onFactorSelected (Step: [MfaEnrollmentStep.SelectFactor]) Callback invoked when the user selects an MFA factor. Receives the selected [MfaFactor]. + * @property onSkipClick (Step: [MfaEnrollmentStep.SelectFactor]) Callback for the "Skip" action. Will be `null` if MFA enrollment is required via [com.firebase.ui.auth.compose.configuration.MfaConfiguration.requireEnrollment]. + * + * @property phoneNumber (Step: [MfaEnrollmentStep.ConfigureSms]) The current value of the phone number input field. Does not include country code prefix. + * @property onPhoneNumberChange (Step: [MfaEnrollmentStep.ConfigureSms]) Callback invoked when the phone number input changes. Receives the new phone number string. + * @property selectedCountry (Step: [MfaEnrollmentStep.ConfigureSms]) The currently selected country for phone number formatting. Contains dial code, country code, and flag. + * @property onCountrySelected (Step: [MfaEnrollmentStep.ConfigureSms]) Callback invoked when the user selects a different country. Receives the new [CountryData]. + * @property onSendSmsCodeClick (Step: [MfaEnrollmentStep.ConfigureSms]) Callback to send the SMS verification code to the entered phone number. + * + * @property totpSecret (Step: [MfaEnrollmentStep.ConfigureTotp]) The TOTP secret containing the shared key and configuration. Use this to display the secret key or access the underlying Firebase TOTP secret. + * @property totpQrCodeUrl (Step: [MfaEnrollmentStep.ConfigureTotp]) A URI that can be rendered as a QR code or used as a deep link to open authenticator apps. Generated via [TotpSecret.generateQrCodeUrl]. + * @property onContinueToVerifyClick (Step: [MfaEnrollmentStep.ConfigureTotp]) Callback to proceed to the verification step after the user has scanned the QR code or entered the secret. + * + * @property verificationCode (Step: [MfaEnrollmentStep.VerifyFactor]) The current value of the verification code input field. Should be a 6-digit string. + * @property onVerificationCodeChange (Step: [MfaEnrollmentStep.VerifyFactor]) Callback invoked when the verification code input changes. Receives the new code string. + * @property onVerifyClick (Step: [MfaEnrollmentStep.VerifyFactor]) Callback to verify the entered code and finalize MFA enrollment. + * @property selectedFactor (Step: [MfaEnrollmentStep.VerifyFactor]) The MFA factor being verified (SMS or TOTP). Use this to customize UI messages. + * @property resendTimer (Step: [MfaEnrollmentStep.VerifyFactor], SMS only) The number of seconds remaining before the "Resend" action is available. Will be 0 when resend is allowed. + * @property onResendCodeClick (Step: [MfaEnrollmentStep.VerifyFactor], SMS only) Callback to resend the SMS verification code. Will be `null` for TOTP verification. + * + * @property recoveryCodes (Step: [MfaEnrollmentStep.ShowRecoveryCodes]) A list of one-time backup codes the user should save. Only present if [com.firebase.ui.auth.compose.configuration.MfaConfiguration.enableRecoveryCodes] is `true`. + * @property onCodesSavedClick (Step: [MfaEnrollmentStep.ShowRecoveryCodes]) Callback invoked when the user confirms they have saved their recovery codes. Completes the enrollment flow. + * + * @since 10.0.0 + */ +data class MfaEnrollmentContentState( + /** The current step in the enrollment flow. Use this to determine which UI to display. */ + val step: MfaEnrollmentStep, + + /** `true` when an async operation is in progress. Use to show loading indicators. */ + val isLoading: Boolean = false, + + /** Optional error message to display. `null` if no error. */ + val error: String? = null, + + /** The last exception encountered during enrollment, if available. */ + val exception: Exception? = null, + + /** Callback to navigate to the previous step. */ + val onBackClick: () -> Unit = {}, + + // SelectFactor step + val availableFactors: List = emptyList(), + + val enrolledFactors: List = emptyList(), + + val onFactorSelected: (MfaFactor) -> Unit = {}, + + val onUnenrollFactor: (MultiFactorInfo) -> Unit = {}, + + val onSkipClick: (() -> Unit)? = null, + + // ConfigureSms step + val phoneNumber: String = "", + + val onPhoneNumberChange: (String) -> Unit = {}, + + val selectedCountry: CountryData? = null, + + val onCountrySelected: (CountryData) -> Unit = {}, + + val onSendSmsCodeClick: () -> Unit = {}, + + // ConfigureTotp step + val totpSecret: TotpSecret? = null, + + val totpQrCodeUrl: String? = null, + + val onContinueToVerifyClick: () -> Unit = {}, + + // VerifyFactor step + val verificationCode: String = "", + + val onVerificationCodeChange: (String) -> Unit = {}, + + val onVerifyClick: () -> Unit = {}, + + val selectedFactor: MfaFactor? = null, + + val resendTimer: Int = 0, + + val onResendCodeClick: (() -> Unit)? = null, + + // ShowRecoveryCodes step + val recoveryCodes: List? = null, + + val onCodesSavedClick: () -> Unit = {} +) { + /** + * Returns true if the current state is valid for the current step. + * + * This can be used to enable/disable action buttons in the UI. + */ + val isValid: Boolean + get() = when (step) { + MfaEnrollmentStep.SelectFactor -> availableFactors.isNotEmpty() + MfaEnrollmentStep.ConfigureSms -> phoneNumber.isNotBlank() + MfaEnrollmentStep.ConfigureTotp -> totpSecret != null && totpQrCodeUrl != null + MfaEnrollmentStep.VerifyFactor -> verificationCode.length == 6 + MfaEnrollmentStep.ShowRecoveryCodes -> !recoveryCodes.isNullOrEmpty() + } + + /** + * Returns true if there is an error in the current state. + */ + val hasError: Boolean + get() = !error.isNullOrBlank() + + /** + * Returns true if the skip action is available (only for SelectFactor step when not required). + */ + val canSkip: Boolean + get() = step == MfaEnrollmentStep.SelectFactor && onSkipClick != null + + /** + * Returns true if the back action is available (for all steps except SelectFactor). + */ + val canGoBack: Boolean + get() = step != MfaEnrollmentStep.SelectFactor +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/mfa/MfaEnrollmentStep.kt b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/MfaEnrollmentStep.kt new file mode 100644 index 000000000..160fa88ab --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/MfaEnrollmentStep.kt @@ -0,0 +1,98 @@ +/* + * 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.mfa + +import com.firebase.ui.auth.compose.configuration.MfaFactor +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider + +/** + * Represents the different steps in the Multi-Factor Authentication (MFA) enrollment flow. + * + * This enum defines the sequence of UI states that users progress through when enrolling + * in MFA, from selecting a factor to completing the setup with recovery codes. + * + * @since 10.0.0 + */ +enum class MfaEnrollmentStep { + /** + * The user is presented with a selection of available MFA factors to enroll in. + * The available factors are determined by the [com.firebase.ui.auth.compose.configuration.MfaConfiguration]. + */ + SelectFactor, + + /** + * The user is configuring SMS-based MFA by entering their phone number. + * This step prepares to send an SMS verification code to the provided number. + */ + ConfigureSms, + + /** + * The user is configuring TOTP (Time-based One-Time Password) MFA. + * This step presents the TOTP secret (as both text and QR code) for the user + * to scan into their authenticator app. + */ + ConfigureTotp, + + /** + * The user is verifying their chosen MFA factor by entering a verification code. + * For SMS, this is the code received via text message. + * For TOTP, this is the code generated by their authenticator app. + */ + VerifyFactor, + + /** + * The enrollment is complete and recovery codes are displayed to the user. + * These backup codes can be used to sign in if the primary MFA method is unavailable. + * This step only appears if recovery codes are enabled in the configuration. + */ + ShowRecoveryCodes +} + +/** + * Returns the localized title text for this enrollment step. + * + * @param stringProvider The string provider for localized strings + * @return The localized title for this step + */ +fun MfaEnrollmentStep.getTitle(stringProvider: AuthUIStringProvider): String = when (this) { + MfaEnrollmentStep.SelectFactor -> stringProvider.mfaStepSelectFactorTitle + MfaEnrollmentStep.ConfigureSms -> stringProvider.mfaStepConfigureSmsTitle + MfaEnrollmentStep.ConfigureTotp -> stringProvider.mfaStepConfigureTotpTitle + MfaEnrollmentStep.VerifyFactor -> stringProvider.mfaStepVerifyFactorTitle + MfaEnrollmentStep.ShowRecoveryCodes -> stringProvider.mfaStepShowRecoveryCodesTitle +} + +/** + * Returns localized helper text providing instructions for this step. + * + * @param stringProvider The string provider for localized strings + * @param selectedFactor The MFA factor being configured or verified. Used for [MfaEnrollmentStep.VerifyFactor] + * to provide factor-specific instructions. Ignored for other steps. + * @return Localized instructional text appropriate for this step + */ +fun MfaEnrollmentStep.getHelperText( + stringProvider: AuthUIStringProvider, + selectedFactor: MfaFactor? = null +): String = when (this) { + MfaEnrollmentStep.SelectFactor -> stringProvider.mfaStepSelectFactorHelper + MfaEnrollmentStep.ConfigureSms -> stringProvider.mfaStepConfigureSmsHelper + MfaEnrollmentStep.ConfigureTotp -> stringProvider.mfaStepConfigureTotpHelper + MfaEnrollmentStep.VerifyFactor -> when (selectedFactor) { + MfaFactor.Sms -> stringProvider.mfaStepVerifyFactorSmsHelper + MfaFactor.Totp -> stringProvider.mfaStepVerifyFactorTotpHelper + null -> stringProvider.mfaStepVerifyFactorGenericHelper + } + MfaEnrollmentStep.ShowRecoveryCodes -> stringProvider.mfaStepShowRecoveryCodesHelper +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/mfa/MfaErrorMapper.kt b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/MfaErrorMapper.kt new file mode 100644 index 000000000..51be1dd98 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/MfaErrorMapper.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.mfa + +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.google.firebase.FirebaseNetworkException +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException +import java.io.IOException + +/** + * Maps Firebase Auth exceptions to localized error messages for MFA enrollment. + * + * @param stringProvider Provider for localized strings + * @return Localized error message appropriate for the exception type + */ +fun Exception.toMfaErrorMessage(stringProvider: AuthUIStringProvider): String { + return when (this) { + is FirebaseAuthRecentLoginRequiredException -> + stringProvider.mfaErrorRecentLoginRequired + is FirebaseAuthInvalidCredentialsException -> + stringProvider.mfaErrorInvalidVerificationCode + is IOException, is FirebaseNetworkException -> + stringProvider.mfaErrorNetwork + else -> stringProvider.mfaErrorGeneric + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/mfa/SmsEnrollmentHandler.kt b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/SmsEnrollmentHandler.kt new file mode 100644 index 000000000..02037c4c9 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/SmsEnrollmentHandler.kt @@ -0,0 +1,376 @@ +/* + * 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.mfa + +import android.app.Activity +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.mfa.SmsEnrollmentHandler.Companion.RESEND_DELAY_SECONDS +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.PhoneMultiFactorGenerator +import kotlinx.coroutines.tasks.await + +/** + * Handler for SMS multi-factor authentication enrollment. + * + * This class manages the complete SMS enrollment flow, including: + * - Sending SMS verification codes to phone numbers + * - Resending codes with timer support + * - Verifying SMS codes entered by users + * - Finalizing enrollment with Firebase Authentication + * + * This handler uses the existing [AuthProvider.Phone.verifyPhoneNumberAwait] infrastructure + * for sending and verifying SMS codes, ensuring consistency with the primary phone auth flow. + * + * **Usage:** + * ```kotlin + * val handler = SmsEnrollmentHandler(auth, user) + * + * // Step 1: Send verification code + * val session = handler.sendVerificationCode("+1234567890") + * + * // Step 2: Display masked phone number and wait for user input + * val masked = session.getMaskedPhoneNumber() + * + * // Step 3: If needed, resend code after timer expires + * val newSession = handler.resendVerificationCode(session) + * + * // Step 4: Verify the code entered by the user + * val verificationCode = "123456" // From user input + * handler.enrollWithVerificationCode(session, verificationCode, "My Phone") + * ``` + * + * @property auth The [FirebaseAuth] instance + * @property user The [FirebaseUser] to enroll in SMS MFA + * + * @since 10.0.0 + * @see TotpEnrollmentHandler + * @see AuthProvider.Phone.verifyPhoneNumberAwait + */ +class SmsEnrollmentHandler( + private val activity: Activity, + private val auth: FirebaseAuth, + private val user: FirebaseUser +) { + private val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + smsCodeLength = SMS_CODE_LENGTH, + timeout = VERIFICATION_TIMEOUT_SECONDS, + isInstantVerificationEnabled = true + ) + /** + * Sends an SMS verification code to the specified phone number. + * + * This method initiates the SMS enrollment process by sending a verification code + * to the provided phone number. The code will be sent via SMS and should be + * displayed to the user for entry. + * + * **Important:** The user must re-authenticate before calling this method if their + * session is not recent. Use [FirebaseUser.reauthenticate] if needed. + * + * @param phoneNumber The phone number in E.164 format (e.g., "+1234567890") + * @return An [SmsEnrollmentSession] containing the verification ID and metadata + * @throws Exception if the user needs to re-authenticate, phone number is invalid, + * or SMS sending fails + * + * @see resendVerificationCode + * @see SmsEnrollmentSession.getMaskedPhoneNumber + */ + suspend fun sendVerificationCode(phoneNumber: String): SmsEnrollmentSession { + require(isValidPhoneNumber(phoneNumber)) { + "Phone number must be in E.164 format (e.g., +1234567890)" + } + + val multiFactorSession = user.multiFactor.session.await() + val result = phoneProvider.verifyPhoneNumberAwait( + auth = auth, + activity = activity, + phoneNumber = phoneNumber, + multiFactorSession = multiFactorSession, + forceResendingToken = null + ) + + return when (result) { + is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { + SmsEnrollmentSession( + verificationId = "", // Not needed when auto-verified + phoneNumber = phoneNumber, + forceResendingToken = null, + sentAt = System.currentTimeMillis(), + autoVerifiedCredential = result.credential + ) + } + is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { + SmsEnrollmentSession( + verificationId = result.verificationId, + phoneNumber = phoneNumber, + forceResendingToken = result.token, + sentAt = System.currentTimeMillis() + ) + } + } + } + + /** + * Resends the SMS verification code to the phone number. + * + * This method uses the force resending token from the original session to + * explicitly request a new SMS code. This should only be called after the + * [RESEND_DELAY_SECONDS] has elapsed to respect rate limits. + * + * @param session The original [SmsEnrollmentSession] from [sendVerificationCode] + * @return A new [SmsEnrollmentSession] with updated verification ID and timestamp + * @throws Exception if resending fails or if the session doesn't have a resend token + * + * @see sendVerificationCode + */ + suspend fun resendVerificationCode(session: SmsEnrollmentSession): SmsEnrollmentSession { + require(session.forceResendingToken != null) { + "Cannot resend code without a force resending token" + } + + val multiFactorSession = user.multiFactor.session.await() + val result = phoneProvider.verifyPhoneNumberAwait( + auth = auth, + activity = activity, + phoneNumber = session.phoneNumber, + multiFactorSession = multiFactorSession, + forceResendingToken = session.forceResendingToken + ) + + return when (result) { + is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { + SmsEnrollmentSession( + verificationId = "", // Not needed when auto-verified + phoneNumber = session.phoneNumber, + forceResendingToken = session.forceResendingToken, + sentAt = System.currentTimeMillis(), + autoVerifiedCredential = result.credential + ) + } + is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { + SmsEnrollmentSession( + verificationId = result.verificationId, + phoneNumber = session.phoneNumber, + forceResendingToken = result.token, + sentAt = System.currentTimeMillis() + ) + } + } + } + + /** + * Verifies an SMS code and completes the enrollment process. + * + * This method creates a multi-factor assertion using the provided session and + * verification code, then enrolls the user in SMS MFA with Firebase Authentication. + * + * @param session The [SmsEnrollmentSession] from [sendVerificationCode] or [resendVerificationCode] + * @param verificationCode The 6-digit code from the SMS message + * @param displayName Optional friendly name for this MFA factor (e.g., "My Phone") + * @throws Exception if the verification code is invalid or if enrollment fails + * + * @see sendVerificationCode + * @see resendVerificationCode + */ + suspend fun enrollWithVerificationCode( + session: SmsEnrollmentSession, + verificationCode: String, + displayName: String? = null + ) { + require(isValidCodeFormat(verificationCode)) { + "Verification code must be 6 digits" + } + + val credential = session.autoVerifiedCredential + ?: PhoneAuthProvider.getCredential(session.verificationId, verificationCode) + + val multiFactorAssertion = PhoneMultiFactorGenerator.getAssertion(credential) + user.multiFactor.enroll(multiFactorAssertion, displayName).await() + } + + /** + * Validates that a verification code has the correct format for SMS. + * + * This method performs basic client-side validation to ensure the code: + * - Is not null or empty + * - Contains only digits + * - Has exactly 6 digits (the standard SMS code length) + * + * **Note:** This does not verify the code against the server. Use + * [enrollWithVerificationCode] to perform actual verification with Firebase. + * + * @param code The verification code to validate + * @return `true` if the code has a valid format, `false` otherwise + */ + fun isValidCodeFormat(code: String): Boolean { + return code.isNotBlank() && + code.length == SMS_CODE_LENGTH && + code.all { it.isDigit() } + } + + /** + * Validates that a phone number is in the correct E.164 format. + * + * E.164 format requirements: + * - Starts with "+" + * - Followed by 1-15 digits + * - No spaces, hyphens, or other characters + * - Minimum 4 digits total (country code + subscriber number) + * + * Examples of valid numbers: + * - +1234567890 (US) + * - +447911123456 (UK) + * - +33612345678 (France) + * + * @param phoneNumber The phone number to validate + * @return `true` if the phone number is in E.164 format, `false` otherwise + */ + fun isValidPhoneNumber(phoneNumber: String): Boolean { + return phoneNumber.matches(Regex("^\\+[1-9]\\d{3,14}$")) + } + + companion object { + /** + * The standard length for SMS verification codes. + */ + const val SMS_CODE_LENGTH = 6 + + /** + * The verification timeout in seconds for phone authentication. + * This is how long Firebase will wait for auto-verification before + * falling back to manual code entry. + */ + const val VERIFICATION_TIMEOUT_SECONDS = 60L + + /** + * The recommended delay in seconds before allowing code resend. + * This prevents users from spamming the resend functionality and + * respects carrier rate limits. + */ + const val RESEND_DELAY_SECONDS = 30 + + /** + * The Firebase factor ID for SMS multi-factor authentication. + */ + const val FACTOR_ID = PhoneMultiFactorGenerator.FACTOR_ID + } +} + +/** + * Represents an active SMS enrollment session with verification state. + * + * This class holds all the information needed to complete an SMS enrollment, + * including the verification ID, phone number, and resend token. + * + * @property verificationId The verification ID from Firebase + * @property phoneNumber The phone number being verified in E.164 format + * @property forceResendingToken Optional token for resending the SMS code + * @property sentAt Timestamp in milliseconds when the code was sent + * @property autoVerifiedCredential Optional credential if auto-verification succeeded + * + * @since 10.0.0 + */ +data class SmsEnrollmentSession( + val verificationId: String, + val phoneNumber: String, + val forceResendingToken: PhoneAuthProvider.ForceResendingToken?, + val sentAt: Long, + val autoVerifiedCredential: PhoneAuthCredential? = null +) { + /** + * Returns a masked version of the phone number for display purposes. + * + * Masks the middle digits of the phone number while keeping the country code + * and last few digits visible for user confirmation. + * + * Examples: + * - "+1234567890" → "+1••••••890" + * - "+447911123456" → "+44•••••••456" + * + * @return The masked phone number string + */ + fun getMaskedPhoneNumber(): String { + return maskPhoneNumber(phoneNumber) + } + + /** + * Checks if the resend delay has elapsed since the code was sent. + * + * @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS]) + * @return `true` if enough time has passed to allow resending + */ + fun canResend(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Boolean { + val elapsed = (System.currentTimeMillis() - sentAt) / 1000 + return elapsed >= delaySec + } + + /** + * Returns the remaining seconds until resend is allowed. + * + * @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS]) + * @return The number of seconds remaining, or 0 if resend is already allowed + */ + fun getRemainingResendSeconds(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Int { + val elapsed = (System.currentTimeMillis() - sentAt) / 1000 + return maxOf(0, delaySec - elapsed.toInt()) + } +} + +/** + * Masks the middle digits of a phone number for privacy. + * + * The function keeps the country code (first 1-3 characters after +) and + * the last 2-4 digits visible, masking everything in between with bullets. + * Longer phone numbers show more last digits for better user confirmation. + * + * Examples: + * - "+1234567890" → "+1••••••890" (11 chars, last 3 digits) + * - "+447911123456" → "+44•••••••456" (13 chars, last 3 digits) + * - "+33612345678" → "+33•••••••678" (12 chars, last 3 digits) + * - "+8861234567890" → "+88••••••••7890" (14+ chars, last 4 digits) + * + * @param phoneNumber The phone number to mask in E.164 format + * @return The masked phone number string + */ +fun maskPhoneNumber(phoneNumber: String): String { + if (!phoneNumber.startsWith("+") || phoneNumber.length < 8) { + return phoneNumber + } + + // Determine country code length (typically 1-3 digits after +) + val digitsOnly = phoneNumber.substring(1) // Remove + + val countryCodeLength = when { + digitsOnly.length > 10 -> 2 // Likely 2-digit country code + digitsOnly[0] == '1' -> 1 // North America + else -> 2 // Most other countries + } + + val countryCode = phoneNumber.substring(0, countryCodeLength + 1) // Include + + // Keep last 3-4 digits visible, with longer numbers showing more + val lastDigitsCount = when { + phoneNumber.length >= 14 -> 4 // Long numbers show 4 digits + phoneNumber.length >= 11 -> 3 // Medium numbers show 3 digits + else -> 2 // Short numbers show 2 digits + } + val lastDigits = phoneNumber.takeLast(lastDigitsCount) + val maskedLength = phoneNumber.length - countryCode.length - lastDigitsCount + + return "$countryCode${"•".repeat(maskedLength)}$lastDigits" +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/mfa/TotpEnrollmentHandler.kt b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/TotpEnrollmentHandler.kt new file mode 100644 index 000000000..9a4d6f8c7 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/TotpEnrollmentHandler.kt @@ -0,0 +1,151 @@ +/* + * 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.mfa + +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorAssertion +import com.google.firebase.auth.TotpMultiFactorGenerator +import kotlinx.coroutines.tasks.await + +/** + * Handler for TOTP (Time-based One-Time Password) multi-factor authentication enrollment. + * + * This class manages the complete TOTP enrollment flow, including: + * - Generating TOTP secrets + * - Creating QR codes for authenticator apps + * - Verifying TOTP codes with clock drift tolerance + * - Finalizing enrollment with Firebase Authentication + * + * **Usage:** + * ```kotlin + * val handler = TotpEnrollmentHandler(auth, user) + * + * // Step 1: Generate a TOTP secret + * val totpSecret = handler.generateSecret() + * + * // Step 2: Display QR code to user + * val qrCodeUrl = totpSecret.generateQrCodeUrl(user.email, "My App") + * + * // Step 3: Verify the code entered by the user + * val verificationCode = "123456" // From user input + * handler.enrollWithVerificationCode(totpSecret, verificationCode, "My Authenticator") + * ``` + * + * @property auth The [FirebaseAuth] instance + * @property user The [FirebaseUser] to enroll in TOTP MFA + * + * @since 10.0.0 + */ +class TotpEnrollmentHandler( + private val auth: FirebaseAuth, + private val user: FirebaseUser +) { + /** + * Generates a new TOTP secret for the current user. + * + * This method initiates the TOTP enrollment process by creating a new secret that + * can be shared with an authenticator app. The secret must be displayed to the user + * (either as text or a QR code) so they can add it to their authenticator app. + * + * **Important:** The user must re-authenticate before calling this method if their + * session is not recent. Use [FirebaseUser.reauthenticate] if needed. + * + * @return A [TotpSecret] containing the shared secret and configuration parameters + * @throws Exception if the user needs to re-authenticate or if secret generation fails + * + * @see TotpSecret.generateQrCodeUrl + * @see TotpSecret.openInOtpApp + */ + suspend fun generateSecret(): TotpSecret { + // Get the multi-factor session + val multiFactorSession = user.multiFactor.session.await() + + // Generate the TOTP secret + val firebaseTotpSecret = TotpMultiFactorGenerator.generateSecret(multiFactorSession).await() + + return TotpSecret.from(firebaseTotpSecret) + } + + /** + * Verifies a TOTP code and completes the enrollment process. + * + * This method creates a multi-factor assertion using the provided TOTP secret and + * verification code, then enrolls the user in TOTP MFA with Firebase Authentication. + * + * The verification includes clock drift tolerance as configured in your Firebase project, + * allowing codes from adjacent time windows to be accepted. This accommodates minor + * time synchronization differences between the server and the user's device. + * + * @param totpSecret The [TotpSecret] generated in the first step + * @param verificationCode The 6-digit code from the user's authenticator app + * @param displayName Optional friendly name for this MFA factor (e.g., "Google Authenticator") + * @throws Exception if the verification code is invalid or if enrollment fails + * + * @see generateSecret + */ + suspend fun enrollWithVerificationCode( + totpSecret: TotpSecret, + verificationCode: String, + displayName: String? = null + ) { + // Create the multi-factor assertion for enrollment + val multiFactorAssertion: MultiFactorAssertion = + TotpMultiFactorGenerator.getAssertionForEnrollment( + totpSecret.getFirebaseTotpSecret(), + verificationCode + ) + + // Enroll the user with the TOTP factor + user.multiFactor.enroll(multiFactorAssertion, displayName).await() + } + + /** + * Validates that a verification code has the correct format for TOTP. + * + * This method performs basic client-side validation to ensure the code: + * - Is not null or empty + * - Contains only digits + * - Has exactly 6 digits (the standard TOTP code length) + * + * **Note:** This does not verify the code against the TOTP secret. Use + * [enrollWithVerificationCode] to perform actual verification with Firebase. + * + * @param code The verification code to validate + * @return `true` if the code has a valid format, `false` otherwise + */ + fun isValidCodeFormat(code: String): Boolean { + return code.isNotBlank() && + code.length == 6 && + code.all { it.isDigit() } + } + + companion object { + /** + * The standard length for TOTP verification codes. + */ + const val TOTP_CODE_LENGTH = 6 + + /** + * The standard time interval in seconds for TOTP codes. + */ + const val TOTP_TIME_INTERVAL_SECONDS = 30 + + /** + * The Firebase factor ID for TOTP multi-factor authentication. + */ + const val FACTOR_ID = TotpMultiFactorGenerator.FACTOR_ID + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/mfa/TotpSecret.kt b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/TotpSecret.kt new file mode 100644 index 000000000..878f0759d --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/TotpSecret.kt @@ -0,0 +1,104 @@ +/* + * 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.mfa + +import android.content.Intent +import android.net.Uri +import com.google.firebase.auth.TotpSecret as FirebaseTotpSecret + +/** + * Wrapper class for Firebase TOTP secret that provides additional utility methods + * for enrollment and integration with authenticator apps. + * + * This class encapsulates the Firebase [FirebaseTotpSecret] and provides methods to: + * - Access the shared secret key + * - Generate QR code URLs for easy scanning + * - Open authenticator apps for automatic configuration + * - Generate hashing algorithm and code generation parameters + * + * @property firebaseTotpSecret The underlying Firebase TOTP secret + * + * @since 10.0.0 + */ +class TotpSecret internal constructor( + private val firebaseTotpSecret: FirebaseTotpSecret +) { + /** + * The shared secret key that should be entered into an authenticator app. + * This is a base32-encoded string that can be manually typed if QR scanning is not available. + */ + val sharedSecretKey: String + get() = firebaseTotpSecret.sharedSecretKey + + /** + * Generates a Google Authenticator-compatible URI that can be encoded as a QR code + * or used to automatically configure an authenticator app. + * + * The generated URI follows the format: + * `otpauth://totp/{accountName}?secret={secret}&issuer={issuer}&algorithm={algorithm}&digits={digits}&period={period}` + * + * @param accountName The account identifier, typically the user's email address + * @param issuer The name of your application or service + * @return A URI string that can be converted to a QR code or used as a deep link + * + * @see openInOtpApp + */ + fun generateQrCodeUrl(accountName: String, issuer: String): String { + return firebaseTotpSecret.generateQrCodeUrl(accountName, issuer) + } + + /** + * Attempts to open the device's default authenticator app with the TOTP configuration. + * + * This method creates an Intent with the provided QR code URL and attempts to open + * an authenticator app (such as Google Authenticator) that can handle the + * `otpauth://` URI scheme. If successful, the app will be pre-configured with the + * TOTP secret without requiring the user to manually scan a QR code. + * + * **Note:** This method may fail silently if no compatible authenticator app is installed + * or if the app doesn't support automatic configuration via URI. + * + * @param qrCodeUrl The OTP auth URL generated by [generateQrCodeUrl] + * + * @see generateQrCodeUrl + */ + fun openInOtpApp(qrCodeUrl: String) { + firebaseTotpSecret.openInOtpApp(qrCodeUrl) + } + + /** + * Gets the underlying Firebase TOTP secret for use in enrollment operations. + * + * This method is primarily used internally by the enrollment handler to complete + * the TOTP enrollment with Firebase Authentication. + * + * @return The underlying [FirebaseTotpSecret] instance + */ + internal fun getFirebaseTotpSecret(): FirebaseTotpSecret { + return firebaseTotpSecret + } + + companion object { + /** + * Creates a [TotpSecret] instance from a Firebase TOTP secret. + * + * @param firebaseTotpSecret The Firebase TOTP secret to wrap + * @return A new [TotpSecret] instance + */ + internal fun from(firebaseTotpSecret: FirebaseTotpSecret): TotpSecret { + return TotpSecret(firebaseTotpSecret) + } + } +} 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..cb0e71a63 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt @@ -0,0 +1,319 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.components + +import androidx.compose.foundation.Image +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.Provider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset +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 context = LocalContext.current + val providerStyle = resolveProviderStyle(provider, style) + val providerLabel = resolveProviderLabel(provider, stringProvider, context) + + 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( + modifier = Modifier + .size(24.dp), + painter = providerIcon.painter, + contentDescription = providerLabel, + tint = iconTint + ) + } else { + Image( + modifier = Modifier + .size(24.dp), + 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, + context: android.content.Context +): String = when (provider) { + is AuthProvider.GenericOAuth -> provider.buttonLabel + is AuthProvider.Apple -> { + // Use Apple-specific locale if provided, otherwise use default stringProvider + if (provider.locale != null) { + val appleLocale = java.util.Locale.forLanguageTag(provider.locale) + val appleStringProvider = DefaultAuthUIStringProvider(context, appleLocale) + appleStringProvider.signInWithApple + } else { + stringProvider.signInWithApple + } + } + 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( + emailLinkActionCodeSettings = 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( + providerName = "Generic Provider", + 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( + providerName = "Generic Provider", + 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( + providerName = "Generic Provider", + 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/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt new file mode 100644 index 000000000..725ba2f12 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt @@ -0,0 +1,258 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.configuration.validators.FieldValidator +import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator + +/** + * A customizable input field with built-in validation display. + * + * **Example usage:** + * ```kotlin + * val emailTextValue = remember { mutableStateOf("") } + * + * val emailValidator = remember { + * EmailValidator(stringProvider = DefaultAuthUIStringProvider(context)) + * } + * + * AuthTextField( + * value = emailTextValue, + * onValueChange = { emailTextValue.value = it }, + * label = { + * Text("Email") + * }, + * validator = emailValidator + * ) + * ``` + * + * @param modifier A modifier for the field. + * @param value The current value of the text field. + * @param onValueChange A callback when the value changes. + * @param label The label for the text field. + * @param enabled If the field is enabled. + * @param isError Manually set the error state. + * @param errorMessage A custom error message to display. + * @param validator A validator to automatically handle error state and messages. + * @param keyboardOptions Keyboard options for the field. + * @param keyboardActions Keyboard actions for the field. + * @param visualTransformation Visual transformation for the input (e.g., password). + * @param leadingIcon An optional icon to display at the start of the field. + * @param trailingIcon An optional icon to display at the start of the field. + */ +@Composable +fun AuthTextField( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit, + label: @Composable (() -> Unit)? = null, + isSecureTextField: Boolean = false, + enabled: Boolean = true, + isError: Boolean? = null, + errorMessage: String? = null, + validator: FieldValidator? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, +) { + var passwordVisible by remember { mutableStateOf(false) } + + // Automatically set the correct keyboard type based on validator or field type + val resolvedKeyboardOptions = remember(validator, isSecureTextField, keyboardOptions) { + when { + keyboardOptions != KeyboardOptions.Default -> keyboardOptions + validator is EmailValidator -> KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ) + isSecureTextField -> KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ) + else -> keyboardOptions + } + } + + TextField( + modifier = modifier + .fillMaxWidth(), + value = value, + onValueChange = { newValue -> + onValueChange(newValue) + validator?.validate(newValue) + }, + label = label, + singleLine = true, + enabled = enabled, + isError = isError ?: validator?.hasError ?: false, + supportingText = { + if (validator?.hasError ?: false) { + Text(text = errorMessage ?: validator.errorMessage) + } + }, + keyboardOptions = resolvedKeyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = if (isSecureTextField && !passwordVisible) + PasswordVisualTransformation() else visualTransformation, + leadingIcon = leadingIcon ?: when { + validator is EmailValidator -> { + { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Email Input Icon" + ) + } + } + + isSecureTextField -> { + { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "Password Input Icon" + ) + } + } + + else -> null + }, + trailingIcon = trailingIcon ?: { + if (isSecureTextField) { + IconButton( + onClick = { + passwordVisible = !passwordVisible + } + ) { + Icon( + imageVector = if (passwordVisible) + Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +internal fun PreviewAuthTextField() { + val context = LocalContext.current + val nameTextValue = remember { mutableStateOf("") } + val emailTextValue = remember { mutableStateOf("") } + val passwordTextValue = remember { mutableStateOf("") } + val emailValidator = remember { + EmailValidator(stringProvider = DefaultAuthUIStringProvider(context)) + } + val passwordValidator = remember { + PasswordValidator( + stringProvider = DefaultAuthUIStringProvider(context), + rules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireUppercase, + PasswordRule.RequireLowercase, + ) + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AuthTextField( + value = nameTextValue.value, + label = { + Text("Name") + }, + onValueChange = { text -> + nameTextValue.value = text + }, + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = emailTextValue.value, + validator = emailValidator, + label = { + Text("Email") + }, + onValueChange = { text -> + emailTextValue.value = text + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = passwordTextValue.value, + validator = passwordValidator, + isSecureTextField = true, + label = { + Text("Password") + }, + onValueChange = { text -> + passwordTextValue.value = text + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "" + ) + } + ) + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/CountrySelector.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/CountrySelector.kt new file mode 100644 index 000000000..38b4e14ec --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/CountrySelector.kt @@ -0,0 +1,218 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.components + +import androidx.compose.foundation.clickable +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.compose.data.ALL_COUNTRIES +import com.firebase.ui.auth.compose.data.CountryData +import com.firebase.ui.auth.compose.data.CountryUtils +import kotlinx.coroutines.launch + +/** + * A country selector component that displays the selected country's flag and dial code with a dropdown icon. + * Designed to be used as a leadingIcon in a TextField. + * + * @param selectedCountry The currently selected country. + * @param onCountrySelected Callback when a country is selected. + * @param enabled Whether the selector is enabled. + * @param allowedCountries Optional set of allowed country codes to filter the list. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CountrySelector( + selectedCountry: CountryData, + onCountrySelected: (CountryData) -> Unit, + enabled: Boolean = true, + allowedCountries: Set? = null, +) { + val context = LocalContext.current + val stringProvider = LocalAuthUIStringProvider.current + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + var showBottomSheet by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + + val countriesList = remember(allowedCountries) { + if (allowedCountries != null) { + CountryUtils.filterByAllowedCountries(allowedCountries) + } else { + ALL_COUNTRIES + } + } + + val filteredCountries = remember(searchQuery, countriesList) { + if (searchQuery.isEmpty()) { + countriesList + } else { + CountryUtils.search(searchQuery).filter { country -> + countriesList.any { it.countryCode == country.countryCode } + } + } + } + + // Clickable row showing flag, dial code and dropdown icon + Row( + modifier = Modifier + .fillMaxHeight() + .clickable(enabled = enabled) { + showBottomSheet = true + } + .padding(start = 8.dp) + .semantics { + role = Role.DropdownList + contentDescription = "Country selector" + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = selectedCountry.flagEmoji, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = selectedCountry.dialCode, + style = MaterialTheme.typography.bodyLarge, + ) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = "Select country", + modifier = Modifier.padding(PaddingValues.Zero) + ) + } + + if (showBottomSheet) { + ModalBottomSheet( + onDismissRequest = { + showBottomSheet = false + searchQuery = "" + }, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + ) { + Text( + text = stringProvider.countrySelectorModalTitle, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp) + ) + + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + label = { Text(stringProvider.searchCountriesHint) }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .height(500.dp) + .testTag("CountrySelector LazyColumn") + ) { + items(filteredCountries) { country -> + Button( + onClick = { + onCountrySelected(country) + scope.launch { + sheetState.hide() + showBottomSheet = false + searchQuery = "" + } + }, + colors = ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + containerColor = Color.Transparent + ), + contentPadding = PaddingValues.Zero + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = country.flagEmoji, + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = country.name, + style = MaterialTheme.typography.bodyLarge + ) + } + Text( + text = country.dialCode, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } +} 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..c583ada1b --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.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.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: (AuthException) -> 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(error) + } + ) { + 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 -> { + // Use the custom message which includes email and provider details + error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage + } + is AuthException.EmailMismatchException -> stringProvider.emailMismatchMessage + is AuthException.InvalidEmailLinkException -> stringProvider.emailLinkInvalidLinkMessage + is AuthException.EmailLinkWrongDeviceException -> stringProvider.emailLinkWrongDeviceMessage + is AuthException.EmailLinkDifferentAnonymousUserException -> + stringProvider.emailLinkDifferentAnonymousUserMessage + is AuthException.EmailLinkPromptForEmailException -> stringProvider.emailLinkPromptForEmailMessage + is AuthException.EmailLinkCrossDeviceLinkingException -> { + val providerName = error.providerName ?: stringProvider.emailProvider + stringProvider.emailLinkCrossDeviceLinkingMessage(providerName) + } + is AuthException.AuthCancelledException -> stringProvider.authCancelledRecoveryMessage + is AuthException.UnknownException -> { + // Use custom message if available (e.g., for configuration errors) + error.message?.takeIf { it.isNotBlank() } ?: 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 -> error.message ?: stringProvider.continueText + is AuthException.EmailAlreadyInUseException -> stringProvider.signInDefault // Use existing "Sign in" text + is AuthException.AccountLinkingRequiredException -> stringProvider.signInDefault // User needs to sign in to link accounts + is AuthException.MfaRequiredException -> stringProvider.continueText // Use "Continue" for MFA + is AuthException.EmailLinkPromptForEmailException -> stringProvider.continueText + is AuthException.EmailLinkCrossDeviceLinkingException -> stringProvider.continueText + is AuthException.EmailLinkWrongDeviceException -> stringProvider.continueText + is AuthException.EmailLinkDifferentAnonymousUserException -> stringProvider.dismissAction + 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.EmailLinkPromptForEmailException -> true + is AuthException.EmailLinkCrossDeviceLinkingException -> true + is AuthException.EmailLinkWrongDeviceException -> true + is AuthException.EmailLinkDifferentAnonymousUserException -> false + is AuthException.UnknownException -> true + else -> true + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/QrCodeImage.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/QrCodeImage.kt new file mode 100644 index 000000000..ab67379c9 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/QrCodeImage.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.components + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.WriterException +import com.google.zxing.qrcode.QRCodeWriter + +/** + * Renders a QR code from the provided content string. + * + * This component is typically used to display TOTP enrollment URIs. The QR code is generated on the + * fly and memoized for the given [content]. + * + * @param content The string content to encode into the QR code (for example the TOTP URI). + * @param modifier Optional [Modifier] applied to the QR container. + * @param size The size of the QR code square in density-independent pixels. + * @param foregroundColor Color used to render the QR pixels (defaults to black). + * @param backgroundColor Background color for the QR code (defaults to white). + */ +@Composable +fun QrCodeImage( + content: String, + modifier: Modifier = Modifier, + size: Dp = 250.dp, + foregroundColor: Color = Color.Black, + backgroundColor: Color = Color.White +) { + val bitmap = remember(content, size, foregroundColor, backgroundColor) { + generateQrCodeBitmap( + content = content, + sizePx = (size.value * 2).toInt(), // Render at 2x for better scaling quality. + foregroundColor = foregroundColor, + backgroundColor = backgroundColor + ) + } + + Box( + modifier = modifier + .size(size) + .background(backgroundColor), + contentAlignment = Alignment.Center + ) { + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "QR code for authenticator app setup", + modifier = Modifier.size(size) + ) + } + } +} + +private fun generateQrCodeBitmap( + content: String, + sizePx: Int, + foregroundColor: Color, + backgroundColor: Color +): Bitmap? { + return try { + val qrCodeWriter = QRCodeWriter() + val hints = mapOf( + EncodeHintType.MARGIN to 1 // Small margin keeps QR code compact while remaining scannable. + ) + + val bitMatrix = qrCodeWriter.encode( + content, + BarcodeFormat.QR_CODE, + sizePx, + sizePx, + hints + ) + + val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + + val foregroundArgb = android.graphics.Color.argb( + (foregroundColor.alpha * 255).toInt(), + (foregroundColor.red * 255).toInt(), + (foregroundColor.green * 255).toInt(), + (foregroundColor.blue * 255).toInt() + ) + + val backgroundArgb = android.graphics.Color.argb( + (backgroundColor.alpha * 255).toInt(), + (backgroundColor.red * 255).toInt(), + (backgroundColor.green * 255).toInt(), + (backgroundColor.blue * 255).toInt() + ) + + for (x in 0 until sizePx) { + for (y in 0 until sizePx) { + bitmap.setPixel( + x, + y, + if (bitMatrix[x, y]) foregroundArgb else backgroundArgb + ) + } + } + + bitmap + } catch (e: WriterException) { + null + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ReauthenticationDialog.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ReauthenticationDialog.kt new file mode 100644 index 000000000..e529fdea5 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ReauthenticationDialog.kt @@ -0,0 +1,210 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FirebaseUser +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +/** + * Dialog presented when Firebase requires the current user to re-authenticate before performing + * a sensitive operation (for example, MFA enrollment). + */ +@Composable +fun ReauthenticationDialog( + user: FirebaseUser, + onDismiss: () -> Unit, + onSuccess: () -> Unit, + onError: (Exception) -> Unit +) { + var password by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + val stringProvider = LocalAuthUIStringProvider.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + AlertDialog( + onDismissRequest = { if (!isLoading) onDismiss() }, + title = { + Text( + text = stringProvider.reauthDialogTitle, + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringProvider.reauthDialogMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + user.email?.let { email -> + Text( + text = stringProvider.reauthAccountLabel(email), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + OutlinedTextField( + value = password, + onValueChange = { + password = it + errorMessage = null + }, + label = { Text(stringProvider.passwordHint) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + if (password.isNotBlank() && !isLoading) { + coroutineScope.launch { + reauthenticate( + user = user, + password = password, + onLoading = { isLoading = it }, + onSuccess = onSuccess, + onError = { error -> + errorMessage = error.toUserMessage(stringProvider) + onError(error) + } + ) + } + } + } + ), + enabled = !isLoading, + isError = errorMessage != null, + supportingText = errorMessage?.let { message -> { Text(message) } }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 8.dp) + ) + } + } + }, + confirmButton = { + Button( + onClick = { + coroutineScope.launch { + reauthenticate( + user = user, + password = password, + onLoading = { isLoading = it }, + onSuccess = onSuccess, + onError = { error -> + errorMessage = error.toUserMessage(stringProvider) + onError(error) + } + ) + } + }, + enabled = password.isNotBlank() && !isLoading + ) { + Text(stringProvider.verifyAction) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + enabled = !isLoading + ) { + Text(stringProvider.dismissAction) + } + } + ) +} + +private suspend fun reauthenticate( + user: FirebaseUser, + password: String, + onLoading: (Boolean) -> Unit, + onSuccess: () -> Unit, + onError: (Exception) -> Unit +) { + try { + onLoading(true) + val email = requireNotNull(user.email) { + "Email must be available to re-authenticate with password." + } + + val credential = EmailAuthProvider.getCredential(email, password) + user.reauthenticate(credential).await() + onSuccess() + } catch (e: Exception) { + onError(e) + } finally { + onLoading(false) + } +} + +private fun Exception.toUserMessage(stringProvider: AuthUIStringProvider): String = when { + message?.contains("password", ignoreCase = true) == true -> + stringProvider.incorrectPasswordError + message?.contains("network", ignoreCase = true) == true -> + stringProvider.noInternet + else -> stringProvider.reauthGenericError +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TermsAndPrivacyForm.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TermsAndPrivacyForm.kt new file mode 100644 index 000000000..82694d3dc --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TermsAndPrivacyForm.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp + +@Composable +fun TermsAndPrivacyForm( + modifier: Modifier = Modifier, + tosUrl: String?, + ppUrl: String? +) { + val uriHandler = LocalUriHandler.current + Row( + modifier = modifier, + ) { + TextButton( + onClick = { + tosUrl?.let { + uriHandler.openUri(it) + } + }, + contentPadding = PaddingValues.Zero, + ) { + Text( + text = "Terms of Service", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + Spacer(modifier = Modifier.width(24.dp)) + TextButton( + onClick = { + ppUrl?.let { + uriHandler.openUri(it) + } + }, + contentPadding = PaddingValues.Zero, + ) { + Text( + text = "Privacy Policy", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TopLevelDialogController.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TopLevelDialogController.kt new file mode 100644 index 000000000..973d6e6d5 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TopLevelDialogController.kt @@ -0,0 +1,172 @@ +/* + * 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.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider + +/** + * CompositionLocal for accessing the top-level dialog controller from any composable. + */ +val LocalTopLevelDialogController = compositionLocalOf { + null +} + +/** + * A top-level dialog controller that allows any child composable to show error recovery dialogs. + * + * It provides a single point of control for showing dialogs from anywhere in the composition tree, + * preventing duplicate dialogs when multiple screens observe the same error state. + * + * **Usage:** + * ```kotlin + * // At the root of your auth flow (FirebaseAuthScreen): + * val dialogController = rememberTopLevelDialogController(stringProvider) + * + * CompositionLocalProvider(LocalTopLevelDialogController provides dialogController) { + * // Your auth screens... + * + * // Show dialog at root level (only one instance) + * dialogController.CurrentDialog() + * } + * + * // In any child screen (EmailAuthScreen, PhoneAuthScreen, etc.): + * val dialogController = LocalTopLevelDialogController.current + * + * LaunchedEffect(error) { + * error?.let { exception -> + * dialogController?.showErrorDialog( + * exception = exception, + * onRetry = { ... }, + * onRecover = { ... }, + * onDismiss = { ... } + * ) + * } + * } + * ``` + * + * @since 10.0.0 + */ +class TopLevelDialogController( + private val stringProvider: AuthUIStringProvider, + private val authState: AuthState +) { + private var dialogState by mutableStateOf(null) + private val shownErrorStates = mutableSetOf() + + /** + * Shows an error recovery dialog at the top level using [ErrorRecoveryDialog]. + * Automatically prevents duplicate dialogs for the same AuthState.Error instance. + * + * @param exception The auth exception to display + * @param onRetry Callback when user clicks retry button + * @param onRecover Callback when user clicks recover button (e.g., navigate to different screen) + * @param onDismiss Callback when dialog is dismissed + */ + fun showErrorDialog( + exception: AuthException, + onRetry: (AuthException) -> Unit = {}, + onRecover: (AuthException) -> Unit = {}, + onDismiss: () -> Unit = {} + ) { + // Get current error state + val currentErrorState = authState as? AuthState.Error + + // If this exact error state has already been shown, skip + if (currentErrorState != null && currentErrorState in shownErrorStates) { + return + } + + // Mark this error state as shown + currentErrorState?.let { shownErrorStates.add(it) } + + dialogState = DialogState.ErrorDialog( + exception = exception, + onRetry = onRetry, + onRecover = onRecover, + onDismiss = { + dialogState = null + onDismiss() + } + ) + } + + /** + * Dismisses the currently shown dialog. + */ + fun dismissDialog() { + dialogState = null + } + + /** + * Composable that renders the current dialog, if any. + * This should be called once at the root level of your auth flow. + * + * Uses the existing [ErrorRecoveryDialog] component. + */ + @Composable + fun CurrentDialog() { + val state = dialogState + when (state) { + is DialogState.ErrorDialog -> { + ErrorRecoveryDialog( + error = state.exception, + stringProvider = stringProvider, + onRetry = { exception -> + state.onRetry(exception) + state.onDismiss() + }, + onRecover = { exception -> + state.onRecover(exception) + state.onDismiss() + }, + onDismiss = state.onDismiss + ) + } + null -> { + // No dialog to show + } + } + } + + private sealed class DialogState { + data class ErrorDialog( + val exception: AuthException, + val onRetry: (AuthException) -> Unit, + val onRecover: (AuthException) -> Unit, + val onDismiss: () -> Unit + ) : DialogState() + } +} + +/** + * Creates and remembers a [TopLevelDialogController]. + */ +@Composable +fun rememberTopLevelDialogController( + stringProvider: AuthUIStringProvider, + authState: AuthState +): TopLevelDialogController { + return remember(stringProvider, authState) { + TopLevelDialogController(stringProvider, authState) + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/VerificationCodeInputField.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/VerificationCodeInputField.kt new file mode 100644 index 000000000..06fdca169 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/VerificationCodeInputField.kt @@ -0,0 +1,396 @@ +package com.firebase.ui.auth.compose.ui.components + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.draw.clip +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.core.text.isDigitsOnly +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.configuration.validators.FieldValidator + +@Composable +fun VerificationCodeInputField( + modifier: Modifier = Modifier, + codeLength: Int = 6, + validator: FieldValidator? = null, + isError: Boolean = false, + errorMessage: String? = null, + onCodeComplete: (String) -> Unit = {}, + onCodeChange: (String) -> Unit = {}, +) { + val code = remember { mutableStateOf(List(codeLength) { null }) } + val focusedIndex = remember { mutableStateOf(null) } + val focusRequesters = remember { (1..codeLength).map { FocusRequester() } } + val keyboardManager = LocalSoftwareKeyboardController.current + + // Derive validation state + val currentCodeString = remember { mutableStateOf("") } + val validationError = remember { mutableStateOf(null) } + + // Auto-focus first field on initial composition + LaunchedEffect(Unit) { + focusRequesters.firstOrNull()?.requestFocus() + } + + // Handle focus changes + LaunchedEffect(focusedIndex.value) { + focusedIndex.value?.let { index -> + focusRequesters.getOrNull(index)?.requestFocus() + } + } + + // Handle code completion and validation + LaunchedEffect(code.value) { + val codeString = code.value.mapNotNull { it }.joinToString("") + currentCodeString.value = codeString + onCodeChange(codeString) + + // Run validation if validator is provided + validator?.let { + val isValid = it.validate(codeString) + validationError.value = if (!isValid && codeString.length == codeLength) { + it.errorMessage + } else { + null + } + } + + val allNumbersEntered = code.value.none { it == null } + if (allNumbersEntered) { + keyboardManager?.hide() + onCodeComplete(codeString) + } + } + + // Determine error state: use validator if provided, otherwise use explicit isError + val showError = if (validator != null) { + validationError.value != null + } else { + isError + } + + val displayErrorMessage = if (validator != null) { + validationError.value + } else { + errorMessage + } + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + code.value.forEachIndexed { index, number -> + SingleDigitField( + modifier = Modifier + .weight(1f) + .aspectRatio(1f), + number = number, + isError = showError, + focusRequester = focusRequesters[index], + onFocusChanged = { isFocused -> + if (isFocused) { + focusedIndex.value = index + } + }, + onNumberChanged = { value -> + val oldValue = code.value[index] + val newCode = code.value.toMutableList() + newCode[index] = value + code.value = newCode + + // Move focus to next field if number was entered (and field was previously empty) + if (value != null && oldValue == null) { + focusedIndex.value = getNextFocusedIndex(newCode, index) + } + }, + onKeyboardBack = { + val previousIndex = getPreviousFocusedIndex(index) + if (previousIndex != null) { + val newCode = code.value.toMutableList() + newCode[previousIndex] = null + code.value = newCode + focusedIndex.value = previousIndex + } + }, + onNumberEntered = { + focusRequesters[index].freeFocus() + } + ) + } + } + + if (showError && displayErrorMessage != null) { + Text( + modifier = Modifier.padding(top = 8.dp), + text = displayErrorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun SingleDigitField( + modifier: Modifier = Modifier, + number: Int?, + isError: Boolean = false, + focusRequester: FocusRequester, + onFocusChanged: (Boolean) -> Unit, + onNumberChanged: (Int?) -> Unit, + onKeyboardBack: () -> Unit, + onNumberEntered: () -> Unit, +) { + val text = remember { mutableStateOf(TextFieldValue()) } + val isFocused = remember { mutableStateOf(false) } + + // Update text field value when number changes externally + LaunchedEffect(number) { + text.value = TextFieldValue( + text = number?.toString().orEmpty(), + selection = TextRange( + index = if (number != null) 1 else 0 + ) + ) + } + + val borderColor = if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + +// val backgroundColor = if (isError) { +// MaterialTheme.colorScheme.errorContainer +// } else { +// MaterialTheme.colorScheme.primaryContainer +// } + + val textColor = if (isError) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.primary + } + + val targetBorderWidth = if (isError || isFocused.value || number != null) 2.dp else 1.dp + val animatedBorderWidth by animateDpAsState( + targetValue = targetBorderWidth, + animationSpec = tween(durationMillis = 150), + label = "borderWidth" + ) + + val shape = RoundedCornerShape(8.dp) + + Box( + modifier = modifier + .clip(shape) + .border( + width = animatedBorderWidth, + shape = shape, + color = borderColor, + ), + //.background(backgroundColor), + contentAlignment = Alignment.Center + ) { + BasicTextField( + modifier = Modifier + .fillMaxSize() + .wrapContentSize() + .semantics { + contentDescription = "Verification code digit" + } + .focusRequester(focusRequester) + .onFocusChanged { + isFocused.value = it.isFocused + onFocusChanged(it.isFocused) + } + .onPreviewKeyEvent { event -> + val isDelete = event.key == Key.Backspace || event.key == Key.Delete + val isInitialDown = event.type == KeyEventType.KeyDown && + event.nativeKeyEvent.repeatCount == 0 + + if (isDelete && isInitialDown && number == null) { + onKeyboardBack() + return@onPreviewKeyEvent true + } + false + }, + value = text.value, + onValueChange = { value -> + val newNumber = value.text + if (newNumber.length <= 1 && newNumber.isDigitsOnly()) { + val digit = newNumber.toIntOrNull() + onNumberChanged(digit) + if (digit != null) { + onNumberEntered() + } + } + }, + cursorBrush = SolidColor(textColor), + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy( + textAlign = TextAlign.Center, + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + color = textColor, + lineHeight = 24.sp, + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword + ), + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + innerTextField() + } + } + ) + } +} + +private fun getPreviousFocusedIndex(currentIndex: Int): Int? { + return currentIndex.minus(1).takeIf { it >= 0 } +} + +private fun getNextFocusedIndex(code: List, currentIndex: Int): Int? { + if (currentIndex >= code.size - 1) return currentIndex + + for (i in (currentIndex + 1) until code.size) { + if (code[i] == null) { + return i + } + } + return currentIndex +} + +@Preview +@Composable +private fun PreviewVerificationCodeInputFieldExample() { + val completedCode = remember { mutableStateOf(null) } + val currentCode = remember { mutableStateOf("") } + val isError = remember { mutableStateOf(false) } + + AuthUITheme { + Scaffold( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + VerificationCodeInputField( + modifier = Modifier.padding(16.dp), + isError = isError.value, + errorMessage = if (isError.value) "Invalid verification code" else null, + onCodeComplete = { code -> + completedCode.value = code + // Simulate validation - in real app this would be async + isError.value = code != "123456" + }, + onCodeChange = { code -> + currentCode.value = code + // Clear error on change + if (isError.value) { + isError.value = false + } + } + ) + + if (!isError.value) { + completedCode.value?.let { code -> + Text( + modifier = Modifier.padding(top = 16.dp), + text = "Code entered: $code", + color = MaterialTheme.colorScheme.primary, + fontSize = 16.sp, + ) + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewVerificationCodeInputFieldError() { + AuthUITheme { + VerificationCodeInputField( + modifier = Modifier.padding(16.dp), + isError = true, + errorMessage = "Invalid verification code" + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewVerificationCodeInputField() { + AuthUITheme { + VerificationCodeInputField( + modifier = Modifier.padding(16.dp) + ) + } +} \ 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..7d0f6359c --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.method_picker + +import android.content.Context +import android.content.Intent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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, + text: String, + vararg links: Pair, + inPreview: Boolean = false, + previewText: String? = null, +) { + val template = if (inPreview && previewText != null) { + previewText + } else { + text + } + + 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..42b620d28 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt @@ -0,0 +1,192 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.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.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +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 + val stringProvider = LocalAuthUIStringProvider.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 = LocalAuthUIStringProvider.current + ) + } + } + } + } + } + AnnotatedStringResource( + modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp), + context = context, + inPreview = inPreview, + previewText = "By continuing, you accept our Terms of Service and Privacy Policy.", + text = stringProvider.tosAndPrivacyPolicy( + termsOfServiceLabel = stringProvider.termsOfService, + privacyPolicyLabel = stringProvider.privacyPolicy + ), + links = arrayOf( + stringProvider.termsOfService to (termsOfServiceUrl ?: ""), + stringProvider.privacyPolicy to (privacyPolicyUrl ?: "") + ) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewAuthMethodPicker() { + Column( + modifier = Modifier + .fillMaxSize() + ) { + AuthMethodPicker( + providers = listOf( + AuthProvider.Email( + emailLinkActionCodeSettings = 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/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt new file mode 100644 index 000000000..6cb2dc6f6 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt @@ -0,0 +1,771 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.screens + +import android.util.Log +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.MfaConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.rememberAnonymousSignInHandler +import com.firebase.ui.auth.compose.configuration.auth_provider.rememberGoogleSignInHandler +import com.firebase.ui.auth.compose.configuration.auth_provider.rememberOAuthSignInHandler +import com.firebase.ui.auth.compose.configuration.auth_provider.rememberSignInWithFacebookLauncher +import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailLink +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.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.compose.ui.components.LocalTopLevelDialogController +import com.firebase.ui.auth.compose.ui.components.rememberTopLevelDialogController +import com.firebase.ui.auth.compose.ui.method_picker.AuthMethodPicker +import com.firebase.ui.auth.compose.ui.screens.phone.PhoneAuthScreen +import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.MultiFactorResolver +import kotlinx.coroutines.launch + +/** + * High-level authentication screen that wires together provider selection, individual provider + * flows, error handling, and multi-factor enrollment/challenge flows. Back navigation is driven by + * the Jetpack Navigation stack so presses behave like native Android navigation. + * + * @param authenticatedContent Optional slot that allows callers to render the authenticated + * state themselves. When provided, it receives the current [AuthState] alongside an + * [AuthSuccessUiContext] containing common callbacks (sign out, manage MFA, reload user). + * + * @since 10.0.0 + */ +@Composable +fun FirebaseAuthScreen( + configuration: AuthUIConfiguration, + onSignInSuccess: (AuthResult) -> Unit, + onSignInFailure: (AuthException) -> Unit, + onSignInCancelled: () -> Unit, + modifier: Modifier = Modifier, + authUI: FirebaseAuthUI = FirebaseAuthUI.getInstance(), + emailLink: String? = null, + mfaConfiguration: MfaConfiguration = MfaConfiguration(), + authenticatedContent: (@Composable (state: AuthState, uiContext: AuthSuccessUiContext) -> Unit)? = null, +) { + val activity = LocalActivity.current + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val stringProvider = DefaultAuthUIStringProvider(context) + val navController = rememberNavController() + + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + val dialogController = rememberTopLevelDialogController(stringProvider, authState) + val lastSuccessfulUserId = remember { mutableStateOf(null) } + val pendingLinkingCredential = remember { mutableStateOf(null) } + val pendingResolver = remember { mutableStateOf(null) } + val emailLinkFromDifferentDevice = remember { mutableStateOf(null) } + + val anonymousProvider = + configuration.providers.filterIsInstance().firstOrNull() + val googleProvider = + configuration.providers.filterIsInstance().firstOrNull() + val emailProvider = configuration.providers.filterIsInstance().firstOrNull() + val facebookProvider = + configuration.providers.filterIsInstance().firstOrNull() + val appleProvider = configuration.providers.filterIsInstance().firstOrNull() + val githubProvider = + configuration.providers.filterIsInstance().firstOrNull() + val microsoftProvider = + configuration.providers.filterIsInstance().firstOrNull() + val yahooProvider = configuration.providers.filterIsInstance().firstOrNull() + val twitterProvider = + configuration.providers.filterIsInstance().firstOrNull() + val genericOAuthProviders = + configuration.providers.filterIsInstance() + + val logoAsset = configuration.logo + + val onSignInAnonymously = anonymousProvider?.let { + authUI.rememberAnonymousSignInHandler() + } + + val onSignInWithGoogle = googleProvider?.let { + authUI.rememberGoogleSignInHandler( + context = context, + config = configuration, + provider = it + ) + } + + val onSignInWithFacebook = facebookProvider?.let { + authUI.rememberSignInWithFacebookLauncher( + context = context, + config = configuration, + provider = it + ) + } + + val onSignInWithApple = appleProvider?.let { + authUI.rememberOAuthSignInHandler( + activity = activity, + config = configuration, + provider = it + ) + } + + val onSignInWithGithub = githubProvider?.let { + authUI.rememberOAuthSignInHandler( + activity = activity, + config = configuration, + provider = it + ) + } + + val onSignInWithMicrosoft = microsoftProvider?.let { + authUI.rememberOAuthSignInHandler( + activity = activity, + config = configuration, + provider = it + ) + } + + val onSignInWithYahoo = yahooProvider?.let { + authUI.rememberOAuthSignInHandler( + activity = activity, + config = configuration, + provider = it + ) + } + + val onSignInWithTwitter = twitterProvider?.let { + authUI.rememberOAuthSignInHandler( + activity = activity, + config = configuration, + provider = it + ) + } + + val genericOAuthHandlers = genericOAuthProviders.associateWith { + authUI.rememberOAuthSignInHandler( + activity = activity, + config = configuration, + provider = it + ) + } + + CompositionLocalProvider( + LocalAuthUIStringProvider provides configuration.stringProvider, + LocalTopLevelDialogController provides dialogController + ) { + Surface( + modifier = Modifier + .fillMaxSize() + ) { + NavHost( + navController = navController, + startDestination = AuthRoute.MethodPicker.route + ) { + composable(AuthRoute.MethodPicker.route) { + AuthMethodPicker( + providers = configuration.providers, + logo = logoAsset, + termsOfServiceUrl = configuration.tosUrl, + privacyPolicyUrl = configuration.privacyPolicyUrl, + onProviderSelected = { provider -> + when (provider) { + is AuthProvider.Anonymous -> onSignInAnonymously?.invoke() + + is AuthProvider.Email -> { + navController.navigate(AuthRoute.Email.route) + } + + is AuthProvider.Phone -> { + navController.navigate(AuthRoute.Phone.route) + } + + is AuthProvider.Google -> onSignInWithGoogle?.invoke() + + is AuthProvider.Facebook -> onSignInWithFacebook?.invoke() + + is AuthProvider.Apple -> onSignInWithApple?.invoke() + + is AuthProvider.Github -> onSignInWithGithub?.invoke() + + is AuthProvider.Microsoft -> onSignInWithMicrosoft?.invoke() + + is AuthProvider.Yahoo -> onSignInWithYahoo?.invoke() + + is AuthProvider.Twitter -> onSignInWithTwitter?.invoke() + + is AuthProvider.GenericOAuth -> genericOAuthHandlers[provider]?.invoke() + + else -> { + onSignInFailure( + AuthException.UnknownException( + message = "Provider ${provider.providerId} is not supported in FirebaseAuthScreen", + cause = IllegalArgumentException( + "Provider ${provider.providerId} is not supported in FirebaseAuthScreen" + ) + ) + ) + } + } + } + ) + } + + composable(AuthRoute.Email.route) { + EmailAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + credentialForLinking = pendingLinkingCredential.value, + emailLinkFromDifferentDevice = emailLinkFromDifferentDevice.value, + onSuccess = { + pendingLinkingCredential.value = null + }, + onError = { exception -> + onSignInFailure(exception) + }, + onCancel = { + pendingLinkingCredential.value = null + if (!navController.popBackStack()) { + navController.navigate(AuthRoute.MethodPicker.route) { + popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + launchSingleTop = true + } + } + } + ) + } + + composable(AuthRoute.Phone.route) { + PhoneAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + onSuccess = {}, + onError = { exception -> + onSignInFailure(exception) + }, + onCancel = { + if (!navController.popBackStack()) { + navController.navigate(AuthRoute.MethodPicker.route) { + popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + launchSingleTop = true + } + } + } + ) + } + + composable(AuthRoute.Success.route) { + val uiContext = remember(authState, stringProvider) { + AuthSuccessUiContext( + authUI = authUI, + stringProvider = stringProvider, + onSignOut = { + coroutineScope.launch { + try { + authUI.signOut(context) + } catch (e: Exception) { + onSignInFailure(AuthException.from(e)) + } finally { + pendingLinkingCredential.value = null + pendingResolver.value = null + } + } + }, + onManageMfa = { + navController.navigate(AuthRoute.MfaEnrollment.route) + }, + onReloadUser = { + coroutineScope.launch { + try { + // Reload user to get fresh data from server + authUI.getCurrentUser()?.reload() + authUI.getCurrentUser()?.getIdToken(true) + + // Check the user's email verification status after reload + val user = authUI.getCurrentUser() + if (user != null) { + // If email is now verified, transition to Success state + if (user.isEmailVerified) { + authUI.updateAuthState( + AuthState.Success( + result = null, + user = user, + isNewUser = false + ) + ) + } else { + // Email still not verified, keep showing verification screen + authUI.updateAuthState( + AuthState.RequiresEmailVerification( + user = user, + email = user.email ?: "" + ) + ) + } + } + } catch (e: Exception) { + Log.e("FirebaseAuthScreen", "Failed to refresh user", e) + } + } + }, + onNavigate = { route -> + navController.navigate(route.route) + } + ) + } + + if (authenticatedContent != null) { + authenticatedContent(authState, uiContext) + } else { + SuccessDestination( + authState = authState, + stringProvider = stringProvider, + uiContext = uiContext + ) + } + } + + composable(AuthRoute.MfaEnrollment.route) { + val user = authUI.getCurrentUser() + if (user != null) { + MfaEnrollmentScreen( + user = user, + auth = authUI.auth, + configuration = mfaConfiguration, + authConfiguration = configuration, + onComplete = { navController.popBackStack() }, + onSkip = { navController.popBackStack() }, + onError = { exception -> + onSignInFailure(AuthException.from(exception)) + } + ) + } else { + navController.popBackStack() + } + } + + composable(AuthRoute.MfaChallenge.route) { + val resolver = pendingResolver.value + if (resolver != null) { + MfaChallengeScreen( + resolver = resolver, + auth = authUI.auth, + onSuccess = { + pendingResolver.value = null + // Reset auth state to Idle so the firebaseAuthFlow Success state takes over + authUI.updateAuthState(AuthState.Idle) + }, + onCancel = { + pendingResolver.value = null + authUI.updateAuthState(AuthState.Cancelled) + navController.popBackStack() + }, + onError = { exception -> + onSignInFailure(AuthException.from(exception)) + } + ) + } else { + navController.popBackStack() + } + } + } + + // Handle email link sign-in (deep links) + LaunchedEffect(emailLink) { + if (emailLink != null && emailProvider != null) { + try { + // Try to retrieve saved email from DataStore (same-device flow) + val savedEmail = EmailLinkPersistenceManager.default.retrieveSessionRecord(context)?.email + + if (savedEmail != null) { + // Same device - we have the email, sign in automatically + authUI.signInWithEmailLink( + context = context, + config = configuration, + provider = emailProvider, + email = savedEmail, + emailLink = emailLink + ) + } else { + // Different device - no saved email + // Call signInWithEmailLink with empty email to trigger validation + // This will throw EmailLinkPromptForEmailException or EmailLinkWrongDeviceException + authUI.signInWithEmailLink( + context = context, + config = configuration, + provider = emailProvider, + email = "", // Empty email triggers cross-device detection + emailLink = emailLink + ) + } + } catch (e: Exception) { + Log.e("FirebaseAuthScreen", "Failed to complete email link sign-in", e) + } + } + } + + // Synchronise auth state changes with navigation stack. + LaunchedEffect(authState) { + val state = authState + val currentRoute = navController.currentBackStackEntry?.destination?.route + when (state) { + is AuthState.Success -> { + pendingResolver.value = null + pendingLinkingCredential.value = null + + state.result?.let { result -> + if (state.user.uid != lastSuccessfulUserId.value) { + onSignInSuccess(result) + lastSuccessfulUserId.value = state.user.uid + } + } + + if (currentRoute != AuthRoute.Success.route) { + navController.navigate(AuthRoute.Success.route) { + popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + launchSingleTop = true + } + } + } + + is AuthState.RequiresEmailVerification, + is AuthState.RequiresProfileCompletion, + -> { + pendingResolver.value = null + pendingLinkingCredential.value = null + if (currentRoute != AuthRoute.Success.route) { + navController.navigate(AuthRoute.Success.route) { + popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + launchSingleTop = true + } + } + } + + is AuthState.RequiresMfa -> { + pendingResolver.value = state.resolver + if (currentRoute != AuthRoute.MfaChallenge.route) { + navController.navigate(AuthRoute.MfaChallenge.route) { + launchSingleTop = true + } + } + } + + is AuthState.Cancelled -> { + pendingResolver.value = null + pendingLinkingCredential.value = null + lastSuccessfulUserId.value = null + if (currentRoute != AuthRoute.MethodPicker.route) { + navController.navigate(AuthRoute.MethodPicker.route) { + popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + launchSingleTop = true + } + } + onSignInCancelled() + } + + is AuthState.Idle -> { + pendingResolver.value = null + pendingLinkingCredential.value = null + lastSuccessfulUserId.value = null + if (currentRoute != AuthRoute.MethodPicker.route) { + navController.navigate(AuthRoute.MethodPicker.route) { + popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + launchSingleTop = true + } + } + } + + else -> Unit + } + } + + // Handle errors using top-level dialog controller + val errorState = authState as? AuthState.Error + if (errorState != null) { + LaunchedEffect(errorState) { + val exception = when (val throwable = errorState.exception) { + is AuthException -> throwable + else -> AuthException.from(throwable) + } + + dialogController.showErrorDialog( + exception = exception, + onRetry = { _ -> + // Child screens handle their own retry logic + }, + onRecover = { exception -> + when (exception) { + is AuthException.EmailAlreadyInUseException -> { + navController.navigate(AuthRoute.Email.route) { + launchSingleTop = true + } + } + + is AuthException.AccountLinkingRequiredException -> { + pendingLinkingCredential.value = exception.credential + navController.navigate(AuthRoute.Email.route) { + launchSingleTop = true + } + } + + is AuthException.EmailLinkPromptForEmailException -> { + // Cross-device flow: User needs to enter their email + emailLinkFromDifferentDevice.value = exception.emailLink + navController.navigate(AuthRoute.Email.route) { + launchSingleTop = true + } + } + + is AuthException.EmailLinkCrossDeviceLinkingException -> { + // Cross-device linking flow: User needs to enter email to link provider + emailLinkFromDifferentDevice.value = exception.emailLink + navController.navigate(AuthRoute.Email.route) { + launchSingleTop = true + } + } + + else -> Unit + } + }, + onDismiss = { + // Dialog dismissed + } + ) + } + } + + // Render the top-level dialog (only one instance) + dialogController.CurrentDialog() + + val loadingState = authState as? AuthState.Loading + if (loadingState != null) { + LoadingDialog(loadingState.message ?: stringProvider.progressDialogLoading) + } + } + } +} + +sealed class AuthRoute(val route: String) { + object MethodPicker : AuthRoute("auth_method_picker") + object Email : AuthRoute("auth_email") + object Phone : AuthRoute("auth_phone") + object Success : AuthRoute("auth_success") + object MfaEnrollment : AuthRoute("auth_mfa_enrollment") + object MfaChallenge : AuthRoute("auth_mfa_challenge") +} + +data class AuthSuccessUiContext( + val authUI: FirebaseAuthUI, + val stringProvider: AuthUIStringProvider, + val onSignOut: () -> Unit, + val onManageMfa: () -> Unit, + val onReloadUser: () -> Unit, + val onNavigate: (AuthRoute) -> Unit, +) + +@Composable +private fun SuccessDestination( + authState: AuthState, + stringProvider: AuthUIStringProvider, + uiContext: AuthSuccessUiContext +) { + when (authState) { + is AuthState.Success -> { + AuthSuccessContent( + authUI = uiContext.authUI, + stringProvider = stringProvider, + onSignOut = uiContext.onSignOut, + onManageMfa = uiContext.onManageMfa + ) + } + + is AuthState.RequiresEmailVerification -> { + EmailVerificationContent( + authUI = uiContext.authUI, + stringProvider = stringProvider, + onCheckStatus = uiContext.onReloadUser, + onSignOut = uiContext.onSignOut + ) + } + + is AuthState.RequiresProfileCompletion -> { + ProfileCompletionContent( + missingFields = authState.missingFields, + stringProvider = stringProvider + ) + } + + else -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + } + } + } +} + +@Composable +private fun AuthSuccessContent( + authUI: FirebaseAuthUI, + stringProvider: AuthUIStringProvider, + onSignOut: () -> Unit, + onManageMfa: () -> Unit +) { + val user = authUI.getCurrentUser() + val userIdentifier = user?.email ?: user?.phoneNumber ?: user?.uid.orEmpty() + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (userIdentifier.isNotBlank()) { + Text( + text = stringProvider.signedInAs(userIdentifier), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + } + if (user != null && authUI.auth.app.options.projectId != null) { + Button(onClick = onManageMfa) { + Text(stringProvider.manageMfaAction) + } + Spacer(modifier = Modifier.height(8.dp)) + } + Button(onClick = onSignOut) { + Text(stringProvider.signOutAction) + } + } +} + +@Composable +private fun EmailVerificationContent( + authUI: FirebaseAuthUI, + stringProvider: AuthUIStringProvider, + onCheckStatus: () -> Unit, + onSignOut: () -> Unit +) { + val user = authUI.getCurrentUser() + val emailLabel = user?.email ?: stringProvider.emailProvider + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringProvider.verifyEmailInstruction(emailLabel), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { user?.sendEmailVerification() }) { + Text(stringProvider.resendVerificationEmailAction) + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onCheckStatus) { + Text(stringProvider.verifiedEmailAction) + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onSignOut) { + Text(stringProvider.signOutAction) + } + } +} + +@Composable +private fun ProfileCompletionContent( + missingFields: List, + stringProvider: AuthUIStringProvider +) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringProvider.profileCompletionMessage, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + if (missingFields.isNotEmpty()) { + Text( + text = stringProvider.profileMissingFieldsMessage(missingFields.joinToString()), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +@Composable +private fun LoadingDialog(message: String) { + AlertDialog( + onDismissRequest = {}, + confirmButton = {}, + containerColor = Color.Transparent, + text = { + Column( + modifier = Modifier + .padding(24.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = message, + textAlign = TextAlign.Center, + color = Color.White + ) + } + } + ) +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeDefaults.kt new file mode 100644 index 000000000..dcc167d53 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeDefaults.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.MfaFactor +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.validators.VerificationCodeValidator +import com.firebase.ui.auth.compose.mfa.MfaChallengeContentState +import com.firebase.ui.auth.compose.ui.components.VerificationCodeInputField + +@Composable +internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { + val isSms = state.factorType == MfaFactor.Sms + val stringProvider = LocalAuthUIStringProvider.current + val verificationCodeValidator = remember { + VerificationCodeValidator(stringProvider) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = if (isSms) { + val phoneLabel = state.maskedPhoneNumber ?: "" + stringProvider.enterVerificationCodeTitle(phoneLabel) + } else { + stringProvider.mfaStepVerifyFactorTitle + }, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + + if (isSms && state.maskedPhoneNumber != null) { + Text( + text = stringProvider.mfaStepVerifyFactorSmsHelper, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (state.error != null) { + Text( + text = state.error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + VerificationCodeInputField( + modifier = Modifier.align(Alignment.CenterHorizontally), + codeLength = 6, + validator = verificationCodeValidator, + isError = state.error != null, + errorMessage = state.error, + onCodeChange = state.onVerificationCodeChange + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (isSms) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = { state.onResendCodeClick?.invoke() }, + enabled = state.onResendCodeClick != null && !state.isLoading && state.resendTimer == 0 + ) { + Text( + text = if (state.resendTimer > 0) { + val minutes = state.resendTimer / 60 + val seconds = state.resendTimer % 60 + val formatted = "$minutes:${String.format(java.util.Locale.ROOT, "%02d", seconds)}" + stringProvider.resendCodeTimer(formatted) + } else { + stringProvider.resendCode + } + ) + } + + TextButton( + onClick = state.onCancelClick, + enabled = !state.isLoading + ) { + Text(stringProvider.useDifferentMethodAction) + } + } + } else { + OutlinedButton( + onClick = state.onCancelClick, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringProvider.dismissAction) + } + } + + Button( + onClick = state.onVerifyClick, + enabled = state.isValid && !state.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.padding(end = 8.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } + Text(stringProvider.verifyAction) + } + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreen.kt new file mode 100644 index 000000000..0b418a716 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreen.kt @@ -0,0 +1,267 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.screens + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import com.firebase.ui.auth.compose.configuration.MfaFactor +import com.firebase.ui.auth.compose.mfa.MfaChallengeContentState +import com.firebase.ui.auth.compose.mfa.SmsEnrollmentHandler +import com.firebase.ui.auth.compose.mfa.maskPhoneNumber +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.MultiFactorResolver +import com.google.firebase.auth.PhoneAuthOptions +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.PhoneMultiFactorGenerator +import com.google.firebase.auth.PhoneMultiFactorInfo +import com.google.firebase.auth.TotpMultiFactorGenerator +import com.google.firebase.auth.TotpMultiFactorInfo +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import java.util.concurrent.TimeUnit + +/** + * A stateful composable that manages the Multi-Factor Authentication (MFA) challenge flow + * when a user attempts to sign in with MFA enabled. + * + * This screen is displayed when an [AuthState.RequiresMfa] state is encountered during sign-in. + * It handles the verification of the user's second factor (SMS or TOTP) and completes the + * sign-in process upon successful verification. + * + * **Challenge Flow:** + * 1. Screen detects available MFA factors from the resolver + * 2. For SMS: automatically sends verification code and shows masked phone number + * 3. For TOTP: prompts user to enter code from authenticator app + * 4. User enters verification code + * 5. System verifies code and completes sign-in + * + * @param resolver The [MultiFactorResolver] containing MFA session and available factors + * @param auth The [FirebaseAuth] instance + * @param onSuccess Callback invoked when MFA challenge completes successfully + * @param onCancel Callback invoked when user cancels the MFA challenge + * @param onError Callback invoked when an error occurs during verification + * @param content A composable lambda that receives [MfaChallengeContentState] to render custom UI + * + * @since 10.0.0 + */ +@Composable +fun MfaChallengeScreen( + resolver: MultiFactorResolver, + auth: FirebaseAuth, + onSuccess: (AuthResult) -> Unit, + onCancel: () -> Unit, + onError: (Exception) -> Unit = {}, + content: @Composable ((MfaChallengeContentState) -> Unit)? = null +) { + val coroutineScope = rememberCoroutineScope() + + val isLoading = remember { mutableStateOf(false) } + val error = remember { mutableStateOf(null) } + val verificationCode = rememberSaveable { mutableStateOf("") } + val verificationId = remember { mutableStateOf(null) } + val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) } + + // Handle resend timer countdown + LaunchedEffect(resendTimerSeconds.intValue) { + if (resendTimerSeconds.intValue > 0) { + delay(1000) + resendTimerSeconds.intValue-- + } + } + + val hints = resolver.hints + val firstHint = hints.firstOrNull() + + val factorType = remember { + when (firstHint?.factorId) { + PhoneMultiFactorGenerator.FACTOR_ID -> MfaFactor.Sms + TotpMultiFactorGenerator.FACTOR_ID -> MfaFactor.Totp + else -> MfaFactor.Sms + } + } + + val maskedPhoneNumber = remember { + if (firstHint is PhoneMultiFactorInfo) { + maskPhoneNumber(firstHint.phoneNumber) + } else null + } + + LaunchedEffect(firstHint) { + if (firstHint is PhoneMultiFactorInfo) { + isLoading.value = true + try { + val callbacks = object : PhoneAuthProvider.OnVerificationStateChangedCallbacks() { + override fun onVerificationCompleted(credential: com.google.firebase.auth.PhoneAuthCredential) { + coroutineScope.launch { + try { + val assertion = PhoneMultiFactorGenerator.getAssertion(credential) + val result = resolver.resolveSignIn(assertion).await() + onSuccess(result) + } catch (e: Exception) { + error.value = e.message + onError(e) + } + } + } + + override fun onVerificationFailed(e: com.google.firebase.FirebaseException) { + error.value = e.message + onError(e) + isLoading.value = false + } + + override fun onCodeSent( + verId: String, + token: PhoneAuthProvider.ForceResendingToken + ) { + verificationId.value = verId + resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS + isLoading.value = false + } + } + + val options = PhoneAuthOptions.newBuilder() + .setMultiFactorHint(firstHint) + .setMultiFactorSession(resolver.session) + .setCallbacks(callbacks) + .setTimeout(SmsEnrollmentHandler.VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + + PhoneAuthProvider.verifyPhoneNumber(options) + } catch (e: Exception) { + error.value = e.message + onError(e) + isLoading.value = false + } + } + } + + val state = MfaChallengeContentState( + factorType = factorType, + maskedPhoneNumber = maskedPhoneNumber, + isLoading = isLoading.value, + error = error.value, + verificationCode = verificationCode.value, + resendTimer = resendTimerSeconds.intValue, + onVerificationCodeChange = { code -> + verificationCode.value = code + error.value = null + }, + onVerifyClick = { + coroutineScope.launch { + isLoading.value = true + try { + val assertion = when (factorType) { + MfaFactor.Sms -> { + val verId = verificationId.value + require(verId != null) { "No verification ID available" } + val credential = PhoneAuthProvider.getCredential( + verId, + verificationCode.value + ) + PhoneMultiFactorGenerator.getAssertion(credential) + } + MfaFactor.Totp -> { + val totpInfo = firstHint as? TotpMultiFactorInfo + require(totpInfo != null) { "No TOTP info available" } + TotpMultiFactorGenerator.getAssertionForSignIn( + totpInfo.uid, + verificationCode.value + ) + } + } + + val result = resolver.resolveSignIn(assertion).await() + onSuccess(result) + error.value = null + } catch (e: Exception) { + error.value = e.message + onError(e) + } finally { + isLoading.value = false + } + } + }, + onResendCodeClick = if (factorType == MfaFactor.Sms && firstHint is PhoneMultiFactorInfo) { + { + if (resendTimerSeconds.intValue == 0) { + coroutineScope.launch { + isLoading.value = true + try { + val callbacks = object : PhoneAuthProvider.OnVerificationStateChangedCallbacks() { + override fun onVerificationCompleted(credential: com.google.firebase.auth.PhoneAuthCredential) { + coroutineScope.launch { + try { + val assertion = PhoneMultiFactorGenerator.getAssertion(credential) + val result = resolver.resolveSignIn(assertion).await() + onSuccess(result) + } catch (e: Exception) { + error.value = e.message + onError(e) + } + } + } + + override fun onVerificationFailed(e: com.google.firebase.FirebaseException) { + error.value = e.message + onError(e) + isLoading.value = false + } + + override fun onCodeSent( + verId: String, + token: PhoneAuthProvider.ForceResendingToken + ) { + verificationId.value = verId + resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS + error.value = null + isLoading.value = false + } + } + + val options = PhoneAuthOptions.newBuilder() + .setMultiFactorHint(firstHint) + .setMultiFactorSession(resolver.session) + .setCallbacks(callbacks) + .setTimeout(SmsEnrollmentHandler.VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + + PhoneAuthProvider.verifyPhoneNumber(options) + } catch (e: Exception) { + error.value = e.message + onError(e) + isLoading.value = false + } + } + } + } + } else null, + onCancelClick = onCancel + ) + + if (content != null) { + content(state) + } else { + DefaultMfaChallengeContent(state) + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentDefaults.kt new file mode 100644 index 000000000..e08eafa33 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentDefaults.kt @@ -0,0 +1,638 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.MfaFactor +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.compose.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.compose.mfa.MfaEnrollmentStep +import com.firebase.ui.auth.compose.mfa.toMfaErrorMessage +import com.firebase.ui.auth.compose.ui.components.QrCodeImage +import com.firebase.ui.auth.compose.ui.components.ReauthenticationDialog +import com.firebase.ui.auth.compose.ui.screens.phone.EnterPhoneNumberUI +import com.firebase.ui.auth.compose.ui.screens.phone.EnterVerificationCodeUI +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorInfo +import com.google.firebase.auth.PhoneMultiFactorInfo +import com.google.firebase.auth.TotpMultiFactorInfo + +@Composable +internal fun DefaultMfaEnrollmentContent( + state: MfaEnrollmentContentState, + authConfiguration: AuthUIConfiguration, + user: FirebaseUser +) { + val stringProvider = LocalAuthUIStringProvider.current + val snackbarHostState = remember { SnackbarHostState() } + val showReauthDialog = remember { mutableStateOf(false) } + val reauthErrorMessage = remember { mutableStateOf(null) } + val successMessage = remember { mutableStateOf(null) } + + LaunchedEffect(state.error, state.exception) { + val exception = state.exception + when { + exception is FirebaseAuthRecentLoginRequiredException -> { + showReauthDialog.value = true + } + exception != null -> { + snackbarHostState.showSnackbar(exception.toMfaErrorMessage(stringProvider)) + } + !state.error.isNullOrBlank() -> { + snackbarHostState.showSnackbar(state.error!!) + } + } + } + + LaunchedEffect(successMessage.value) { + successMessage.value?.let { message -> + snackbarHostState.showSnackbar(message) + successMessage.value = null + } + } + + LaunchedEffect(reauthErrorMessage.value) { + reauthErrorMessage.value?.let { message -> + snackbarHostState.showSnackbar(message) + reauthErrorMessage.value = null + } + } + + if (showReauthDialog.value) { + ReauthenticationDialog( + user = user, + onDismiss = { + showReauthDialog.value = false + }, + onSuccess = { + showReauthDialog.value = false + successMessage.value = stringProvider.identityVerifiedMessage + }, + onError = { exception -> + reauthErrorMessage.value = when { + exception.message?.contains("password", ignoreCase = true) == true -> + stringProvider.incorrectPasswordError + exception.message?.contains("network", ignoreCase = true) == true -> + stringProvider.noInternet + else -> stringProvider.reauthGenericError + } + } + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + when (state.step) { + MfaEnrollmentStep.SelectFactor -> { + SelectFactorUI( + availableFactors = state.availableFactors, + enrolledFactors = state.enrolledFactors, + onFactorSelected = state.onFactorSelected, + onUnenrollFactor = state.onUnenrollFactor, + onSkipClick = state.onSkipClick, + isLoading = state.isLoading, + error = state.error, + stringProvider = stringProvider + ) + } + + MfaEnrollmentStep.ConfigureSms -> { + state.selectedCountry?.let { country -> + EnterPhoneNumberUI( + configuration = authConfiguration, + isLoading = state.isLoading, + phoneNumber = state.phoneNumber, + selectedCountry = country, + onPhoneNumberChange = state.onPhoneNumberChange, + onCountrySelected = state.onCountrySelected, + onSendCodeClick = state.onSendSmsCodeClick, + title = stringProvider.mfaEnrollmentEnterPhoneNumber + ) + } + } + + MfaEnrollmentStep.ConfigureTotp -> { + ConfigureTotpUI( + totpSecret = state.totpSecret?.sharedSecretKey, + totpQrCodeUrl = state.totpQrCodeUrl, + onContinueClick = state.onContinueToVerifyClick, + onBackClick = state.onBackClick, + isLoading = state.isLoading, + isValid = state.isValid, + error = state.error, + stringProvider = stringProvider + ) + } + + MfaEnrollmentStep.VerifyFactor -> { + when (state.selectedFactor) { + MfaFactor.Sms -> { + val formattedPhone = + "${state.selectedCountry?.dialCode ?: ""}${state.phoneNumber}" + EnterVerificationCodeUI( + configuration = authConfiguration, + isLoading = state.isLoading, + verificationCode = state.verificationCode, + fullPhoneNumber = formattedPhone, + resendTimer = state.resendTimer, + onVerificationCodeChange = state.onVerificationCodeChange, + onVerifyCodeClick = state.onVerifyClick, + onResendCodeClick = state.onResendCodeClick ?: {}, + onChangeNumberClick = state.onBackClick, + title = stringProvider.mfaEnrollmentVerifySmsCode + ) + } + + MfaFactor.Totp -> { + VerifyTotpUI( + verificationCode = state.verificationCode, + onVerificationCodeChange = state.onVerificationCodeChange, + onVerifyClick = state.onVerifyClick, + onBackClick = state.onBackClick, + isLoading = state.isLoading, + isValid = state.isValid, + error = state.error, + stringProvider = stringProvider + ) + } + + null -> Unit + } + } + + MfaEnrollmentStep.ShowRecoveryCodes -> { + ShowRecoveryCodesUI( + recoveryCodes = state.recoveryCodes.orEmpty(), + onDoneClick = state.onCodesSavedClick, + isLoading = state.isLoading, + error = state.error, + stringProvider = stringProvider + ) + } + } + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun SelectFactorUI( + availableFactors: List, + enrolledFactors: List, + onFactorSelected: (MfaFactor) -> Unit, + onUnenrollFactor: (MultiFactorInfo) -> Unit, + onSkipClick: (() -> Unit)?, + isLoading: Boolean, + error: String?, + stringProvider: com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +) { + val enrolledFactorIds = enrolledFactors.mapNotNull { + when (it) { + is PhoneMultiFactorInfo -> MfaFactor.Sms + is TotpMultiFactorInfo -> MfaFactor.Totp + else -> null + } + }.toSet() + + val factorsToEnroll = availableFactors.filter { it !in enrolledFactorIds } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringProvider.mfaManageFactorsTitle) } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringProvider.mfaManageFactorsDescription, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + + if (enrolledFactors.isNotEmpty()) { + Text( + text = stringProvider.mfaActiveMethodsTitle, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + + enrolledFactors.forEach { factorInfo -> + EnrolledFactorItem( + factorInfo = factorInfo, + onRemove = { onUnenrollFactor(factorInfo) }, + enabled = !isLoading, + stringProvider = stringProvider + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + } + + if (factorsToEnroll.isNotEmpty()) { + Text( + text = stringProvider.mfaAddNewMethodTitle, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + + factorsToEnroll.forEach { factor -> + Button( + onClick = { onFactorSelected(factor) }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth() + ) { + when (factor) { + MfaFactor.Sms -> Text(stringProvider.mfaStepConfigureSmsTitle) + MfaFactor.Totp -> Text(stringProvider.mfaStepConfigureTotpTitle) + } + } + } + } else if (enrolledFactors.isNotEmpty()) { + Text( + text = stringProvider.mfaAllMethodsEnrolledMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + + onSkipClick?.let { + TextButton( + onClick = it, + enabled = !isLoading + ) { + Text(stringProvider.skipAction) + } + } + } + } +} + +@Composable +private fun EnrolledFactorItem( + factorInfo: MultiFactorInfo, + onRemove: () -> Unit, + enabled: Boolean, + stringProvider: com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = when (factorInfo) { + is PhoneMultiFactorInfo -> stringProvider.smsAuthenticationLabel + is TotpMultiFactorInfo -> stringProvider.totpAuthenticationLabel + else -> stringProvider.unknownMethodLabel + }, + style = MaterialTheme.typography.titleSmall + ) + Text( + text = when (factorInfo) { + is PhoneMultiFactorInfo -> factorInfo.phoneNumber ?: stringProvider.smsAuthenticationLabel + is TotpMultiFactorInfo -> factorInfo.displayName ?: stringProvider.totpAuthenticationLabel + else -> "" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringProvider.enrolledOnDateLabel( + java.text.SimpleDateFormat( + "MMM dd, yyyy", + java.util.Locale.getDefault() + ).format(java.util.Date(factorInfo.enrollmentTimestamp * 1000)) + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + OutlinedButton( + onClick = onRemove, + enabled = enabled, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringProvider.removeAction) + } + } + } +} + +@Composable +private fun ConfigureTotpUI( + totpSecret: String?, + totpQrCodeUrl: String?, + onContinueClick: () -> Unit, + onBackClick: () -> Unit, + isLoading: Boolean, + isValid: Boolean, + error: String?, + stringProvider: com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +) { + Scaffold { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringProvider.mfaStepConfigureTotpTitle, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Text( + text = stringProvider.setupAuthenticatorDescription, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + + totpQrCodeUrl?.let { url -> + QrCodeImage( + content = url, + size = 220.dp + ) + } + + totpSecret?.let { secret -> + Text( + text = stringProvider.secretKeyLabel, + style = MaterialTheme.typography.titleSmall + ) + Text( + text = secret, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextButton( + onClick = onBackClick, + enabled = !isLoading, + modifier = Modifier.weight(1f) + ) { + Text(stringProvider.backAction) + } + + Button( + onClick = onContinueClick, + enabled = !isLoading && isValid, + modifier = Modifier.weight(1f) + ) { + Text(stringProvider.continueText) + } + } + } + } +} + +@Composable +private fun VerifyTotpUI( + verificationCode: String, + onVerificationCodeChange: (String) -> Unit, + onVerifyClick: () -> Unit, + onBackClick: () -> Unit, + isLoading: Boolean, + isValid: Boolean, + error: String?, + stringProvider: com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +) { + Scaffold { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringProvider.mfaStepVerifyFactorTitle, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Text( + text = stringProvider.mfaStepVerifyFactorTotpHelper, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + + OutlinedTextField( + value = verificationCode, + onValueChange = onVerificationCodeChange, + label = { Text(stringProvider.verificationCodeLabel) }, + enabled = !isLoading, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onBackClick, + enabled = !isLoading, + modifier = Modifier.weight(1f) + ) { + Text(stringProvider.backAction) + } + + Button( + onClick = onVerifyClick, + enabled = !isLoading && isValid, + modifier = Modifier.weight(1f) + ) { + Text(stringProvider.verifyAction) + } + } + } + } +} + +@Composable +private fun ShowRecoveryCodesUI( + recoveryCodes: List, + onDoneClick: () -> Unit, + isLoading: Boolean, + error: String?, + stringProvider: com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +) { + Scaffold { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringProvider.mfaStepShowRecoveryCodesTitle, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Text( + text = stringProvider.mfaStepShowRecoveryCodesHelper, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error + ) + + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + recoveryCodes.forEach { code -> + Text( + text = code, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + + Button( + onClick = onDoneClick, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringProvider.recoveryCodesSavedAction) + } + } + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentScreen.kt new file mode 100644 index 000000000..082a3c980 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentScreen.kt @@ -0,0 +1,396 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.screens + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.platform.LocalContext +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.MfaConfiguration +import com.firebase.ui.auth.compose.configuration.MfaFactor +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.data.CountryData +import com.firebase.ui.auth.compose.data.CountryUtils +import com.firebase.ui.auth.compose.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.compose.mfa.MfaEnrollmentStep +import com.firebase.ui.auth.compose.mfa.SmsEnrollmentHandler +import com.firebase.ui.auth.compose.mfa.SmsEnrollmentSession +import com.firebase.ui.auth.compose.mfa.TotpEnrollmentHandler +import com.firebase.ui.auth.compose.mfa.TotpSecret +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * A stateful composable that manages the Multi-Factor Authentication (MFA) enrollment flow. + * + * This screen handles all steps of MFA enrollment including factor selection, configuration, + * verification, and recovery code display. It uses the provided handlers to communicate with + * Firebase Authentication and exposes state through a content slot for custom UI rendering. + * + * **Enrollment Flow:** + * 1. **SelectFactor** - User chooses between SMS or TOTP + * 2. **ConfigureSms** or **ConfigureTotp** - User sets up their chosen factor + * 3. **VerifyFactor** - User verifies with a code + * 4. **ShowRecoveryCodes** - (Optional) User receives backup codes + * + * @param user The currently authenticated [FirebaseUser] to enroll in MFA + * @param auth The [FirebaseAuth] instance + * @param configuration MFA configuration controlling available factors and behavior + * @param onComplete Callback invoked when enrollment completes successfully + * @param onSkip Callback invoked when user skips enrollment (only if not required) + * @param onError Callback invoked when an error occurs during enrollment + * @param content A composable lambda that receives [MfaEnrollmentContentState] to render custom UI + * + * @since 10.0.0 + */ +@Composable +fun MfaEnrollmentScreen( + user: FirebaseUser, + auth: FirebaseAuth, + configuration: MfaConfiguration, + authConfiguration: AuthUIConfiguration? = null, + onComplete: () -> Unit, + onSkip: () -> Unit = {}, + onError: (Exception) -> Unit = {}, + content: @Composable ((MfaEnrollmentContentState) -> Unit)? = null +) { + val activity = requireNotNull(LocalActivity.current) { + "MfaEnrollmentScreen must be used within an Activity context for SMS verification" + } + val coroutineScope = rememberCoroutineScope() + val applicationContext = LocalContext.current.applicationContext + + val smsHandler = remember(activity, auth, user) { SmsEnrollmentHandler(activity, auth, user) } + val totpHandler = remember(auth, user) { TotpEnrollmentHandler(auth, user) } + + val currentStep = rememberSaveable { mutableStateOf(MfaEnrollmentStep.SelectFactor) } + val selectedFactor = rememberSaveable { mutableStateOf(null) } + val isLoading = remember { mutableStateOf(false) } + val error = remember { mutableStateOf(null) } + val lastException = remember { mutableStateOf(null) } + val enrolledFactors = remember { mutableStateOf(user.multiFactor.enrolledFactors) } + + val phoneNumber = rememberSaveable { mutableStateOf("") } + val selectedCountry = remember { mutableStateOf(CountryUtils.getDefaultCountry()) } + val smsSession = remember { mutableStateOf(null) } + + val totpSecret = remember { mutableStateOf(null) } + val totpQrCodeUrl = remember { mutableStateOf(null) } + + val verificationCode = rememberSaveable { mutableStateOf("") } + + val recoveryCodes = remember { mutableStateOf?>(null) } + + val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) } + + val phoneAuthConfiguration = remember(authConfiguration, applicationContext) { + authConfiguration ?: authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + } + } + } + + // Handle resend timer countdown + LaunchedEffect(resendTimerSeconds.intValue) { + if (resendTimerSeconds.intValue > 0) { + delay(1000) + resendTimerSeconds.intValue-- + } + } + + LaunchedEffect(Unit) { + if (configuration.allowedFactors.size == 1) { + selectedFactor.value = configuration.allowedFactors.first() + when (selectedFactor.value) { + MfaFactor.Sms -> currentStep.value = MfaEnrollmentStep.ConfigureSms + MfaFactor.Totp -> { + currentStep.value = MfaEnrollmentStep.ConfigureTotp + isLoading.value = true + try { + val secret = totpHandler.generateSecret() + totpSecret.value = secret + totpQrCodeUrl.value = secret.generateQrCodeUrl( + accountName = user.email ?: user.phoneNumber ?: "User", + issuer = auth.app.name + ) + error.value = null + lastException.value = null + } catch (e: Exception) { + error.value = e.message + lastException.value = e + onError(e) + } finally { + isLoading.value = false + } + } + null -> {} + } + } + } + + val state = MfaEnrollmentContentState( + step = currentStep.value, + isLoading = isLoading.value, + error = error.value, + exception = lastException.value, + onBackClick = { + when (currentStep.value) { + MfaEnrollmentStep.SelectFactor -> {} + MfaEnrollmentStep.ConfigureSms, MfaEnrollmentStep.ConfigureTotp -> { + currentStep.value = MfaEnrollmentStep.SelectFactor + selectedFactor.value = null + phoneNumber.value = "" + totpSecret.value = null + totpQrCodeUrl.value = null + } + MfaEnrollmentStep.VerifyFactor -> { + verificationCode.value = "" + when (selectedFactor.value) { + MfaFactor.Sms -> currentStep.value = MfaEnrollmentStep.ConfigureSms + MfaFactor.Totp -> currentStep.value = MfaEnrollmentStep.ConfigureTotp + null -> currentStep.value = MfaEnrollmentStep.SelectFactor + } + } + MfaEnrollmentStep.ShowRecoveryCodes -> { + currentStep.value = MfaEnrollmentStep.VerifyFactor + } + } + error.value = null + lastException.value = null + }, + availableFactors = configuration.allowedFactors, + enrolledFactors = enrolledFactors.value, + onFactorSelected = { factor -> + selectedFactor.value = factor + when (factor) { + MfaFactor.Sms -> { + currentStep.value = MfaEnrollmentStep.ConfigureSms + } + MfaFactor.Totp -> { + currentStep.value = MfaEnrollmentStep.ConfigureTotp + coroutineScope.launch { + isLoading.value = true + try { + val secret = totpHandler.generateSecret() + totpSecret.value = secret + totpQrCodeUrl.value = secret.generateQrCodeUrl( + accountName = user.email ?: user.phoneNumber ?: "User", + issuer = auth.app.name + ) + error.value = null + lastException.value = null + } catch (e: Exception) { + error.value = e.message + lastException.value = e + onError(e) + } finally { + isLoading.value = false + } + } + } + } + }, + onUnenrollFactor = { factorInfo -> + coroutineScope.launch { + isLoading.value = true + try { + user.multiFactor.unenroll(factorInfo).addOnCompleteListener { task -> + if (task.isSuccessful) { + // Refresh the enrolled factors list + enrolledFactors.value = user.multiFactor.enrolledFactors + error.value = null + } else { + error.value = task.exception?.message + task.exception?.let { + lastException.value = it + onError(it) + } + } + isLoading.value = false + } + } catch (e: Exception) { + error.value = e.message + lastException.value = e + onError(e) + isLoading.value = false + } + } + }, + onSkipClick = if (!configuration.requireEnrollment) { + { onSkip() } + } else null, + phoneNumber = phoneNumber.value, + onPhoneNumberChange = { phone -> + phoneNumber.value = phone + error.value = null + }, + selectedCountry = selectedCountry.value, + onCountrySelected = { country -> + selectedCountry.value = country + }, + onSendSmsCodeClick = { + coroutineScope.launch { + isLoading.value = true + try { + val fullPhoneNumber = "${selectedCountry.value.dialCode}${phoneNumber.value}" + val session = smsHandler.sendVerificationCode(fullPhoneNumber) + smsSession.value = session + currentStep.value = MfaEnrollmentStep.VerifyFactor + resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS + error.value = null + lastException.value = null + } catch (e: Exception) { + error.value = e.message + lastException.value = e + onError(e) + } finally { + isLoading.value = false + } + } + }, + totpSecret = totpSecret.value, + totpQrCodeUrl = totpQrCodeUrl.value, + onContinueToVerifyClick = { + currentStep.value = MfaEnrollmentStep.VerifyFactor + }, + verificationCode = verificationCode.value, + onVerificationCodeChange = { code -> + verificationCode.value = code + error.value = null + }, + onVerifyClick = { + coroutineScope.launch { + isLoading.value = true + try { + when (selectedFactor.value) { + MfaFactor.Sms -> { + val session = smsSession.value + if (session != null) { + smsHandler.enrollWithVerificationCode( + session = session, + verificationCode = verificationCode.value, + displayName = "SMS" + ) + } else { + throw IllegalStateException("No SMS session available") + } + } + MfaFactor.Totp -> { + val secret = totpSecret.value + if (secret != null) { + totpHandler.enrollWithVerificationCode( + totpSecret = secret, + verificationCode = verificationCode.value, + displayName = "Authenticator App" + ) + } else { + throw IllegalStateException("No TOTP secret available") + } + } + null -> throw IllegalStateException("No factor selected") + } + + // Refresh enrolled factors after successful enrollment + enrolledFactors.value = user.multiFactor.enrolledFactors + + if (configuration.enableRecoveryCodes) { + recoveryCodes.value = generateRecoveryCodes() + currentStep.value = MfaEnrollmentStep.ShowRecoveryCodes + } else { + onComplete() + } + error.value = null + lastException.value = null + } catch (e: Exception) { + error.value = e.message + lastException.value = e + onError(e) + } finally { + isLoading.value = false + } + } + }, + selectedFactor = selectedFactor.value, + resendTimer = resendTimerSeconds.intValue, + onResendCodeClick = if (selectedFactor.value == MfaFactor.Sms) { + { + if (resendTimerSeconds.intValue == 0) { + coroutineScope.launch { + val session = smsSession.value + if (session != null) { + isLoading.value = true + try { + val newSession = smsHandler.resendVerificationCode(session) + smsSession.value = newSession + resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS + error.value = null + lastException.value = null + } catch (e: Exception) { + error.value = e.message + lastException.value = e + onError(e) + } finally { + isLoading.value = false + } + } + } + } + } + } else null, + recoveryCodes = recoveryCodes.value, + onCodesSavedClick = { + onComplete() + } + ) + + if (content != null) { + content(state) + } else { + DefaultMfaEnrollmentContent( + state = state, + authConfiguration = phoneAuthConfiguration, + user = user + ) + } +} + +/** + * Generates placeholder recovery codes. + * In a production implementation, these would come from Firebase or a backend service. + */ +private fun generateRecoveryCodes(): List { + return List(10) { index -> + List(4) { (0..9).random() } + .joinToString("") + .let { if (index % 2 == 0) "$it-${(1000..9999).random()}" else it } + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt new file mode 100644 index 000000000..11db03107 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt @@ -0,0 +1,394 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.screens + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.createOrLinkUserWithEmailAndPassword +import com.firebase.ui.auth.compose.configuration.auth_provider.sendPasswordResetEmail +import com.firebase.ui.auth.compose.configuration.auth_provider.sendSignInLinkToEmail +import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailAndPassword +import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailLink +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.compose.ui.components.LocalTopLevelDialogController +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import kotlinx.coroutines.launch + +enum class EmailAuthMode { + SignIn, + EmailLinkSignIn, + SignUp, + ResetPassword, +} + +/** + * A class passed to the content slot, containing all the necessary information to render custom + * UIs for sign-in, sign-up, and password reset flows. + * + * @param mode An enum representing the current UI mode. Use a when expression on this to render + * the correct screen. + * @param isLoading true when an asynchronous operation (like signing in or sending an email) + * is in progress. + * @param error An optional error message to display to the user. + * @param email The current value of the email input field. + * @param onEmailChange (Modes: [EmailAuthMode.SignIn], [EmailAuthMode.SignUp], + * [EmailAuthMode.ResetPassword]) A callback to be invoked when the email input changes. + * @param password An optional custom layout composable for the provider buttons. + * @param onPasswordChange (Modes: [EmailAuthMode.SignIn], [EmailAuthMode.SignUp]) The current + * value of the password input field. + * @param confirmPassword (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when the password + * input changes. + * @param onConfirmPasswordChange (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when + * the password confirmation input changes. + * @param displayName (Mode: [EmailAuthMode.SignUp]) The current value of the display name field. + * @param onDisplayNameChange (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when the + * display name input changes. + * @param onSignInClick (Mode: [EmailAuthMode.SignIn]) A callback to be invoked to attempt a + * sign-in with the provided credentials. + * @param onSignUpClick (Mode: [EmailAuthMode.SignUp]) A callback to be invoked to attempt to + * create a new account. + * @param onSendResetLinkClick (Mode: [EmailAuthMode.ResetPassword]) A callback to be invoked to + * send a password reset email. + * @param resetLinkSent (Mode: [EmailAuthMode.ResetPassword]) true after the password reset link + * has been successfully sent. + * @param emailSignInLinkSent (Mode: [EmailAuthMode.SignIn]) true after the email sign in link has + * been successfully sent. + * @param onGoToSignUp A callback to switch the UI to the SignUp mode. + * @param onGoToSignIn A callback to switch the UI to the SignIn mode. + * @param onGoToResetPassword A callback to switch the UI to the ResetPassword mode. + */ +class EmailAuthContentState( + val mode: EmailAuthMode, + val isLoading: Boolean = false, + val error: String? = null, + val email: String, + val onEmailChange: (String) -> Unit, + val password: String, + val onPasswordChange: (String) -> Unit, + val confirmPassword: String, + val onConfirmPasswordChange: (String) -> Unit, + val displayName: String, + val onDisplayNameChange: (String) -> Unit, + val onSignInClick: () -> Unit, + val onSignInEmailLinkClick: () -> Unit, + val onSignUpClick: () -> Unit, + val onSendResetLinkClick: () -> Unit, + val resetLinkSent: Boolean = false, + val emailSignInLinkSent: Boolean = false, + val onGoToSignUp: () -> Unit, + val onGoToSignIn: () -> Unit, + val onGoToResetPassword: () -> Unit, + val onGoToEmailLinkSignIn: () -> Unit, +) + +/** + * A stateful composable that manages the logic for all email-based authentication flows, + * including sign-in, sign-up, and password reset. It exposes the state for the current mode to + * a custom UI via a trailing lambda (slot), allowing for complete visual customization. + * + * @param configuration + * @param onSuccess + * @param onError + * @param onCancel + * @param content + */ +@Composable +fun EmailAuthScreen( + context: Context, + configuration: AuthUIConfiguration, + authUI: FirebaseAuthUI, + credentialForLinking: AuthCredential? = null, + emailLinkFromDifferentDevice: String? = null, + onSuccess: (AuthResult) -> Unit, + onError: (AuthException) -> Unit, + onCancel: () -> Unit, + content: @Composable ((EmailAuthContentState) -> Unit)? = null, +) { + val provider = configuration.providers.filterIsInstance().first() + val stringProvider = LocalAuthUIStringProvider.current + val dialogController = LocalTopLevelDialogController.current + val coroutineScope = rememberCoroutineScope() + + // Start in EmailLinkSignIn mode if coming from cross-device flow + val initialMode = if (emailLinkFromDifferentDevice != null && provider.isEmailLinkSignInEnabled) { + EmailAuthMode.EmailLinkSignIn + } else { + EmailAuthMode.SignIn + } + val mode = rememberSaveable { mutableStateOf(initialMode) } + val displayNameValue = rememberSaveable { mutableStateOf("") } + val emailTextValue = rememberSaveable { mutableStateOf("") } + val passwordTextValue = rememberSaveable { mutableStateOf("") } + val confirmPasswordTextValue = rememberSaveable { mutableStateOf("") } + + // Used for clearing text fields when switching EmailAuthMode changes + val textValues = listOf( + displayNameValue, + emailTextValue, + passwordTextValue, + confirmPasswordTextValue + ) + + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + val isLoading = authState is AuthState.Loading + val authCredentialForLinking = remember { credentialForLinking } + val errorMessage = + if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null + val resetLinkSent = authState is AuthState.PasswordResetLinkSent + val emailSignInLinkSent = authState is AuthState.EmailSignInLinkSent + + LaunchedEffect(authState) { + Log.d("EmailAuthScreen", "Current state: $authState") + when (val state = authState) { + is AuthState.Success -> { + state.result?.let { result -> + onSuccess(result) + } + } + + is AuthState.Error -> { + val exception = AuthException.from(state.exception) + onError(exception) + dialogController?.showErrorDialog( + exception = exception, + onRetry = { ex -> + when (ex) { + is AuthException.InvalidCredentialsException -> { + // User can retry sign in with corrected credentials + } + + is AuthException.EmailAlreadyInUseException -> { + // Switch to sign-in mode + mode.value = EmailAuthMode.SignIn + } + + else -> Unit + } + }, + onDismiss = { + // Dialog dismissed + } + ) + } + + is AuthState.Cancelled -> { + onCancel() + } + + else -> Unit + } + } + + val state = EmailAuthContentState( + mode = mode.value, + displayName = displayNameValue.value, + email = emailTextValue.value, + password = passwordTextValue.value, + confirmPassword = confirmPasswordTextValue.value, + isLoading = isLoading, + error = errorMessage, + resetLinkSent = resetLinkSent, + emailSignInLinkSent = emailSignInLinkSent, + onEmailChange = { email -> + emailTextValue.value = email + }, + onPasswordChange = { password -> + passwordTextValue.value = password + }, + onConfirmPasswordChange = { confirmPassword -> + confirmPasswordTextValue.value = confirmPassword + }, + onDisplayNameChange = { displayName -> + displayNameValue.value = displayName + }, + onSignInClick = { + coroutineScope.launch { + try { + authUI.signInWithEmailAndPassword( + context = context, + config = configuration, + email = emailTextValue.value, + password = passwordTextValue.value, + credentialForLinking = authCredentialForLinking, + ) + } catch (e: Exception) { + onError(AuthException.from(e)) + } + } + }, + onSignInEmailLinkClick = { + coroutineScope.launch { + try { + if (emailLinkFromDifferentDevice != null) { + authUI.signInWithEmailLink( + context = context, + config = configuration, + provider = provider, + email = emailTextValue.value, + emailLink = emailLinkFromDifferentDevice, + ) + } else { + authUI.sendSignInLinkToEmail( + context = context, + config = configuration, + provider = provider, + email = emailTextValue.value, + credentialForLinking = authCredentialForLinking, + ) + } + } catch (e: Exception) { + onError(AuthException.from(e)) + } + } + }, + onSignUpClick = { + coroutineScope.launch { + try { + authUI.createOrLinkUserWithEmailAndPassword( + context = context, + config = configuration, + provider = provider, + name = displayNameValue.value, + email = emailTextValue.value, + password = passwordTextValue.value, + ) + } catch (e: Exception) { + + } + } + }, + onSendResetLinkClick = { + coroutineScope.launch { + try { + authUI.sendPasswordResetEmail( + email = emailTextValue.value, + actionCodeSettings = configuration.passwordResetActionCodeSettings, + ) + } catch (e: Exception) { + + } + } + }, + onGoToSignUp = { + textValues.forEach { it.value = "" } + mode.value = EmailAuthMode.SignUp + }, + onGoToSignIn = { + textValues.forEach { it.value = "" } + mode.value = EmailAuthMode.SignIn + }, + onGoToResetPassword = { + textValues.forEach { it.value = "" } + mode.value = EmailAuthMode.ResetPassword + }, + onGoToEmailLinkSignIn = { + textValues.forEach { it.value = "" } + mode.value = EmailAuthMode.EmailLinkSignIn + }, + ) + + if (content != null) { + content(state) + } else { + DefaultEmailAuthContent( + configuration = configuration, + state = state, + onCancel = onCancel + ) + } +} + +@Composable +private fun DefaultEmailAuthContent( + configuration: AuthUIConfiguration, + state: EmailAuthContentState, + onCancel: () -> Unit, +) { + when (state.mode) { + EmailAuthMode.SignIn -> { + SignInUI( + configuration = configuration, + email = state.email, + isLoading = state.isLoading, + emailSignInLinkSent = state.emailSignInLinkSent, + password = state.password, + onEmailChange = state.onEmailChange, + onPasswordChange = state.onPasswordChange, + onSignInClick = state.onSignInClick, + onGoToSignUp = state.onGoToSignUp, + onGoToResetPassword = state.onGoToResetPassword, + onGoToEmailLinkSignIn = state.onGoToEmailLinkSignIn, + onNavigateBack = onCancel + ) + } + + EmailAuthMode.EmailLinkSignIn -> { + SignInEmailLinkUI( + configuration = configuration, + email = state.email, + isLoading = state.isLoading, + emailSignInLinkSent = state.emailSignInLinkSent, + onEmailChange = state.onEmailChange, + onSignInWithEmailLink = state.onSignInEmailLinkClick, + onGoToSignIn = state.onGoToSignIn, + onGoToResetPassword = state.onGoToResetPassword, + onNavigateBack = onCancel + ) + } + + EmailAuthMode.SignUp -> { + SignUpUI( + configuration = configuration, + isLoading = state.isLoading, + displayName = state.displayName, + email = state.email, + password = state.password, + confirmPassword = state.confirmPassword, + onDisplayNameChange = state.onDisplayNameChange, + onEmailChange = state.onEmailChange, + onPasswordChange = state.onPasswordChange, + onConfirmPasswordChange = state.onConfirmPasswordChange, + onSignUpClick = state.onSignUpClick, + onGoToSignIn = state.onGoToSignIn + ) + } + + EmailAuthMode.ResetPassword -> { + ResetPasswordUI( + configuration = configuration, + isLoading = state.isLoading, + email = state.email, + resetLinkSent = state.resetLinkSent, + onEmailChange = state.onEmailChange, + onSendResetLink = state.onSendResetLinkClick, + onGoToSignIn = state.onGoToSignIn + ) + } + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/ResetPasswordUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/ResetPasswordUI.kt new file mode 100644 index 000000000..1f8ba5e66 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/ResetPasswordUI.kt @@ -0,0 +1,210 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.ui.components.AuthTextField +import com.firebase.ui.auth.compose.ui.components.TermsAndPrivacyForm + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ResetPasswordUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + email: String, + resetLinkSent: Boolean, + onEmailChange: (String) -> Unit, + onSendResetLink: () -> Unit, + onGoToSignIn: () -> Unit, +) { + + val context = LocalContext.current + val stringProvider = LocalAuthUIStringProvider.current + val emailValidator = remember { + EmailValidator(stringProvider) + } + + val isFormValid = remember(email) { + derivedStateOf { emailValidator.validate(email) } + } + + val isDialogVisible = remember(resetLinkSent) { mutableStateOf(resetLinkSent) } + + if (isDialogVisible.value) { + AlertDialog( + title = { + Text( + text = stringProvider.recoverPasswordLinkSentDialogTitle, + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Text( + text = stringProvider.recoverPasswordLinkSentDialogBody(email), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start + ) + }, + confirmButton = { + TextButton( + onClick = { + onGoToSignIn() + isDialogVisible.value = false + } + ) { + Text(stringProvider.dismissAction) + } + }, + onDismissRequest = { + isDialogVisible.value = false + }, + ) + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(stringProvider.recoverPasswordPageTitle) + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + AuthTextField( + value = email, + validator = emailValidator, + enabled = !isLoading, + label = { + Text(stringProvider.emailHint) + }, + onValueChange = { text -> + onEmailChange(text) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .align(Alignment.End), + ) { + Button( + onClick = { + onGoToSignIn() + }, + enabled = !isLoading, + ) { + Text(stringProvider.signInDefault.uppercase()) + } + Spacer(modifier = Modifier.width(16.dp)) + Button( + onClick = { + onSendResetLink() + }, + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text(stringProvider.sendButtonText.uppercase()) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewResetPasswordUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = false, + isEmailLinkForceSameDeviceEnabled = true, + emailLinkActionCodeSettings = null, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + + AuthUITheme { + ResetPasswordUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + email = "", + isLoading = false, + resetLinkSent = true, + onEmailChange = { email -> }, + onSendResetLink = {}, + onGoToSignIn = {}, + ) + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInEmailLinkUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInEmailLinkUI.kt new file mode 100644 index 000000000..f06667cd2 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInEmailLinkUI.kt @@ -0,0 +1,275 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.ui.components.AuthTextField +import com.firebase.ui.auth.compose.ui.components.TermsAndPrivacyForm +import com.google.firebase.auth.actionCodeSettings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignInEmailLinkUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + emailSignInLinkSent: Boolean, + email: String, + onEmailChange: (String) -> Unit, + onSignInWithEmailLink: () -> Unit, + onGoToSignIn: () -> Unit, + onGoToResetPassword: () -> Unit, + onNavigateBack: (() -> Unit)? = null, +) { + val provider = configuration.providers.filterIsInstance().first() + val stringProvider = LocalAuthUIStringProvider.current + val emailValidator = remember { EmailValidator(stringProvider) } + + val isFormValid = remember(email) { + derivedStateOf { + emailValidator.validate(email) + } + } + + if (provider.isEmailLinkSignInEnabled) { + val isDialogVisible = + remember(emailSignInLinkSent) { mutableStateOf(emailSignInLinkSent) } + + if (isDialogVisible.value) { + AlertDialog( + title = { + Text( + text = stringProvider.emailSignInLinkSentDialogTitle, + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Text( + text = stringProvider.emailSignInLinkSentDialogBody(email), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start + ) + }, + confirmButton = { + TextButton( + onClick = { + isDialogVisible.value = false + } + ) { + Text(stringProvider.dismissAction) + } + }, + onDismissRequest = { + isDialogVisible.value = false + }, + ) + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text( + text = stringProvider.signInDefault, + modifier = Modifier.semantics { heading() } + ) + }, + navigationIcon = { + if (onNavigateBack != null) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringProvider.backAction + ) + } + } + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + AuthTextField( + value = email, + validator = emailValidator, + enabled = !isLoading, + label = { + Text(stringProvider.emailHint) + }, + onValueChange = { text -> + onEmailChange(text) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + TextButton( + modifier = Modifier + .align(Alignment.Start), + onClick = { + onGoToResetPassword() + }, + enabled = !isLoading, + contentPadding = PaddingValues.Zero + ) { + Text( + modifier = modifier, + text = stringProvider.troubleSigningIn, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + onSignInWithEmailLink() + }, + modifier = Modifier.align(Alignment.End), + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp) + ) + } else { + Text(stringProvider.signInDefault.uppercase()) + } + } + + // Show toggle to go back to password mode + Spacer(modifier = Modifier.height(64.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text( + text = stringProvider.orContinueWith, + modifier = Modifier.padding(horizontal = 8.dp), + style = MaterialTheme.typography.bodySmall + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = { + onGoToSignIn() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text(stringProvider.signInWithPassword.uppercase()) + } + + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewSignInEmailLinkUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = true, + isEmailLinkForceSameDeviceEnabled = true, + emailLinkActionCodeSettings = actionCodeSettings { + url = "https://fake-project-id.firebaseapp.com" + handleCodeInApp = true + setAndroidPackageName( + "fake.project.id", + true, + null + ) + }, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + + AuthUITheme { + SignInEmailLinkUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + email = "", + isLoading = false, + emailSignInLinkSent = false, + onEmailChange = { email -> }, + onSignInWithEmailLink = {}, + onGoToSignIn = {}, + onGoToResetPassword = {}, + ) + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInUI.kt new file mode 100644 index 000000000..698b99def --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInUI.kt @@ -0,0 +1,278 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator +import com.firebase.ui.auth.compose.ui.components.AuthTextField +import com.firebase.ui.auth.compose.ui.components.TermsAndPrivacyForm + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignInUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + emailSignInLinkSent: Boolean, + email: String, + password: String, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onSignInClick: () -> Unit, + onGoToSignUp: () -> Unit, + onGoToResetPassword: () -> Unit, + onGoToEmailLinkSignIn: () -> Unit, + onNavigateBack: (() -> Unit)? = null, +) { + val provider = configuration.providers.filterIsInstance().first() + val stringProvider = LocalAuthUIStringProvider.current + val emailValidator = remember { EmailValidator(stringProvider) } + val passwordValidator = remember { + PasswordValidator(stringProvider = stringProvider, rules = emptyList()) + } + + val isFormValid = remember(email, password) { + derivedStateOf { + listOf( + emailValidator.validate(email), + passwordValidator.validate(password) + ).all { it } + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text( + text = stringProvider.signInDefault, + modifier = Modifier.semantics { heading() } + ) + }, + navigationIcon = { + if (onNavigateBack != null) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringProvider.backAction + ) + } + } + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + AuthTextField( + value = email, + validator = emailValidator, + enabled = !isLoading, + label = { + Text(stringProvider.emailHint) + }, + onValueChange = { text -> + onEmailChange(text) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = password, + validator = passwordValidator, + enabled = !isLoading, + isSecureTextField = true, + label = { + Text(stringProvider.passwordHint) + }, + onValueChange = { text -> + onPasswordChange(text) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton( + modifier = Modifier + .align(Alignment.Start), + onClick = { + onGoToResetPassword() + }, + enabled = !isLoading, + contentPadding = PaddingValues.Zero + ) { + Text( + modifier = modifier, + text = stringProvider.troubleSigningIn, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .align(Alignment.End), + ) { + Button( + onClick = { + onGoToSignUp() + }, + enabled = !isLoading, + ) { + Text(stringProvider.signupPageTitle.uppercase()) + } + Spacer(modifier = Modifier.width(16.dp)) + Button( + onClick = { + // TODO(demolaf): When signIn is fired if Exception is UserNotFound + // then we check if provider.isNewAccountsAllowed then we show signUp + // else we show an error dialog stating signup is not allowed + onSignInClick() + }, + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text(stringProvider.signInDefault.uppercase()) + } + } + } + + // Show toggle to email link sign-in + if (provider.isEmailLinkSignInEnabled) { + Spacer(modifier = Modifier.height(64.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text( + text = stringProvider.orContinueWith, + modifier = Modifier.padding(horizontal = 8.dp), + style = MaterialTheme.typography.bodySmall + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = { + onGoToEmailLinkSignIn() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text(stringProvider.signInWithEmailLink.uppercase()) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewSignInUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = false, + isEmailLinkForceSameDeviceEnabled = true, + emailLinkActionCodeSettings = null, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + + AuthUITheme { + SignInUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + email = "", + password = "", + isLoading = false, + emailSignInLinkSent = false, + onEmailChange = { email -> }, + onPasswordChange = { password -> }, + onSignInClick = {}, + onGoToSignUp = {}, + onGoToResetPassword = {}, + onGoToEmailLinkSignIn = {}, + ) + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignUpUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignUpUI.kt new file mode 100644 index 000000000..655b2c5f6 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignUpUI.kt @@ -0,0 +1,245 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.configuration.validators.GeneralFieldValidator +import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator +import com.firebase.ui.auth.compose.ui.components.AuthTextField +import com.firebase.ui.auth.compose.ui.components.TermsAndPrivacyForm + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignUpUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + displayName: String, + email: String, + password: String, + confirmPassword: String, + onDisplayNameChange: (String) -> Unit, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onConfirmPasswordChange: (String) -> Unit, + onGoToSignIn: () -> Unit, + onSignUpClick: () -> Unit, +) { + val provider = configuration.providers.filterIsInstance().first() + val context = LocalContext.current + val stringProvider = LocalAuthUIStringProvider.current + val displayNameValidator = remember { GeneralFieldValidator(stringProvider) } + val emailValidator = remember { EmailValidator(stringProvider) } + val passwordValidator = remember { + PasswordValidator( + stringProvider = stringProvider, + rules = provider.passwordValidationRules + ) + } + val confirmPasswordValidator = remember(password) { + GeneralFieldValidator( + stringProvider = stringProvider, + isValid = { value -> + value == password + }, + customMessage = stringProvider.passwordsDoNotMatch + ) + } + + val isFormValid = remember(displayName, email, password, confirmPassword) { + derivedStateOf { + listOf( + displayNameValidator.validate(displayName), + emailValidator.validate(email), + passwordValidator.validate(password), + confirmPasswordValidator.validate(confirmPassword) + ).all { it } + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(stringProvider.signupPageTitle) + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + if (provider.isDisplayNameRequired) { + AuthTextField( + value = displayName, + validator = displayNameValidator, + enabled = !isLoading, + label = { + Text(stringProvider.nameHint) + }, + onValueChange = { text -> + onDisplayNameChange(text) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + } + AuthTextField( + value = email, + validator = emailValidator, + enabled = !isLoading, + label = { + Text(stringProvider.emailHint) + }, + onValueChange = { text -> + onEmailChange(text) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = password, + validator = passwordValidator, + enabled = !isLoading, + isSecureTextField = true, + label = { + Text(stringProvider.passwordHint) + }, + onValueChange = { text -> + onPasswordChange(text) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = confirmPassword, + validator = confirmPasswordValidator, + enabled = !isLoading, + isSecureTextField = true, + label = { + Text(stringProvider.confirmPasswordHint) + }, + onValueChange = { text -> + onConfirmPasswordChange(text) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .align(Alignment.End), + ) { + Button( + onClick = { + onGoToSignIn() + }, + enabled = !isLoading, + ) { + Text(stringProvider.signInDefault.uppercase()) + } + Spacer(modifier = Modifier.width(16.dp)) + Button( + onClick = { + onSignUpClick() + }, + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text(stringProvider.signupPageTitle.uppercase()) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewSignUpUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = false, + isEmailLinkForceSameDeviceEnabled = true, + emailLinkActionCodeSettings = null, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + + AuthUITheme { + SignUpUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + isLoading = false, + displayName = "", + email = "", + password = "", + confirmPassword = "", + onDisplayNameChange = { name -> }, + onEmailChange = { email -> }, + onPasswordChange = { password -> }, + onConfirmPasswordChange = { confirmPassword -> }, + onSignUpClick = {}, + onGoToSignIn = {} + ) + } +} \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/EnterPhoneNumberUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/EnterPhoneNumberUI.kt new file mode 100644 index 000000000..2a56ffde4 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/EnterPhoneNumberUI.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.screens.phone + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckBox +import androidx.compose.material.icons.filled.CheckBoxOutlineBlank +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.configuration.validators.PhoneNumberValidator +import com.firebase.ui.auth.compose.data.CountryData +import com.firebase.ui.auth.compose.data.CountryUtils +import com.firebase.ui.auth.compose.ui.components.AuthTextField +import com.firebase.ui.auth.compose.ui.components.CountrySelector +import com.firebase.ui.auth.compose.ui.components.TermsAndPrivacyForm + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EnterPhoneNumberUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + phoneNumber: String, + selectedCountry: CountryData, + onPhoneNumberChange: (String) -> Unit, + onCountrySelected: (CountryData) -> Unit, + onSendCodeClick: () -> Unit, + title: String? = null, + onNavigateBack: (() -> Unit)? = null, +) { + val context = LocalContext.current + val provider = configuration.providers.filterIsInstance().first() + val stringProvider = LocalAuthUIStringProvider.current + val phoneNumberValidator = remember(selectedCountry) { + PhoneNumberValidator(stringProvider, selectedCountry) + } + + val isFormValid = remember(selectedCountry, phoneNumber) { + derivedStateOf { + phoneNumberValidator.validate(phoneNumber) + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(title ?: stringProvider.signInWithPhone) + }, + navigationIcon = { + if (onNavigateBack != null) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringProvider.backAction + ) + } + } + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text(stringProvider.enterPhoneNumberTitle) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = phoneNumber, + validator = phoneNumberValidator, + enabled = !isLoading, + label = { + Text(stringProvider.phoneNumberHint) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone + ), + leadingIcon = { + CountrySelector( + selectedCountry = selectedCountry, + onCountrySelected = onCountrySelected, + enabled = !isLoading, + allowedCountries = provider.allowedCountries?.toSet() + ) + }, + onValueChange = { + onPhoneNumberChange(it) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .align(Alignment.End), + ) { + Button( + onClick = onSendCodeClick, + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text(stringProvider.sendVerificationCode.uppercase()) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewEnterPhoneNumberUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + + AuthUITheme { + EnterPhoneNumberUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + isLoading = false, + phoneNumber = "", + selectedCountry = CountryUtils.getDefaultCountry(), + onPhoneNumberChange = {}, + onCountrySelected = {}, + onSendCodeClick = {}, + ) + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/EnterVerificationCodeUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/EnterVerificationCodeUI.kt new file mode 100644 index 000000000..c22562168 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/EnterVerificationCodeUI.kt @@ -0,0 +1,211 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.screens.phone + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.configuration.validators.VerificationCodeValidator +import com.firebase.ui.auth.compose.ui.components.TermsAndPrivacyForm +import com.firebase.ui.auth.compose.ui.components.VerificationCodeInputField +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EnterVerificationCodeUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + verificationCode: String, + fullPhoneNumber: String, + resendTimer: Int, + onVerificationCodeChange: (String) -> Unit, + onVerifyCodeClick: () -> Unit, + onResendCodeClick: () -> Unit, + onChangeNumberClick: () -> Unit, + title: String? = null, +) { + val context = LocalContext.current + val stringProvider = LocalAuthUIStringProvider.current + val verificationCodeValidator = remember { + VerificationCodeValidator(stringProvider) + } + + val isFormValid = remember(verificationCode) { + derivedStateOf { + verificationCodeValidator.validate(verificationCode) + } + } + + val resendEnabled = resendTimer == 0 && !isLoading + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(title ?: stringProvider.verifyPhoneNumber) + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringProvider.enterVerificationCodeTitle(fullPhoneNumber), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + modifier = Modifier.align(Alignment.Start), + onClick = onChangeNumberClick, + enabled = !isLoading, + contentPadding = PaddingValues.Zero + ) { + Text( + text = stringProvider.changePhoneNumber, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + Spacer(modifier = Modifier.height(16.dp)) + + VerificationCodeInputField( + modifier = Modifier.align(Alignment.CenterHorizontally), + validator = verificationCodeValidator, + onCodeChange = onVerificationCodeChange + ) + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + modifier = Modifier.align(Alignment.Start), + onClick = onResendCodeClick, + enabled = resendEnabled, + contentPadding = PaddingValues.Zero + ) { + Text( + text = if (resendTimer > 0) { + val minutes = resendTimer / 60 + val seconds = resendTimer % 60 + val timeFormatted = + "$minutes:${String.format(Locale.ROOT, "%02d", seconds)}" + stringProvider.resendCodeTimer(timeFormatted) + } else { + stringProvider.resendCode + }, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = if (resendEnabled) TextDecoration.Underline else TextDecoration.None + ) + } + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .align(Alignment.End), + ) { + Button( + onClick = onVerifyCodeClick, + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text(stringProvider.verifyPhoneNumber.uppercase()) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewEnterVerificationCodeUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + + AuthUITheme { + EnterVerificationCodeUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + isLoading = false, + verificationCode = "", + fullPhoneNumber = "+1234567890", + resendTimer = 30, + onVerificationCodeChange = {}, + onVerifyCodeClick = {}, + onResendCodeClick = {}, + onChangeNumberClick = {}, + ) + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/PhoneAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/PhoneAuthScreen.kt new file mode 100644 index 000000000..47998014c --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/PhoneAuthScreen.kt @@ -0,0 +1,351 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.screens.phone + +import android.content.Context +import android.util.Log +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithPhoneAuthCredential +import com.firebase.ui.auth.compose.configuration.auth_provider.submitVerificationCode +import com.firebase.ui.auth.compose.configuration.auth_provider.verifyPhoneNumber +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.compose.data.CountryData +import com.firebase.ui.auth.compose.data.CountryUtils +import com.firebase.ui.auth.compose.ui.components.LocalTopLevelDialogController +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.PhoneAuthProvider +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +enum class PhoneAuthStep { + /** + * An enum representing a view requiring a phone number which needs to be entered. + */ + EnterPhoneNumber, + + /** + * An enum representing a view requiring a phone number verification code which needs to + * be entered. + */ + EnterVerificationCode +} + +/** + * A class passed to the content slot, containing all the necessary information to render a custom + * UI for every step of the phone authentication process. + * + * @param step An enum representing the current step in the flow. Use a when expression on this + * to render the correct UI. + * @param isLoading true when an asynchronous operation (like sending or verifying a code) is in + * progress. + * @param error An optional error message to display to the user. + * @param phoneNumber (Step: [PhoneAuthStep.EnterPhoneNumber]) The current value of the phone + * number input field. + * @param onPhoneNumberChange (Step: [PhoneAuthStep.EnterPhoneNumber]) A callback to be invoked + * when the phone number input changes. + * @param selectedCountry (Step: [PhoneAuthStep.EnterPhoneNumber]) The currently selected country + * object, containing its name, dial code, and flag. + * @param onCountrySelected (Step: [PhoneAuthStep.EnterPhoneNumber]) A callback to be invoked when + * the user selects a new country. + * @param onSendCodeClick (Step: [PhoneAuthStep.EnterPhoneNumber]) A callback to be invoked to + * send the verification code to the entered number. + * @param verificationCode (Step: [PhoneAuthStep.EnterVerificationCode]) The current value of the + * 6-digit code input field. + * @param onVerificationCodeChange (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to be + * invoked when the verification code input changes. + * @param onVerifyCodeClick (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to be invoked + * to submit the verification code. + * @param fullPhoneNumber (Step: [PhoneAuthStep.EnterVerificationCode]) The formatted full phone + * number to display for user confirmation. + * @param onResendCodeClick (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to be invoked + * when the user clicks "Resend Code". + * @param resendTimer (Step: [PhoneAuthStep.EnterVerificationCode]) The number of seconds remaining + * before the "Resend" action is available. + * @param onChangeNumberClick (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to navigate + * back to the [PhoneAuthStep.EnterPhoneNumber] step. + */ +class PhoneAuthContentState( + val step: PhoneAuthStep, + val isLoading: Boolean = false, + val error: String? = null, + val phoneNumber: String, + val onPhoneNumberChange: (String) -> Unit, + val selectedCountry: CountryData, + val onCountrySelected: (CountryData) -> Unit, + val onSendCodeClick: () -> Unit, + val verificationCode: String, + val onVerificationCodeChange: (String) -> Unit, + val onVerifyCodeClick: () -> Unit, + val fullPhoneNumber: String, + val onResendCodeClick: () -> Unit, + val resendTimer: Int = 0, + val onChangeNumberClick: () -> Unit, +) + +/** + * A stateful composable that manages the complete logic for phone number authentication. It handles + * the multi-step flow of sending and verifying an SMS code, exposing the state for each step to a + * custom UI via a trailing lambda (slot). This component renders no UI itself. + * + * @param context The Android context. + * @param configuration The authentication UI configuration containing the phone provider settings. + * @param authUI The FirebaseAuthUI instance used for authentication operations. + * @param onSuccess Callback invoked when authentication succeeds with the [AuthResult]. + * @param onError Callback invoked when an authentication error occurs. + * @param onCancel Callback invoked when the user cancels the authentication flow. + * @param modifier Optional [Modifier] for the composable. + * @param content A composable lambda that receives [PhoneAuthContentState] to render the UI for + * each step. If null, no UI will be rendered. + */ +@Composable +fun PhoneAuthScreen( + context: Context, + configuration: AuthUIConfiguration, + authUI: FirebaseAuthUI, + onSuccess: (AuthResult) -> Unit, + onError: (AuthException) -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ((PhoneAuthContentState) -> Unit)? = null, +) { + val activity = LocalActivity.current + val provider = configuration.providers.filterIsInstance().first() + val stringProvider = LocalAuthUIStringProvider.current + val dialogController = LocalTopLevelDialogController.current + val coroutineScope = rememberCoroutineScope() + + val step = rememberSaveable { mutableStateOf(PhoneAuthStep.EnterPhoneNumber) } + val phoneNumberValue = rememberSaveable { mutableStateOf(provider.defaultNumber ?: "") } + val verificationCodeValue = rememberSaveable { mutableStateOf("") } + val selectedCountry = remember { + mutableStateOf( + provider.defaultCountryCode?.let { code -> + CountryUtils.findByCountryCode(code) + } ?: CountryUtils.getDefaultCountry() + ) + } + val fullPhoneNumber = remember(selectedCountry.value, phoneNumberValue.value) { + CountryUtils.formatPhoneNumber(selectedCountry.value.dialCode, phoneNumberValue.value) + } + val verificationId = rememberSaveable { mutableStateOf(null) } + val forceResendingToken = + rememberSaveable { mutableStateOf(null) } + val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) } + + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + val isLoading = authState is AuthState.Loading + val errorMessage = + if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null + + // Handle resend timer countdown + LaunchedEffect(resendTimerSeconds.intValue) { + if (resendTimerSeconds.intValue > 0) { + delay(1000) + resendTimerSeconds.intValue-- + } + } + + LaunchedEffect(authState) { + Log.d("PhoneAuthScreen", "Current state: $authState") + when (val state = authState) { + is AuthState.Success -> { + state.result?.let { result -> + onSuccess(result) + } + } + + is AuthState.PhoneNumberVerificationRequired -> { + verificationId.value = state.verificationId + forceResendingToken.value = state.forceResendingToken + step.value = PhoneAuthStep.EnterVerificationCode + resendTimerSeconds.intValue = provider.timeout.toInt() // Start 60-second countdown + } + + is AuthState.SMSAutoVerified -> { + // Auto-verification succeeded, sign in with the credential + coroutineScope.launch { + try { + authUI.signInWithPhoneAuthCredential( + config = configuration, + credential = state.credential + ) + } catch (e: Exception) { + // Error will be handled by authState flow + } + } + } + + is AuthState.Error -> { + val exception = AuthException.from(state.exception) + onError(exception) + + // Show dialog for phone-specific errors using top-level controller + dialogController?.showErrorDialog( + exception = exception, + onRetry = { ex -> + when (ex) { + is AuthException.InvalidCredentialsException -> { + // User can retry with corrected code or phone number + } + else -> Unit + } + }, + onDismiss = { + // Dialog dismissed + } + ) + } + + is AuthState.Cancelled -> { + onCancel() + } + + else -> Unit + } + } + + val state = PhoneAuthContentState( + step = step.value, + isLoading = isLoading, + error = errorMessage, + phoneNumber = phoneNumberValue.value, + onPhoneNumberChange = { number -> + phoneNumberValue.value = number + }, + selectedCountry = selectedCountry.value, + onCountrySelected = { country -> + selectedCountry.value = country + }, + onSendCodeClick = { + coroutineScope.launch { + try { + authUI.verifyPhoneNumber( + provider = provider, + activity = activity, + phoneNumber = fullPhoneNumber, + ) + } catch (e: Exception) { + // Error will be handled by authState flow + } + } + }, + verificationCode = verificationCodeValue.value, + onVerificationCodeChange = { code -> + verificationCodeValue.value = code + }, + onVerifyCodeClick = { + coroutineScope.launch { + try { + verificationId.value?.let { id -> + authUI.submitVerificationCode( + config = configuration, + verificationId = id, + code = verificationCodeValue.value + ) + } + } catch (e: Exception) { + // Error will be handled by authState flow + } + } + }, + fullPhoneNumber = fullPhoneNumber, + onResendCodeClick = { + if (resendTimerSeconds.intValue == 0) { + coroutineScope.launch { + try { + authUI.verifyPhoneNumber( + activity = activity, + provider = provider, + phoneNumber = fullPhoneNumber, + forceResendingToken = forceResendingToken.value, + ) + resendTimerSeconds.intValue = provider.timeout.toInt() // Restart timer + } catch (e: Exception) { + // Error will be handled by authState flow + } + } + } + }, + resendTimer = resendTimerSeconds.intValue, + onChangeNumberClick = { + step.value = PhoneAuthStep.EnterPhoneNumber + verificationCodeValue.value = "" + verificationId.value = null + forceResendingToken.value = null + resendTimerSeconds.intValue = 0 + } + ) + + if (content != null) { + content(state) + } else { + DefaultPhoneAuthContent( + configuration = configuration, + state = state, + onCancel = onCancel + ) + } +} + +@Composable +private fun DefaultPhoneAuthContent( + configuration: AuthUIConfiguration, + state: PhoneAuthContentState, + onCancel: () -> Unit, +) { + when (state.step) { + PhoneAuthStep.EnterPhoneNumber -> { + EnterPhoneNumberUI( + configuration = configuration, + isLoading = state.isLoading, + phoneNumber = state.phoneNumber, + selectedCountry = state.selectedCountry, + onPhoneNumberChange = state.onPhoneNumberChange, + onCountrySelected = state.onCountrySelected, + onSendCodeClick = state.onSendCodeClick, + onNavigateBack = onCancel + ) + } + + PhoneAuthStep.EnterVerificationCode -> { + EnterVerificationCodeUI( + configuration = configuration, + isLoading = state.isLoading, + verificationCode = state.verificationCode, + fullPhoneNumber = state.fullPhoneNumber, + resendTimer = state.resendTimer, + onVerificationCodeChange = state.onVerificationCodeChange, + onVerifyCodeClick = state.onVerifyCodeClick, + onResendCodeClick = state.onResendCodeClick, + onChangeNumberClick = state.onChangeNumberClick + ) + } + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/util/EmailLinkConstants.kt b/auth/src/main/java/com/firebase/ui/auth/compose/util/EmailLinkConstants.kt new file mode 100644 index 000000000..d02cbedaf --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/util/EmailLinkConstants.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.util + +/** + * Constants for email link authentication. + * + * ## Usage Example: + * + * Check for email link in your MainActivity: + * ```kotlin + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * + * val authUI = FirebaseAuthUI.getInstance() + * + * // Check if intent contains email link (from deep link) + * var emailLink: String? = null + * + * if (authUI.canHandleIntent(intent)) { + * emailLink = intent.data?.toString() + * } + * + * if (emailLink != null) { + * // Handle email link sign-in + * // Pass to FirebaseAuthScreen or handle manually + * } + * } + * ``` + * + * @since 10.0.0 + */ +object EmailLinkConstants { + + /** + * Intent extra key for the email link. + * + * Use this constant when passing email links between activities via Intent extras. + * + * **Example:** + * ```kotlin + * // Sending activity + * val intent = Intent(this, MainActivity::class.java) + * intent.putExtra(EmailLinkConstants.EXTRA_EMAIL_LINK, emailLink) + * startActivity(intent) + * + * // Receiving activity + * val emailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK) + * ``` + */ + const val EXTRA_EMAIL_LINK = "com.firebase.ui.auth.EXTRA_EMAIL_LINK" +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/util/EmailLinkPersistenceManager.kt b/auth/src/main/java/com/firebase/ui/auth/compose/util/EmailLinkPersistenceManager.kt new file mode 100644 index 000000000..34c23cccc --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/util/EmailLinkPersistenceManager.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.util + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.Provider +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.FacebookAuthProvider +import com.google.firebase.auth.GoogleAuthProvider +import kotlinx.coroutines.flow.first + +private val Context.dataStore: DataStore by preferencesDataStore(name = "com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager") + +/** + * Manages saving/retrieving from DataStore for email link sign in. + * + * This class provides persistence for email link authentication sessions, including: + * - Email address + * - Session ID for same-device validation + * - Anonymous user ID for upgrade flows + * - Social provider credentials for linking flows + * + * @since 10.0.0 + */ +object EmailLinkPersistenceManager { + + /** + * Default instance. + */ + internal val default: PersistenceManager = DefaultPersistenceManager() + + /** + * The default implementation of [PersistenceManager] that uses DataStore. + */ + private class DefaultPersistenceManager : PersistenceManager { + override suspend fun saveEmail( + context: Context, + email: String, + sessionId: String, + anonymousUserId: String? + ) { + context.dataStore.edit { prefs -> + prefs[AuthProvider.Email.KEY_EMAIL] = email + prefs[AuthProvider.Email.KEY_SESSION_ID] = sessionId + prefs[AuthProvider.Email.KEY_ANONYMOUS_USER_ID] = anonymousUserId ?: "" + } + } + + override suspend fun saveCredentialForLinking( + context: Context, + providerType: String, + idToken: String?, + accessToken: String? + ) { + context.dataStore.edit { prefs -> + prefs[AuthProvider.Email.KEY_PROVIDER] = providerType + prefs[AuthProvider.Email.KEY_IDP_TOKEN] = idToken ?: "" + prefs[AuthProvider.Email.KEY_IDP_SECRET] = accessToken ?: "" + } + } + + override suspend fun retrieveSessionRecord(context: Context): SessionRecord? { + val prefs = context.dataStore.data.first() + val email = prefs[AuthProvider.Email.KEY_EMAIL] + val sessionId = prefs[AuthProvider.Email.KEY_SESSION_ID] + + if (email == null || sessionId == null) { + return null + } + + val anonymousUserId = prefs[AuthProvider.Email.KEY_ANONYMOUS_USER_ID] + val providerType = Provider.fromId(prefs[AuthProvider.Email.KEY_PROVIDER]) + val idToken = prefs[AuthProvider.Email.KEY_IDP_TOKEN] + val accessToken = prefs[AuthProvider.Email.KEY_IDP_SECRET] + + // Rebuild credential if we have provider data + val credentialForLinking = if (providerType != null && idToken != null) { + when (providerType) { + Provider.GOOGLE -> GoogleAuthProvider.getCredential(idToken, accessToken) + Provider.FACEBOOK -> FacebookAuthProvider.getCredential(accessToken ?: "") + else -> null + } + } else { + null + } + + return SessionRecord( + sessionId = sessionId, + email = email, + anonymousUserId = anonymousUserId, + credentialForLinking = credentialForLinking + ) + } + + override suspend fun clear(context: Context) { + context.dataStore.edit { prefs -> + prefs.remove(AuthProvider.Email.KEY_SESSION_ID) + prefs.remove(AuthProvider.Email.KEY_EMAIL) + prefs.remove(AuthProvider.Email.KEY_ANONYMOUS_USER_ID) + prefs.remove(AuthProvider.Email.KEY_PROVIDER) + prefs.remove(AuthProvider.Email.KEY_IDP_TOKEN) + prefs.remove(AuthProvider.Email.KEY_IDP_SECRET) + } + } + } + + /** + * Holds the necessary information to complete the email link sign in flow. + * + * @property sessionId Unique session identifier for same-device validation + * @property email Email address for sign-in + * @property anonymousUserId Optional anonymous user ID for upgrade flows + * @property credentialForLinking Optional social provider credential to link after sign-in + */ + data class SessionRecord( + val sessionId: String, + val email: String, + val anonymousUserId: String?, + val credentialForLinking: AuthCredential? + ) +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/util/PersistenceManager.kt b/auth/src/main/java/com/firebase/ui/auth/compose/util/PersistenceManager.kt new file mode 100644 index 000000000..5640ce639 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/util/PersistenceManager.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.util + +import android.content.Context + +/** + * Interface for managing email link authentication session persistence. + * + * This interface abstracts the persistence layer for email link sign-in sessions, + * allowing for different implementations (DataStore, in-memory for testing, etc.). + * + * @since 10.0.0 + */ +interface PersistenceManager { + + /** + * Saves email and session information for email link sign-in. + * + * @param context Android context for storage access + * @param email Email address to save + * @param sessionId Unique session identifier for same-device validation + * @param anonymousUserId Optional anonymous user ID for upgrade flows + */ + suspend fun saveEmail( + context: Context, + email: String, + sessionId: String, + anonymousUserId: String? + ) + + /** + * Saves social provider credential information for linking after email link sign-in. + * + * @param context Android context for storage access + * @param providerType Provider ID ("google.com", "facebook.com", etc.) + * @param idToken ID token from the provider + * @param accessToken Access token from the provider (optional, used by Facebook) + */ + suspend fun saveCredentialForLinking( + context: Context, + providerType: String, + idToken: String?, + accessToken: String? + ) + + /** + * Retrieves session information from storage. + * + * @param context Android context for storage access + * @return SessionRecord containing saved session data, or null if no session exists + */ + suspend fun retrieveSessionRecord(context: Context): EmailLinkPersistenceManager.SessionRecord? + + /** + * Clears all saved data from storage. + * + * @param context Android context for storage access + */ + suspend fun clear(context: Context) +} diff --git a/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml b/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml index 4aee44997..85afe860d 100644 --- a/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml +++ b/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml @@ -1,13 +1,9 @@ - - - + + diff --git a/auth/src/main/res/drawable/fui_ic_twitter_x_white_24dp.xml b/auth/src/main/res/drawable/fui_ic_twitter_x_white_24dp.xml new file mode 100644 index 000000000..5f3553369 --- /dev/null +++ b/auth/src/main/res/drawable/fui_ic_twitter_x_white_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/auth/src/main/res/values-ar/strings.xml b/auth/src/main/res/values-ar/strings.xml index 6c18953e4..4355c5b7c 100755 --- a/auth/src/main/res/values-ar/strings.xml +++ b/auth/src/main/res/values-ar/strings.xml @@ -25,6 +25,8 @@ البريد الإلكتروني رقم الهاتف البلد + اختيار بلد + البحث عن بلد مثلاً +1 أو "US" كلمة المرور كلمة المرور الجديدة يجب ملء هذا الحقل. @@ -73,10 +75,13 @@ تأكيد عنوان البريد الإلكتروني لمتابعة تسجيل الدخول رفض لقد بدأت هذه العملية بهدف ربط %1$s ببريدك الإلكتروني إلّا أنك فتحت الرابط على جهاز آخر لم يتمّ تسجيل الدخول عليه.\n\nلربط حسابك على %1$s، يجب فتح الرابط على الجهاز نفسه حيث سجّلت الدخول في البداية. ولإلغاء عملية الربط، يُرجى النقر على \"متابعة\" لتسجيل الدخول على هذا الجهاز. + أو المتابعة باستخدام + تسجيل الدخول باستخدام رابط البريد الإلكتروني + تسجيل الدخول باستخدام كلمة المرور أدخل رقم هاتفك يُرجى إدخال رقم هاتف صالح أدخل الرمز المكوّن من 6 أرقام الذي أرسلناه إلى - إعادة إرسال الرمز بعد 0:%02d + إعادة إرسال الرمز بعد %1$s إثبات ملكية رقم هاتفك جارٍ التحقق… الرمز غير صحيح. يُرجى المحاولة مجددًا. @@ -87,6 +92,79 @@ تمّ التحقّق تلقائيًا من رقم الهاتف. إعادة إرسال الرمز تأكيد ملكية رقم الهاتف + Use a different phone number عند النقر على “%1$s”، قد يتمّ إرسال رسالة قصيرة SMS وقد يتمّ تطبيق رسوم الرسائل والبيانات. - يشير النقر على “%1$s” إلى موافقتك على %2$s و%3$s. وقد يتمّ إرسال رسالة قصيرة كما قد تنطبق رسوم الرسائل والبيانات. + يشير النقر على "%1$s" إلى موافقتك على %2$s و%3$s. وقد يتمّ إرسال رسالة قصيرة كما قد تنطبق رسوم الرسائل والبيانات. + خطأ في المصادقة + أعد المحاولة + مطلوب تحقق إضافي. يرجى إكمال المصادقة متعددة العوامل. + يجب ربط الحساب. جرب طريقة تسجيل دخول مختلفة. + تم إلغاء المصادقة. أعد المحاولة عندما تكون جاهزاً. + + + اختر طريقة المصادقة + إعداد التحقق بالرسائل القصيرة + إعداد تطبيق المصادقة + تحقق من الرمز + احفظ رموز الاسترداد + + اختر طريقة مصادقة ثانية لتأمين حسابك + أدخل رقم هاتفك لتلقي رموز التحقق + امسح رمز الاستجابة السريعة باستخدام تطبيق المصادقة + أدخل الرمز المرسل إلى هاتفك + أدخل الرمز من تطبيق المصادقة + أدخل رمز التحقق + احفظ هذه الرموز في مكان آمن. يمكنك استخدامها لتسجيل الدخول إذا فقدت الوصول إلى طريقة المصادقة. + + تأكيد كلمة المرور + كلمتا المرور غير متطابقتين + يجب أن تتكون كلمة المرور من %1$d حرفًا على الأقل + يجب أن تحتوي كلمة المرور على حرف كبير واحد على الأقل + يجب أن تحتوي كلمة المرور على حرف صغير واحد على الأقل + يجب أن تحتوي كلمة المرور على رقم واحد على الأقل + يجب أن تحتوي كلمة المرور على حرف خاص واحد على الأقل + + + إعداد مصادقة الرسائل القصيرة + التحقق من رمز الرسائل القصيرة + + تتطلب هذه العملية مصادقة حديثة. يُرجى تسجيل الدخول مرة أخرى والمحاولة مرة أخرى. + رمز التحقق غير صحيح. يُرجى المحاولة مرة أخرى. + حدث خطأ في الشبكة. يُرجى التحقق من اتصالك والمحاولة مرة أخرى. + حدث خطأ أثناء التسجيل. يُرجى المحاولة مرة أخرى. + + رجوع + تم التحقق من الهوية. أعد المحاولة. + إدارة المصادقة الثنائية + كلمة المرور غير صحيحة + اختر طريقة التحقق + أضف طبقة أمان إضافية + رسالة نصية + تطبيق المصادقة + رقم الهاتف هذا مرتبط بحساب آخر + التحقق مطلوب + امسح رمز الاستجابة السريعة باستخدام تطبيق المصادقة + تطبيق المصادقة معد بالفعل + هل أنت متأكد من إزالة طريقة التحقق هذه؟ + تعذرت إزالة طريقة التحقق + تمت إزالة طريقة التحقق + أدخل رمز التحقق + تعذر تحديث الملف الشخصي + تم تحديث الملف الشخصي + أكد هويتك للمتابعة + إعادة المصادقة مطلوبة + نجحت إعادة المصادقة + إعادة المصادقة + حفظت رموز الاسترداد + إزالة + إعادة إرسال بريد التحقق + المفتاح السري + تسجيل الخروج + مسجل الدخول باسم + تخطي + استخدم طريقة أخرى + رمز التحقق + البريد الإلكتروني محقق + تحقق + أرسلنا بريدًا للتحقق إلى %1$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..766bee2de 100755 --- a/auth/src/main/res/values-b+es+419/strings.xml +++ b/auth/src/main/res/values-b+es+419/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,97 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Choose Authentication Method + Set Up SMS Verification + Set Up Authenticator App + Verify Your Code + Save Your Recovery Codes + + Select a second authentication method to secure your account + Enter your phone number to receive verification codes + Scan the QR code with your authenticator app + Enter the code sent to your phone + Enter the code from your authenticator app + Enter your verification code + Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method. + + Confirmar contraseña + Las contraseñas no coinciden + La contraseña debe tener al menos %1$d caracteres + La contraseña debe contener al menos una letra mayúscula + La contraseña debe contener al menos una letra minúscula + La contraseña debe contener al menos un número + La contraseña debe contener al menos un carácter especial + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-bg/strings.xml b/auth/src/main/res/values-bg/strings.xml index c96ed2f29..7cc8e3fab 100755 --- a/auth/src/main/res/values-bg/strings.xml +++ b/auth/src/main/res/values-bg/strings.xml @@ -25,6 +25,8 @@ Имейл Телефонен номер Държава + Изберете държава + Търсене на държава напр. +1, "US" Парола Нова парола Трябва да се попълни. @@ -73,10 +75,13 @@ Потвърдете имейл адреса, за да продължите с влизането в профила Отхвърляне Първоначално искахте да свържете %1$s с имейла на профила си, но отворихте връзката на различно устройство без вход в профила.\n\nАко все още искате да свържете профила си в(ъв) %1$s, отворете връзката на същото устройство, на което започнахте влизането в профила. В противен случай докоснете „Напред“, за да влезете на това устройство. + или Продължете с + Вход чрез имейл връзка + Вход с парола Въвеждане на телефонния ви номер Въведете валиден телефонен номер Въведете 6-цифрения код, който изпратихме до - Повторно изпращане на кода след 0:%02d + Повторно изпращане на кода след %1$s Потвърждаване на телефонния ви номер Потвърждава се… Неправилен код. Опитайте отново. @@ -87,6 +92,79 @@ Телефонният номер е потвърден автоматично Повторно изпращане на кода Потвърждаване на телефонния номер + Use a different phone number Докосвайки „%1$s“, може да получите SMS съобщение. То може да се таксува по тарифите за данни и SMS. - Докосвайки „%1$s“, приемате нашите %2$s и %3$s. Възможно е да получите SMS съобщение. То може да се таксува по тарифите за данни и SMS. + Докосвайки „%1$s", приемате нашите %2$s и %3$s. Възможно е да получите SMS съобщение. То може да се таксува по тарифите за данни и SMS. + Грешка при удостоверяване + Опитай отново + Необходима е допълнителна проверка. Моля, завършете многофакторното удостоверяване. + Акаунтът трябва да бъде свързан. Опитайте различен метод за влизане. + Удостоверяването беше отменено. Опитайте отново, когато сте готови. + + + Изберете метод за удостоверяване + Настройте SMS проверка + Настройте приложение за удостоверяване + Потвърдете кода си + Запазете кодовете за възстановяване + + Изберете втори метод за удостоверяване, за да защитите акаунта си + Въведете телефонния си номер, за да получавате кодове за проверка + Сканирайте QR кода с приложението си за удостоверяване + Въведете кода, изпратен на телефона ви + Въведете кода от приложението си за удостоверяване + Въведете кода си за проверка + Съхранявайте тези кодове на сигурно място. Можете да ги използвате за влизане, ако загубите достъп до метода си за удостоверяване. + + Потвърдете паролата + Паролите не съвпадат + Паролата трябва да е с дължина поне %1$d знака + Паролата трябва да съдържа поне една главна буква + Паролата трябва да съдържа поне една малка буква + Паролата трябва да съдържа поне една цифра + Паролата трябва да съдържа поне един специален знак + + + Настройка на SMS удостоверяване + Потвърждаване на SMS код + + Тази операция изисква скорошно удостоверяване. Моля, влезте отново и опитайте отново. + Кодът за потвърждение е неправилен. Моля, опитайте отново. + Възникна мрежова грешка. Моля, проверете връзката си и опитайте отново. + Възникна грешка по време на регистрацията. Моля, опитайте отново. + + Назад + Самоличността е потвърдена. Опитайте отново действието си. + Управление на двуфакторно удостоверяване + Неправилна парола + Изберете метод за потвърждение + Добавете допълнително ниво на сигурност + SMS + Приложение за удостоверяване + Този телефонен номер е свързан с друг акаунт + Необходимо е потвърждение + Сканирайте QR кода с приложението си за удостоверяване + Приложението за удостоверяване вече е настроено + Сигурни ли сте, че искате да премахнете този метод? + Методът не може да бъде премахнат + Методът е премахнат + Въведете кода за потвърждение + Профилът не може да бъде актуализиран + Профилът е актуализиран + Потвърдете самоличността си, за да продължите + Необходимо е повторно удостоверяване + Повторното удостоверяване е успешно + Повторно удостоверяване + Запазих кодовете за възстановяване + Премахване + Изпращане на имейл за потвърждение отново + Таен ключ + Изход + Влезли сте като + Пропускане + Използване на друг метод + Код за потвърждение + Имейлът е потвърден + Потвърждаване + Изпратихме имейл за потвърждение до %1$s diff --git a/auth/src/main/res/values-bn/strings.xml b/auth/src/main/res/values-bn/strings.xml index bb304053b..8edf34d4e 100755 --- a/auth/src/main/res/values-bn/strings.xml +++ b/auth/src/main/res/values-bn/strings.xml @@ -25,6 +25,8 @@ ইমেল ফোন নম্বর দেশ + একটি দেশ বেছে নিন + দেশ খুঁজুন যেমন +1, "US" পাসওয়ার্ড নতুন পাসওয়ার্ড আপনি এটি খালি ছাড়তে পারেন না। @@ -73,10 +75,13 @@ সাইন-ইন করতে ইমেল আইডি কনফার্ম করুন খারিজ করুন আপনি %1$s অ্যাকাউন্ট ইমেল অ্যাকাউন্টের সাথে কানেক্ট করতে চেয়েছিলেন কিন্তু এমন একটি ডিভাইসে লিঙ্কটি খুলেছেন যেখানে সাইন-ইন করেননি।\n\nআপনি যদি এখনও %1$s অ্যাকাউন্টটি কানেক্ট করতে চান, যে ডিভাইসে সাইন-ইন করেছিলেন সেখানেই লিঙ্কটি খুলুন। নাহলে, ডিভাইসের সাইন-ইন বিকল্পে ট্যাপ করুন। + বা এটি দিয়ে চালিয়ে যান + ইমেল লিঙ্ক দিয়ে সাইন ইন করুন + পাসওয়ার্ড দিয়ে সাইন ইন করুন আপনার ফোন নম্বর লিখুন একটি সঠিক ফোন নম্বর লিখুন আমাদের পাঠানো ৬-সংখ্যার কোডটি লিখুন - 0:%02d এ কোডটি আবার পাঠান + %1$s এ কোডটি আবার পাঠান আপনার ফোন নম্বর যাচাই করুন যাচাই করা হচ্ছে… কোডটি ভুল। আবার চেষ্টা করুন। @@ -87,6 +92,80 @@ ফোন নম্বরটি নিজে থেকে যাচাই করা হয়েছে কোডটি আবার পাঠান ফোন নম্বর যাচাই করুন + Use a different phone number %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. + + + প্রমাণীকরণ পদ্ধতি চয়ন করুন + SMS যাচাইকরণ সেট আপ করুন + প্রমাণীকরণকারী অ্যাপ সেট আপ করুন + আপনার কোড যাচাই করুন + আপনার পুনরুদ্ধার কোড সংরক্ষণ করুন + + আপনার অ্যাকাউন্ট সুরক্ষিত করতে দ্বিতীয় প্রমাণীকরণ পদ্ধতি নির্বাচন করুন + যাচাইকরণ কোড পেতে আপনার ফোন নম্বর লিখুন + আপনার প্রমাণীকরণকারী অ্যাপ দিয়ে QR কোড স্ক্যান করুন + আপনার ফোনে পাঠানো কোড লিখুন + আপনার প্রমাণীকরণকারী অ্যাপ থেকে কোড লিখুন + আপনার যাচাইকরণ কোড লিখুন + এই কোডগুলি একটি নিরাপদ স্থানে সংরক্ষণ করুন। আপনি যদি আপনার প্রমাণীকরণ পদ্ধতিতে অ্যাক্সেস হারান তবে সাইন ইন করতে এগুলি ব্যবহার করতে পারেন। + + পাসওয়ার্ড নিশ্চিত করুন + পাসওয়ার্ড মিলছে না + পাসওয়ার্ডে অন্তত %1$dটি অক্ষর থাকতে হবে + পাসওয়ার্ডে অন্তত একটি বড় হাতের অক্ষর থাকতে হবে + পাসওয়ার্ডে অন্তত একটি ছোট হাতের অক্ষর থাকতে হবে + পাসওয়ার্ডে অন্তত একটি সংখ্যা থাকতে হবে + পাসওয়ার্ডে অন্তত একটি বিশেষ অক্ষর থাকতে হবে + + + এসএমএস প্রমাণীকরণ সেট আপ করুন + এসএমএস কোড যাচাই করুন + + এই কাজটির জন্য সাম্প্রতিক প্রমাণীকরণ প্রয়োজন৷ আবার সাইন-ইন করুন এবং আবার চেষ্টা করুন৷ + যাচাইকরণ কোডটি ভুল৷ আবার চেষ্টা করুন৷ + একটি নেটওয়ার্ক ত্রুটি হয়েছে৷ আপনার সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন৷ + নথিভুক্তির সময় একটি ত্রুটি ঘটেছে৷ আবার চেষ্টা করুন৷ + + ফিরে যান + পরিচয় যাচাই করা হয়েছে। আপনার কাজ আবার চেষ্টা করুন। + দ্বি-ফ্যাক্টর প্রমাণীকরণ পরিচালনা করুন + ভুল পাসওয়ার্ড + একটি যাচাইকরণ পদ্ধতি বেছে নিন + অতিরিক্ত নিরাপত্তা স্তর যোগ করুন + SMS + প্রমাণীকরণকারী অ্যাপ + এই ফোন নম্বরটি অন্য অ্যাকাউন্টের সাথে যুক্ত + যাচাইকরণ প্রয়োজন + আপনার প্রমাণীকরণকারী অ্যাপ দিয়ে QR কোড স্ক্যান করুন + প্রমাণীকরণকারী অ্যাপ ইতিমধ্যে সেট আপ করা আছে + আপনি কি নিশ্চিত যে এই পদ্ধতিটি সরাতে চান? + পদ্ধতি সরাতে অক্ষম + পদ্ধতি সরানো হয়েছে + যাচাইকরণ কোড লিখুন + প্রোফাইল আপডেট করতে অক্ষম + প্রোফাইল আপডেট করা হয়েছে + চালিয়ে যেতে আপনার পরিচয় নিশ্চিত করুন + পুনরায় প্রমাণীকরণ প্রয়োজন + পুনরায় প্রমাণীকরণ সফল + পুনরায় প্রমাণীকরণ + আমি আমার পুনরুদ্ধার কোড সংরক্ষণ করেছি + সরান + যাচাইকরণ ইমেল পুনরায় পাঠান + গোপন কী + সাইন আউট + হিসাবে সাইন ইন করা হয়েছে + এড়িয়ে যান + একটি ভিন্ন পদ্ধতি ব্যবহার করুন + যাচাইকরণ কোড + ইমেল যাচাই করা হয়েছে + যাচাই করুন + আমরা %1$s-এ একটি যাচাইকরণ ইমেল পাঠিয়েছি diff --git a/auth/src/main/res/values-ca/strings.xml b/auth/src/main/res/values-ca/strings.xml index e0c126e59..b4d5af949 100755 --- a/auth/src/main/res/values-ca/strings.xml +++ b/auth/src/main/res/values-ca/strings.xml @@ -25,6 +25,8 @@ Adreça electrònica Número de telèfon País + Selecciona un país + Cerca país p. ex. +1, "US" Contrasenya Contrasenya nova Aquest camp no es pot deixar en blanc. @@ -73,10 +75,13 @@ Confirma l\'adreça electrònica per continuar amb l\'inici de sessió Ignora Inicialment has intentat connectar %1$s amb el teu compte de correu electrònic, però has obert l\'enllaç amb un dispositiu diferent en què no has iniciat la sessió.\n\nSi encara vols connectar el compte que tens a %1$s, obre l\'enllaç al mateix dispositiu en què has començat a iniciar la sessió. Si no, toca Continua per iniciar la sessió en aquest dispositiu. + o Continua amb + Inicia la sessió amb l\'enllaç de correu electrònic + Inicia la sessió amb la contrasenya Introdueix el número de telèfon Introdueix un número de telèfon vàlid Introdueix el codi de 6 dígits que s\'ha enviat al número - Torna a enviar el codi d\'aquí a 0:%02d + Torna a enviar el codi d\'aquí a %1$s Verifica el número de telèfon S\'està verificant… El codi no és correcte. Torna-ho a provar. @@ -87,6 +92,80 @@ El número de telèfon s\'ha verificat automàticament Torna a enviar el codi Verifica el número de telèfon + Use a different phone number 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. + + + Trieu el mètode d\'autenticació + Configureu la verificació per SMS + Configureu l\'aplicació d\'autenticació + Verifiqueu el codi + Deseu els codis de recuperació + + Seleccioneu un segon mètode d\'autenticació per protegir el vostre compte + Introduïu el vostre número de telèfon per rebre codis de verificació + Escanegeu el codi QR amb la vostra aplicació d\'autenticació + Introduïu el codi enviat al vostre telèfon + Introduïu el codi de la vostra aplicació d\'autenticació + Introduïu el vostre codi de verificació + Deseu aquests codis en un lloc segur. Podeu utilitzar-los per iniciar sessió si perdeu l\'accés al vostre mètode d\'autenticació. + + Confirma la contrasenya + Les contrasenyes no coincideixen + La contrasenya ha de tenir almenys %1$d caràcters + La contrasenya ha de contenir almenys una lletra majúscula + La contrasenya ha de contenir almenys una lletra minúscula + La contrasenya ha de contenir almenys un número + La contrasenya ha de contenir almenys un caràcter especial + + + Configura l\'autenticació per SMS + Verifica el codi SMS + + Aquesta operació requereix autenticació recent. Torna a iniciar la sessió i torna-ho a provar. + El codi de verificació és incorrecte. Torna-ho a provar. + S\'ha produït un error de xarxa. Comprova la connexió i torna-ho a provar. + S\'ha produït un error durant la inscripció. Torna-ho a provar. + + Enrere + Identitat verificada. Torna a provar la teva acció. + Gestiona l\'autenticació de dos factors + Contrasenya incorrecta + Tria un mètode de verificació + Afegeix una capa de seguretat addicional + SMS + Aplicació d\'autenticació + Aquest número de telèfon està associat a un altre compte + Verificació necessària + Escaneja el codi QR amb la teva aplicació d\'autenticació + L\'aplicació d\'autenticació ja està configurada + Estàs segur que vols eliminar aquest mètode? + No es pot eliminar el mètode + Mètode eliminat + Introdueix el codi de verificació + No es pot actualitzar el perfil + Perfil actualitzat + Confirma la teva identitat per continuar + Es requereix reautenticació + Reautenticació correcta + Reautentica + He desat els meus codis de recuperació + Elimina + Torna a enviar el correu de verificació + Clau secreta + Tanca la sessió + Has iniciat sessió com a + Omet + Utilitza un mètode diferent + Codi de verificació + Correu verificat + Verifica + Hem enviat un correu de verificació a %1$s diff --git a/auth/src/main/res/values-cs/strings.xml b/auth/src/main/res/values-cs/strings.xml index cb328a6fa..bc54d330c 100755 --- a/auth/src/main/res/values-cs/strings.xml +++ b/auth/src/main/res/values-cs/strings.xml @@ -25,6 +25,8 @@ E-mail Telefonní číslo Země + Vyberte zemi + Hledat zemi např. +1, "US" Heslo Nové heslo Toto pole nesmí zůstat prázdné. @@ -73,10 +75,13 @@ Pokud chcete pokračovat v přihlášení, potvrďte e-mailovou adresu. Zavřít Původně jste chtěli propojit %1$s se svým e-mailovým účtem, ale otevřeli jste odkaz na jiném zařízení, na kterém nejste přihlášení.\n\nPokud stále chcete propojit svůj účet %1$s, otevřete odkaz na stejném zařízení, na kterém jste se začali přihlašovat. V opačném případě klepněte na možnost Pokračovat a přihlaste se na tomto zařízení. + nebo Pokračovat s + Přihlásit se pomocí e-mailového odkazu + Přihlásit se pomocí hesla Zadejte své telefonní číslo Zadejte platné telefonní číslo Zadejte šestimístný kód, který jsme vám zaslali - Znovu zaslat kód za 0:%02d + Znovu zaslat kód za %1$s Ověřit telefonní číslo Ověřování… Špatný kód. Zkuste to znovu. @@ -87,6 +92,79 @@ Telefonní číslo bylo automaticky ověřeno Znovu poslat kód Ověřit telefonní číslo + Use a different phone number 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. + + + Vyberte metodu ověření + Nastavit ověření SMS + Nastavit ověřovací aplikaci + Ověřte svůj kód + Uložte obnovací kódy + + Vyberte druhou metodu ověření pro zabezpečení účtu + Zadejte telefonní číslo pro příjem ověřovacích kódů + Naskenujte QR kód pomocí ověřovací aplikace + Zadejte kód odeslaný na váš telefon + Zadejte kód z ověřovací aplikace + Zadejte ověřovací kód + Uložte tyto kódy na bezpečném místě. Můžete je použít k přihlášení, pokud ztratíte přístup k metodě ověření. + + Potvrďte heslo + Hesla se neshodují + Heslo musí mít alespoň %1$d znaků + Heslo musí obsahovat alespoň jedno velké písmeno + Heslo musí obsahovat alespoň jedno malé písmeno + Heslo musí obsahovat alespoň jednu číslici + Heslo musí obsahovat alespoň jeden speciální znak + + + Nastavit SMS ověření + Ověřit SMS kód + + Tato operace vyžaduje nedávné ověření. Přihlaste se znovu a zkuste to znovu. + Ověřovací kód je nesprávný. Zkuste to znovu. + Došlo k chybě sítě. Zkontrolujte připojení a zkuste to znovu. + Během registrace došlo k chybě. Zkuste to znovu. + + Zpět + Identita ověřena. Zkuste akci znovu. + Správa dvoufázového ověření + Nesprávné heslo + Vyberte metodu ověření + Přidejte další vrstvu zabezpečení + SMS + Ověřovací aplikace + Toto telefonní číslo je spojeno s jiným účtem + Vyžaduje se ověření + Naskenujte QR kód pomocí ověřovací aplikace + Ověřovací aplikace je již nastavena + Opravdu chcete odstranit tuto metodu? + Metodu nelze odstranit + Metoda odstraněna + Zadejte ověřovací kód + Profil nelze aktualizovat + Profil aktualizován + Potvrďte svou identitu a pokračujte + Vyžaduje se opětovné ověření + Opětovné ověření proběhlo úspěšně + Znovu ověřit + Uložil jsem kódy pro obnovení + Odstranit + Znovu odeslat ověřovací e-mail + Tajný klíč + Odhlásit se + Přihlášen jako + Přeskočit + Použít jinou metodu + Ověřovací kód + E-mail ověřen + Ověřit + Odeslali jsme ověřovací e-mail na %1$s diff --git a/auth/src/main/res/values-da/strings.xml b/auth/src/main/res/values-da/strings.xml index c9c86762e..e8be3e799 100755 --- a/auth/src/main/res/values-da/strings.xml +++ b/auth/src/main/res/values-da/strings.xml @@ -25,6 +25,8 @@ Mail Telefonnummer Land + Vælg et land + Søg efter land f.eks. +1, "US" Adgangskode Ny adgangskode Dette felt skal udfyldes. @@ -73,10 +75,13 @@ Bekræft mailadresse for at fortsætte loginprocessen Afvis Du ville egentlig knytte %1$s til din mailkonto, men du har åbnet linket på en anden enhed, som du ikke er logget ind på.\n\nHvis du stadig vil tilknytte din %1$s-konto, skal du åbne linket på den enhed, hvor du startede loginprocessen. Ellers kan du klikke på Fortsæt for at fortsætte med at logge ind på denne enhed. + eller Fortsæt med + Log ind med maillink + Log ind med adgangskode Angiv dit telefonnummer Angiv et gyldigt telefonnummer Angiv den 6-cifrede kode, vi sendte til - Send koden igen om 0:%02d + Send koden igen om %1$s Bekræft dit telefonnummer Bekræfter… Koden er forkert. Prøv igen. @@ -87,6 +92,79 @@ Telefonnummeret blev bekræftet automatisk Send koden igen Bekræft telefonnummer + Use a different phone number 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. + + + Vælg godkendelsesmetode + Konfigurer SMS-bekræftelse + Konfigurer godkendelsesapp + Bekræft din kode + Gem dine gendannelseskoder + + Vælg en anden godkendelsesmetode for at beskytte din konto + Indtast dit telefonnummer for at modtage bekræftelseskoder + Scan QR-koden med din godkendelsesapp + Indtast koden sendt til din telefon + Indtast koden fra din godkendelsesapp + Indtast din bekræftelseskode + Gem disse koder et sikkert sted. Du kan bruge dem til at logge ind, hvis du mister adgang til din godkendelsesmetode. + + Bekræft adgangskode + Adgangskoderne stemmer ikke overens + Adgangskoden skal være på mindst %1$d tegn + Adgangskoden skal indeholde mindst ét stort bogstav + Adgangskoden skal indeholde mindst ét lille bogstav + Adgangskoden skal indeholde mindst ét tal + Adgangskoden skal indeholde mindst ét specialtegn + + + Konfigurer SMS-godkendelse + Bekræft SMS-kode + + Denne handling kræver ny godkendelse. Log ind igen, og prøv igen. + Bekræftelseskoden er forkert. Prøv igen. + Der opstod en netværksfejl. Tjek din forbindelse, og prøv igen. + Der opstod en fejl under tilmelding. Prøv igen. + + Tilbage + Identitet bekræftet. Prøv din handling igen. + Administrer totrinsgodkendelse + Forkert adgangskode + Vælg en bekræftelsesmetode + Tilføj et ekstra sikkerhedslag + SMS + Godkendelsesapp + Dette telefonnummer er tilknyttet en anden konto + Bekræftelse påkrævet + Scan QR-koden med din godkendelsesapp + Godkendelsesapp er allerede konfigureret + Er du sikker på, at du vil fjerne denne metode? + Kan ikke fjerne metode + Metode fjernet + Indtast bekræftelseskode + Kan ikke opdatere profil + Profil opdateret + Bekræft din identitet for at fortsætte + Gengodkendelse påkrævet + Gengodkendelse lykkedes + Gengodkend + Jeg har gemt mine gendannelseskoder + Fjern + Send bekræftelsesemail igen + Hemmelig nøgle + Log ud + Logget ind som + Spring over + Brug en anden metode + Bekræftelseskode + E-mail bekræftet + Bekræft + Vi har sendt en bekræftelsesemail til %1$s diff --git a/auth/src/main/res/values-de-rAT/strings.xml b/auth/src/main/res/values-de-rAT/strings.xml index 378aff0c0..e39663a53 100755 --- a/auth/src/main/res/values-de-rAT/strings.xml +++ b/auth/src/main/res/values-de-rAT/strings.xml @@ -25,6 +25,8 @@ E-Mail-Adresse Telefonnummer Land + Land auswählen + Land suchen z. B. +1, "US" Passwort Neues Passwort Pflichtfeld. @@ -73,10 +75,13 @@ E-Mail-Adresse bestätigen, um Anmeldung fortzusetzen Ablehnen Sie haben versucht, %1$s auf einem Gerät, auf dem Sie nicht angemeldet sind, mit Ihrem E-Mail-Konto zu verbinden.\n\nWenn Sie Ihr %1$s-Konto weiterhin verbinden möchten, öffnen Sie den Link bitte auf dem Gerät, auf dem Sie den Anmeldevorgang gestartet haben. Andernfalls tippen Sie auf \"Weiter\", um sich auf diesem Gerät anzumelden. + oder Fortfahren mit + Mit E-Mail-Link anmelden + Mit Passwort anmelden Telefonnummer eingeben Geben Sie eine gültige Telefonnummer ein Geben Sie den 6-stelligen Code ein, der gesendet wurde an - Code in 0:%02d erneut senden + Code in %1$s erneut senden Telefonnummer bestätigen Wird verifiziert… Falscher Code. Versuchen Sie es noch einmal. @@ -87,6 +92,97 @@ Telefonnummer wurde automatisch bestätigt Code erneut senden Telefonnummer bestätigen + Use a different phone number 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. + + + Authentifizierungsmethode auswählen + SMS-Bestätigung einrichten + Authenticator-App einrichten + Code bestätigen + Wiederherstellungscodes speichern + + Wählen Sie eine zweite Authentifizierungsmethode aus, um Ihr Konto zu schützen + Geben Sie Ihre Telefonnummer ein, um Bestätigungscodes zu erhalten + Scannen Sie den QR-Code mit Ihrer Authenticator-App + Geben Sie den an Ihr Telefon gesendeten Code ein + Geben Sie den Code aus Ihrer Authenticator-App ein + Geben Sie Ihren Bestätigungscode ein + Bewahren Sie diese Codes an einem sicheren Ort auf. Sie können sie zum Anmelden verwenden, wenn Sie den Zugriff auf Ihre Authentifizierungsmethode verlieren. + + Passwort bestätigen + Passwörter stimmen nicht überein + Das Passwort muss mindestens %1$d Zeichen lang sein + Das Passwort muss mindestens einen Großbuchstaben enthalten + Das Passwort muss mindestens einen Kleinbuchstaben enthalten + Das Passwort muss mindestens eine Ziffer enthalten + Das Passwort muss mindestens ein Sonderzeichen enthalten + + + SMS-Authentifizierung einrichten + SMS-Code bestätigen + + Für diesen Vorgang ist eine kürzliche Authentifizierung erforderlich. Bitte melden Sie sich erneut an und versuchen Sie es erneut. + Der Bestätigungscode ist falsch. Bitte versuchen Sie es erneut. + Ein Netzwerkfehler ist aufgetreten. Bitte überprüfen Sie Ihre Verbindung und versuchen Sie es erneut. + Bei der Registrierung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut. + + + Zurück + Identität verifiziert. Bitte versuchen Sie Ihre Aktion erneut. + Falsches Passwort. Bitte versuchen Sie es erneut. + Multifaktor-Authentifizierung verwalten + Aktive Methoden + Neue Methode hinzufügen + Alle verfügbaren Authentifizierungsmethoden sind registriert + Registriert am %1$s + SMS-Authentifizierung + Authenticator-App + Unbekannte Methode + Authentifizierungsmethoden für Ihr Konto hinzufügen oder entfernen + Zwei-Faktor-Authentifizierung verwalten + Scannen Sie den QR-Code oder geben Sie den geheimen Schlüssel in Ihrer Authenticator-App ein + Bitte vervollständigen Sie Ihre Profilinformationen, um fortzufahren. + Fehlende Felder: %1$s + Konto: %1$s + Zu Ihrer Sicherheit geben Sie bitte Ihr Passwort erneut ein, um fortzufahren. + Verifizieren Sie Ihre Identität + Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut. + Ich habe diese Codes gespeichert + Entfernen + Bestätigungs-E-Mail erneut senden + Geheimer Schlüssel + Abmelden + Angemeldet als %1$s + Vorerst überspringen + Eine andere Methode verwenden + Verifizierungscode + Ich habe meine E-Mail verifiziert + Verifizieren + Bitte verifizieren Sie %1$s, um fortzufahren. + + Wählen Sie eine Verifizierungsmethode + Fügen Sie eine zusätzliche Sicherheitsebene hinzu + SMS + Authenticator-App + Diese Telefonnummer ist mit einem anderen Konto verknüpft + Verifizierung erforderlich + Scannen Sie den QR-Code mit Ihrer Authenticator-App + Authenticator-App ist bereits eingerichtet + Möchten Sie diese Methode wirklich entfernen? + Methode kann nicht entfernt werden + Methode entfernt + Geben Sie den Verifizierungscode ein + Profil kann nicht aktualisiert werden + Profil aktualisiert + Bestätigen Sie Ihre Identität, um fortzufahren + Erneute Authentifizierung erforderlich + Erneute Authentifizierung erfolgreich + Erneut authentifizieren diff --git a/auth/src/main/res/values-de-rCH/strings.xml b/auth/src/main/res/values-de-rCH/strings.xml index 378aff0c0..71c187c29 100755 --- a/auth/src/main/res/values-de-rCH/strings.xml +++ b/auth/src/main/res/values-de-rCH/strings.xml @@ -25,6 +25,8 @@ E-Mail-Adresse Telefonnummer Land + Land auswählen + Land suchen z. B. +1, "US" Passwort Neues Passwort Pflichtfeld. @@ -73,10 +75,13 @@ E-Mail-Adresse bestätigen, um Anmeldung fortzusetzen Ablehnen Sie haben versucht, %1$s auf einem Gerät, auf dem Sie nicht angemeldet sind, mit Ihrem E-Mail-Konto zu verbinden.\n\nWenn Sie Ihr %1$s-Konto weiterhin verbinden möchten, öffnen Sie den Link bitte auf dem Gerät, auf dem Sie den Anmeldevorgang gestartet haben. Andernfalls tippen Sie auf \"Weiter\", um sich auf diesem Gerät anzumelden. + oder Fortfahren mit + Mit E-Mail-Link anmelden + Mit Passwort anmelden Telefonnummer eingeben Geben Sie eine gültige Telefonnummer ein Geben Sie den 6-stelligen Code ein, der gesendet wurde an - Code in 0:%02d erneut senden + Code in %1$s erneut senden Telefonnummer bestätigen Wird verifiziert… Falscher Code. Versuchen Sie es noch einmal. @@ -87,6 +92,98 @@ Telefonnummer wurde automatisch bestätigt Code erneut senden Telefonnummer bestätigen + Use a different phone number 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. + + + Authentifizierungsmethode auswählen + SMS-Bestätigung einrichten + Authenticator-App einrichten + Code bestätigen + Wiederherstellungscodes speichern + + Wählen Sie eine zweite Authentifizierungsmethode aus, um Ihr Konto zu schützen + Geben Sie Ihre Telefonnummer ein, um Bestätigungscodes zu erhalten + Scannen Sie den QR-Code mit Ihrer Authenticator-App + Geben Sie den an Ihr Telefon gesendeten Code ein + Geben Sie den Code aus Ihrer Authenticator-App ein + Geben Sie Ihren Bestätigungscode ein + Bewahren Sie diese Codes an einem sicheren Ort auf. Sie können sie zum Anmelden verwenden, wenn Sie den Zugriff auf Ihre Authentifizierungsmethode verlieren. + + Passwort bestätigen + Passwörter stimmen nicht überein + Das Passwort muss mindestens %1$d Zeichen lang sein + Das Passwort muss mindestens einen Grossbuchstaben enthalten + Das Passwort muss mindestens einen Kleinbuchstaben enthalten + Das Passwort muss mindestens eine Ziffer enthalten + Das Passwort muss mindestens ein Sonderzeichen enthalten + + + SMS-Authentifizierung einrichten + SMS-Code bestätigen + + Für diesen Vorgang ist eine kürzliche Authentifizierung erforderlich. Bitte melden Sie sich erneut an und versuchen Sie es erneut. + Der Bestätigungscode ist falsch. Bitte versuchen Sie es erneut. + Ein Netzwerkfehler ist aufgetreten. Bitte überprüfen Sie Ihre Verbindung und versuchen Sie es erneut. + Bei der Registrierung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut. + + + Zurück + Identität verifiziert. Bitte versuchen Sie Ihre Aktion erneut. + Falsches Passwort. Bitte versuchen Sie es erneut. + Multifaktor-Authentifizierung verwalten + Aktive Methoden + Neue Methode hinzufügen + Alle verfügbaren Authentifizierungsmethoden sind registriert + Registriert am %1$s + SMS-Authentifizierung + Authenticator-App + Unbekannte Methode + Authentifizierungsmethoden für Ihr Konto hinzufügen oder entfernen + Zwei-Faktor-Authentifizierung verwalten + Scannen Sie den QR-Code oder geben Sie den geheimen Schlüssel in Ihrer Authenticator-App ein + Bitte vervollständigen Sie Ihre Profilinformationen, um fortzufahren. + Fehlende Felder: %1$s + Konto: %1$s + Zu Ihrer Sicherheit geben Sie bitte Ihr Passwort erneut ein, um fortzufahren. + Verifizieren Sie Ihre Identität + Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut. + Ich habe diese Codes gespeichert + Entfernen + Bestätigungs-E-Mail erneut senden + Geheimer Schlüssel + Abmelden + Angemeldet als %1$s + Vorerst überspringen + Eine andere Methode verwenden + Verifizierungscode + Ich habe meine E-Mail verifiziert + Verifizieren + Bitte verifizieren Sie %1$s, um fortzufahren. + + Wählen Sie eine Verifizierungsmethode + Fügen Sie eine zusätzliche Sicherheitsebene hinzu + SMS + Authenticator-App + Diese Telefonnummer ist mit einem anderen Konto verknüpft + Verifizierung erforderlich + Scannen Sie den QR-Code mit Ihrer Authenticator-App + Authenticator-App ist bereits eingerichtet + Möchten Sie diese Methode wirklich entfernen? + Methode kann nicht entfernt werden + Methode entfernt + Geben Sie den Verifizierungscode ein + Profil kann nicht aktualisiert werden + Profil aktualisiert + Bestätigen Sie Ihre Identität, um fortzufahren + Erneute Authentifizierung erforderlich + Erneute Authentifizierung erfolgreich + Erneut authentifizieren diff --git a/auth/src/main/res/values-de/strings.xml b/auth/src/main/res/values-de/strings.xml index 378aff0c0..cb558cc6b 100755 --- a/auth/src/main/res/values-de/strings.xml +++ b/auth/src/main/res/values-de/strings.xml @@ -25,6 +25,8 @@ E-Mail-Adresse Telefonnummer Land + Land auswählen + Land suchen z. B. +1, "US" Passwort Neues Passwort Pflichtfeld. @@ -73,10 +75,13 @@ E-Mail-Adresse bestätigen, um Anmeldung fortzusetzen Ablehnen Sie haben versucht, %1$s auf einem Gerät, auf dem Sie nicht angemeldet sind, mit Ihrem E-Mail-Konto zu verbinden.\n\nWenn Sie Ihr %1$s-Konto weiterhin verbinden möchten, öffnen Sie den Link bitte auf dem Gerät, auf dem Sie den Anmeldevorgang gestartet haben. Andernfalls tippen Sie auf \"Weiter\", um sich auf diesem Gerät anzumelden. + oder Fortfahren mit + Mit E-Mail-Link anmelden + Mit Passwort anmelden Telefonnummer eingeben Geben Sie eine gültige Telefonnummer ein Geben Sie den 6-stelligen Code ein, der gesendet wurde an - Code in 0:%02d erneut senden + Code in %1$s erneut senden Telefonnummer bestätigen Wird verifiziert… Falscher Code. Versuchen Sie es noch einmal. @@ -87,6 +92,97 @@ Telefonnummer wurde automatisch bestätigt Code erneut senden Telefonnummer bestätigen + Use a different phone number 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. + + + Authentifizierungsmethode auswählen + SMS-Bestätigung einrichten + Authenticator-App einrichten + Code bestätigen + Wiederherstellungscodes speichern + + Wählen Sie eine zweite Authentifizierungsmethode aus, um Ihr Konto zu schützen + Geben Sie Ihre Telefonnummer ein, um Bestätigungscodes zu erhalten + Scannen Sie den QR-Code mit Ihrer Authenticator-App + Geben Sie den an Ihr Telefon gesendeten Code ein + Geben Sie den Code aus Ihrer Authenticator-App ein + Geben Sie Ihren Bestätigungscode ein + Bewahren Sie diese Codes an einem sicheren Ort auf. Sie können sie zum Anmelden verwenden, wenn Sie den Zugriff auf Ihre Authentifizierungsmethode verlieren. + + Passwort bestätigen + Passwörter stimmen nicht überein + Das Passwort muss mindestens %1$d Zeichen lang sein + Das Passwort muss mindestens einen Großbuchstaben enthalten + Das Passwort muss mindestens einen Kleinbuchstaben enthalten + Das Passwort muss mindestens eine Ziffer enthalten + Das Passwort muss mindestens ein Sonderzeichen enthalten + + + SMS-Authentifizierung einrichten + SMS-Code bestätigen + + Für diesen Vorgang ist eine kürzliche Authentifizierung erforderlich. Bitte melden Sie sich erneut an und versuchen Sie es erneut. + Der Bestätigungscode ist falsch. Bitte versuchen Sie es erneut. + Ein Netzwerkfehler ist aufgetreten. Bitte überprüfen Sie Ihre Verbindung und versuchen Sie es erneut. + Bei der Registrierung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut. + + + Zurück + Identität verifiziert. Bitte versuchen Sie Ihre Aktion erneut. + Falsches Passwort. Bitte versuchen Sie es erneut. + Multifaktor-Authentifizierung verwalten + Aktive Methoden + Neue Methode hinzufügen + Alle verfügbaren Authentifizierungsmethoden sind registriert + Registriert am %1$s + SMS-Authentifizierung + Authenticator-App + Unbekannte Methode + Authentifizierungsmethoden für Ihr Konto hinzufügen oder entfernen + Zwei-Faktor-Authentifizierung verwalten + Scannen Sie den QR-Code oder geben Sie den geheimen Schlüssel in Ihrer Authenticator-App ein + Bitte vervollständigen Sie Ihre Profilinformationen, um fortzufahren. + Fehlende Felder: %1$s + Konto: %1$s + Zu Ihrer Sicherheit geben Sie bitte Ihr Passwort erneut ein, um fortzufahren. + Verifizieren Sie Ihre Identität + Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut. + Ich habe diese Codes gespeichert + Entfernen + Bestätigungs-E-Mail erneut senden + Geheimer Schlüssel + Abmelden + Angemeldet als %1$s + Vorerst überspringen + Eine andere Methode verwenden + Verifizierungscode + Ich habe meine E-Mail verifiziert + Verifizieren + Bitte verifizieren Sie %1$s, um fortzufahren. + + Wählen Sie eine Verifizierungsmethode + Fügen Sie eine zusätzliche Sicherheitsebene hinzu + SMS + Authenticator-App + Diese Telefonnummer ist mit einem anderen Konto verknüpft + Verifizierung erforderlich + Scannen Sie den QR-Code mit Ihrer Authenticator-App + Authenticator-App ist bereits eingerichtet + Möchten Sie diese Methode wirklich entfernen? + Methode kann nicht entfernt werden + Methode entfernt + Geben Sie den Verifizierungscode ein + Profil kann nicht aktualisiert werden + Profil aktualisiert + Bestätigen Sie Ihre Identität, um fortzufahren + Erneute Authentifizierung erforderlich + Erneute Authentifizierung erfolgreich + Erneut authentifizieren diff --git a/auth/src/main/res/values-el/strings.xml b/auth/src/main/res/values-el/strings.xml index dd34114d4..dbf59db3c 100755 --- a/auth/src/main/res/values-el/strings.xml +++ b/auth/src/main/res/values-el/strings.xml @@ -25,6 +25,8 @@ Ηλεκτρονικό ταχυδρομείο Αριθμός τηλεφώνου Χώρα + Επιλογή χώρας + Αναζήτηση χώρας π.χ. +1, "US" Κωδικός πρόσβασης Νέος κωδικός πρόσβασης Αυτό το πεδίο είναι υποχρεωτικό. @@ -73,10 +75,13 @@ Επιβεβαιώστε τη διεύθυνση ηλεκτρονικού ταχυδρομείου για να συνεχίσετε τη σύνδεση Παράβλεψη Αρχικά, σκοπεύατε να συνδέσετε το %1$s με τον λογαριασμό ηλεκτρονικού ταχυδρομείου, αλλά, για το άνοιγμα του συνδέσμου, χρησιμοποιήσατε άλλη συσκευή στην οποία δεν είστε συνδεδεμένοι.\n\nΑν θέλετε να συνεχίσετε τη σύνδεση του λογαριασμού %1$s, ανοίξτε τον σύνδεσμο στην ίδια συσκευή από την οποία ξεκινήσατε τη σύνδεση. Διαφορετικά, πατήστε Συνέχεια για να συνδεθείτε σε αυτήν τη συσκευή. + ή Συνέχεια με + Σύνδεση με σύνδεσμο email + Σύνδεση με κωδικό πρόσβασης Εισαγάγετε τον αριθμό τηλεφώνου σας Καταχωρίστε έναν έγκυρο αριθμό τηλεφώνου Εισαγάγετε τον 6ψήφιο κωδικό που σας στείλαμε στο - Επανάληψη αποστολής κωδικού σε 0:%02d + Επανάληψη αποστολής κωδικού σε %1$s Επαλήθευση του τηλεφώνου σας Επαλήθευση… Εσφαλμένος κωδικός. Δοκιμάστε ξανά. @@ -87,6 +92,80 @@ Ο αριθμός τηλεφώνου επαληθεύτηκε αυτόματα Επανάληψη αποστολής κωδικού Επαλήθευση αριθμού τηλεφώνου + Use a different phone number Αν πατήσετε “%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. + + + Επιλέξτε μέθοδο ελέγχου ταυτότητας + Ρύθμιση επαλήθευσης SMS + Ρύθμιση εφαρμογής ελέγχου ταυτότητας + Επαληθεύστε τον κωδικό σας + Αποθηκεύστε τους κωδικούς ανάκτησης + + Επιλέξτε μια δεύτερη μέθοδο ελέγχου ταυτότητας για να προστατεύσετε τον λογαριασμό σας + Εισαγάγετε τον αριθμό τηλεφώνου σας για να λαμβάνετε κωδικούς επαλήθευσης + Σαρώστε τον κωδικό QR με την εφαρμογή ελέγχου ταυτότητας + Εισαγάγετε τον κωδικό που στάλθηκε στο τηλέφωνό σας + Εισαγάγετε τον κωδικό από την εφαρμογή ελέγχου ταυτότητας + Εισαγάγετε τον κωδικό επαλήθευσης + Αποθηκεύστε αυτούς τους κωδικούς σε ασφαλές μέρος. Μπορείτε να τους χρησιμοποιήσετε για σύνδεση εάν χάσετε την πρόσβαση στη μέθοδο ελέγχου ταυτότητας. + + Επιβεβαίωση κωδικού πρόσβασης + Οι κωδικοί πρόσβασης δεν ταιριάζουν + Ο κωδικός πρόσβασης πρέπει να αποτελείται από τουλάχιστον %1$d χαρακτήρες + Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα κεφαλαίο γράμμα + Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα μικρό γράμμα + Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον έναν αριθμό + Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον έναν ειδικό χαρακτήρα + + + Ρύθμιση ελέγχου ταυτότητας SMS + Επαλήθευση κωδικού SMS + + Αυτή η λειτουργία απαιτεί πρόσφατο έλεγχο ταυτότητας. Συνδεθείτε ξανά και δοκιμάστε ξανά. + Ο κωδικός επαλήθευσης είναι εσφαλμένος. Δοκιμάστε ξανά. + Παρουσιάστηκε σφάλμα δικτύου. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά. + Παρουσιάστηκε σφάλμα κατά την εγγραφή. Δοκιμάστε ξανά. + + Πίσω + Η ταυτότητα επαληθεύτηκε. Δοκιμάστε ξανά την ενέργειά σας. + Διαχείριση ελέγχου ταυτότητας δύο παραγόντων + Εσφαλμένος κωδικός πρόσβασης + Επιλέξτε μέθοδο επαλήθευσης + Προσθέστε ένα επιπλέον επίπεδο ασφαλείας + SMS + Εφαρμογή ελέγχου ταυτότητας + Αυτός ο αριθμός τηλεφώνου σχετίζεται με άλλο λογαριασμό + Απαιτείται επαλήθευση + Σαρώστε τον κωδικό QR με την εφαρμογή ελέγχου ταυτότητας + Η εφαρμογή ελέγχου ταυτότητας έχει ήδη ρυθμιστεί + Θέλετε σίγουρα να καταργήσετε αυτήν τη μέθοδο; + Δεν είναι δυνατή η κατάργηση της μεθόδου + Η μέθοδος καταργήθηκε + Εισαγάγετε τον κωδικό επαλήθευσης + Δεν είναι δυνατή η ενημέρωση του προφίλ + Το προφίλ ενημερώθηκε + Επιβεβαιώστε την ταυτότητά σας για να συνεχίσετε + Απαιτείται επανέλεγχος ταυτότητας + Ο επανέλεγχος ταυτότητας ολοκληρώθηκε + Επανέλεγχος + Έχω αποθηκεύσει τους κωδικούς ανάκτησης + Κατάργηση + Επαναποστολή email επαλήθευσης + Μυστικό κλειδί + Αποσύνδεση + Συνδεδεμένος ως + Παράλειψη + Χρήση διαφορετικής μεθόδου + Κωδικός επαλήθευσης + Το email επαληθεύτηκε + Επαλήθευση + Στείλαμε email επαλήθευσης στο %1$s diff --git a/auth/src/main/res/values-en-rAU/strings.xml b/auth/src/main/res/values-en-rAU/strings.xml index 58b0b9245..8509ca999 100755 --- a/auth/src/main/res/values-en-rAU/strings.xml +++ b/auth/src/main/res/values-en-rAU/strings.xml @@ -13,7 +13,7 @@ Email Sign in with Google Sign in with Facebook - Sign in with Twitter + Sign in with X Sign in with GitHub Sign in with email Sign in with phone @@ -25,6 +25,8 @@ Email Phone number Country + Select a country + Select for country e.g. +1, "US" Password New password You can\'t leave this empty. @@ -73,10 +75,13 @@ Confirm email to continue sign in Dismiss You originally intended to connect %1$s to your email account, but have opened the link on a different device where you are not signed in.\n\nIf you still want to connect your %1$s account, open the link on the same device on which you started sign-in. Otherwise, tap \'Continue\' to sign in on this device. + or Continue with + Sign in with email link + Sign in with password Enter your phone number Enter a valid phone number Enter the 6-digit code that we sent to - Resend code in 0:%02d + Resend code in %1$s Verify your phone number Verifying… Wrong code. Try again. @@ -87,6 +92,79 @@ Phone number automatically verified Resend code Verify Phone Number + Use a different 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. + + + Choose Authentication Method + Set Up SMS Verification + Set Up Authenticator App + Verify Your Code + Save Your Recovery Codes + + Select a second authentication method to secure your account + Enter your phone number to receive verification codes + Scan the QR code with your authenticator app + Enter the code sent to your phone + Enter the code from your authenticator app + Enter your verification code + Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method. + + Confirm 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 + + + Set Up SMS Authentication + Verify SMS Code + + This operation requires recent authentication. Please sign in again and try again. + The verification code is incorrect. Please try again. + A network error occurred. Please check your connection and try again. + An error occurred during enrolment. Please try again. + + Back + Identity verified. Retry your action. + Manage two-factor authentication + Incorrect password + Choose a verification method + Add an extra layer of security + SMS + Authenticator app + This phone number is associated with another account + Verification required + Scan the QR code with your authenticator app + Authenticator app is already set up + Are you sure you want to remove this method? + Unable to remove method + Method removed + Enter the verification code + Unable to update profile + Profile updated + Confirm your identity to continue + Re-authentication required + Re-authentication successful + Re-authenticate + I have saved my recovery codes + Remove + Resend verification email + Secret key + Sign out + Signed in as + Skip + Use a different method + Verification code + Email verified + Verify + We sent a verification email to %1$s diff --git a/auth/src/main/res/values-en-rCA/strings.xml b/auth/src/main/res/values-en-rCA/strings.xml index 58b0b9245..1912dd905 100755 --- a/auth/src/main/res/values-en-rCA/strings.xml +++ b/auth/src/main/res/values-en-rCA/strings.xml @@ -13,7 +13,7 @@ Email Sign in with Google Sign in with Facebook - Sign in with Twitter + Sign in with X Sign in with GitHub Sign in with email Sign in with phone @@ -25,6 +25,8 @@ Email Phone number Country + Select a country + Select for country e.g. +1, "US" Password New password You can\'t leave this empty. @@ -73,10 +75,13 @@ Confirm email to continue sign in Dismiss You originally intended to connect %1$s to your email account, but have opened the link on a different device where you are not signed in.\n\nIf you still want to connect your %1$s account, open the link on the same device on which you started sign-in. Otherwise, tap \'Continue\' to sign in on this device. + or Continue with + Sign in with email link + Sign in with password Enter your phone number Enter a valid phone number Enter the 6-digit code that we sent to - Resend code in 0:%02d + Resend code in %1$s Verify your phone number Verifying… Wrong code. Try again. @@ -87,6 +92,79 @@ Phone number automatically verified Resend code Verify Phone Number + Use a different 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. + + + Choose Authentication Method + Set Up SMS Verification + Set Up Authenticator App + Verify Your Code + Save Your Recovery Codes + + Select a second authentication method to secure your account + Enter your phone number to receive verification codes + Scan the QR code with your authenticator app + Enter the code sent to your phone + Enter the code from your authenticator app + Enter your verification code + Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method. + + Confirm password + Passwords do not match + Password must be at least %1$d characters long + Password must contain at least one upper-case letter + Password must contain at least one lower-case letter + Password must contain at least one number + Password must contain at least one special character + + + Set Up SMS Authentication + Verify SMS Code + + This operation requires recent authentication. Please sign in again and try again. + The verification code is incorrect. Please try again. + A network error occurred. Please check your connection and try again. + An error occurred during enrollment. Please try again. + + Back + Identity verified. Retry your action. + Manage two-factor authentication + Incorrect password + Choose a verification method + Add an extra layer of security + SMS + Authenticator app + This phone number is associated with another account + Verification required + Scan the QR code with your authenticator app + Authenticator app is already set up + Are you sure you want to remove this method? + Unable to remove method + Method removed + Enter the verification code + Unable to update profile + Profile updated + Confirm your identity to continue + Re-authentication required + Re-authentication successful + Re-authenticate + I have saved my recovery codes + Remove + Resend verification email + Secret key + Sign out + Signed in as + Skip + Use a different method + Verification code + Email verified + Verify + We sent a verification email to %1$s diff --git a/auth/src/main/res/values-en-rGB/strings.xml b/auth/src/main/res/values-en-rGB/strings.xml index 58b0b9245..5c3b2492c 100755 --- a/auth/src/main/res/values-en-rGB/strings.xml +++ b/auth/src/main/res/values-en-rGB/strings.xml @@ -13,7 +13,7 @@ Email Sign in with Google Sign in with Facebook - Sign in with Twitter + Sign in with X Sign in with GitHub Sign in with email Sign in with phone @@ -25,6 +25,8 @@ Email Phone number Country + Select a country + Select for country e.g. +1, "US" Password New password You can\'t leave this empty. @@ -73,10 +75,13 @@ Confirm email to continue sign in Dismiss You originally intended to connect %1$s to your email account, but have opened the link on a different device where you are not signed in.\n\nIf you still want to connect your %1$s account, open the link on the same device on which you started sign-in. Otherwise, tap \'Continue\' to sign in on this device. + or Continue with + Sign in with email link + Sign in with password Enter your phone number Enter a valid phone number Enter the 6-digit code that we sent to - Resend code in 0:%02d + Resend code in %1$s Verify your phone number Verifying… Wrong code. Try again. @@ -87,6 +92,79 @@ Phone number automatically verified Resend code Verify Phone Number + Use a different 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. + + + Choose Authentication Method + Set Up SMS Verification + Set Up Authenticator App + Verify Your Code + Save Your Recovery Codes + + Select a second authentication method to secure your account + Enter your phone number to receive verification codes + Scan the QR code with your authenticator app + Enter the code sent to your phone + Enter the code from your authenticator app + Enter your verification code + Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method. + + Confirm password + Passwords do not match + Password must be at least %1$d characters long + Password must contain at least one upper-case letter + Password must contain at least one lower-case letter + Password must contain at least one number + Password must contain at least one special character + + + Set Up SMS Authentication + Verify SMS Code + + This operation requires recent authentication. Please sign in again and try again. + The verification code is incorrect. Please try again. + A network error occurred. Please check your connection and try again. + An error occurred during enrolment. Please try again. + + Back + Identity verified. Retry your action. + Manage two-factor authentication + Incorrect password + Choose a verification method + Add an extra layer of security + SMS + Authenticator app + This phone number is associated with another account + Verification required + Scan the QR code with your authenticator app + Authenticator app is already set up + Are you sure you want to remove this method? + Unable to remove method + Method removed + Enter the verification code + Unable to update profile + Profile updated + Confirm your identity to continue + Re-authentication required + Re-authentication successful + Re-authenticate + I have saved my recovery codes + Remove + Resend verification email + Secret key + Sign out + Signed in as + Skip + Use a different method + Verification code + Email verified + Verify + We sent a verification email to %1$s diff --git a/auth/src/main/res/values-en-rIE/strings.xml b/auth/src/main/res/values-en-rIE/strings.xml index 58b0b9245..17311fc1d 100755 --- a/auth/src/main/res/values-en-rIE/strings.xml +++ b/auth/src/main/res/values-en-rIE/strings.xml @@ -13,7 +13,7 @@ Email Sign in with Google Sign in with Facebook - Sign in with Twitter + Sign in with X Sign in with GitHub Sign in with email Sign in with phone @@ -25,6 +25,8 @@ Email Phone number Country + Select a country + Select for country e.g. +1, "US" Password New password You can\'t leave this empty. @@ -73,10 +75,13 @@ Confirm email to continue sign in Dismiss You originally intended to connect %1$s to your email account, but have opened the link on a different device where you are not signed in.\n\nIf you still want to connect your %1$s account, open the link on the same device on which you started sign-in. Otherwise, tap \'Continue\' to sign in on this device. + or Continue with + Sign in with email link + Sign in with password Enter your phone number Enter a valid phone number Enter the 6-digit code that we sent to - Resend code in 0:%02d + Resend code in %1$s Verify your phone number Verifying… Wrong code. Try again. @@ -87,6 +92,72 @@ Phone number automatically verified Resend code Verify Phone Number + Use a different 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. + + + Choose Authentication Method + Set Up SMS Verification + Set Up Authenticator App + Verify Your Code + Save Your Recovery Codes + + Select a second authentication method to secure your account + Enter your phone number to receive verification codes + Scan the QR code with your authenticator app + Enter the code sent to your phone + Enter the code from your authenticator app + Enter your verification code + Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method. + + + + Set Up SMS Authentication + Verify SMS Code + + This operation requires recent authentication. Please sign in again and try again. + The verification code is incorrect. Please try again. + A network error occurred. Please check your connection and try again. + An error occurred during enrolment. Please try again. + + Back + Identity verified. Retry your action. + Manage two-factor authentication + Incorrect password + Choose a verification method + Add an extra layer of security + SMS + Authenticator app + This phone number is associated with another account + Verification required + Scan the QR code with your authenticator app + Authenticator app is already set up + Are you sure you want to remove this method? + Unable to remove method + Method removed + Enter the verification code + Unable to update profile + Profile updated + Confirm your identity to continue + Re-authentication required + Re-authentication successful + Re-authenticate + I have saved my recovery codes + Remove + Resend verification email + Secret key + Sign out + Signed in as + Skip + Use a different method + Verification code + Email verified + Verify + We sent a verification email to %1$s diff --git a/auth/src/main/res/values-en-rIN/strings.xml b/auth/src/main/res/values-en-rIN/strings.xml index 58b0b9245..17311fc1d 100755 --- a/auth/src/main/res/values-en-rIN/strings.xml +++ b/auth/src/main/res/values-en-rIN/strings.xml @@ -13,7 +13,7 @@ Email Sign in with Google Sign in with Facebook - Sign in with Twitter + Sign in with X Sign in with GitHub Sign in with email Sign in with phone @@ -25,6 +25,8 @@ Email Phone number Country + Select a country + Select for country e.g. +1, "US" Password New password You can\'t leave this empty. @@ -73,10 +75,13 @@ Confirm email to continue sign in Dismiss You originally intended to connect %1$s to your email account, but have opened the link on a different device where you are not signed in.\n\nIf you still want to connect your %1$s account, open the link on the same device on which you started sign-in. Otherwise, tap \'Continue\' to sign in on this device. + or Continue with + Sign in with email link + Sign in with password Enter your phone number Enter a valid phone number Enter the 6-digit code that we sent to - Resend code in 0:%02d + Resend code in %1$s Verify your phone number Verifying… Wrong code. Try again. @@ -87,6 +92,72 @@ Phone number automatically verified Resend code Verify Phone Number + Use a different 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. + + + Choose Authentication Method + Set Up SMS Verification + Set Up Authenticator App + Verify Your Code + Save Your Recovery Codes + + Select a second authentication method to secure your account + Enter your phone number to receive verification codes + Scan the QR code with your authenticator app + Enter the code sent to your phone + Enter the code from your authenticator app + Enter your verification code + Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method. + + + + Set Up SMS Authentication + Verify SMS Code + + This operation requires recent authentication. Please sign in again and try again. + The verification code is incorrect. Please try again. + A network error occurred. Please check your connection and try again. + An error occurred during enrolment. Please try again. + + Back + Identity verified. Retry your action. + Manage two-factor authentication + Incorrect password + Choose a verification method + Add an extra layer of security + SMS + Authenticator app + This phone number is associated with another account + Verification required + Scan the QR code with your authenticator app + Authenticator app is already set up + Are you sure you want to remove this method? + Unable to remove method + Method removed + Enter the verification code + Unable to update profile + Profile updated + Confirm your identity to continue + Re-authentication required + Re-authentication successful + Re-authenticate + I have saved my recovery codes + Remove + Resend verification email + Secret key + Sign out + Signed in as + Skip + Use a different method + Verification code + Email verified + Verify + We sent a verification email to %1$s diff --git a/auth/src/main/res/values-en-rSG/strings.xml b/auth/src/main/res/values-en-rSG/strings.xml index 58b0b9245..17311fc1d 100755 --- a/auth/src/main/res/values-en-rSG/strings.xml +++ b/auth/src/main/res/values-en-rSG/strings.xml @@ -13,7 +13,7 @@ Email Sign in with Google Sign in with Facebook - Sign in with Twitter + Sign in with X Sign in with GitHub Sign in with email Sign in with phone @@ -25,6 +25,8 @@ Email Phone number Country + Select a country + Select for country e.g. +1, "US" Password New password You can\'t leave this empty. @@ -73,10 +75,13 @@ Confirm email to continue sign in Dismiss You originally intended to connect %1$s to your email account, but have opened the link on a different device where you are not signed in.\n\nIf you still want to connect your %1$s account, open the link on the same device on which you started sign-in. Otherwise, tap \'Continue\' to sign in on this device. + or Continue with + Sign in with email link + Sign in with password Enter your phone number Enter a valid phone number Enter the 6-digit code that we sent to - Resend code in 0:%02d + Resend code in %1$s Verify your phone number Verifying… Wrong code. Try again. @@ -87,6 +92,72 @@ Phone number automatically verified Resend code Verify Phone Number + Use a different 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. + + + Choose Authentication Method + Set Up SMS Verification + Set Up Authenticator App + Verify Your Code + Save Your Recovery Codes + + Select a second authentication method to secure your account + Enter your phone number to receive verification codes + Scan the QR code with your authenticator app + Enter the code sent to your phone + Enter the code from your authenticator app + Enter your verification code + Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method. + + + + Set Up SMS Authentication + Verify SMS Code + + This operation requires recent authentication. Please sign in again and try again. + The verification code is incorrect. Please try again. + A network error occurred. Please check your connection and try again. + An error occurred during enrolment. Please try again. + + Back + Identity verified. Retry your action. + Manage two-factor authentication + Incorrect password + Choose a verification method + Add an extra layer of security + SMS + Authenticator app + This phone number is associated with another account + Verification required + Scan the QR code with your authenticator app + Authenticator app is already set up + Are you sure you want to remove this method? + Unable to remove method + Method removed + Enter the verification code + Unable to update profile + Profile updated + Confirm your identity to continue + Re-authentication required + Re-authentication successful + Re-authenticate + I have saved my recovery codes + Remove + Resend verification email + Secret key + Sign out + Signed in as + Skip + Use a different method + Verification code + Email verified + Verify + We sent a verification email to %1$s diff --git a/auth/src/main/res/values-en-rZA/strings.xml b/auth/src/main/res/values-en-rZA/strings.xml index 58b0b9245..17311fc1d 100755 --- a/auth/src/main/res/values-en-rZA/strings.xml +++ b/auth/src/main/res/values-en-rZA/strings.xml @@ -13,7 +13,7 @@ Email Sign in with Google Sign in with Facebook - Sign in with Twitter + Sign in with X Sign in with GitHub Sign in with email Sign in with phone @@ -25,6 +25,8 @@ Email Phone number Country + Select a country + Select for country e.g. +1, "US" Password New password You can\'t leave this empty. @@ -73,10 +75,13 @@ Confirm email to continue sign in Dismiss You originally intended to connect %1$s to your email account, but have opened the link on a different device where you are not signed in.\n\nIf you still want to connect your %1$s account, open the link on the same device on which you started sign-in. Otherwise, tap \'Continue\' to sign in on this device. + or Continue with + Sign in with email link + Sign in with password Enter your phone number Enter a valid phone number Enter the 6-digit code that we sent to - Resend code in 0:%02d + Resend code in %1$s Verify your phone number Verifying… Wrong code. Try again. @@ -87,6 +92,72 @@ Phone number automatically verified Resend code Verify Phone Number + Use a different 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. + + + Choose Authentication Method + Set Up SMS Verification + Set Up Authenticator App + Verify Your Code + Save Your Recovery Codes + + Select a second authentication method to secure your account + Enter your phone number to receive verification codes + Scan the QR code with your authenticator app + Enter the code sent to your phone + Enter the code from your authenticator app + Enter your verification code + Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method. + + + + Set Up SMS Authentication + Verify SMS Code + + This operation requires recent authentication. Please sign in again and try again. + The verification code is incorrect. Please try again. + A network error occurred. Please check your connection and try again. + An error occurred during enrolment. Please try again. + + Back + Identity verified. Retry your action. + Manage two-factor authentication + Incorrect password + Choose a verification method + Add an extra layer of security + SMS + Authenticator app + This phone number is associated with another account + Verification required + Scan the QR code with your authenticator app + Authenticator app is already set up + Are you sure you want to remove this method? + Unable to remove method + Method removed + Enter the verification code + Unable to update profile + Profile updated + Confirm your identity to continue + Re-authentication required + Re-authentication successful + Re-authenticate + I have saved my recovery codes + Remove + Resend verification email + Secret key + Sign out + Signed in as + Skip + Use a different method + Verification code + Email verified + Verify + We sent a verification email to %1$s diff --git a/auth/src/main/res/values-es-rAR/strings.xml b/auth/src/main/res/values-es-rAR/strings.xml index fccdf579c..c192b123f 100755 --- a/auth/src/main/res/values-es-rAR/strings.xml +++ b/auth/src/main/res/values-es-rAR/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Volvé a acceder y probá de nuevo. + El código de verificación es incorrecto. Probá de nuevo. + Se produjo un error de red. Verificá tu conexión y probá de nuevo. + Se produjo un error durante la inscripción. Probá de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rBO/strings.xml b/auth/src/main/res/values-es-rBO/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rBO/strings.xml +++ b/auth/src/main/res/values-es-rBO/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rCL/strings.xml b/auth/src/main/res/values-es-rCL/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rCL/strings.xml +++ b/auth/src/main/res/values-es-rCL/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rCO/strings.xml b/auth/src/main/res/values-es-rCO/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rCO/strings.xml +++ b/auth/src/main/res/values-es-rCO/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rCR/strings.xml b/auth/src/main/res/values-es-rCR/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rCR/strings.xml +++ b/auth/src/main/res/values-es-rCR/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rDO/strings.xml b/auth/src/main/res/values-es-rDO/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rDO/strings.xml +++ b/auth/src/main/res/values-es-rDO/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rEC/strings.xml b/auth/src/main/res/values-es-rEC/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rEC/strings.xml +++ b/auth/src/main/res/values-es-rEC/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rGT/strings.xml b/auth/src/main/res/values-es-rGT/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rGT/strings.xml +++ b/auth/src/main/res/values-es-rGT/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rHN/strings.xml b/auth/src/main/res/values-es-rHN/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rHN/strings.xml +++ b/auth/src/main/res/values-es-rHN/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rMX/strings.xml b/auth/src/main/res/values-es-rMX/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rMX/strings.xml +++ b/auth/src/main/res/values-es-rMX/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rNI/strings.xml b/auth/src/main/res/values-es-rNI/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rNI/strings.xml +++ b/auth/src/main/res/values-es-rNI/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rPA/strings.xml b/auth/src/main/res/values-es-rPA/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rPA/strings.xml +++ b/auth/src/main/res/values-es-rPA/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rPE/strings.xml b/auth/src/main/res/values-es-rPE/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rPE/strings.xml +++ b/auth/src/main/res/values-es-rPE/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rPR/strings.xml b/auth/src/main/res/values-es-rPR/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rPR/strings.xml +++ b/auth/src/main/res/values-es-rPR/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rPY/strings.xml b/auth/src/main/res/values-es-rPY/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rPY/strings.xml +++ b/auth/src/main/res/values-es-rPY/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rSV/strings.xml b/auth/src/main/res/values-es-rSV/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rSV/strings.xml +++ b/auth/src/main/res/values-es-rSV/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rUS/strings.xml b/auth/src/main/res/values-es-rUS/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rUS/strings.xml +++ b/auth/src/main/res/values-es-rUS/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rUY/strings.xml b/auth/src/main/res/values-es-rUY/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rUY/strings.xml +++ b/auth/src/main/res/values-es-rUY/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es-rVE/strings.xml b/auth/src/main/res/values-es-rVE/strings.xml index fccdf579c..60a074ff2 100755 --- a/auth/src/main/res/values-es-rVE/strings.xml +++ b/auth/src/main/res/values-es-rVE/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Contraseña nueva Este campo no puede estar en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el acceso Descartar Originalmente, intentaste conectar tu cuenta de correo electrónico con %1$s, pero abriste el vínculo en un dispositivo diferente en el que no accediste.\n\nSi quieres conectar tu cuenta de %1$s, abre el vínculo en el mismo dispositivo con el que iniciaste el acceso. De lo contrario, presiona Continuar para acceder en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Ingresa tu número de teléfono Ingresa un número de teléfono válido Ingresa el código de 6 dígitos que enviamos al número - Se reenviará el código en 0:%02d + Se reenviará el código en %1$s Verifica tu número de teléfono Verificando… Código incorrecto. Vuelve a intentarlo. @@ -87,6 +92,90 @@ Se verificó automáticamente el número de teléfono Reenviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se produjo un error de red. Verifica tu conexión e inténtalo de nuevo. + Se produjo un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-es/strings.xml b/auth/src/main/res/values-es/strings.xml index c5decbad9..61949a41b 100755 --- a/auth/src/main/res/values-es/strings.xml +++ b/auth/src/main/res/values-es/strings.xml @@ -25,6 +25,8 @@ Correo electrónico Número de teléfono País + Selecciona un país + Buscar país por ej. +1, "US" Contraseña Nueva contraseña No puedes dejar este campo en blanco. @@ -73,10 +75,13 @@ Confirma el correo electrónico para continuar con el inicio de sesión Cerrar En un principio intentaste conectar %1$s a tu cuenta de correo electrónico, pero has abierto el enlace en un dispositivo diferente en el que no has iniciado sesión.\n\nSi sigues queriendo vincular tu cuenta de %1$s, abre el enlace en el mismo dispositivo en el que empezaste el inicio de sesión. De lo contrario, toca Continuar para iniciar sesión en este dispositivo. + o Continuar con + Iniciar sesión con enlace de correo + Iniciar sesión con contraseña Introduce tu número de teléfono Introduce un número de teléfono válido Introduce el código de seis dígitos que hemos enviado a - Volver a enviar el código en 0:%02d + Volver a enviar el código en %1$s Verificar el número de teléfono Verificando… El código es incorrecto. Vuelve a intentarlo. @@ -87,6 +92,97 @@ Se ha verificado automáticamente el número de teléfono Volver a enviar código Verificar número de teléfono + Use a different phone number 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. + + + Elige el método de autenticación + Configurar verificación por SMS + Configurar aplicación de autenticación + Verifica tu código + Guarda tus códigos de recuperación + + Selecciona un segundo método de autenticación para proteger tu cuenta + Introduce tu número de teléfono para recibir códigos de verificación + Escanea el código QR con tu aplicación de autenticación + Introduce el código enviado a tu teléfono + Introduce el código de tu aplicación de autenticación + Introduce tu código de verificación + Guarda estos códigos en un lugar seguro. Puedes usarlos para iniciar sesión si pierdes el acceso a tu método de autenticación. + + Confirmar contraseña + Las contraseñas no coinciden + La contraseña debe tener al menos %1$d caracteres + La contraseña debe contener al menos una letra mayúscula + La contraseña debe contener al menos una letra minúscula + La contraseña debe contener al menos un número + La contraseña debe contener al menos un carácter especial + + + Configurar autenticación por SMS + Verificar código SMS + + Esta operación requiere autenticación reciente. Vuelve a iniciar sesión e inténtalo de nuevo. + El código de verificación es incorrecto. Inténtalo de nuevo. + Se ha producido un error de red. Comprueba tu conexión e inténtalo de nuevo. + Se ha producido un error durante la inscripción. Inténtalo de nuevo. + + + Atrás + Identidad verificada. Vuelve a intentar tu acción. + Contraseña incorrecta. Inténtalo de nuevo. + Administrar autenticación multifactor + Métodos activos + Agregar nuevo método + Todos los métodos de autenticación disponibles están registrados + Registrado el %1$s + Autenticación por SMS + Aplicación de autenticación + Método desconocido + Agregar o eliminar métodos de autenticación para tu cuenta + Administrar autenticación de dos factores + Escanea el código QR o ingresa la clave secreta en tu aplicación de autenticación + Completa tu información de perfil para continuar. + Campos faltantes: %1$s + Cuenta: %1$s + Por tu seguridad, vuelve a ingresar tu contraseña para continuar. + Verifica tu identidad + Error de autenticación. Inténtalo de nuevo. + He guardado estos códigos + Eliminar + Reenviar correo de verificación + Clave secreta + Cerrar sesión + Sesión iniciada como %1$s + Omitir por ahora + Usar un método diferente + Código de verificación + He verificado mi correo electrónico + Verificar + Verifica %1$s para continuar. + + Elige un método de verificación + Agrega una capa adicional de seguridad + SMS + Aplicación de autenticación + Este número de teléfono está asociado con otra cuenta + Verificación requerida + Escanea el código QR con tu aplicación de autenticación + La aplicación de autenticación ya está configurada + ¿Estás seguro de que quieres eliminar este método? + No se puede eliminar el método + Método eliminado + Ingresa el código de verificación + No se puede actualizar el perfil + Perfil actualizado + Confirma tu identidad para continuar + Se requiere reautenticación + Reautenticación exitosa + Reautenticar diff --git a/auth/src/main/res/values-fa/strings.xml b/auth/src/main/res/values-fa/strings.xml index 9085ee1d3..52b14eac5 100755 --- a/auth/src/main/res/values-fa/strings.xml +++ b/auth/src/main/res/values-fa/strings.xml @@ -25,6 +25,8 @@ ایمیل شماره تلفن کشور + انتخاب کشور + جستجوی کشور مثلاً +1، "US" گذرواژه گذرواژه جدید اینجا نباید خالی باشد. @@ -73,10 +75,13 @@ برای ادامه ورود به سیستم، ایمیل را به‌تأیید برسانید نپذیرفتن ابتدا قصد داشتید %1$s را به حساب ایمیلتان متصل کنید، اما پیوند را در دستگاه دیگری باز کردید که در آن وارد سیستم نشده‌اید.\n\nاگر همچنان می‌خواهید حساب %1$s را متصل کنید، پیوند را در همان دستگاهی باز کنید که ورود به سیستم را با آن شروع کرده‌اید. درغیراین‌صورت، برای ورود به سیستم با این دستگاه، روی «ادامه» ضربه بزنید. + یا ادامه با + ورود با پیوند ایمیل + ورود با رمز عبور شماره تلفن خود را وارد کنید شماره تلفن معتبری وارد کنید وارد کردن کد ۶ رقمی ارسال‌شده به - کد پس از %02d:0 مجدداً ارسال می‌شود + کد پس از %1$s:0 مجدداً ارسال می‌شود تأیید شماره تلفن درحال تأیید… کد اشتباه است. دوباره امتحان کنید. @@ -87,6 +92,80 @@ شماره تلفن به‌طور خودکار به‌تأیید رسید ارسال مجدد کد تأیید شماره تلفن + Use a different phone number با ضربه زدن روی «%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. + + + روش احراز هویت را انتخاب کنید + تأیید پیامک را راه‌اندازی کنید + برنامه احراز هویت را راه‌اندازی کنید + کد خود را تأیید کنید + کدهای بازیابی خود را ذخیره کنید + + برای ایمن کردن حساب خود، روش دوم احراز هویت را انتخاب کنید + شماره تلفن خود را وارد کنید تا کدهای تأیید دریافت کنید + کد QR را با برنامه احراز هویت خود اسکن کنید + کد ارسال شده به تلفن خود را وارد کنید + کد برنامه احراز هویت خود را وارد کنید + کد تأیید خود را وارد کنید + این کدها را در مکانی امن ذخیره کنید. اگر دسترسی به روش احراز هویت خود را از دست دادید، می‌توانید از آن‌ها برای ورود استفاده کنید. + + تأیید گذرواژه + گذرواژه‌ها مطابقت ندارند + گذرواژه باید حداقل %1$d نویسه باشد + گذرواژه باید حداقل یک حرف بزرگ داشته باشد + گذرواژه باید حداقل یک حرف کوچک داشته باشد + گذرواژه باید حداقل یک عدد داشته باشد + گذرواژه باید حداقل یک نویسه خاص داشته باشد + + + راه‌اندازی احراز هویت پیامک + تأیید کد پیامک + + این عملیات نیاز به احراز هویت اخیر دارد. لطفاً دوباره وارد سیستم شوید و دوباره امتحان کنید. + کد تأیید نادرست است. لطفاً دوباره امتحان کنید. + خطای شبکه رخ داد. لطفاً اتصال خود را بررسی کنید و دوباره امتحان کنید. + در طول ثبت‌نام خطایی رخ داد. لطفاً دوباره امتحان کنید. + + بازگشت + هویت تأیید شد. دوباره امتحان کنید. + مدیریت احراز هویت دو مرحله‌ای + رمز عبور نادرست + روش تأیید را انتخاب کنید + لایه امنیتی اضافی اضافه کنید + پیامک + برنامه احراز هویت + این شماره تلفن با حساب دیگری مرتبط است + تأیید لازم است + کد QR را با برنامه احراز هویت خود اسکن کنید + برنامه احراز هویت قبلاً تنظیم شده است + آیا مطمئن هستید که می‌خواهید این روش را حذف کنید؟ + نمی‌توان روش را حذف کرد + روش حذف شد + کد تأیید را وارد کنید + نمی‌توان نمایه را به‌روزرسانی کرد + نمایه به‌روزرسانی شد + برای ادامه، هویت خود را تأیید کنید + احراز هویت مجدد لازم است + احراز هویت مجدد موفق بود + احراز هویت مجدد + کدهای بازیابی خود را ذخیره کردم + حذف + ارسال مجدد ایمیل تأیید + کلید مخفی + خروج + وارد شده به عنوان + رد شدن + استفاده از روش دیگر + کد تأیید + ایمیل تأیید شده + تأیید + ایمیل تأیید به %1$s ارسال کردیم diff --git a/auth/src/main/res/values-fi/strings.xml b/auth/src/main/res/values-fi/strings.xml index 3713aeb0c..6257d0e87 100755 --- a/auth/src/main/res/values-fi/strings.xml +++ b/auth/src/main/res/values-fi/strings.xml @@ -25,6 +25,8 @@ Sähköposti Puhelinnumero Maa + Valitse maa + Hae maata esim. +1, "US" Salasana Uusi salasana Tätä ei voi jättää tyhjäksi. @@ -73,10 +75,13 @@ Vahvista sähköposti jatkaaksesi kirjautumista Hylkää Tarkoituksesi oli yhdistää palvelu %1$s sähköpostiosoitteeseesi, mutta avasit linkin eri laitteella, johon et ollut kirjautunut.\n\Jos haluat edelleen yhdistää palvelun %1$s tilisi, avaa linkki samalla laitteella, jolla aloitit kirjautumisen. Jos haluat jatkaa kirjautumista tällä laitteella, napauta Jatka. + tai Jatka käyttäen + Kirjaudu sähköpostilinkillä + Kirjaudu salasanalla Anna puhelinnumerosi Anna voimassa oleva puhelinnumero. Anna 6 merkin pituinen koodi, jonka lähetimme numeroon - Lähetä koodi uudelleen seuraavan ajan kuluttua: 0:%02d. + Lähetä koodi uudelleen seuraavan ajan kuluttua: %1$s. Puhelinnumeron vahvistaminen Vahvistetaan… Väärä koodi. Yritä uudelleen. @@ -87,6 +92,79 @@ Puhelinnumero vahvistettu automaattisesti Lähetä koodi uudelleen Vahvista puhelinnumero + Use a different phone number 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. + + + Valitse todennusmenetelmä + Määritä tekstiviestivahvistus + Määritä todennussovellus + Vahvista koodisi + Tallenna palautuskoodisi + + Valitse toinen todennusmenetelmä tilisi suojaamiseksi + Anna puhelinnumerosi vastaanottaaksesi vahvistuskoodit + Skannaa QR-koodi todennussovelluksellasi + Anna puhelimeesi lähetetty koodi + Anna todennussovelluksesi koodi + Anna vahvistuskoodisi + Tallenna nämä koodit turvalliseen paikkaan. Voit käyttää niitä kirjautumiseen, jos menetät pääsyn todennusmenetelmääsi. + + Vahvista salasana + Salasanat eivät täsmää + Salasanan on oltava vähintään %1$d merkkiä pitkä + Salasanan on sisällettävä vähintään yksi iso kirjain + Salasanan on sisällettävä vähintään yksi pieni kirjain + Salasanan on sisällettävä vähintään yksi numero + Salasanan on sisällettävä vähintään yksi erikoismerkki + + + Määritä tekstiviestivahvistus + Vahvista tekstiviestikoodi + + Tämä toiminto edellyttää viimeaikaista todennusta. Kirjaudu sisään uudelleen ja yritä uudelleen. + Vahvistuskoodi on väärä. Yritä uudelleen. + Verkkovirhe. Tarkista yhteytesi ja yritä uudelleen. + Rekisteröinnin aikana tapahtui virhe. Yritä uudelleen. + + Takaisin + Henkilöllisyys vahvistettu. Yritä toimintoasi uudelleen. + Hallinnoi kaksivaiheista todennusta + Virheellinen salasana + Valitse vahvistustapa + Lisää ylimääräinen turvallisuuskerros + SMS + Todennussovellus + Tämä puhelinnumero on liitetty toiseen tiliin + Vahvistus vaaditaan + Skannaa QR-koodi todennussovelluksellasi + Todennussovellus on jo määritetty + Haluatko varmasti poistaa tämän menetelmän? + Menetelmää ei voitu poistaa + Menetelmä poistettu + Syötä vahvistuskoodi + Profiilia ei voitu päivittää + Profiili päivitetty + Vahvista henkilöllisyytesi jatkaaksesi + Uudelleentodennus vaaditaan + Uudelleentodennus onnistui + Todenna uudelleen + Olen tallentanut palautuskoodini + Poista + Lähetä vahvistussähköposti uudelleen + Salainen avain + Kirjaudu ulos + Kirjautuneena nimellä + Ohita + Käytä eri menetelmää + Vahvistuskoodi + Sähköposti vahvistettu + Vahvista + Lähetimme vahvistussähköpostin osoitteeseen %1$s diff --git a/auth/src/main/res/values-fil/strings.xml b/auth/src/main/res/values-fil/strings.xml index eb4768d02..35651d605 100755 --- a/auth/src/main/res/values-fil/strings.xml +++ b/auth/src/main/res/values-fil/strings.xml @@ -25,6 +25,8 @@ Mag-email Numero ng Telepono Bansa + Pumili ng bansa + Maghanap ng bansa hal. +1, "US" Password Bagong password Hindi mo ito maaaring iwanan na walang laman. @@ -73,10 +75,13 @@ Kumpirmahin ang email para magpatuloy sa pag-sign in I-dismiss Orihinal na sinadya mong ikonekta ang %1$s sa iyong email account pero nabuksan ang link sa ibang device kung saan hindi ka naka-sign in.\n\nKung gusto mo pa ring ikonekta ang iyong %1$s account, buksan ang link sa parehong device kung saan ka nagsimulang mag-sign in. Kung hindi, i-tap ang Magpatuloy para makapag-sign in sa device na ito. + o Magpatuloy gamit ang + Mag-sign in gamit ang email link + Mag-sign in gamit ang password Ilagay ang numero ng iyong telepono Maglagay ng wastong numero ng telepono Ilagay ang 6-digit na code na ipinadala namin sa - Ipadala muli ang code sa loob ng 0:%02d + Ipadala muli ang code sa loob ng %1$s I-verify ang numero ng iyong telepono Bine-verify… Maling code. Subukang muli. @@ -87,6 +92,79 @@ Awtomatikong na-verify ang numero ng telepono Ipadala Muli ang Code I-verify ang Numero ng Telepono + Use a different phone number 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. + + + Piliin ang Paraan ng Authentication + I-set Up ang SMS Verification + I-set Up ang Authenticator App + I-verify ang Iyong Code + I-save ang Iyong Mga Recovery Code + + Pumili ng pangalawang paraan ng authentication para protektahan ang iyong account + Ilagay ang iyong numero ng telepono para makatanggap ng mga verification code + I-scan ang QR code gamit ang iyong authenticator app + Ilagay ang code na ipinadala sa iyong telepono + Ilagay ang code mula sa iyong authenticator app + Ilagay ang iyong verification code + I-imbak ang mga code na ito sa ligtas na lugar. Magagamit mo ang mga ito para mag-sign in kung mawawala ang access sa iyong paraan ng authentication. + + Kumpirmahin ang password + Hindi tugma ang mga password + Dapat may hindi bababa sa %1$d na character ang password + Dapat may kahit isang malaking titik ang password + Dapat may kahit isang maliit na titik ang password + Dapat may kahit isang numero ang password + Dapat may kahit isang espesyal na character ang password + + + I-set Up ang SMS Authentication + I-verify ang SMS Code + + Kailangan ng kamakailang pag-authenticate para sa operasyong ito. Mag-sign in muli at subukang muli. + Mali ang verification code. Subukang muli. + May naganap na error sa network. Tingnan ang iyong koneksyon at subukang muli. + May naganap na error habang nag-e-enroll. Subukang muli. + + Bumalik + Na-verify ang pagkakakilanlan. Subukan muli ang iyong aksyon. + Pamahalaan ang two-factor authentication + Maling password + Pumili ng paraan ng pag-verify + Magdagdag ng karagdagang layer ng seguridad + SMS + Authenticator app + Ang numerong ito ay nauugnay sa ibang account + Kinakailangan ang pag-verify + I-scan ang QR code gamit ang iyong authenticator app + Naka-set up na ang authenticator app + Sigurado ka bang gusto mong alisin ang paraang ito? + Hindi ma-alis ang paraan + Naalis ang paraan + Ilagay ang verification code + Hindi ma-update ang profile + Na-update ang profile + Kumpirmahin ang iyong pagkakakilanlan upang magpatuloy + Kinakailangan ang muling pag-authenticate + Matagumpay ang muling pag-authenticate + Mag-authenticate muli + Na-save ko ang aking mga recovery code + Alisin + Ipadala muli ang verification email + Secret key + Mag-sign out + Naka-sign in bilang + Laktawan + Gumamit ng ibang paraan + Verification code + Na-verify ang email + I-verify + Nagpadala kami ng verification email sa %1$s diff --git a/auth/src/main/res/values-fr-rCH/strings.xml b/auth/src/main/res/values-fr-rCH/strings.xml index 86b3110d4..778950c72 100755 --- a/auth/src/main/res/values-fr-rCH/strings.xml +++ b/auth/src/main/res/values-fr-rCH/strings.xml @@ -25,6 +25,8 @@ E-mail Numéro de téléphone Pays + Sélectionnez un pays + Rechercher un pays p. ex. +1, "US" Mot de passe Nouveau mot de passe Ce champ est obligatoire. @@ -73,10 +75,13 @@ Confirmez votre adresse e-mail pour vous connecter Ignorer Vous aviez décidé d\'associer votre compte %1$s à votre adresse e-mail, mais vous avez ouvert le lien sur un appareil différent de celui avec lequel vous vous êtes connecté.\n\nSi vous souhaitez toujours associer votre compte %1$s, ouvrez le lien sur l\'appareil avec lequel vous avez commencé à vous connecter. Sinon, appuyez sur \"Continuer\" pour vous connecter depuis un autre appareil. + ou Continuer avec + Se connecter avec le lien e-mail + Se connecter avec le mot de passe Saisissez votre numéro de téléphone Saisissez un numéro de téléphone valide Saisissez le code à six chiffres envoyé au - Renvoyer le code dans 0:%02d + Renvoyer le code dans %1$s Valider votre numéro de téléphone Validation… Code erroné. Veuillez réessayer. @@ -87,6 +92,91 @@ Numéro de téléphone validé automatiquement Renvoyer le code Valider le numéro de téléphone + Use a different phone number 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. + + + Choisir la méthode d\'authentification + Configurer la vérification par SMS + Configurer l\'application d\'authentification + Vérifiez votre code + Enregistrez vos codes de récupération + + Sélectionnez une deuxième méthode d\'authentification pour sécuriser votre compte + Saisissez votre numéro de téléphone pour recevoir les codes de vérification + Scannez le code QR avec votre application d\'authentification + Saisissez le code envoyé à votre téléphone + Saisissez le code de votre application d\'authentification + Saisissez votre code de vérification + Conservez ces codes en lieu sûr. Vous pouvez les utiliser pour vous connecter si vous perdez l\'accès à votre méthode d\'authentification. + + + + Configurer l\'authentification par SMS + Vérifier le code SMS + + Cette opération nécessite une authentification récente. Veuillez vous reconnecter et réessayer. + Le code de vérification est incorrect. Veuillez réessayer. + Une erreur réseau s\'est produite. Veuillez vérifier votre connexion et réessayer. + Une erreur s\'est produite lors de l\'inscription. Veuillez réessayer. + + + Retour + Identité vérifiée. Veuillez réessayer votre action. + Mot de passe incorrect. Veuillez réessayer. + Gérer l\'authentification multifacteur + Méthodes actives + Ajouter une nouvelle méthode + Toutes les méthodes d\'authentification disponibles sont enregistrées + Enregistré le %1$s + Authentification par SMS + Application d\'authentification + Méthode inconnue + Ajouter ou supprimer des méthodes d\'authentification pour votre compte + Gérer l\'authentification à deux facteurs + Scannez le code QR ou saisissez la clé secrète dans votre application d\'authentification + Veuillez compléter les informations de votre profil pour continuer. + Champs manquants: %1$s + Compte: %1$s + Pour votre sécurité, veuillez saisir à nouveau votre mot de passe pour continuer. + Vérifiez votre identité + Échec de l\'authentification. Veuillez réessayer. + J\'ai sauvegardé ces codes + Supprimer + Renvoyer l\'e-mail de vérification + Clé secrète + Se déconnecter + Connecté en tant que %1$s + Ignorer pour le moment + Utiliser une méthode différente + Code de vérification + J\'ai vérifié mon e-mail + Vérifier + Veuillez vérifier %1$s pour continuer. + + Choisissez une méthode de vérification + Ajoutez une couche de sécurité supplémentaire + SMS + Application d\'authentification + Ce numéro de téléphone est associé à un autre compte + Vérification requise + Scannez le code QR avec votre application d\'authentification + L\'application d\'authentification est déjà configurée + Êtes-vous sûr de vouloir supprimer cette méthode ? + Impossible de supprimer la méthode + Méthode supprimée + Entrez le code de vérification + Impossible de mettre à jour le profil + Profil mis à jour + Confirmez votre identité pour continuer + Réauthentification requise + Réauthentification réussie + Se réauthentifier diff --git a/auth/src/main/res/values-fr/strings.xml b/auth/src/main/res/values-fr/strings.xml index 86b3110d4..427475fbd 100755 --- a/auth/src/main/res/values-fr/strings.xml +++ b/auth/src/main/res/values-fr/strings.xml @@ -25,6 +25,8 @@ E-mail Numéro de téléphone Pays + Sélectionnez un pays + Rechercher un pays p. ex. +1, "US" Mot de passe Nouveau mot de passe Ce champ est obligatoire. @@ -73,10 +75,13 @@ Confirmez votre adresse e-mail pour vous connecter Ignorer Vous aviez décidé d\'associer votre compte %1$s à votre adresse e-mail, mais vous avez ouvert le lien sur un appareil différent de celui avec lequel vous vous êtes connecté.\n\nSi vous souhaitez toujours associer votre compte %1$s, ouvrez le lien sur l\'appareil avec lequel vous avez commencé à vous connecter. Sinon, appuyez sur \"Continuer\" pour vous connecter depuis un autre appareil. + ou Continuer avec + Se connecter avec un lien e-mail + Se connecter avec un mot de passe Saisissez votre numéro de téléphone Saisissez un numéro de téléphone valide Saisissez le code à six chiffres envoyé au - Renvoyer le code dans 0:%02d + Renvoyer le code dans %1$s Valider votre numéro de téléphone Validation… Code erroné. Veuillez réessayer. @@ -87,6 +92,97 @@ Numéro de téléphone validé automatiquement Renvoyer le code Valider le numéro de téléphone + Use a different phone number 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. + + + Choisir la méthode d\'authentification + Configurer la vérification par SMS + Configurer l\'application d\'authentification + Vérifiez votre code + Enregistrez vos codes de récupération + + Sélectionnez une deuxième méthode d\'authentification pour sécuriser votre compte + Saisissez votre numéro de téléphone pour recevoir les codes de vérification + Scannez le code QR avec votre application d\'authentification + Saisissez le code envoyé à votre téléphone + Saisissez le code de votre application d\'authentification + Saisissez votre code de vérification + Conservez ces codes en lieu sûr. Vous pouvez les utiliser pour vous connecter si vous perdez l\'accès à votre méthode d\'authentification. + + Confirmer le mot de passe + Les mots de passe ne correspondent pas + Le mot de passe doit contenir au moins %1$d caractères + Le mot de passe doit contenir au moins une lettre majuscule + Le mot de passe doit contenir au moins une lettre minuscule + Le mot de passe doit contenir au moins un chiffre + Le mot de passe doit contenir au moins un caractère spécial + + + Configurer l\'authentification par SMS + Vérifier le code SMS + + Cette opération nécessite une authentification récente. Veuillez vous reconnecter et réessayer. + Le code de vérification est incorrect. Veuillez réessayer. + Une erreur réseau s\'est produite. Veuillez vérifier votre connexion et réessayer. + Une erreur s\'est produite lors de l\'inscription. Veuillez réessayer. + + + Retour + Identité vérifiée. Veuillez réessayer votre action. + Mot de passe incorrect. Veuillez réessayer. + Gérer l\'authentification multifacteur + Méthodes actives + Ajouter une nouvelle méthode + Toutes les méthodes d\'authentification disponibles sont enregistrées + Enregistré le %1$s + Authentification par SMS + Application d\'authentification + Méthode inconnue + Ajouter ou supprimer des méthodes d\'authentification pour votre compte + Gérer l\'authentification à deux facteurs + Scannez le code QR ou saisissez la clé secrète dans votre application d\'authentification + Veuillez compléter les informations de votre profil pour continuer. + Champs manquants: %1$s + Compte: %1$s + Pour votre sécurité, veuillez saisir à nouveau votre mot de passe pour continuer. + Vérifiez votre identité + Échec de l\'authentification. Veuillez réessayer. + J\'ai sauvegardé ces codes + Supprimer + Renvoyer l\'e-mail de vérification + Clé secrète + Se déconnecter + Connecté en tant que %1$s + Ignorer pour le moment + Utiliser une méthode différente + Code de vérification + J\'ai vérifié mon e-mail + Vérifier + Veuillez vérifier %1$s pour continuer. + + Choisissez une méthode de vérification + Ajoutez une couche de sécurité supplémentaire + SMS + Application d\'authentification + Ce numéro de téléphone est associé à un autre compte + Vérification requise + Scannez le code QR avec votre application d\'authentification + L\'application d\'authentification est déjà configurée + Êtes-vous sûr de vouloir supprimer cette méthode ? + Impossible de supprimer la méthode + Méthode supprimée + Entrez le code de vérification + Impossible de mettre à jour le profil + Profil mis à jour + Confirmez votre identité pour continuer + Réauthentification requise + Réauthentification réussie + Se réauthentifier diff --git a/auth/src/main/res/values-gsw/strings.xml b/auth/src/main/res/values-gsw/strings.xml index 378aff0c0..5759bb326 100755 --- a/auth/src/main/res/values-gsw/strings.xml +++ b/auth/src/main/res/values-gsw/strings.xml @@ -25,6 +25,8 @@ E-Mail-Adresse Telefonnummer Land + Land uswähle + Land sueche z. B. +1, "US" Passwort Neues Passwort Pflichtfeld. @@ -73,10 +75,13 @@ E-Mail-Adresse bestätigen, um Anmeldung fortzusetzen Ablehnen Sie haben versucht, %1$s auf einem Gerät, auf dem Sie nicht angemeldet sind, mit Ihrem E-Mail-Konto zu verbinden.\n\nWenn Sie Ihr %1$s-Konto weiterhin verbinden möchten, öffnen Sie den Link bitte auf dem Gerät, auf dem Sie den Anmeldevorgang gestartet haben. Andernfalls tippen Sie auf \"Weiter\", um sich auf diesem Gerät anzumelden. + oder Fortfahren mit + Mit E-Mail-Link anmelden + Mit Passwort anmelden Telefonnummer eingeben Geben Sie eine gültige Telefonnummer ein Geben Sie den 6-stelligen Code ein, der gesendet wurde an - Code in 0:%02d erneut senden + Code in %1$s erneut senden Telefonnummer bestätigen Wird verifiziert… Falscher Code. Versuchen Sie es noch einmal. @@ -87,6 +92,79 @@ Telefonnummer wurde automatisch bestätigt Code erneut senden Telefonnummer bestätigen + Use a different phone number 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. + + + Authentifizierungsmethode wählen + SMS-Bestätigung iirichte + Authenticator-App iirichte + Code bestätige + Wiederherstelligscodes speichere + + Wähle Sie e zweiti Authentifizierungsmethode us, zum Ihres Konto z schütze + Gäbe Sie Ihri Telefonnummer ii, zum Bestätigungscodes z erhalte + Scanne Sie de QR-Code mit Ihrer Authenticator-App + Gäbe Sie de Code ii, wo an Ihres Telefon gschickt worde isch + Gäbe Sie de Code us Ihrer Authenticator-App ii + Gäbe Sie Ihre Bestätigungscode ii + Bewahre Sie die Codes a eme sichere Ort uuf. Sie chönd si zum Aamälde benutze, falls Sie de Zuegriff uf Ihri Authentifizierungsmethode verliere. + + Passwort bestätige + Passwörter stimmed nöd überiin + S Passwort muess mindischtens %1$d Zeiche lang sii + S Passwort muess mindischtens en Grossbuechschtabe enthalte + S Passwort muess mindischtens en Chliibuechschtabe enthalte + S Passwort muess mindischtens e Ziffere enthalte + S Passwort muess mindischtens es Sonderzeiche enthalte + + + SMS-Authentifizierig iirichte + SMS-Code bestätige + + Für dä Vorgang isch e kürzlichi Authentifizierig erforderlich. Bitte mälde Sie sich erneut aa und versuche Sie es erneut. + De Bestätigungscode isch falsch. Bitte versuche Sie es erneut. + Es isch e Netzwerkfähler uftrete. Bitte überprüfe Sie Ihri Verbindig und versuche Sie es erneut. + Bi de Registrierig isch e Fähler uftrete. Bitte versuche Sie es erneut. + + Zrugg + Identität verifiziert. Probier dini Aktion no mal. + Zwei-Faktor-Authentifizierig verwalte + Falsches Passwort + Wähl e Verifizierigsmethode + Füeg e zuesätzlichi Sicherheitsschicht hinzu + SMS + Authentifizierigs-App + Die Telefonnummer isch mit emne andere Konto verbunde + Verifizierig erforderlech + Scann de QR-Code mit dinere Authentifizierigs-App + Authentifizierigs-App isch scho iigrichtet + Bisch sicher, dass du die Methode entferne möchtisch? + Methode chan nöd entfernt wärde + Methode entfernt + Gib de Verifizierigs-Code ii + Profil chan nöd aktualisiert wärde + Profil aktualisiert + Bestätig dini Identität zum Fortfahre + Erneuti Authentifizierig erforderlech + Erneuti Authentifizierig erfolgriich + Erneut authentifiziere + Ich ha mini Wiederherstelligscodes gspicheret + Entferne + Verifizierigs-E-Mail erneut sende + Gheime Schlüssel + Abmelde + Agmeldet als + Überspringe + E anderi Methode verwände + Verifizierigscode + E-Mail verifiziert + Verifiziere + Mir hend e Verifizierigs-E-Mail an %1$s gschickt diff --git a/auth/src/main/res/values-gu/strings.xml b/auth/src/main/res/values-gu/strings.xml index e5d55cd9f..696c7bb2f 100755 --- a/auth/src/main/res/values-gu/strings.xml +++ b/auth/src/main/res/values-gu/strings.xml @@ -25,6 +25,8 @@ ઇમેઇલ ફોન નંબર દેશ + દેશ પસંદ કરો + દેશ શોધો ઉદા. +1, "US" પાસવર્ડ નવો પાસવર્ડ તમારે આ ફીલ્ડ ભરવું આવશ્યક છે. @@ -73,10 +75,13 @@ સાઇન ઇન ચાલુ રાખવા માટે ઇમેઇલ કન્ફર્મ કરો છોડી દો તમે ઑરિજિનલ રીતે %1$sને તમારા ઇમેઇલ એકાઉન્ટ જોડે કનેક્ટ કરવા માગતા હતા પણ તમે લિંકને કોઈ અલગ ડિવાઇસ પરથી ખોલી છે, જેમાં તમે સાઇન ઇન થયા નથી.\n\nજો તમે હજી પણ તમારા %1$s એકાઉન્ટને કનેક્ટ કરવા માગતા હો, તો તમે જે ડિવાઇસ પરથી સાઇન-ઇન કરવાની પ્રક્રિયા શરૂ કરી હોય એના પરથી જ લિંક ખોલો. અન્યથા, આ ડિવાઇસ પરથી સાઇન-ઇન કરવા માટે આગળ વધો પર ટૅપ કરો. + અથવા આનાથી ચાલુ રાખો + ઇમેઇલ લિંક વડે સાઇન ઇન કરો + પાસવર્ડ વડે સાઇન ઇન કરો તમારો ફોન નંબર દાખલ કરો એક માન્ય ફોન નંબર દાખલ કરો અમે આ ફોન નંબર પર મોકલેલ 6-અંકનો કોડ દાખલ કરો - 0 માં કોડ ફરીથી મોકલો:%02d + 0 માં કોડ ફરીથી મોકલો:%1$s તમારો ફોન નંબર ચકાસો ચકાસી રહ્યાં છીએ… કોડ ખોટો છે. ફરી પ્રયાસ કરો. @@ -87,6 +92,80 @@ ફોન નંબર આપમેળે ચકાસવામાં આવ્યો કોડ ફરીથી મોકલો ફોન નંબર ચકાસો + Use a different phone number “%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. + + + પ્રમાણીકરણ પદ્ધતિ પસંદ કરો + SMS ચકાસણી સેટ કરો + પ્રમાણીકરણ ઍપ સેટ કરો + તમારો કોડ ચકાસો + તમારા પુનઃપ્રાપ્તિ કોડ સાચવો + + તમારા એકાઉન્ટને સુરક્ષિત કરવા બીજી પ્રમાણીકરણ પદ્ધતિ પસંદ કરો + ચકાસણી કોડ મેળવવા તમારો ફોન નંબર દાખલ કરો + તમારી પ્રમાણીકરણ ઍપથી QR કોડ સ્કેન કરો + તમારા ફોન પર મોકલવામાં આવેલો કોડ દાખલ કરો + તમારી પ્રમાણીકરણ ઍપનો કોડ દાખલ કરો + તમારો ચકાસણી કોડ દાખલ કરો + આ કોડને સુરક્ષિત સ્થળે સંગ્રહિત કરો. જો તમે તમારી પ્રમાણીકરણ પદ્ધતિની ઍક્સેસ ગુમાવો છો, તો તમે સાઇન ઇન કરવા માટે તેનો ઉપયોગ કરી શકો છો. + + પાસવર્ડ કન્ફર્મ કરો + પાસવર્ડ મેળ ખાતા નથી + પાસવર્ડમાં ઓછામાં ઓછા %1$d અક્ષરો હોવા આવશ્યક છે + પાસવર્ડમાં ઓછામાં ઓછો એક અપકેસ અક્ષર હોવો આવશ્યક છે + પાસવર્ડમાં ઓછામાં ઓછો એક લોઅરકેસ અક્ષર હોવો આવશ્યક છે + પાસવર્ડમાં ઓછામાં ઓછો એક નંબર હોવો આવશ્યક છે + પાસવર્ડમાં ઓછામાં ઓછો એક વિશેષ અક્ષર હોવો આવશ્યક છે + + + SMS પ્રમાણીકરણ સેટ કરો + SMS કોડ ચકાસો + + આ કામગીરી માટે તાજેતરના પ્રમાણીકરણની જરૂર છે. કૃપા કરીને ફરીથી સાઇન ઇન કરો અને ફરી પ્રયાસ કરો. + ચકાસણી કોડ ખોટો છે. કૃપા કરીને ફરી પ્રયાસ કરો. + નેટવર્ક ભૂલ આવી. કૃપા કરીને તમારું કનેક્શન તપાસો અને ફરી પ્રયાસ કરો. + નોંધણી દરમિયાન એક ભૂલ આવી. કૃપા કરીને ફરી પ્રયાસ કરો. + + પાછળ + ઓળખ ચકાસાઈ. તમારી ક્રિયા ફરી પ્રયાસ કરો. + બે-પરિબળ પ્રમાણીકરણ સંચાલિત કરો + ખોટો પાસવર્ડ + ચકાસણી પદ્ધતિ પસંદ કરો + સુરક્ષાનું વધારાનું સ્તર ઉમેરો + SMS + પ્રમાણીકરણ એપ + આ ફોન નંબર અન્ય એકાઉન્ટ સાથે સંકળાયેલ છે + ચકાસણી આવશ્યક છે + તમારી પ્રમાણીકરણ એપથી QR કોડ સ્કેન કરો + પ્રમાણીકરણ એપ પહેલાથી સેટઅપ છે + શું તમે ખરેખર આ પદ્ધતિ દૂર કરવા માગો છો? + પદ્ધતિ દૂર કરવામાં અસમર્થ + પદ્ધતિ દૂર કરી + ચકાસણી કોડ દાખલ કરો + પ્રોફાઇલ અપડેટ કરવામાં અસમર્થ + પ્રોફાઇલ અપડેટ થઈ + ચાલુ રાખવા માટે તમારી ઓળખ પુષ્ટિ કરો + ફરીથી પ્રમાણીકરણ આવશ્યક છે + ફરીથી પ્રમાણીકરણ સફળ + ફરીથી પ્રમાણીકરણ + મેં મારા પુનઃપ્રાપ્તિ કોડ સાચવ્યા છે + દૂર કરો + ચકાસણી ઇમેઇલ ફરી મોકલો + ગુપ્ત કી + સાઇન આઉટ + તરીકે સાઇન ઇન કર્યું + છોડો + એક અલગ પદ્ધતિ વાપરો + ચકાસણી કોડ + ઇમેઇલ ચકાસાયેલ + ચકાસો + અમે %1$s પર ચકાસણી ઇમેઇલ મોકલ્યો diff --git a/auth/src/main/res/values-hi/strings.xml b/auth/src/main/res/values-hi/strings.xml index d885dd392..b317ab695 100755 --- a/auth/src/main/res/values-hi/strings.xml +++ b/auth/src/main/res/values-hi/strings.xml @@ -25,6 +25,8 @@ ईमेल फ़ोन नंबर देश + देश चुनें + देश खोजें जैसे +1, "US" पासवर्ड नया पासवर्ड आप इसे खाली नहीं छोड़ सकते. @@ -73,10 +75,13 @@ साइन इन जारी रखने के लिए ईमेल की पुष्टि करें खारिज करें आप पहले %1$s को अपने ईमेल खाते से जोड़ना चाहते थे, लेकिन आपने ऐसे किसी अलग डिवाइस से लिंक खोला है, जहां आप साइन-इन नहीं हैं.\n\nअगर आप अब भी अपने %1$s खाते को जोड़ना चाहते हैं, तो उसी डिवाइस पर लिंक खोलें जिस पर आपने साइन-इन करना शुरू किया था. या फिर, इस डिवाइस से साइन इन करने के लिए \'जारी रखें\' पर टैप करें. + या इसके साथ जारी रखें + ईमेल लिंक से साइन इन करें + पासवर्ड से साइन इन करें अपना फ़ोन नंबर डालें कोई मान्य फ़ोन नंबर डालें हमारी ओर से भेजा गया 6-अंकों वाला कोड डालें - 0:%02d में कोड फिर से भेजें + %1$s में कोड फिर से भेजें अपने फ़ोन नंबर की पुष्टि करें पुष्टि की जा रही है… गलत कोड. फिर से कोशिश करें. @@ -87,6 +92,80 @@ फ़ोन नंबर की अपने आप पुष्टि की गई कोड फिर से भेजें फ़ोन नंबर की पुष्टि करें + Use a different phone number “%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. + + + प्रमाणीकरण विधि चुनें + SMS सत्यापन सेट करें + प्रमाणक ऐप सेट करें + अपना कोड सत्यापित करें + अपने पुनर्प्राप्ति कोड सहेजें + + अपने खाते को सुरक्षित करने के लिए दूसरी प्रमाणीकरण विधि चुनें + सत्यापन कोड प्राप्त करने के लिए अपना फ़ोन नंबर दर्ज करें + अपने प्रमाणक ऐप से QR कोड स्कैन करें + अपने फ़ोन पर भेजा गया कोड दर्ज करें + अपने प्रमाणक ऐप से कोड दर्ज करें + अपना सत्यापन कोड दर्ज करें + इन कोड को सुरक्षित स्थान पर संग्रहीत करें। यदि आप अपनी प्रमाणीकरण विधि तक पहुंच खो देते हैं, तो आप साइन इन करने के लिए इनका उपयोग कर सकते हैं। + + पासवर्ड की पुष्टि करें + पासवर्ड मेल नहीं खाते + पासवर्ड कम से कम %1$d वर्णों का होना चाहिए + पासवर्ड में कम से कम एक बड़ा अक्षर होना चाहिए + पासवर्ड में कम से कम एक छोटा अक्षर होना चाहिए + પાસવર્ડમાં ઓછામાં ઓછો એક નંબર હોવો આવશ્યક છે + પાસવર્ડમાં ઓછામાં ઓછો એક વિશેષ અક્ષર હોવો આવશ્યક છે + + + एसएमएस प्रमाणीकरण सेट करें + एसएमएस कोड सत्यापित करें + + इस ऑपरेशन के लिए हाल ही की पुष्टि की ज़रूरत है. कृपया फिर से साइन इन करें और फिर से कोशिश करें. + पुष्टि करने वाला कोड गलत है. कृपया फिर से कोशिश करें. + नेटवर्क में कोई गड़बड़ी हुई. कृपया अपने कनेक्शन की जांच करें और फिर से कोशिश करें. + नामांकन के दौरान एक गड़बड़ी हुई. कृपया फिर से कोशिश करें. + + वापस + पहचान सत्यापित हुई। अपनी कार्रवाई फिर से आज़माएं। + दो-कारक प्रमाणीकरण प्रबंधित करें + गलत पासवर्ड + सत्यापन विधि चुनें + सुरक्षा की एक अतिरिक्त परत जोड़ें + SMS + प्रमाणीकरण ऐप + यह फ़ोन नंबर किसी अन्य खाते से जुड़ा है + सत्यापन आवश्यक है + अपने प्रमाणीकरण ऐप से QR कोड स्कैन करें + प्रमाणीकरण ऐप पहले से सेट अप है + क्या आप वाकई इस विधि को हटाना चाहते हैं? + विधि हटाने में असमर्थ + विधि हटाई गई + सत्यापन कोड दर्ज करें + प्रोफ़ाइल अपडेट करने में असमर्थ + प्रोफ़ाइल अपडेट हुआ + जारी रखने के लिए अपनी पहचान की पुष्टि करें + पुनः प्रमाणीकरण आवश्यक है + पुनः प्रमाणीकरण सफल + पुनः प्रमाणीकरण + मैंने अपने पुनर्प्राप्ति कोड सहेज लिए हैं + हटाएं + सत्यापन ईमेल फिर से भेजें + गुप्त कुंजी + साइन आउट + इस रूप में साइन इन किया + छोड़ें + एक अलग विधि उपयोग करें + सत्यापन कोड + ईमेल सत्यापित + सत्यापित करें + हमने %1$s पर एक सत्यापन ईमेल भेजा diff --git a/auth/src/main/res/values-hr/strings.xml b/auth/src/main/res/values-hr/strings.xml index 809c035ae..c247e775c 100755 --- a/auth/src/main/res/values-hr/strings.xml +++ b/auth/src/main/res/values-hr/strings.xml @@ -25,6 +25,8 @@ E-adresa Telefonski broj Zemlja + Odaberite zemlju + Pretraživanje zemlje npr. +1, "US" Zaporka Nova zaporka Ne možete to ostaviti prazno. @@ -73,10 +75,13 @@ Za nastavak prijave potvrdite e-adresu Odbaci Izvorno ste namjeravali povezati %1$s račun sa svojim računom e-pošte, no otvorili ste vezu na drugom uređaju na kojem niste prijavljeni.\n\nAko i dalje želite povezati svoj %1$s račun, otvorite vezu na uređaju na kojem ste pokrenuli postupak prijave. U suprotnom dodirnite Nastavi da biste se prijavili na ovom uređaju. + ili Nastavi s + Prijavite se pomoću poveznice e-pošte + Prijavite se pomoću lozinke Unesite telefonski broj Unesite važeći telefonski broj Unesite 6-znamenkasti kôd koji smo poslali na broj - Ponovno slanje koda za 0:%02d + Ponovno slanje koda za %1$s Potvrda telefonskog broja Potvrđivanje… Pogrešan kôd. Pokušajte ponovo. @@ -87,6 +92,79 @@ Telefonski je broj automatski potvrđen Ponovo pošalji kôd Potvrda telefonskog broja + Use a different phone number 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. + + + Odaberite metodu provjere autentičnosti + Postavite SMS provjeru + Postavite aplikaciju za provjeru autentičnosti + Potvrdite svoju šifru + Spremite šifre za oporavak + + Odaberite drugu metodu provjere autentičnosti kako biste zaštitili svoj račun + Unesite svoj telefonski broj kako biste primali šifre za potvrdu + Skenirajte QR kôd svojom aplikacijom za provjeru autentičnosti + Unesite šifru poslanu na vaš telefon + Unesite šifru iz svoje aplikacije za provjeru autentičnosti + Unesite svoju šifru za potvrdu + Pohranite ove šifre na sigurno mjesto. Možete ih koristiti za prijavu ako izgubite pristup svojoj metodi provjere autentičnosti. + + Potvrdite zaporku + Zaporke se ne podudaraju + Zaporka mora imati najmanje %1$d znakova + Zaporka mora sadržavati barem jedno veliko slovo + Zaporka mora sadržavati barem jedno malo slovo + Zaporka mora sadržavati barem jedan broj + Zaporka mora sadržavati barem jedan poseban znak + + + Postavi SMS autentifikaciju + Potvrdi SMS kod + + Za ovu operaciju potrebna je nedavna autentifikacija. Prijavite se ponovno i pokušajte ponovno. + Potvrdni kôd nije točan. Pokušajte ponovno. + Došlo je do pogreške na mreži. Provjerite vezu i pokušajte ponovno. + Došlo je do pogreške tijekom upisa. Pokušajte ponovno. + + Natrag + Identitet potvrđen. Pokušajte ponovno svoju radnju. + Upravljaj dvofaktorskom autentifikacijom + Netočna lozinka + Odaberite metodu provjere + Dodajte dodatni sigurnosni sloj + SMS + Aplikacija za autentifikaciju + Ovaj telefonski broj povezan je s drugim računom + Potrebna je provjera + Skenirajte QR kod aplikacijom za autentifikaciju + Aplikacija za autentifikaciju već je postavljena + Jeste li sigurni da želite ukloniti ovu metodu? + Nije moguće ukloniti metodu + Metoda uklonjena + Unesite kod za provjeru + Nije moguće ažurirati profil + Profil ažuriran + Potvrdite svoj identitet za nastavak + Potrebna je ponovna autentifikacija + Ponovna autentifikacija uspješna + Ponovno autentificiraj + Spremio sam kodove za oporavak + Ukloni + Ponovno pošalji e-poštu za provjeru + Tajni ključ + Odjava + Prijavljen kao + Preskoči + Koristi drugu metodu + Kod za provjeru + E-pošta potvrđena + Provjeri + Poslali smo e-poštu za provjeru na %1$s diff --git a/auth/src/main/res/values-hu/strings.xml b/auth/src/main/res/values-hu/strings.xml index c89d279c2..d82376665 100755 --- a/auth/src/main/res/values-hu/strings.xml +++ b/auth/src/main/res/values-hu/strings.xml @@ -25,6 +25,8 @@ E-mail Telefonszám Ország + Válasszon országot + Ország keresése pl. +1, "US" Jelszó Az új jelszó Ezt nem hagyhatja üresen. @@ -73,10 +75,13 @@ A bejelentkezés folytatásához erősítse meg az e-mail-címet Elvetés Eredetileg össze szerette volna kapcsolni %1$s-fiókját az e-mail-fiókjával, a linket azonban olyan eszközön nyitotta meg, amelyen nem jelentkezett be.\n\nHa továbbra is össze kívánja kapcsolni %1$s-fiókját, akkor azon az eszközön nyissa meg a linket, amelyen megkezdte a bejelentkezést. Ha ezen az eszközön kíván bejelentkezni, akkor koppintson a Tovább gombra. + vagy Tovább ezzel + Bejelentkezés e-mail linkkel + Bejelentkezés jelszóval Adja meg telefonszámát Érvényes telefonszámot adjon meg. Adja meg a telefonszámra elküldött 6 számjegyű kódot - Kód újraküldése ennyi idő elteltével: 0:%02d + Kód újraküldése ennyi idő elteltével: %1$s Telefonszám igazolása Ellenőrzés… Hibás kód. Próbálja újra. @@ -87,6 +92,79 @@ A telefonszám automatikusan ellenőrizve Kód újraküldése Telefonszám igazolása + Use a different phone number 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. + + + Válasszon hitelesítési módszert + SMS-ellenőrzés beállítása + Hitelesítő alkalmazás beállítása + Kód ellenőrzése + Helyreállítási kódok mentése + + Válasszon második hitelesítési módszert fiókja védelme érdekében + Adja meg telefonszámát az ellenőrző kódok fogadásához + Olvassa be a QR-kódot hitelesítő alkalmazásával + Adja meg a telefonjára küldött kódot + Adja meg a hitelesítő alkalmazásából származó kódot + Adja meg ellenőrző kódját + Tárolja ezeket a kódokat biztonságos helyen. Ezekkel jelentkezhet be, ha elveszti a hitelesítési módszeréhez való hozzáférést. + + Jelszó megerősítése + A jelszavak nem egyeznek + A jelszónak legalább %1$d karakter hosszúnak kell lennie + A jelszónak tartalmaznia kell legalább egy nagybetűt + A jelszónak tartalmaznia kell legalább egy kisbetűt + A jelszónak tartalmaznia kell legalább egy számot + A jelszónak tartalmaznia kell legalább egy speciális karaktert + + + SMS-hitelesítés beállítása + SMS-kód ellenőrzése + + Ehhez a művelethez újabb hitelesítés szükséges. Kérjük, jelentkezzen be újra, és próbálja újra. + A megerősítő kód helytelen. Kérjük, próbálja újra. + Hálózati hiba történt. Ellenőrizze a kapcsolatot, és próbálja újra. + Hiba történt a regisztráció során. Kérjük, próbálja újra. + + Vissza + Személyazonosság ellenőrizve. Próbálja újra a műveletet. + Kétfaktoros hitelesítés kezelése + Helytelen jelszó + Válasszon ellenőrzési módszert + Adjon hozzá egy extra biztonsági réteget + SMS + Hitelesítő alkalmazás + Ez a telefonszám másik fiókhoz van társítva + Ellenőrzés szükséges + Olvassa be a QR-kódot a hitelesítő alkalmazással + A hitelesítő alkalmazás már be van állítva + Biztosan eltávolítja ezt a módszert? + A módszer nem távolítható el + Módszer eltávolítva + Adja meg az ellenőrző kódot + A profil nem frissíthető + Profil frissítve + Erősítse meg személyazonosságát a folytatáshoz + Újrahitelesítés szükséges + Újrahitelesítés sikeres + Újrahitelesítés + Elmentettem a helyreállítási kódokat + Eltávolítás + Ellenőrző e-mail újraküldése + Titkos kulcs + Kijelentkezés + Bejelentkezve mint + Kihagyás + Másik módszer használata + Ellenőrző kód + E-mail ellenőrizve + Ellenőrzés + Ellenőrző e-mailt küldtünk a következő címre: %1$s diff --git a/auth/src/main/res/values-in/strings.xml b/auth/src/main/res/values-in/strings.xml index a16ec1962..c119bb140 100755 --- a/auth/src/main/res/values-in/strings.xml +++ b/auth/src/main/res/values-in/strings.xml @@ -25,6 +25,8 @@ Email Nomor Telepon Negara + Pilih negara + Telusuri negara, mis. +1, "US" Sandi Sandi baru Anda wajib mengisinya. @@ -73,10 +75,13 @@ Konfirmasi email untuk melanjutkan login Tutup Anda sebelumnya ingin menghubungkan %1$s ke akun email Anda, tapi telah membuka link di perangkat berbeda yang tidak digunakan untuk login.\n\nJika Anda masih ingin menghubungkan ke akun %1$s, buka link di perangkat yang sama yang digunakan untuk login. Jika tidak, ketuk Lanjutkan untuk login di perangkat ini. + atau Lanjutkan dengan + Login dengan link email + Login dengan kata sandi Masukkan nomor telepon Anda Masukkan nomor telepon yang valid Masukkan kode 6 digit yang kami kirimkan ke - Kirimkan ulang kode dalam 0.%02d + Kirimkan ulang kode dalam 0.%1$s Verifikasi nomor telepon Anda Memverifikasi… Kode salah. Coba lagi. @@ -87,6 +92,80 @@ Nomor telepon terverifikasi secara otomatis Kirim Ulang Kode Verifikasi Nomor Telepon + Use a different phone number 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. + + + Pilih Metode Autentikasi + Siapkan Verifikasi SMS + Siapkan Aplikasi Autentikator + Verifikasi Kode Anda + Simpan Kode Pemulihan Anda + + Pilih metode autentikasi kedua untuk mengamankan akun Anda + Masukkan nomor telepon Anda untuk menerima kode verifikasi + Pindai kode QR dengan aplikasi autentikator Anda + Masukkan kode yang dikirim ke ponsel Anda + Masukkan kode dari aplikasi autentikator Anda + Masukkan kode verifikasi Anda + Simpan kode ini di tempat yang aman. Anda dapat menggunakannya untuk masuk jika kehilangan akses ke metode autentikasi Anda. + + Konfirmasi sandi + Sandi tidak cocok + Sandi minimal harus terdiri dari %1$d karakter + Sandi harus berisi setidaknya satu huruf besar + Sandi harus berisi setidaknya satu huruf kecil + Sandi harus berisi setidaknya satu angka + Sandi harus berisi setidaknya satu karakter khusus + + + Siapkan Autentikasi SMS + Verifikasi Kode SMS + + Operasi ini memerlukan autentikasi terbaru. Harap login lagi dan coba lagi. + Kode verifikasi salah. Harap coba lagi. + Terjadi error jaringan. Harap periksa koneksi Anda dan coba lagi. + Terjadi error saat pendaftaran. Harap coba lagi. + + Kembali + Identitas terverifikasi. Coba lagi tindakan Anda. + Kelola autentikasi dua faktor + Kata sandi salah + Pilih metode verifikasi + Tambahkan lapisan keamanan ekstra + SMS + Aplikasi autentikator + Nomor telepon ini terkait dengan akun lain + Verifikasi diperlukan + Pindai kode QR dengan aplikasi autentikator Anda + Aplikasi autentikator sudah diatur + Yakin ingin menghapus metode ini? + Tidak dapat menghapus metode + Metode dihapus + Masukkan kode verifikasi + Tidak dapat memperbarui profil + Profil diperbarui + Konfirmasi identitas Anda untuk melanjutkan + Autentikasi ulang diperlukan + Autentikasi ulang berhasil + Autentikasi ulang + Saya telah menyimpan kode pemulihan saya + Hapus + Kirim ulang email verifikasi + Kunci rahasia + Keluar + Masuk sebagai + Lewati + Gunakan metode lain + Kode verifikasi + Email terverifikasi + Verifikasi + Kami telah mengirim email verifikasi ke %1$s diff --git a/auth/src/main/res/values-it/strings.xml b/auth/src/main/res/values-it/strings.xml index ed9f48450..8d765da5d 100755 --- a/auth/src/main/res/values-it/strings.xml +++ b/auth/src/main/res/values-it/strings.xml @@ -25,6 +25,8 @@ Indirizzo email Numero di telefono Paese + Seleziona un paese + Cerca paese ad es. +1, "US" Password Nuova password Questo campo non può restare vuoto. @@ -73,10 +75,13 @@ Conferma l\'indirizzo email per proseguire con l\'accesso Ignora Inizialmente intendevi collegare %1$s al tuo account email, ma hai aperto il link su un altro dispositivo, sul quale non hai eseguito l\'accesso.\n\nSe vuoi continuare a collegare l\'account su %1$s, apri il link sullo stesso dispositivo su cui hai iniziato la procedura di accesso. Altrimenti, tocca Continua per accedere su questo dispositivo. + o Continua con + Accedi con link email + Accedi con password Inserisci il numero di telefono Inserisci un numero di telefono valido Inserisci il codice a 6 cifre che abbiamo inviato al numero - Invia di nuovo il codice tra 0:%02d + Invia di nuovo il codice tra %1$s Verifica il numero di telefono Verifica… Codice errato. Riprova. @@ -87,6 +92,79 @@ Numero di telefono verificato automaticamente Invia di nuovo il codice Verifica numero di telefono + Use a different phone number 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. + + + Scegli il metodo di autenticazione + Configura verifica SMS + Configura app di autenticazione + Verifica il codice + Salva i codici di recupero + + Seleziona un secondo metodo di autenticazione per proteggere il tuo account + Inserisci il tuo numero di telefono per ricevere i codici di verifica + Scansiona il codice QR con la tua app di autenticazione + Inserisci il codice inviato al tuo telefono + Inserisci il codice dalla tua app di autenticazione + Inserisci il codice di verifica + Conserva questi codici in un luogo sicuro. Puoi usarli per accedere se perdi l\'accesso al tuo metodo di autenticazione. + + Conferma password + Le password non corrispondono + La password deve contenere almeno %1$d caratteri + La password deve contenere almeno una lettera maiuscola + La password deve contenere almeno una lettera minuscola + La password deve contenere almeno un numero + La password deve contenere almeno un carattere speciale + + + Configura autenticazione SMS + Verifica codice SMS + + Questa operazione richiede un\'autenticazione recente. Accedi di nuovo e riprova. + Il codice di verifica non è corretto. Riprova. + Si è verificato un errore di rete. Controlla la connessione e riprova. + Si è verificato un errore durante la registrazione. Riprova. + + Indietro + Identità verificata. Riprova l\'azione. + Gestisci autenticazione a due fattori + Password non corretta + Scegli un metodo di verifica + Aggiungi un livello di sicurezza extra + SMS + App di autenticazione + Questo numero di telefono è già associato a un altro account + Verifica richiesta + Scansiona il codice QR con la tua app di autenticazione + L\'app di autenticazione è già configurata + Sei sicuro di voler rimuovere questo metodo? + Impossibile rimuovere il metodo + Metodo rimosso + Inserisci il codice di verifica + Impossibile aggiornare il profilo + Profilo aggiornato + Conferma la tua identità per continuare + Riautenticazione richiesta + Riautenticazione riuscita + Riautentica + Ho salvato i miei codici di recupero + Rimuovi + Invia nuovamente email di verifica + Chiave segreta + Esci + Connesso come + Salta + Usa un metodo diverso + Codice di verifica + Email verificata + Verifica + Abbiamo inviato un\'email di verifica a %1$s diff --git a/auth/src/main/res/values-iw/strings.xml b/auth/src/main/res/values-iw/strings.xml index 5ace0fb64..88c7b4a9f 100755 --- a/auth/src/main/res/values-iw/strings.xml +++ b/auth/src/main/res/values-iw/strings.xml @@ -25,6 +25,8 @@ אימייל מספר טלפון מדינה + בחירת מדינה + חיפוש מדינה למשל +1, "US" סיסמה סיסמה חדשה אי אפשר להשאיר את השדה הזה ריק. @@ -73,10 +75,13 @@ יש לאשר את כתובת האימייל כדי להמשיך בתהליך הכניסה סגירה כוונתך המקורית הייתה לקשר את חשבון %1$s לחשבון שלך, אבל פתחת את הקישור במכשיר אחר שממנו לא נכנסת לחשבון.\n\nבכל זאת רוצה לקשר את חשבון %1$s לחשבון שלך? אם כן, יש לפתוח את הקישור באותו מכשיר שבו התחלת את תהליך הכניסה. אם לא, יש להקיש על \'המשך\' כדי להיכנס לחשבון במכשיר הזה. + או המשך עם + היכנס באמצעות קישור אימייל + היכנס באמצעות סיסמה הזן את מספר הטלפון שלך מספר הטלפון שהזנת לא תקין הזן את הקוד בן 6 הספרות ששלחנו אל - שולח קוד חדש בעוד %02d:0 + שולח קוד חדש בעוד %1$s:0 אמת את מספר הטלפון מאמת… הקוד שגוי. נסה שוב. @@ -87,6 +92,80 @@ מספר הטלפון אומת באופן אוטומטי שלח קוד חדש אמת את מספר הטלפון + Use a different phone number הקשה על “%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. + + + בחר שיטת אימות + הגדר אימות SMS + הגדר אפליקציית מאמת + אמת את הקוד שלך + שמור את קודי השחזור שלך + + בחר שיטת אימות שנייה כדי לאבטח את החשבון שלך + הזן את מספר הטלפון שלך כדי לקבל קודי אימות + סרוק את קוד ה-QR באמצעות אפליקציית המאמת שלך + הזן את הקוד שנשלח לטלפון שלך + הזן את הקוד מאפליקציית המאמת שלך + הזן את קוד האימות שלך + שמור קודים אלה במקום בטוח. תוכל להשתמש בהם כדי להיכנס אם תאבד גישה לשיטת האימות שלך. + + אישור סיסמה + הסיסמאות אינן תואמות + הסיסמה חייבת להכיל לפחות %1$d תווים + הסיסמה חייבת להכיל לפחות אות גדולה אחת + הסיסמה חייבת להכיל לפחות אות קטנה אחת + הסיסמה חייבת להכיל לפחות מספר אחד + הסיסמה חייבת להכיל לפחות תו מיוחד אחד + + + הגדרת אימות SMS + אימות קוד SMS + + הפעולה הזו דורשת אימות עדכני. יש להיכנס שוב ולנסות שוב. + קוד האימות שגוי. יש לנסות שוב. + אירעה שגיאת רשת. יש לבדוק את החיבור ולנסות שוב. + אירעה שגיאה במהלך ההרשמה. יש לנסות שוב. + + חזור + הזהות אומתה. נסה שוב את הפעולה. + ניהול אימות דו-שלבי + סיסמה שגויה + בחר שיטת אימות + הוסף שכבת אבטחה נוספת + SMS + אפליקציית אימות + מספר הטלפון הזה משויך לחשבון אחר + נדרש אימות + סרוק את קוד ה-QR באפליקציית האימות שלך + אפליקציית האימות כבר מוגדרת + האם אתה בטוח שברצונך להסיר את שיטת האימות הזו? + לא ניתן להסיר את השיטה + השיטה הוסרה + הזן את קוד האימות + לא ניתן לעדכן את הפרופיל + הפרופיל עודכן + אמת את זהותך כדי להמשיך + נדרש אימות מחדש + אימות מחדש בוצע בהצלחה + אמת מחדש + שמרתי את קודי השחזור + הסר + שלח שוב אימייל אימות + מפתח סודי + התנתק + מחובר בתור + דלג + השתמש בשיטה אחרת + קוד אימות + אימייל מאומת + אמת + שלחנו אימייל אימות אל %1$s diff --git a/auth/src/main/res/values-ja/strings.xml b/auth/src/main/res/values-ja/strings.xml index c87ba6c85..d5fc232a7 100755 --- a/auth/src/main/res/values-ja/strings.xml +++ b/auth/src/main/res/values-ja/strings.xml @@ -25,6 +25,8 @@ メールアドレス 電話番号 + 国を選択 + 国を検索(例:+1、「US」) パスワード 新しいパスワード 入力必須項目です。 @@ -73,10 +75,13 @@ ログインを続行するにはメールを確認してください 閉じる 元々は %1$s をメール アカウントに接続しようとしましたが、ログインしていないデバイスでリンクが開かれました。%1$s アカウントの接続を続行する場合は、ログインを開始したデバイスでリンクを開いてください。接続しない場合は、[続行] をタップしてこのデバイスでログインします。 + または次で続行 + メールリンクでログイン + パスワードでログイン 電話番号を入力してください 有効な電話番号を入力してください 送信された 6 桁のコードを入力してください - 0:%02d 秒後にコードを再送信します + %1$s 秒後にコードを再送信します 電話番号を確認 確認しています… コードが間違っています。もう一度お試しください。 @@ -87,6 +92,79 @@ 電話番号は自動的に確認されました コードを再送信 電話番号を確認 + Use a different phone number [%1$s] をタップすると、SMS が送信されます。データ通信料がかかることがあります。 [%1$s] をタップすると、%2$s と %3$s に同意したことになり、SMS が送信されます。データ通信料がかかることがあります。 + 認証エラー + 再試行 + 追加の認証が必要です。多要素認証を完了してください。 + アカウントをリンクする必要があります。別のサインイン方法をお試しください。 + 認証がキャンセルされました。準備ができたら再度お試しください。 + + + 認証方法を選択 + SMS認証を設定 + 認証アプリを設定 + コードを確認 + 復元コードを保存 + + アカウントを保護するため、2つ目の認証方法を選択してください + 確認コードを受け取るために電話番号を入力してください + 認証アプリでQRコードをスキャンしてください + 電話に送信されたコードを入力してください + 認証アプリのコードを入力してください + 確認コードを入力してください + これらのコードを安全な場所に保管してください。認証方法にアクセスできなくなった場合、これらを使用してログインできます。 + + パスワードの確認 + パスワードが一致しません + パスワードは%1$d文字以上にする必要があります + パスワードには大文字が1文字以上必要です + パスワードには小文字が1文字以上必要です + パスワードには数字が1文字以上必要です + パスワードには特殊文字が1文字以上必要です + + + SMS認証を設定 + SMSコードを確認 + + この操作には最近の認証が必要です。再度ログインして、もう一度お試しください。 + 確認コードが正しくありません。もう一度お試しください。 + ネットワークエラーが発生しました。接続を確認して、もう一度お試しください。 + 登録中にエラーが発生しました。もう一度お試しください。 + + 戻る + 本人確認が完了しました。操作を再試行してください。 + 2段階認証を管理 + パスワードが正しくありません + 確認方法を選択してください + セキュリティをさらに強化 + SMS + 認証アプリ + この電話番号は別のアカウントに関連付けられています + 確認が必要です + 認証アプリでQRコードをスキャンしてください + 認証アプリは既に設定されています + この確認方法を削除してもよろしいですか? + 確認方法を削除できません + 確認方法を削除しました + 確認コードを入力してください + プロフィールを更新できません + プロフィールを更新しました + 本人確認を行って続行してください + 再認証が必要です + 再認証に成功しました + 再認証 + 復旧コードを保存しました + 削除 + 確認メールを再送信 + シークレットキー + ログアウト + ログイン中 + スキップ + 別の方法を使用 + 確認コード + メールアドレスを確認済み + 確認 + %1$s に確認メールを送信しました diff --git a/auth/src/main/res/values-kn/strings.xml b/auth/src/main/res/values-kn/strings.xml index edde0659b..3b95052ac 100755 --- a/auth/src/main/res/values-kn/strings.xml +++ b/auth/src/main/res/values-kn/strings.xml @@ -25,6 +25,8 @@ ಇಮೇಲ್ ಫೋನ್ ಸಂಖ್ಯೆ ದೇಶ + ದೇಶವನ್ನು ಆಯ್ಕೆಮಾಡಿ + ದೇಶವನ್ನು ಹುಡುಕಿ ಉದಾ. +1, "US" ಪಾಸ್‌ವರ್ಡ್ ಹೊಸ ಪಾಸ್‌ವರ್ಡ್ ನೀವು ಇದನ್ನು ಖಾಲಿ ಬಿಡುವಂತಿಲ್ಲ. @@ -73,10 +75,13 @@ ಸೈನ್ ಇನ್ ಮುಂದುವರಿಸಲು ಇಮೇಲ್ ದೃಢೀಕರಿಸಿ ವಜಾಗೊಳಿಸಿ ನೀವು ಮೂಲತಃ %1$s ಅನ್ನು ನಿಮ್ಮ ಇಮೇಲ್ ಜೊತೆಗೆ ಸಂಪರ್ಕಿಸಲು ಬಯಸಿದ್ದೀರಿ ಆದರೆ ನೀವು ಸೈನ್ ಇನ್ ಮಾಡದಿರುವ ಬೇರೆ ಸಾಧನದಲ್ಲಿ ಲಿಂಕ್ ಅನ್ನು ತೆರೆದಿದ್ದೀರಿ.n\nನಿಮ್ಮ %1$s ಖಾತೆಯನ್ನು ಇನ್ನೂ ನೀವು ಸಂಪರ್ಕಿಸಲು ಬಯಸಿದರೆ, ನೀವು ಸೈನ್ ಇನ್ ಮಾಡದೇ ಇರುವ ಅದೇ ಸಾಧನದಲ್ಲಿ ಲಿಂಕ್ ತೆರೆಯಿರಿ. ಇಲ್ಲದಿದ್ದರೆ ಈ ಸಾಧನದಲ್ಲಿ ಸೈನ್ ಇನ್ ಮಾಡಲು ಮುಂದುವರಿಸಿ ಟ್ಯಾಪ್ ಮಾಡಿ. + ಅಥವಾ ಇದರೊಂದಿಗೆ ಮುಂದುವರಿಸಿ + ಇಮೇಲ್ ಲಿಂಕ್‌ನೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಿ + ಪಾಸ್‌ವರ್ಡ್‌ನೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಿ ನಿಮ್ಮ ಫೋನ್‌ ಸಂಖ್ಯೆಯನ್ನು ನಮೂದಿಸಿ ಮಾನ್ಯವಾದ ಫೋನ್ ಸಂಖ್ಯೆಯನ್ನು ನಮೂದಿಸಿ ನಾವು ಕಳುಹಿಸಿರುವ 6 ಅಂಕಿಯ ಕೋಡ್‌ ನಮೂದಿಸಿ - ಇಷ್ಟರ ಒಳಗೆ ಕೋಡ್ ಮತ್ತೆ ಕಳುಹಿಸಿ 0:%02d + ಇಷ್ಟರ ಒಳಗೆ ಕೋಡ್ ಮತ್ತೆ ಕಳುಹಿಸಿ %1$s ನಿಮ್ಮ ಪೋನ್‌ ಸಂಖ್ಯೆಯನ್ನು ಪರಿಶೀಲಿಸಿ ಪರಿಶೀಲಿಸಲಾಗುತ್ತಿದೆ… ಕೋಡ್ ತಪ್ಪಾಗಿದೆ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ. @@ -87,6 +92,80 @@ ಫೋನ್ ಸಂಖ್ಯೆಯನ್ನು ಸ್ವಯಂಚಾಲಿತವಾಗಿ ಪರಿಶೀಲಿಸಲಾಗಿದೆ ಕೋಡ್ ಪುನಃ ಕಳುಹಿಸಿ ಫೋನ್ ಸಂಖ್ಯೆಯನ್ನು ಪರಿಶೀಲಿಸಿ + Use a different phone number “%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. + + + ದೃಢೀಕರಣ ವಿಧಾನವನ್ನು ಆಯ್ಕೆಮಾಡಿ + SMS ಪರಿಶೀಲನೆಯನ್ನು ಹೊಂದಿಸಿ + ದೃಢೀಕರಣ ಅಪ್ಲಿಕೇಶನ್ ಹೊಂದಿಸಿ + ನಿಮ್ಮ ಕೋಡ್ ಅನ್ನು ಪರಿಶೀಲಿಸಿ + ನಿಮ್ಮ ಮರುಪಡೆಯುವಿಕೆ ಕೋಡ್‌ಗಳನ್ನು ಉಳಿಸಿ + + ನಿಮ್ಮ ಖಾತೆಯನ್ನು ಸುರಕ್ಷಿತವಾಗಿರಿಸಲು ಎರಡನೇ ದೃಢೀಕರಣ ವಿಧಾನವನ್ನು ಆಯ್ಕೆಮಾಡಿ + ಪರಿಶೀಲನೆ ಕೋಡ್‌ಗಳನ್ನು ಸ್ವೀಕರಿಸಲು ನಿಮ್ಮ ಫೋನ್ ಸಂಖ್ಯೆಯನ್ನು ನಮೂದಿಸಿ + ನಿಮ್ಮ ದೃಢೀಕರಣ ಅಪ್ಲಿಕೇಶನ್‌ನೊಂದಿಗೆ QR ಕೋಡ್ ಅನ್ನು ಸ್ಕ್ಯಾನ್ ಮಾಡಿ + ನಿಮ್ಮ ಫೋನ್‌ಗೆ ಕಳುಹಿಸಿದ ಕೋಡ್ ಅನ್ನು ನಮೂದಿಸಿ + ನಿಮ್ಮ ದೃಢೀಕರಣ ಅಪ್ಲಿಕೇಶನ್‌ನಿಂದ ಕೋಡ್ ಅನ್ನು ನಮೂದಿಸಿ + ನಿಮ್ಮ ಪರಿಶೀಲನೆ ಕೋಡ್ ಅನ್ನು ನಮೂದಿಸಿ + ಈ ಕೋಡ್‌ಗಳನ್ನು ಸುರಕ್ಷಿತ ಸ್ಥಳದಲ್ಲಿ ಸಂಗ್ರಹಿಸಿ. ನಿಮ್ಮ ದೃಢೀಕರಣ ವಿಧಾನಕ್ಕೆ ಪ್ರವೇಶವನ್ನು ಕಳೆದುಕೊಂಡರೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಅವುಗಳನ್ನು ಬಳಸಬಹುದು. + + ಪಾಸ್‌ವರ್ಡ್ ದೃಢೀಕರಿಸಿ + ಪಾಸ್‌ವರ್ಡ್‌ಗಳು ಹೊಂದಿಕೆಯಾಗುತ್ತಿಲ್ಲ + ಪಾಸ್‌ವರ್ಡ್ ಕನಿಷ್ಠ %1$d ಅಕ್ಷರಗಳಷ್ಟು ಉದ್ದವಿರಬೇಕು + ಪಾಸ್‌ವರ್ಡ್ ಕನಿಷ್ಠ ಒಂದು ದೊಡ್ಡಕ್ಷರವನ್ನು ಹೊಂದಿರಬೇಕು + ಪಾಸ್‌ವರ್ಡ್ ಕನಿಷ್ಠ ಒಂದು ಸಣ್ಣಕ್ಷರವನ್ನು ಹೊಂದಿರಬೇಕು + ಪಾಸ್‌ವರ್ಡ್ ಕನಿಷ್ಠ ಒಂದು ಸಂಖ್ಯೆಯನ್ನು ಹೊಂದಿರಬೇಕು + ಪಾಸ್‌ವರ್ಡ್ ಕನಿಷ್ಠ ಒಂದು ವಿಶೇಷ ಅಕ್ಷರವನ್ನು ಹೊಂದಿರಬೇಕು + + + SMS ದೃಢೀಕರಣವನ್ನು ಹೊಂದಿಸಿ + SMS ಕೋಡ್ ಅನ್ನು ಪರಿಶೀಲಿಸಿ + + ಈ ಕಾರ್ಯಕ್ಕೆ ಇತ್ತೀಚಿನ ದೃಢೀಕರಣದ ಅಗತ್ಯವಿದೆ. ದಯವಿಟ್ಟು ಮತ್ತೆ ಸೈನ್ ಇನ್ ಮಾಡಿ ಮತ್ತು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ. + ಪರಿಶೀಲನಾ ಕೋಡ್ ತಪ್ಪಾಗಿದೆ. ದಯವಿಟ್ಟು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ. + ನೆಟ್‌ವರ್ಕ್ ದೋಷ ಸಂಭವಿಸಿದೆ. ದಯವಿಟ್ಟು ನಿಮ್ಮ ಸಂಪರ್ಕವನ್ನು ಪರಿಶೀಲಿಸಿ ಮತ್ತು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ. + ದಾಖಲಾತಿ ಸಮಯದಲ್ಲಿ ದೋಷ ಸಂಭವಿಸಿದೆ. ದಯವಿಟ್ಟು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ. + + ಹಿಂದಕ್ಕೆ + ಗುರುತನ್ನು ಪರಿಶೀಲಿಸಲಾಗಿದೆ. ನಿಮ್ಮ ಕ್ರಿಯೆಯನ್ನು ಮರುಪ್ರಯತ್ನಿಸಿ. + ಎರಡು-ಅಂಶ ಪ್ರಮಾಣೀಕರಣವನ್ನು ನಿರ್ವಹಿಸಿ + ತಪ್ಪಾದ ಪಾಸ್‌ವರ್ಡ್ + ಪರಿಶೀಲನೆ ವಿಧಾನವನ್ನು ಆರಿಸಿ + ಹೆಚ್ಚುವರಿ ಭದ್ರತಾ ಪದರವನ್ನು ಸೇರಿಸಿ + SMS + ಪ್ರಮಾಣೀಕರಣ ಅಪ್ಲಿಕೇಶನ್ + ಈ ಫೋನ್ ಸಂಖ್ಯೆ ಮತ್ತೊಂದು ಖಾತೆಯೊಂದಿಗೆ ಸಂಬಂಧ ಹೊಂದಿದೆ + ಪರಿಶೀಲನೆ ಅಗತ್ಯವಿದೆ + ನಿಮ್ಮ ಪ್ರಮಾಣೀಕರಣ ಅಪ್ಲಿಕೇಶನ್‌ನಲ್ಲಿ QR ಕೋಡ್ ಅನ್ನು ಸ್ಕ್ಯಾನ್ ಮಾಡಿ + ಪ್ರಮಾಣೀಕರಣ ಅಪ್ಲಿಕೇಶನ್ ಈಗಾಗಲೇ ಸೆಟಪ್ ಆಗಿದೆ + ನೀವು ಈ ವಿಧಾನವನ್ನು ತೆಗೆದುಹಾಕಲು ಖಚಿತವಾಗಿ ಬಯಸುವಿರಾ? + ವಿಧಾನವನ್ನು ತೆಗೆದುಹಾಕಲು ಸಾಧ್ಯವಿಲ್ಲ + ವಿಧಾನ ತೆಗೆದುಹಾಕಲಾಗಿದೆ + ಪರಿಶೀಲನೆ ಕೋಡ್ ನಮೂದಿಸಿ + ಪ್ರೊಫೈಲ್ ಅನ್ನು ಅಪ್‌ಡೇಟ್ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ + ಪ್ರೊಫೈಲ್ ಅಪ್‌ಡೇಟ್ ಆಗಿದೆ + ಮುಂದುವರಿಸಲು ನಿಮ್ಮ ಗುರುತನ್ನು ದೃಢೀಕರಿಸಿ + ಮರು-ಪ್ರಮಾಣೀಕರಣ ಅಗತ್ಯವಿದೆ + ಮರು-ಪ್ರಮಾಣೀಕರಣ ಯಶಸ್ವಿಯಾಗಿದೆ + ಮರು-ಪ್ರಮಾಣೀಕರಣ + ನಾನು ನನ್ನ ಮರುಪಡೆಯುವ ಕೋಡ್‌ಗಳನ್ನು ಉಳಿಸಿದ್ದೇನೆ + ತೆಗೆದುಹಾಕಿ + ಪರಿಶೀಲನೆ ಇಮೇಲ್ ಮರುಕಳುಹಿಸಿ + ರಹಸ್ಯ ಕೀ + ಸೈನ್ ಔಟ್ + ಹೀಗೆ ಸೈನ್ ಇನ್ ಆಗಿದ್ದೀರಿ + ಸ್ಕಿಪ್ ಮಾಡಿ + ವಿಭಿನ್ನ ವಿಧಾನವನ್ನು ಬಳಸಿ + ಪರಿಶೀಲನೆ ಕೋಡ್ + ಇಮೇಲ್ ಪರಿಶೀಲಿಸಲಾಗಿದೆ + ಪರಿಶೀಲಿಸಿ + ನಾವು %1$s ಗೆ ಪರಿಶೀಲನೆ ಇಮೇಲ್ ಕಳುಹಿಸಿದ್ದೇವೆ diff --git a/auth/src/main/res/values-ko/strings.xml b/auth/src/main/res/values-ko/strings.xml index 4127fe1c3..e2465e1fc 100755 --- a/auth/src/main/res/values-ko/strings.xml +++ b/auth/src/main/res/values-ko/strings.xml @@ -25,6 +25,8 @@ 이메일 전화번호 국가 + 국가 선택 + 국가 검색(예: +1, "US") 비밀번호 새 비밀번호 이 입력란을 비워둘 수 없습니다. @@ -73,10 +75,13 @@ 계속해서 로그인하려면 이메일을 확인하세요. 닫기 원래 이메일 계정에 %1$s을(를) 연결하려고 했지만 로그인되지 않은 다른 기기에서 링크를 열었습니다.\n\n%1$s 계정에 연결하려는 경우 로그인을 시작한 것과 동일한 기기에서 링크를 여세요. 또는 \'계속\'을 탭하여 이 기기에서 로그인하세요. + 또는 계속 + 이메일 링크로 로그인 + 비밀번호로 로그인 전화번호 입력 올바른 전화번호를 입력하세요. 전송된 6자리 코드를 입력하세요. - 0:%02d 후에 코드 재전송 + %1$s 후에 코드 재전송 전화번호 인증 인증 중… 코드가 잘못되었습니다. 다시 시도하세요. @@ -87,6 +92,78 @@ 전화번호가 자동으로 확인되었습니다. 코드 재전송 전화번호 인증 + Use a different phone number “%1$s” 버튼을 탭하면 SMS가 발송될 수 있으며, 메시지 및 데이터 요금이 부과될 수 있습니다. - ‘%1$s’ 버튼을 탭하면 %2$s 및 %3$s에 동의하는 것으로 간주됩니다. SMS가 발송될 수 있으며, 메시지 및 데이터 요금이 부과될 수 있습니다. + 인증 오류 + 다시 시도 + 추가 인증이 필요합니다. 다단계 인증을 완료해 주세요. + 계정을 연결해야 합니다. 다른 로그인 방법을 시도해 주세요. + 인증이 취소되었습니다. 준비가 되면 다시 시도해 주세요. + + + 인증 방법 선택 + SMS 인증 설정 + 인증 앱 설정 + 코드 확인 + 복구 코드 저장 + + 계정을 보호하기 위해 두 번째 인증 방법을 선택하세요 + 인증 코드를 받을 전화번호를 입력하세요 + 인증 앱으로 QR 코드를 스캔하세요 + 휴대전화로 전송된 코드를 입력하세요 + 인증 앱의 코드를 입력하세요 + 인증 코드를 입력하세요 + 이 코드를 안전한 곳에 보관하세요. 인증 방법에 액세스할 수 없게 되면 이 코드를 사용하여 로그인할 수 있습니다. + + 비밀번호 확인 + 비밀번호가 일치하지 않습니다 + 비밀번호는 %1$d자 이상이어야 합니다 + 비밀번호에 대문자가 하나 이상 포함되어야 합니다 + 비밀번호에 소문자가 하나 이상 포함되어야 합니다 + 비밀번호에 숫자가 하나 이상 포함되어야 합니다 + 비밀번호에 특수문자가 하나 이상 포함되어야 합니다 + + + SMS 인증 설정 + SMS 코드 확인 + + 이 작업을 수행하려면 최근 인증이 필요합니다. 다시 로그인한 후 다시 시도하세요. + 인증 코드가 잘못되었습니다. 다시 시도하세요. + 네트워크 오류가 발생했습니다. 연결 상태를 확인한 후 다시 시도하세요. + 등록 중 오류가 발생했습니다. 다시 시도하세요. + + 뒤로 + 신원이 확인되었습니다. 작업을 다시 시도하세요. + 2단계 인증 관리 + 비밀번호가 올바르지 않습니다 + 확인 방법을 선택하세요 + 추가 보안 계층 추가 + SMS + 인증 앱 + 이 전화번호는 이미 다른 계정과 연결되어 있습니다 + 확인이 필요합니다 + 인증 앱으로 QR 코드를 스캔하세요 + 인증 앱이 이미 설정되어 있습니다 + 이 확인 방법을 삭제하시겠습니까? + 확인 방법을 삭제할 수 없습니다 + 확인 방법이 삭제되었습니다 + 확인 코드를 입력하세요 + 프로필을 업데이트할 수 없습니다 + 프로필이 업데이트되었습니다 + 계속하려면 본인 확인을 진행하세요 + 재인증이 필요합니다 + 재인증에 성공했습니다 + 재인증 + 복구 코드를 저장했습니다 + 삭제 + 확인 이메일 다시 보내기 + 비밀 키 + 로그아웃 + 로그인 상태 + 건너뛰기 + 다른 방법 사용 + 확인 코드 + 이메일 확인 완료 + 확인 + %1$s(으)로 확인 이메일을 보냈습니다 diff --git a/auth/src/main/res/values-ln/strings.xml b/auth/src/main/res/values-ln/strings.xml index 86b3110d4..9252fa39a 100755 --- a/auth/src/main/res/values-ln/strings.xml +++ b/auth/src/main/res/values-ln/strings.xml @@ -25,6 +25,8 @@ E-mail Numéro de téléphone Pays + Pona mboka + Luka mboka ndakisa +1, "US" Mot de passe Nouveau mot de passe Ce champ est obligatoire. @@ -73,10 +75,13 @@ Confirmez votre adresse e-mail pour vous connecter Ignorer Vous aviez décidé d\'associer votre compte %1$s à votre adresse e-mail, mais vous avez ouvert le lien sur un appareil différent de celui avec lequel vous vous êtes connecté.\n\nSi vous souhaitez toujours associer votre compte %1$s, ouvrez le lien sur l\'appareil avec lequel vous avez commencé à vous connecter. Sinon, appuyez sur \"Continuer\" pour vous connecter depuis un autre appareil. + to Kokoba na + Kokɔta na nzela ya lien ya email + Kokɔta na nzela ya mot ya kobombama Saisissez votre numéro de téléphone Saisissez un numéro de téléphone valide Saisissez le code à six chiffres envoyé au - Renvoyer le code dans 0:%02d + Renvoyer le code dans %1$s Valider votre numéro de téléphone Validation… Code erroné. Veuillez réessayer. @@ -87,6 +92,80 @@ Numéro de téléphone validé automatiquement Renvoyer le code Valider le numéro de téléphone + Use a different phone number 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. + + + Pona lolenge ya bondimi + Bongisa bondimi ya SMS + Bongisa aplikasyo ya bondimi + Talela kode na yo + Bomba ba kode ya bozongisi + + Pona lolenge ya mibale ya bondimi po na kobatela konte na yo + Kota nimero ya telefone na yo po na kozwa ba kode ya bondimi + Talela kode QR na aplikasyo na yo ya bondimi + Kota kode oyo etindaki na telefone na yo + Kota kode oyo euti na aplikasyo na yo ya bondimi + Kota kode na yo ya bondimi + Bomba ba kode oyo na esika moko ya libateli. Okoki kosalela yango po na kokota soki obungi nzela ya bondimi na yo. + + Ndima mot de passe + Ba mot de passe ekokani te + Mot de passe esengeli kozala na ba caractères %1$d na molayi + Mot de passe esengeli kozala na ata lettre moko ya monene + Mot de passe esengeli kozala na ata lettre moko ya moke + Mot de passe esengeli kozala na ata nimero moko + Mot de passe esengeli kozala na ata caractère moko ya spécial + + + Bongisa Bondimisi SMS + Talela Kode SMS + + Mosala oyo esengaka bondimi ya kala mingi te. Tosɛngi okɔta lisusu mpe omeka lisusu. + Code ya bondimisi ezali mabe. Meka lisusu. + Libunga ya réseau esalemi. Talela boyokani na yo mpe meka lisusu. + Libunga esalemi na ntango ya kokɔtisa. Meka lisusu. + + Zonga + Identité esalemi. Meka lisusu. + Kotambwisa authentification ya facteur mibale + Mot de passe ezali malamu te + Pona méthode ya vérification + Bakisa couche ya sécurité mosusu + SMS + Application ya authentification + Numéro oyo ya téléphone ezali na compte mosusu + Vérification esengeli + Scanner code QR na application ya authentification na yo + Application ya authentification etii nanu + Ozali na ntembe olinga kolongola méthode oyo? + Ekoki kolongola méthode te + Méthode elongwami + Kotisa code ya vérification + Ekoki ko-update profil te + Profil e-update + Ndima identité na yo mpo na kokoba + Re-authentification esengeli + Re-authentification elongi + Re-authentifier + Nabombi ba codes ya récupération na ngai + Longola + Tinda lisusu e-mail ya vérification + Clé secrète + Kobima + Okoti lokola + Leka + Salelá méthode mosusu + Code ya vérification + E-mail esalemi + Sala vérification + Totindi e-mail ya vérification na %1$s diff --git a/auth/src/main/res/values-lt/strings.xml b/auth/src/main/res/values-lt/strings.xml index 125ddf0fb..822e63802 100755 --- a/auth/src/main/res/values-lt/strings.xml +++ b/auth/src/main/res/values-lt/strings.xml @@ -25,6 +25,8 @@ El. pašto adresas Telefono numeris Šalis + Pasirinkite šalį + Ieškoti šalies, pvz., +1, "US" Slaptažodis Naujas slaptažodis Šį lauką būtina užpildyti. @@ -73,10 +75,13 @@ Patvirtinkite el. paštą, kad galėtumėte tęsti prisijungimą Atsisakyti Iš pradžių ketinote susieti %1$s ir el. pašto paskyras, bet atidarėte nuorodą kitame įrenginyje, kuriame nesate prisijungę.\n\nJei vis tiek norite susieti %1$s paskyrą, atidarykite nuorodą tame pačiame įrenginyje, kuriame pradėjote prisijungimo procesą. Jei nenorite, palieskite „Tęsti“ ir prisijunkite šiame įrenginyje. + arba Tęsti su + Prisijungti el. pašto nuoroda + Prisijungti slaptažodžiu Įveskite telefono numerį Įveskite tinkamą telefono numerį Įveskite 6 skaitmenų kodą, kurį išsiuntėme - Siųsti kodą iš naujo po 0:%02d + Siųsti kodą iš naujo po %1$s Patvirtinti telefono numerį Patvirtinama… Klaidingas kodas. Bandykite dar kartą. @@ -87,6 +92,80 @@ Telefono numeris patvirtintas automatiškai Siųsti kodą iš naujo Patvirtinti telefono numerį + Use a different phone number 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. + + + Pasirinkite autentifikavimo metodą + Nustatyti SMS patvirtinimą + Nustatyti autentifikatoriaus programą + Patvirtinkite kodą + Išsaugokite atkūrimo kodus + + Pasirinkite antrą autentifikavimo metodą, kad apsaugotumėte paskyrą + Įveskite telefono numerį, kad gautumėte patvirtinimo kodus + Nuskaitykite QR kodą naudodami autentifikatoriaus programą + Įveskite kodą, išsiųstą į telefoną + Įveskite kodą iš autentifikatoriaus programos + Įveskite patvirtinimo kodą + Išsaugokite šiuos kodus saugioje vietoje. Galite juos naudoti prisijungti, jei prarasite prieigą prie autentifikavimo metodo. + + Patvirtinti slaptažodį + Slaptažodžiai nesutampa + Slaptažodis turi būti ne trumpesnis kaip %1$d simbolių + Slaptažodyje turi būti bent viena didžioji raidė + Slaptažodyje turi būti bent viena mažoji raidė + Slaptažodyje turi būti bent vienas skaičius + Slaptažodyje turi būti bent vienas specialusis simbolis + + + Nustatyti SMS autentifikavimą + Patvirtinti SMS kodą + + Šiai operacijai reikalingas naujas autentifikavimas. Prisijunkite dar kartą ir bandykite dar kartą. + Patvirtinimo kodas neteisingas. Bandykite dar kartą. + Įvyko tinklo klaida. Patikrinkite ryšį ir bandykite dar kartą. + Registracijos metu įvyko klaida. Bandykite dar kartą. + + Atgal + Tapatybė patvirtinta. Bandykite dar kartą. + Tvarkyti dviejų veiksnių autentifikavimą + Neteisingas slaptažodis + Pasirinkite patvirtinimo metodą + Pridėkite papildomą saugumo sluoksnį + SMS + Autentifikavimo programa + Šis telefono numeris susietas su kita paskyra + Reikalingas patvirtinimas + Nuskaitykite QR kodą autentifikavimo programa + Autentifikavimo programa jau nustatyta + Ar tikrai norite pašalinti šį metodą? + Nepavyko pašalinti metodo + Metodas pašalintas + Įveskite patvirtinimo kodą + Nepavyko atnaujinti profilio + Profilis atnaujintas + Patvirtinkite savo tapatybę, kad tęstumėte + Reikalingas pakartotinis autentifikavimas + Pakartotinis autentifikavimas sėkmingas + Autentifikuoti iš naujo + Išsaugojau atkūrimo kodus + Pašalinti + Siųsti patvirtinimo el. laišką iš naujo + Slaptasis raktas + Atsijungti + Prisijungta kaip + Praleisti + Naudoti kitą metodą + Patvirtinimo kodas + El. paštas patvirtintas + Patvirtinti + Išsiuntėme patvirtinimo el. laišką adresu %1$s diff --git a/auth/src/main/res/values-lv/strings.xml b/auth/src/main/res/values-lv/strings.xml index 7f915b466..123dd5463 100755 --- a/auth/src/main/res/values-lv/strings.xml +++ b/auth/src/main/res/values-lv/strings.xml @@ -25,6 +25,8 @@ E-pasts Tālruņa numurs Valsts + Atlasiet valsti + Meklēt valsti, piem., +1, "US" Parole Jauna parole Šo laukumu nedrīkst atstāt tukšu. @@ -73,10 +75,13 @@ Lai turpinātu pierakstīšanos, apstipriniet e-pasta adresi. Nerādīt Sākotnēji vēlējāties saistīt %1$s ar savu e-pasta kontu, tomēr saiti atvērāt citā ierīcē, kurā neesat pierakstījies.\n\nJa joprojām vēlaties saistīt savu %1$s kontu, atveriet saiti tajā pašā ierīcē, kurā sākāt pierakstīšanos. Pretējā gadījumā pieskarieties vienumam Turpināt, lai pierakstītos šajā ierīcē. + vai Turpināt ar + Pierakstīties ar e-pasta saiti + Pierakstīties ar paroli Ievadiet savu tālruņa numuru Ievadiet derīgu tālruņa numuru Ievadiet 6 ciparu kodu, ko nosūtījām - Vēlreiz nosūtīt kodu pēc 0:%02d + Vēlreiz nosūtīt kodu pēc %1$s Verificēt tālruņa numuru Notiek verifikācija… Nepareizs kods. Mēģiniet vēlreiz. @@ -87,6 +92,80 @@ Tālruņa numurs tika automātiski verificēts Vēlreiz nosūtīt kodu Verificēt tālruņa numuru + Use a different phone number 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. + + + Izvēlieties autentifikācijas metodi + Iestatīt SMS verifikāciju + Iestatīt autentifikatora lietotni + Verificējiet savu kodu + Saglabājiet atgūšanas kodus + + Izvēlieties otro autentifikācijas metodi, lai aizsargātu savu kontu + Ievadiet savu tālruņa numuru, lai saņemtu verificēšanas kodus + Skenējiet QR kodu ar savu autentifikatora lietotni + Ievadiet uz tālruni nosūtīto kodu + Ievadiet kodu no savas autentifikatora lietotnes + Ievadiet verificēšanas kodu + Glabājiet šos kodus drošā vietā. Varat tos izmantot, lai pierakstītos, ja zaudējat piekļuvi autentifikācijas metodei. + + Apstipriniet paroli + Paroles nesakrīt + Parolei jābūt vismaz %1$d rakstzīmes garai + Parolei jāsatur vismaz viens lielais burts + Parolei jāsatur vismaz viens mazais burts + Parolei jāsatur vismaz viens cipars + Parolei jāsatur vismaz viena īpašā rakstzīme + + + Iestatīt SMS autentifikāciju + Pārbaudīt SMS kodu + + Šai darbībai ir nepieciešama nesena autentifikācija. Lūdzu, piesakieties vēlreiz un mēģiniet vēlreiz. + Verifikācijas kods ir nepareizs. Lūdzu, mēģiniet vēlreiz. + Radās tīkla kļūda. Lūdzu, pārbaudiet savienojumu un mēģiniet vēlreiz. + Reģistrācijas laikā radās kļūda. Lūdzu, mēģiniet vēlreiz. + + Atpakaļ + Identitāte apstiprināta. Mēģiniet vēlreiz. + Pārvaldīt divfaktoru autentifikāciju + Nepareiza parole + Izvēlieties verifikācijas metodi + Pievienojiet papildu drošības slāni + SMS + Autentifikācijas lietotne + Šis tālruņa numurs ir saistīts ar citu kontu + Nepieciešama verifikācija + Skenējiet QR kodu ar autentifikācijas lietotni + Autentifikācijas lietotne jau ir iestatīta + Vai tiešām vēlaties noņemt šo metodi? + Nevar noņemt metodi + Metode noņemta + Ievadiet verifikācijas kodu + Nevar atjaunināt profilu + Profils atjaunināts + Apstipriniet savu identitāti, lai turpinātu + Nepieciešama atkārtota autentifikācija + Atkārtota autentifikācija veiksmīga + Autentificēt atkārtoti + Esmu saglabājis atgūšanas kodus + Noņemt + Atkārtoti nosūtīt verifikācijas e-pastu + Slepenā atslēga + Izrakstīties + Pierakstījies kā + Izlaist + Izmantot citu metodi + Verifikācijas kods + E-pasts apstiprināts + Verificēt + Nosūtījām verifikācijas e-pastu uz %1$s diff --git a/auth/src/main/res/values-mo/strings.xml b/auth/src/main/res/values-mo/strings.xml index f8d72d534..ed950ff97 100755 --- a/auth/src/main/res/values-mo/strings.xml +++ b/auth/src/main/res/values-mo/strings.xml @@ -25,6 +25,8 @@ Adresă de e-mail Număr de telefon Țară + Selectați o țară + Căutați țară de ex. +1, "US" Parolă Parolă nouă Câmpul este obligatoriu. @@ -73,10 +75,13 @@ Confirmați adresa de e-mail pentru a continua conectarea Închideți Inițial ați intenționat să conectați %1$s la contul de e-mail, dar ați deschis linkul pe un dispozitiv pe care nu sunteți conectat(ă).\n\nDacă doriți să continuați conectarea contului %1$s, deschideți linkul pe același dispozitiv pe care ați început conectarea. În caz contrar, atingeți Continuați pentru a vă conecta pe acest dispozitiv. + sau Continuă cu + Conectați-vă cu linkul din e-mail + Conectați-vă cu parola Introduceți numărul de telefon Introduceți un număr valid de telefon Introduceți codul din 6 cifre pe care l-am trimis la - Retrimiteți codul în 00:%02d + Retrimiteți codul în 0%1$s Confirmați numărul de telefon Se verifică… Cod greșit. Încercați din nou. @@ -87,6 +92,80 @@ Numărul de telefon este verificat automat Retrimiteți codul Confirmați numărul de telefon + Use a different phone number 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. + + + Alegeți metoda de autentificare + Configurați verificarea prin SMS + Configurați aplicația de autentificare + Verificați codul + Salvați codurile de recuperare + + Selectați o a doua metodă de autentificare pentru a vă securiza contul + Introduceți numărul de telefon pentru a primi coduri de verificare + Scanați codul QR cu aplicația dvs. de autentificare + Introduceți codul trimis pe telefon + Introduceți codul din aplicația dvs. de autentificare + Introduceți codul de verificare + Stocați aceste coduri într-un loc sigur. Le puteți folosi pentru a vă conecta dacă pierdeți accesul la metoda de autentificare. + + Confirmați parola + Parolele nu se potrivesc + Parola trebuie să aibă cel puțin %1$d caractere + Parola trebuie să conțină cel puțin o literă mare + Parola trebuie să conțină cel puțin o literă mică + Parola trebuie să conțină cel puțin un număr + Parola trebuie să conțină cel puțin un caracter special + + + Configurați autentificarea prin SMS + Verificați codul SMS + + Această operațiune necesită autentificare recentă. Vă rugăm să vă conectați din nou și să încercați din nou. + Codul de verificare este incorect. Vă rugăm să încercați din nou. + A apărut o eroare de rețea. Vă rugăm să verificați conexiunea și să încercați din nou. + A apărut o eroare în timpul înregistrării. Vă rugăm să încercați din nou. + + Înapoi + Identitate verificată. Reîncercați acțiunea. + Gestionați autentificarea în doi pași + Parolă incorectă + Alegeți o metodă de verificare + Adăugați un strat suplimentar de securitate + SMS + Aplicație de autentificare + Acest număr de telefon este asociat cu alt cont + Verificare necesară + Scanați codul QR cu aplicația de autentificare + Aplicația de autentificare este deja configurată + Sigur doriți să eliminați această metodă? + Nu se poate elimina metoda + Metodă eliminată + Introduceți codul de verificare + Nu se poate actualiza profilul + Profil actualizat + Confirmați-vă identitatea pentru a continua + Este necesară reautentificarea + Reautentificare reușită + Reautentificare + Am salvat codurile de recuperare + Eliminare + Retrimite e-mailul de verificare + Cheie secretă + Deconectare + Conectat ca + Omite + Folosiți o altă metodă + Cod de verificare + E-mail verificat + Verificare + Am trimis un e-mail de verificare la %1$s diff --git a/auth/src/main/res/values-mr/strings.xml b/auth/src/main/res/values-mr/strings.xml index 722aa21a5..1cafe69e3 100755 --- a/auth/src/main/res/values-mr/strings.xml +++ b/auth/src/main/res/values-mr/strings.xml @@ -25,6 +25,8 @@ ईमेल फोन नंबर देश + देश निवडा + देश शोधा उदा. +1, "US" पासवर्ड नवीन पासवर्ड तुम्ही हे रिक्त सोडू शकत नाही. @@ -73,10 +75,13 @@ साइन इन करणे सुरू ठेवण्यासाठी ईमेलची खात्री करा डिसमिस करा तुमचे ईमेल खाते %1$s सह कनेक्ट करणे हा तुमचा मूळ उद्देश होता पण तुम्ही साइन इन न केलेल्या वेगळ्या डिव्हाइसवर लिंक उघडली आहे. \n\nतुम्हाला तरीही तुमचे %1$s खाते कनेक्ट करायचे असल्यास, तुम्ही साइन इन सुरू केले त्याच डिव्हाइसवर लिंक उघडा. नाहीतर, या डिव्हाइसवर साइन इन करण्यासाठी सुरू ठेवा वर टॅप करा. + किंवा यासह सुरू ठेवा + ईमेल लिंक वापरून साइन इन करा + पासवर्ड वापरून साइन इन करा तुमचा फोन नंबर टाका कृपया वैध फोन नंबर टाका वर आम्ही पाठवलेला 6 अंकी कोड टाका - कोड 0:%02dमध्ये पुन्हा पाठवा + कोड %1$sमध्ये पुन्हा पाठवा तुमच्या फोन नंबरची पडताळणी करा पडताळणी करत आहे… कोड चुकीचा आहे. पुन्हा प्रयत्न करा. @@ -87,6 +92,80 @@ फोन नंबरची अपोआप पडताळणी केली आहे कोड पुन्हा पाठवा फोन नंबरची पडताळणी करा + Use a different phone number “%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. + + + प्रमाणीकरण पद्धत निवडा + SMS पडताळणी सेट करा + प्रमाणीकरणकर्ता अॅप सेट करा + तुमचा कोड पडताळा + तुमचे पुनर्प्राप्ती कोड जतन करा + + तुमचे खाते सुरक्षित करण्यासाठी दुसरी प्रमाणीकरण पद्धत निवडा + पडताळणी कोड प्राप्त करण्यासाठी तुमचा फोन नंबर प्रविष्ट करा + तुमच्या प्रमाणीकरणकर्ता अॅपसह QR कोड स्कॅन करा + तुमच्या फोनवर पाठवलेला कोड प्रविष्ट करा + तुमच्या प्रमाणीकरणकर्ता अॅपमधील कोड प्रविष्ट करा + तुमचा पडताळणी कोड प्रविष्ट करा + हे कोड सुरक्षित ठिकाणी संग्रहित करा. तुम्ही तुमच्या प्रमाणीकरण पद्धतीचा प्रवेश गमावल्यास साइन इन करण्यासाठी त्यांचा वापर करू शकता. + + पासवर्डची पुष्टी करा + पासवर्ड जुळत नाहीत + पासवर्ड किमान %1$d वर्णांचा असावा + पासवर्डमध्ये किमान एक मोठे अक्षर असावे + पासवर्डमध्ये किमान एक लहान अक्षर असावे + पासवर्डमध्ये किमान एक अंक असावा + पासवर्डमध्ये किमान एक विशेष वर्ण असावा + + + एसएमएस प्रमाणीकरण सेट करा + एसएमएस कोड सत्यापित करा + + या ऑपरेशनसाठी अलीकडील प्रमाणीकरण आवश्यक आहे. कृपया पुन्हा साइन इन करा आणि पुन्हा प्रयत्न करा. + पडताळणी कोड चुकीचा आहे. कृपया पुन्हा प्रयत्न करा. + नेटवर्क एरर आली. कृपया तुमचे कनेक्शन तपासा आणि पुन्हा प्रयत्न करा. + नोंदणी दरम्यान एरर आली. कृपया पुन्हा प्रयत्न करा. + + मागे + ओळख सत्यापित झाली. तुमची क्रिया पुन्हा प्रयत्न करा. + दोन-घटक प्रमाणीकरण व्यवस्थापित करा + चुकीचा पासवर्ड + सत्यापन पद्धत निवडा + सुरक्षेचा अतिरिक्त स्तर जोडा + SMS + प्रमाणीकरण ॲप + हा फोन नंबर दुसऱ्या खात्याशी संबंधित आहे + सत्यापन आवश्यक आहे + तुमच्या प्रमाणीकरण ॲपसह QR कोड स्कॅन करा + प्रमाणीकरण ॲप आधीच सेट अप आहे + तुमची खात्री आहे की तुम्हाला ही पद्धत काढायची आहे? + पद्धत काढू शकत नाही + पद्धत काढली + सत्यापन कोड प्रविष्ट करा + प्रोफाइल अपडेट करू शकत नाही + प्रोफाइल अपडेट केले + सुरू ठेवण्यासाठी तुमची ओळख पुष्टी करा + पुन्हा प्रमाणीकरण आवश्यक आहे + पुन्हा प्रमाणीकरण यशस्वी + पुन्हा प्रमाणीकरण + मी माझे पुनर्प्राप्ती कोड सुरक्षित केले आहेत + काढा + सत्यापन ईमेल पुन्हा पाठवा + गुप्त की + साइन आउट + म्हणून साइन इन केले + वगळा + वेगळी पद्धत वापरा + सत्यापन कोड + ईमेल सत्यापित + सत्यापित करा + आम्ही %1$s वर सत्यापन ईमेल पाठवला diff --git a/auth/src/main/res/values-ms/strings.xml b/auth/src/main/res/values-ms/strings.xml index bdba53bb6..38f6e00f4 100755 --- a/auth/src/main/res/values-ms/strings.xml +++ b/auth/src/main/res/values-ms/strings.xml @@ -25,6 +25,8 @@ E-mel Nombor Telefon Negara + Pilih negara + Cari negara cth. +1, "US" Kata Laluan Kata laluan baharu Anda tidak boleh membiarkan ruang ini kosong. @@ -73,10 +75,13 @@ Sahkan e-mel untuk meneruskan log masuk Ketepikan Pada asalnya, anda mahu menyambungkan %1$s kepada akaun e-mel anda tetapi telah membuka pautan pada peranti berbeza yang tidak dilog masuk.\n\nJika anda masih mahu memautkan akaun %1$s anda, buka pautan pada peranti yang anda gunakan untuk log masuk. Jika tidak, ketik Teruskan untuk log masuk pada peranti ini. + atau Teruskan dengan + Log masuk dengan pautan e-mel + Log masuk dengan kata laluan Masukkan nombor telefon anda Masukkan nombor telefon yang sah Masukkan kod 6 digit yang kami hantar ke - Hantar semula kod dalam 0:%02d + Hantar semula kod dalam %1$s Sahkan nombor telefon anda Mengesahkan… Kod salah. Cuba lagi. @@ -87,6 +92,80 @@ Nombor telefon disahkan secara automatik Hantar Semula Kod Sahkan Nombor Telefon + Use a different phone number 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. + + + Pilih Kaedah Pengesahan + Sediakan Pengesahan SMS + Sediakan Aplikasi Pengesah + Sahkan Kod Anda + Simpan Kod Pemulihan Anda + + Pilih kaedah pengesahan kedua untuk melindungi akaun anda + Masukkan nombor telefon anda untuk menerima kod pengesahan + Imbas kod QR dengan aplikasi pengesah anda + Masukkan kod yang dihantar ke telefon anda + Masukkan kod dari aplikasi pengesah anda + Masukkan kod pengesahan anda + Simpan kod ini di tempat yang selamat. Anda boleh menggunakannya untuk log masuk jika anda kehilangan akses kepada kaedah pengesahan anda. + + Sahkan Kata Laluan + Kata laluan tidak sepadan + Kata laluan mestilah sekurang-kurangnya %1$d aksara panjang + Kata laluan mestilah mengandungi sekurang-kurangnya satu huruf besar + Kata laluan mestilah mengandungi sekurang-kurangnya satu huruf kecil + Kata laluan mestilah mengandungi sekurang-kurangnya satu nombor + Kata laluan mestilah mengandungi sekurang-kurangnya satu aksara khas + + + Sediakan Pengesahan SMS + Sahkan Kod SMS + + Operasi ini memerlukan pengesahan terkini. Sila log masuk semula dan cuba lagi. + Kod pengesahan tidak betul. Sila cuba lagi. + Ralat rangkaian berlaku. Sila semak sambungan anda dan cuba lagi. + Ralat berlaku semasa pendaftaran. Sila cuba lagi. + + Kembali + Identiti disahkan. Cuba tindakan anda semula. + Urus pengesahan dua faktor + Kata laluan salah + Pilih kaedah pengesahan + Tambah lapisan keselamatan tambahan + SMS + Apl pengesahan + Nombor telefon ini dikaitkan dengan akaun lain + Pengesahan diperlukan + Imbas kod QR dengan apl pengesahan anda + Apl pengesahan telah disediakan + Adakah anda pasti mahu membuang kaedah ini? + Tidak dapat membuang kaedah + Kaedah dibuang + Masukkan kod pengesahan + Tidak dapat mengemas kini profil + Profil dikemas kini + Sahkan identiti anda untuk meneruskan + Pengesahan semula diperlukan + Pengesahan semula berjaya + Sahkan semula + Saya telah menyimpan kod pemulihan saya + Buang + Hantar semula e-mel pengesahan + Kunci rahsia + Log keluar + Log masuk sebagai + Langkau + Gunakan kaedah lain + Kod pengesahan + E-mel disahkan + Sahkan + Kami menghantar e-mel pengesahan ke %1$s diff --git a/auth/src/main/res/values-nb/strings.xml b/auth/src/main/res/values-nb/strings.xml index a7ebc177a..9fae51f08 100755 --- a/auth/src/main/res/values-nb/strings.xml +++ b/auth/src/main/res/values-nb/strings.xml @@ -25,6 +25,8 @@ E-post Telefonnummer Land + Velg et land + Søk etter land, f.eks. +1, "US" Passord Nytt passord Du må fylle ut dette feltet. @@ -73,10 +75,13 @@ Bekreft e-postadressen for å fortsette med påloggingen Lukk Du forsøkte opprinnelig å koble %1$s til e-postkontoen din, men du åpnet linken på en annen enhet enn den du er pålogget med.\n\nHvis du fortsatt ønsker å koble til %1$s-kontoen din, åpner du linken på enheten du er pålogget med. Hvis ikke trykker du på Fortsett for å logge på med denne enheten. + eller Fortsett med + Logg på med e-postlenke + Logg på med passord Oppgi telefonnummeret ditt Oppgi et gyldig telefonnummer Oppgi den 6-sifrede koden vi sendte til - Send koden på nytt om 0:%02d + Send koden på nytt om %1$s Bekreft telefonnummeret ditt Bekrefter… Feil kode. Prøv på nytt. @@ -87,6 +92,79 @@ Telefonnummeret ble bekreftet automatisk Send koden på nytt Bekreft telefonnummeret + Use a different phone number 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. + + + Velg autentiseringsmetode + Konfigurer SMS-bekreftelse + Konfigurer autentiseringsapp + Bekreft koden din + Lagre gjenopprettingskodene dine + + Velg en andre autentiseringsmetode for å sikre kontoen din + Skriv inn telefonnummeret ditt for å motta bekreftelseskoder + Skann QR-koden med autentiseringsappen din + Skriv inn koden som ble sendt til telefonen din + Skriv inn koden fra autentiseringsappen din + Skriv inn bekreftelseskoden din + Lagre disse kodene på et sikkert sted. Du kan bruke dem til å logge inn hvis du mister tilgang til autentiseringsmetoden din. + + Bekreft passord + Passordene stemmer ikke overens + Passordet må være på minst %1$d tegn + Passordet må inneholde minst én stor bokstav + Passordet må inneholde minst én liten bokstav + Passordet må inneholde minst ett tall + Passordet må inneholde minst ett spesialtegn + + + Konfigurer SMS-godkjenning + Bekreft SMS-kode + + Denne handlingen krever nylig godkjenning. Logg på igjen og prøv igjen. + Bekreftelseskoden er feil. Prøv igjen. + Det oppstod en nettverksfeil. Sjekk tilkoblingen og prøv igjen. + Det oppstod en feil under registrering. Prøv igjen. + + Tilbake + Identitet bekreftet. Prøv handlingen din på nytt. + Administrer tofaktorautentisering + Feil passord + Velg en bekreftelsesmetode + Legg til et ekstra sikkerhetslag + SMS + Autentiseringsapp + Dette telefonnummeret er knyttet til en annen konto + Bekreftelse påkrevd + Skann QR-koden med autentiseringsappen din + Autentiseringsappen er allerede konfigurert + Er du sikker på at du vil fjerne denne metoden? + Kan ikke fjerne metode + Metode fjernet + Angi bekreftelseskode + Kan ikke oppdatere profil + Profil oppdatert + Bekreft identiteten din for å fortsette + Ny autentisering påkrevd + Ny autentisering vellykket + Autentiser på nytt + Jeg har lagret gjenopprettingskodene mine + Fjern + Send bekreftelsese-post på nytt + Hemmelig nøkkel + Logg ut + Logget inn som + Hopp over + Bruk en annen metode + Bekreftelseskode + E-post bekreftet + Bekreft + Vi sendte en bekreftelsese-post til %1$s diff --git a/auth/src/main/res/values-nl/strings.xml b/auth/src/main/res/values-nl/strings.xml index 73bc277a9..71283ae3e 100755 --- a/auth/src/main/res/values-nl/strings.xml +++ b/auth/src/main/res/values-nl/strings.xml @@ -25,6 +25,8 @@ E-mail Telefoonnummer Land + Selecteer een land + Land zoeken bijv. +1, "US" Wachtwoord Nieuw wachtwoord Dit veld mag niet leeg zijn. @@ -73,10 +75,13 @@ Bevestig uw e-mailadres om door te gaan met inloggen Afwijzen U wilde aanvankelijk %1$s aan uw e-mailaccount koppelen, maar u heeft de link geopend op een andere apparaat, waarop u niet ingelogd bent.\n\nAls u nog steeds verbinding wilt maken met uw %1$s-account, opent u de link op het apparaat waarop u het inlogproces bent gestart. Tik anders op Doorgaan om in te loggen op dit apparaat. + of Doorgaan met + Inloggen met e-maillink + Inloggen met wachtwoord Voer uw telefoonnummer in Voer een geldig telefoonnummer in Voer de zescijferige code in die we hebben verzonden naar - Code opnieuw verzenden over 0:%02d + Code opnieuw verzenden over %1$s Uw telefoonnummer verifiëren Verifiëren… Onjuiste code. Probeer het opnieuw. @@ -87,6 +92,79 @@ Telefoonnummer is automatisch geverifieerd Code opnieuw verzenden Telefoonnummer verifiëren + Use a different phone number 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. + + + Kies authenticatiemethode + SMS-verificatie instellen + Authenticator-app instellen + Code verifiëren + Herstelcodes opslaan + + Selecteer een tweede authenticatiemethode om je account te beveiligen + Voer je telefoonnummer in om verificatiecodes te ontvangen + Scan de QR-code met je authenticator-app + Voer de code in die naar je telefoon is verzonden + Voer de code uit je authenticator-app in + Voer je verificatiecode in + Bewaar deze codes op een veilige plek. Je kunt ze gebruiken om in te loggen als je geen toegang meer hebt tot je authenticatiemethode. + + Bevestig wachtwoord + Wachtwoorden komen niet overeen + Wachtwoord moet minimaal %1$d tekens lang zijn + Wachtwoord moet minimaal één hoofdletter bevatten + Wachtwoord moet minimaal één kleine letter bevatten + Wachtwoord moet minimaal één cijfer bevatten + Wachtwoord moet minimaal één speciaal teken bevatten + + + SMS-authenticatie instellen + SMS-code verifiëren + + Voor deze bewerking is recente authenticatie vereist. Log opnieuw in en probeer het opnieuw. + De verificatiecode is onjuist. Probeer het opnieuw. + Er is een netwerkfout opgetreden. Controleer je verbinding en probeer het opnieuw. + Er is een fout opgetreden tijdens de inschrijving. Probeer het opnieuw. + + Terug + Identiteit geverifieerd. Probeer je actie opnieuw. + Tweefactorauthenticatie beheren + Onjuist wachtwoord + Kies een verificatiemethode + Voeg een extra beveiligingslaag toe + SMS + Authenticatie-app + Dit telefoonnummer is al gekoppeld aan een ander account + Verificatie vereist + Scan de QR-code met je authenticatie-app + Authenticatie-app is al ingesteld + Weet je zeker dat je deze methode wilt verwijderen? + Kan methode niet verwijderen + Methode verwijderd + Voer de verificatiecode in + Kan profiel niet bijwerken + Profiel bijgewerkt + Bevestig je identiteit om door te gaan + Herauthenticatie vereist + Herauthenticatie geslaagd + Opnieuw authenticeren + Ik heb mijn herstelcodes opgeslagen + Verwijderen + Verificatie-e-mail opnieuw verzenden + Geheime sleutel + Uitloggen + Ingelogd als + Overslaan + Gebruik een andere methode + Verificatiecode + E-mail geverifieerd + Verifiëren + We hebben een verificatie-e-mail verzonden naar %1$s diff --git a/auth/src/main/res/values-no/strings.xml b/auth/src/main/res/values-no/strings.xml index a7ebc177a..2461c4194 100755 --- a/auth/src/main/res/values-no/strings.xml +++ b/auth/src/main/res/values-no/strings.xml @@ -25,6 +25,8 @@ E-post Telefonnummer Land + Velg et land + Søk etter land, f.eks. +1, "US" Passord Nytt passord Du må fylle ut dette feltet. @@ -73,10 +75,13 @@ Bekreft e-postadressen for å fortsette med påloggingen Lukk Du forsøkte opprinnelig å koble %1$s til e-postkontoen din, men du åpnet linken på en annen enhet enn den du er pålogget med.\n\nHvis du fortsatt ønsker å koble til %1$s-kontoen din, åpner du linken på enheten du er pålogget med. Hvis ikke trykker du på Fortsett for å logge på med denne enheten. + eller Fortsett med + Logg på med e-postlenke + Logg på med passord Oppgi telefonnummeret ditt Oppgi et gyldig telefonnummer Oppgi den 6-sifrede koden vi sendte til - Send koden på nytt om 0:%02d + Send koden på nytt om %1$s Bekreft telefonnummeret ditt Bekrefter… Feil kode. Prøv på nytt. @@ -87,6 +92,80 @@ Telefonnummeret ble bekreftet automatisk Send koden på nytt Bekreft telefonnummeret + Use a different phone number 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. + + + Velg autentiseringsmetode + Konfigurer SMS-bekreftelse + Konfigurer autentiseringsapp + Bekreft koden din + Lagre gjenopprettingskodene dine + + Velg en andre autentiseringsmetode for å sikre kontoen din + Skriv inn telefonnummeret ditt for å motta bekreftelseskoder + Skann QR-koden med autentiseringsappen din + Skriv inn koden som ble sendt til telefonen din + Skriv inn koden fra autentiseringsappen din + Skriv inn bekreftelseskoden din + Lagre disse kodene på et sikkert sted. Du kan bruke dem til å logge inn hvis du mister tilgang til autentiseringsmetoden din. + + Bekreft passord + Passordene stemmer ikke overens + Passordet må være på minst %1$d tegn + Passordet må inneholde minst én stor bokstav + Passordet må inneholde minst én liten bokstav + Passordet må inneholde minst ett tall + Passordet må inneholde minst ett spesialtegn + + + Konfigurer SMS-godkjenning + Bekreft SMS-kode + + Denne handlingen krever nylig godkjenning. Logg på igjen og prøv igjen. + Bekreftelseskoden er feil. Prøv igjen. + Det oppstod en nettverksfeil. Sjekk tilkoblingen og prøv igjen. + Det oppstod en feil under registrering. Prøv igjen. + + Tilbake + Identitet bekreftet. Prøv handlingen din på nytt. + Administrer tofaktorautentisering + Feil passord + Velg en bekreftelsesmetode + Legg til et ekstra sikkerhetslag + SMS + Autentiseringsapp + Dette telefonnummeret er knyttet til en annen konto + Bekreftelse påkrevd + Skann QR-koden med autentiseringsappen din + Autentiseringsappen er allerede konfigurert + Er du sikker på at du vil fjerne denne metoden? + Kan ikke fjerne metode + Metode fjernet + Angi bekreftelseskode + Kan ikke oppdatere profil + Profil oppdatert + Bekreft identiteten din for å fortsette + Ny autentisering påkrevd + Ny autentisering vellykket + Autentiser på nytt + Jeg har lagret gjenopprettingskodene mine + Fjern + Send bekreftelsese-post på nytt + Hemmelig nøkkel + Logg ut + Logget inn som + Hopp over + Bruk en annen metode + Bekreftelseskode + E-post bekreftet + Bekreft + Vi sendte en bekreftelsese-post til %1$s diff --git a/auth/src/main/res/values-pl/strings.xml b/auth/src/main/res/values-pl/strings.xml index 4a4b0770f..6d67332c3 100755 --- a/auth/src/main/res/values-pl/strings.xml +++ b/auth/src/main/res/values-pl/strings.xml @@ -25,6 +25,8 @@ Adres e-mail Numer telefonu Kraj + Wybierz kraj + Szukaj kraju np. +1, "US" Hasło Nowe hasło Nie możesz zostawić tego pola pustego. @@ -73,10 +75,13 @@ Potwierdź adres e-mail, aby kontynuować logowanie się Odrzuć Podjęto próbę połączenia konta %1$s z kontem, na którym używasz podanego adresu e-mail, ale link został otwarty na innym urządzeniu, na którym użytkownik nie był zalogowany.\n\nJeśli nadal chcesz połączyć konto w serwisie %1$s, otwórz link na urządzeniu, na którym rozpoczęto logowanie. W przeciwnym razie wybierz Dalej, by zalogować się na tym urządzeniu. + lub Kontynuuj z + Zaloguj się za pomocą linku e-mail + Zaloguj się za pomocą hasła Wpisz numer telefonu Wpisz prawidłowy numer telefonu Wpisz sześciocyfrowy kod, który wysłaliśmy na numer - Kod można ponownie wysłać za 0:%02d + Kod można ponownie wysłać za %1$s Zweryfikuj swój numer telefonu Weryfikuję… Nieprawidłowy kod. Spróbuj jeszcze raz. @@ -87,6 +92,79 @@ Numer telefonu został automatycznie zweryfikowany Wyślij kod ponownie Zweryfikuj numer telefonu + Use a different phone number 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. + + + Wybierz metodę uwierzytelniania + Skonfiguruj weryfikację SMS + Skonfiguruj aplikację uwierzytelniającą + Zweryfikuj kod + Zapisz kody odzyskiwania + + Wybierz drugą metodę uwierzytelniania, aby zabezpieczyć swoje konto + Wprowadź numer telefonu, aby otrzymywać kody weryfikacyjne + Zeskanuj kod QR za pomocą aplikacji uwierzytelniającej + Wprowadź kod wysłany na Twój telefon + Wprowadź kod z aplikacji uwierzytelniającej + Wprowadź kod weryfikacyjny + Przechowuj te kody w bezpiecznym miejscu. Możesz ich użyć do zalogowania się, jeśli stracisz dostęp do metody uwierzytelniania. + + Potwierdź hasło + Hasła nie są takie same + Hasło musi mieć co najmniej %1$d znaków + Hasło musi zawierać co najmniej jedną wielką literę + Hasło musi zawierać co najmniej jedną małą literę + Hasło musi zawierać co najmniej jedną cyfrę + Hasło musi zawierać co najmniej jeden znak specjalny + + + Skonfiguruj uwierzytelnianie SMS + Zweryfikuj kod SMS + + Ta operacja wymaga niedawnego uwierzytelnienia. Zaloguj się ponownie i spróbuj ponownie. + Kod weryfikacyjny jest nieprawidłowy. Spróbuj ponownie. + Wystąpił błąd sieci. Sprawdź połączenie i spróbuj ponownie. + Wystąpił błąd podczas rejestracji. Spróbuj ponownie. + + Wstecz + Tożsamość zweryfikowana. Spróbuj ponownie wykonać czynność. + Zarządzaj uwierzytelnianiem dwuskładnikowym + Nieprawidłowe hasło + Wybierz metodę weryfikacji + Dodaj dodatkową warstwę zabezpieczeń + SMS + Aplikacja uwierzytelniająca + Ten numer telefonu jest powiązany z innym kontem + Wymagana weryfikacja + Zeskanuj kod QR za pomocą aplikacji uwierzytelniającej + Aplikacja uwierzytelniająca jest już skonfigurowana + Czy na pewno chcesz usunąć tę metodę? + Nie można usunąć metody + Metoda usunięta + Wprowadź kod weryfikacyjny + Nie można zaktualizować profilu + Profil zaktualizowany + Potwierdź swoją tożsamość, aby kontynuować + Wymagane ponowne uwierzytelnienie + Ponowne uwierzytelnienie zakończone sukcesem + Uwierzytelnij ponownie + Zapisałem kody odzyskiwania + Usuń + Wyślij ponownie e-mail weryfikacyjny + Tajny klucz + Wyloguj się + Zalogowano jako + Pomiń + Użyj innej metody + Kod weryfikacyjny + E-mail zweryfikowany + Zweryfikuj + Wysłaliśmy e-mail weryfikacyjny na adres %1$s diff --git a/auth/src/main/res/values-pt-rBR/strings.xml b/auth/src/main/res/values-pt-rBR/strings.xml index 283085667..994c4935f 100755 --- a/auth/src/main/res/values-pt-rBR/strings.xml +++ b/auth/src/main/res/values-pt-rBR/strings.xml @@ -25,6 +25,8 @@ E-mail Número de telefone País + Selecione um país + Pesquisar país, por ex., +1, "US" Senha Nova senha Esse campo não pode ficar em branco. @@ -73,10 +75,13 @@ Confirme o e-mail para continuar o login Dispensar Inicialmente, você quis conectar %1$s à sua conta de e-mail, mas abriu o link em outro dispositivo no qual você não está conectado.\n\nSe você ainda quiser conectar sua conta de %1$s, abra o link no mesmo dispositivo em que fez login. Se preferir, toque em \"Continuar\" para fazer login neste dispositivo. + ou Continuar com + Fazer login com link de e-mail + Fazer login com senha Insira o número do seu telefone Insira um número de telefone válido Insira o código de 6 dígitos enviado para - Reenviar código em 0:%02d + Reenviar código em %1$s Confirmar seu número de telefone Verificando… Código incorreto. Tente novamente. @@ -87,6 +92,98 @@ O número de telefone foi verificado automaticamente Reenviar código Confirmar número de telefone + Use a different phone number 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. + + + Escolher método de autenticação + Configurar verificação por SMS + Configurar app de autenticação + Verificar código + Salvar códigos de recuperação + + Selecione um segundo método de autenticação para proteger sua conta + Digite seu número de telefone para receber códigos de verificação + Digitalize o código QR com seu app de autenticação + Digite o código enviado para seu telefone + Digite o código do seu app de autenticação + Digite seu código de verificação + Armazene esses códigos em um local seguro. Você pode usá-los para fazer login se perder o acesso ao seu método de autenticação. + + Confirmar senha + As senhas não correspondem + A senha precisa ter no mínimo %1$d caracteres + A senha precisa conter pelo menos uma letra maiúscula + A senha precisa conter pelo menos uma letra minúscula + A senha precisa conter pelo menos um número + A senha precisa conter pelo menos um caractere especial + + + Configurar autenticação por SMS + Verificar código SMS + + Esta operação requer autenticação recente. Faça login novamente e tente novamente. + O código de verificação está incorreto. Tente novamente. + Ocorreu um erro de rede. Verifique sua conexão e tente novamente. + Ocorreu um erro durante a inscrição. Tente novamente. + + + Voltar + Identidade verificada. Tente sua ação novamente. + Senha incorreta. Tente novamente. + Gerenciar autenticação multifator + Métodos ativos + Adicionar novo método + Todos os métodos de autenticação disponíveis estão cadastrados + Cadastrado em %1$s + Autenticação por SMS + Aplicativo autenticador + Método desconhecido + Adicionar ou remover métodos de autenticação para sua conta + Gerenciar autenticação de dois fatores + Escaneie o código QR ou insira a chave secreta no seu aplicativo autenticador + Complete as informações do seu perfil para continuar. + Campos faltando: %1$s + Conta: %1$s + Para sua segurança, insira sua senha novamente para continuar. + Verifique sua identidade + Falha na autenticação. Tente novamente. + Salvei estes códigos + Remover + Reenviar e-mail de verificação + Chave secreta + Sair + Conectado como %1$s + Pular por enquanto + Usar um método diferente + Código de verificação + Verifiquei meu e-mail + Verificar + Verifique %1$s para continuar. + + Escolha um método de verificação + Adicione uma camada extra de segurança + SMS + Aplicativo autenticador + Este número de telefone está associado a outra conta + Verificação necessária + Escaneie o código QR com seu aplicativo autenticador + O aplicativo autenticador já está configurado + Tem certeza de que deseja remover este método? + Não foi possível remover o método + Método removido + Insira o código de verificação + Não foi possível atualizar o perfil + Perfil atualizado + Confirme sua identidade para continuar + Reautenticação necessária + Reautenticação bem-sucedida + Reautenticar diff --git a/auth/src/main/res/values-pt-rPT/strings.xml b/auth/src/main/res/values-pt-rPT/strings.xml index 2e19b618e..d6d06d3e1 100755 --- a/auth/src/main/res/values-pt-rPT/strings.xml +++ b/auth/src/main/res/values-pt-rPT/strings.xml @@ -25,6 +25,8 @@ Email Número de telefone País + Selecione um país + Pesquisar país, por ex., +1, "US" Palavra-passe Nova palavra-passe Não pode deixar este campo em branco. @@ -73,10 +75,13 @@ Confirme o email para iniciar sessão. Ignorar Originalmente, pretendia associar %1$s à sua conta de email, mas abriu o link num dispositivo diferente no qual não tem sessão iniciada.\n\nSe ainda pretender associar a sua conta do %1$s, abra o link no mesmo dispositivo em que iniciou sessão. Caso contrário, toque em Continuar para iniciar sessão neste dispositivo. + ou Continuar com + Iniciar sessão com link de e-mail + Iniciar sessão com palavra-passe Introduza o seu número de telefone Introduza um número de telefone válido Introduza o código de 6 dígitos que enviámos para - Reenviar código em 0:%02d + Reenviar código em %1$s Validar o número de telefone A validar… Código errado. Tente novamente. @@ -87,6 +92,98 @@ Número de telefone verificado automaticamente Reenviar código Validar número de telefone + Use a different phone number 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. + + + Escolher método de autenticação + Configurar verificação por SMS + Configurar app de autenticação + Verificar código + Salvar códigos de recuperação + + Selecione um segundo método de autenticação para proteger sua conta + Digite seu número de telefone para receber códigos de verificação + Digitalize o código QR com seu app de autenticação + Digite o código enviado para seu telefone + Digite o código do seu app de autenticação + Digite seu código de verificação + Armazene esses códigos em um local seguro. Você pode usá-los para fazer login se perder o acesso ao seu método de autenticação. + + Confirmar palavra-passe + As palavras-passe não correspondem + A palavra-passe tem de ter, pelo menos, %1$d caracteres + A palavra-passe tem de conter, pelo menos, uma letra maiúscula + A palavra-passe tem de conter, pelo menos, uma letra minúscula + A palavra-passe tem de conter, pelo menos, um número + A palavra-passe tem de conter, pelo menos, um caráter especial + + + Configurar autenticação por SMS + Verificar código SMS + + Esta operação requer autenticação recente. Inicie sessão novamente e tente novamente. + O código de verificação está incorreto. Tente novamente. + Ocorreu um erro de rede. Verifique a ligação e tente novamente. + Ocorreu um erro durante a inscrição. Tente novamente. + + + Voltar + Identidade verificada. Tente sua ação novamente. + Senha incorreta. Tente novamente. + Gerenciar autenticação multifator + Métodos ativos + Adicionar novo método + Todos os métodos de autenticação disponíveis estão cadastrados + Cadastrado em %1$s + Autenticação por SMS + Aplicativo autenticador + Método desconhecido + Adicionar ou remover métodos de autenticação para sua conta + Gerenciar autenticação de dois fatores + Escaneie o código QR ou insira a chave secreta no seu aplicativo autenticador + Complete as informações do seu perfil para continuar. + Campos faltando: %1$s + Conta: %1$s + Para sua segurança, insira sua senha novamente para continuar. + Verifique sua identidade + Falha na autenticação. Tente novamente. + Salvei estes códigos + Remover + Reenviar e-mail de verificação + Chave secreta + Sair + Conectado como %1$s + Pular por enquanto + Usar um método diferente + Código de verificação + Verifiquei meu e-mail + Verificar + Verifique %1$s para continuar. + + Escolha um método de verificação + Adicione uma camada extra de segurança + SMS + Aplicativo autenticador + Este número de telefone está associado a outra conta + Verificação necessária + Escaneie o código QR com seu aplicativo autenticador + O aplicativo autenticador já está configurado + Tem certeza de que deseja remover este método? + Não foi possível remover o método + Método removido + Insira o código de verificação + Não foi possível atualizar o perfil + Perfil atualizado + Confirme sua identidade para continuar + Reautenticação necessária + Reautenticação bem-sucedida + Reautenticar diff --git a/auth/src/main/res/values-pt/strings.xml b/auth/src/main/res/values-pt/strings.xml index 283085667..d5c89d056 100755 --- a/auth/src/main/res/values-pt/strings.xml +++ b/auth/src/main/res/values-pt/strings.xml @@ -25,6 +25,8 @@ E-mail Número de telefone País + Selecione um país + Pesquisar país, por ex., +1, "US" Senha Nova senha Esse campo não pode ficar em branco. @@ -73,10 +75,13 @@ Confirme o e-mail para continuar o login Dispensar Inicialmente, você quis conectar %1$s à sua conta de e-mail, mas abriu o link em outro dispositivo no qual você não está conectado.\n\nSe você ainda quiser conectar sua conta de %1$s, abra o link no mesmo dispositivo em que fez login. Se preferir, toque em \"Continuar\" para fazer login neste dispositivo. + ou Continuar com + Fazer login com link de e-mail + Fazer login com senha Insira o número do seu telefone Insira um número de telefone válido Insira o código de 6 dígitos enviado para - Reenviar código em 0:%02d + Reenviar código em %1$s Confirmar seu número de telefone Verificando… Código incorreto. Tente novamente. @@ -87,6 +92,97 @@ O número de telefone foi verificado automaticamente Reenviar código Confirmar número de telefone + Use a different phone number 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. + + + Escolher método de autenticação + Configurar verificação por SMS + Configurar app de autenticação + Verificar código + Salvar códigos de recuperação + + Selecione um segundo método de autenticação para proteger sua conta + Digite seu número de telefone para receber códigos de verificação + Digitalize o código QR com seu app de autenticação + Digite o código enviado para seu telefone + Digite o código do seu app de autenticação + Digite seu código de verificação + Armazene esses códigos em um local seguro. Você pode usá-los para fazer login se perder o acesso ao seu método de autenticação. + + Confirmar senha + As senhas não correspondem + A senha deve ter no mínimo %1$d caracteres + A senha deve conter pelo menos uma letra maiúscula + A senha deve conter pelo menos uma letra minúscula + A senha deve conter pelo menos um número + A senha deve conter pelo menos um caractere especial + + + Configurar autenticação por SMS + Verificar código SMS + + Esta operação requer autenticação recente. Faça login novamente e tente novamente. + O código de verificação está incorreto. Tente novamente. + Ocorreu um erro de rede. Verifique sua conexão e tente novamente. + Ocorreu um erro durante a inscrição. Tente novamente. + + + Voltar + Identidade verificada. Tente sua ação novamente. + Senha incorreta. Tente novamente. + Gerenciar autenticação multifator + Métodos ativos + Adicionar novo método + Todos os métodos de autenticação disponíveis estão cadastrados + Cadastrado em %1$s + Autenticação por SMS + Aplicativo autenticador + Método desconhecido + Adicionar ou remover métodos de autenticação para sua conta + Gerenciar autenticação de dois fatores + Escaneie o código QR ou insira a chave secreta no seu aplicativo autenticador + Complete as informações do seu perfil para continuar. + Campos faltando: %1$s + Conta: %1$s + Para sua segurança, insira sua senha novamente para continuar. + Verifique sua identidade + Falha na autenticação. Tente novamente. + Salvei estes códigos + Remover + Reenviar e-mail de verificação + Chave secreta + Sair + Conectado como %1$s + Pular por enquanto + Usar um método diferente + Código de verificação + Verifiquei meu e-mail + Verificar + Verifique %1$s para continuar. + + Escolha um método de verificação + Adicione uma camada extra de segurança + SMS + Aplicativo autenticador + Este número de telefone está associado a outra conta + Verificação necessária + Escaneie o código QR com seu aplicativo autenticador + O aplicativo autenticador já está configurado + Tem certeza de que deseja remover este método? + Não foi possível remover o método + Método removido + Insira o código de verificação + Não foi possível atualizar o perfil + Perfil atualizado + Confirme sua identidade para continuar + Reautenticação necessária + Reautenticação bem-sucedida + Reautenticar diff --git a/auth/src/main/res/values-ro/strings.xml b/auth/src/main/res/values-ro/strings.xml index f8d72d534..633c29cec 100755 --- a/auth/src/main/res/values-ro/strings.xml +++ b/auth/src/main/res/values-ro/strings.xml @@ -25,6 +25,8 @@ Adresă de e-mail Număr de telefon Țară + Selectați o țară + Căutați țară de ex. +1, "US" Parolă Parolă nouă Câmpul este obligatoriu. @@ -73,10 +75,13 @@ Confirmați adresa de e-mail pentru a continua conectarea Închideți Inițial ați intenționat să conectați %1$s la contul de e-mail, dar ați deschis linkul pe un dispozitiv pe care nu sunteți conectat(ă).\n\nDacă doriți să continuați conectarea contului %1$s, deschideți linkul pe același dispozitiv pe care ați început conectarea. În caz contrar, atingeți Continuați pentru a vă conecta pe acest dispozitiv. + sau Continuă cu + Conectați-vă cu linkul din e-mail + Conectați-vă cu parola Introduceți numărul de telefon Introduceți un număr valid de telefon Introduceți codul din 6 cifre pe care l-am trimis la - Retrimiteți codul în 00:%02d + Retrimiteți codul în 0%1$s Confirmați numărul de telefon Se verifică… Cod greșit. Încercați din nou. @@ -87,6 +92,79 @@ Numărul de telefon este verificat automat Retrimiteți codul Confirmați numărul de telefon + Use a different phone number 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. + + + Alegeți metoda de autentificare + Configurați verificarea prin SMS + Configurați aplicația de autentificare + Verificați codul + Salvați codurile de recuperare + + Selectați o a doua metodă de autentificare pentru a vă securiza contul + Introduceți numărul de telefon pentru a primi coduri de verificare + Scanați codul QR cu aplicația dvs. de autentificare + Introduceți codul trimis pe telefon + Introduceți codul din aplicația dvs. de autentificare + Introduceți codul de verificare + Stocați aceste coduri într-un loc sigur. Le puteți folosi pentru a vă conecta dacă pierdeți accesul la metoda de autentificare. + + Confirmați parola + Parolele nu se potrivesc + Parola trebuie să aibă cel puțin %1$d caractere + Parola trebuie să conțină cel puțin o literă mare + Parola trebuie să conțină cel puțin o literă mică + Parola trebuie să conțină cel puțin un număr + Parola trebuie să conțină cel puțin un caracter special + + + Configurați autentificarea prin SMS + Verificați codul SMS + + Această operațiune necesită autentificare recentă. Vă rugăm să vă conectați din nou și să încercați din nou. + Codul de verificare este incorect. Vă rugăm să încercați din nou. + A apărut o eroare de rețea. Vă rugăm să verificați conexiunea și să încercați din nou. + A apărut o eroare în timpul înregistrării. Vă rugăm să încercați din nou. + + Înapoi + Identitate verificată. Reîncercați acțiunea. + Gestionați autentificarea în doi pași + Parolă incorectă + Alegeți o metodă de verificare + Adăugați un strat suplimentar de securitate + SMS + Aplicație de autentificare + Acest număr de telefon este asociat cu alt cont + Verificare necesară + Scanați codul QR cu aplicația de autentificare + Aplicația de autentificare este deja configurată + Sigur doriți să eliminați această metodă? + Nu se poate elimina metoda + Metodă eliminată + Introduceți codul de verificare + Nu se poate actualiza profilul + Profil actualizat + Confirmați-vă identitatea pentru a continua + Este necesară reautentificarea + Reautentificare reușită + Reautentificare + Am salvat codurile de recuperare + Eliminare + Retrimite e-mailul de verificare + Cheie secretă + Deconectare + Conectat ca + Omite + Folosiți o altă metodă + Cod de verificare + E-mail verificat + Verificare + Am trimis un e-mail de verificare la %1$s diff --git a/auth/src/main/res/values-ru/strings.xml b/auth/src/main/res/values-ru/strings.xml index 5022f71bc..a24a8ea0f 100755 --- a/auth/src/main/res/values-ru/strings.xml +++ b/auth/src/main/res/values-ru/strings.xml @@ -25,6 +25,8 @@ Адрес электронной почты Номер телефона Страна + Выберите страну + Поиск страны, напр. +1, "US" Пароль Новый пароль Это поле должно быть заполнено. @@ -73,10 +75,13 @@ Чтобы продолжить вход, подтвердите адрес электронной почты. ОК Вы пытались связать аккаунт сервиса %1$s со своим адресом электронной почты, однако перешли по ссылке на устройстве, на котором у вас не выполнен вход.\n\nЧтобы пройти авторизацию на текущем компьютере, телефоне или планшете, нажмите \"Продолжить\". Если вы все-таки хотите связать аккаунт сервиса %1$s со своим адресом электронной почты, откройте ссылку на устройстве, на котором начали процесс входа. + или Продолжить с + Войти по ссылке из письма + Войти с паролем Введите номер телефона Введите действительный номер телефона. Введите 6-значный код, отправленный на номер - Отправить код ещё раз можно будет через 0:%02d. + Отправить код ещё раз можно будет через %1$s. Подтвердить номер телефона Проверка… Неверный код. Повторите попытку. @@ -87,6 +92,79 @@ Номер телефона был подтвержден автоматически Отправить код ещё раз Подтвердить номер телефона + Use a different phone number Нажимая кнопку “%1$s”, вы соглашаетесь получить SMS. За его отправку и обмен данными может взиматься плата. - Нажимая кнопку “%1$s”, вы принимаете %2$s и %3$s, а также соглашаетесь получить SMS. За его отправку и обмен данными может взиматься плата. + Нажимая кнопку "%1$s", вы принимаете %2$s и %3$s, а также соглашаетесь получить SMS. За его отправку и обмен данными может взиматься плата. + Ошибка аутентификации + Повторить + Требуется дополнительная проверка. Пожалуйста, завершите многофакторную аутентификацию. + Необходимо связать аккаунт. Попробуйте другой способ входа. + Аутентификация была отменена. Повторите попытку, когда будете готовы. + + + Выберите способ аутентификации + Настроить SMS-подтверждение + Настроить приложение-аутентификатор + Подтвердите код + Сохраните коды восстановления + + Выберите второй способ аутентификации для защиты аккаунта + Введите номер телефона для получения кодов подтверждения + Отсканируйте QR-код с помощью приложения-аутентификатора + Введите код, отправленный на ваш телефон + Введите код из приложения-аутентификатора + Введите код подтверждения + Сохраните эти коды в безопасном месте. Вы можете использовать их для входа, если потеряете доступ к способу аутентификации. + + Подтвердите пароль + Пароли не совпадают + Пароль должен содержать не менее %1$d символов + Пароль должен содержать хотя бы одну заглавную букву + Пароль должен содержать хотя бы одну строчную букву + Пароль должен содержать хотя бы одну цифру + Пароль должен содержать хотя бы один специальный символ + + + Настроить SMS-аутентификацию + Подтвердить SMS-код + + Для этой операции требуется недавняя аутентификация. Войдите снова и повторите попытку. + Код подтверждения неверен. Повторите попытку. + Произошла ошибка сети. Проверьте соединение и повторите попытку. + Произошла ошибка при регистрации. Повторите попытку. + + Назад + Личность подтверждена. Повторите действие. + Управление двухфакторной аутентификацией + Неверный пароль + Выберите способ подтверждения + Добавьте дополнительный уровень безопасности + SMS + Приложение для аутентификации + Этот номер телефона уже привязан к другому аккаунту + Требуется подтверждение + Отсканируйте QR-код приложением для аутентификации + Приложение для аутентификации уже настроено + Вы уверены, что хотите удалить этот способ? + Не удалось удалить способ + Способ удален + Введите код подтверждения + Не удалось обновить профиль + Профиль обновлен + Подтвердите свою личность, чтобы продолжить + Требуется повторная аутентификация + Повторная аутентификация выполнена + Повторная аутентификация + Я сохранил коды восстановления + Удалить + Отправить письмо подтверждения повторно + Секретный ключ + Выйти + Вы вошли как + Пропустить + Использовать другой способ + Код подтверждения + Email подтвержден + Подтвердить + Мы отправили письмо подтверждения на %1$s diff --git a/auth/src/main/res/values-sk/strings.xml b/auth/src/main/res/values-sk/strings.xml index 00ea026d8..ab446c2f4 100755 --- a/auth/src/main/res/values-sk/strings.xml +++ b/auth/src/main/res/values-sk/strings.xml @@ -25,6 +25,8 @@ E-mail Telefónne číslo Krajina + Vyberte krajinu + Vyhľadať krajinu napr. +1, "US" Heslo Nové heslo Toto pole nesmie byť prázdne. @@ -73,10 +75,13 @@ Ak chcete pokračovať v prihlasovaní, potvrďte e‑mailovú adresu Odmietnuť Pôvodne ste chceli prepojiť svoj účet v službe %1$s so svojím e‑mailovým účtom, ale odkaz ste otvorili v inom zariadení, v ktorom ste sa neprihlásili.\n\nAk stále chcete prepojiť svoj účet v službe %1$s, otvorte odkaz v rovnakom zariadení, v ktorom ste sa začali prihlasovať. V opačnom prípade klepnite na tlačidlo Pokračovať a prihláste sa v tomto zariadení. + alebo Pokračovať s + Prihlásiť sa pomocou e-mailového odkazu + Prihlásiť sa pomocou hesla Zadajte svoje telefónne číslo Zadajte platné telefónne číslo Zadajte 6-ciferný kód, ktorý sme odoslali na adresu - Kód sa znova odošle o 0:%02d + Kód sa znova odošle o %1$s Overiť telefónne číslo Overuje sa… Nesprávny kód. Skúste to znova. @@ -87,6 +92,79 @@ Telefónne číslo bolo automaticky overené Znova odoslať kód Overiť telefónne číslo + Use a different phone number 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í. + + + Vyberte metódu overenia + Nastaviť overenie SMS + Nastaviť overovaciu aplikáciu + Overte svoj kód + Uložte obnovovacie kódy + + Vyberte druhú metódu overenia na zabezpečenie účtu + Zadajte telefónne číslo na príjem overovacích kódov + Naskenujte QR kód pomocou overovacej aplikácie + Zadajte kód odoslaný na váš telefón + Zadajte kód z overovacej aplikácie + Zadajte overovací kód + Uložte tieto kódy na bezpečnom mieste. Môžete ich použiť na prihlásenie, ak stratíte prístup k metóde overenia. + + Potvrdiť heslo + Heslá sa nezhodujú + Heslo musí mať aspoň %1$d znakov + Heslo musí obsahovať aspoň jedno veľké písmeno + Heslo musí obsahovať aspoň jedno malé písmeno + Heslo musí obsahovať aspoň jedno číslo + Heslo musí obsahovať aspoň jeden špeciálny znak + + + Nastaviť SMS overenie + Overiť SMS kód + + Táto operácia vyžaduje nedávne overenie. Prihláste sa znova a skúste to znova. + Overovací kód je nesprávny. Skúste to znova. + Vyskytla sa sieťová chyba. Skontrolujte pripojenie a skúste to znova. + Počas registrácie sa vyskytla chyba. Skúste to znova. + + Späť + Identita overená. Skúste akciu znova. + Spravovať dvojfaktorové overenie + Nesprávne heslo + Vyberte metódu overenia + Pridajte ďalšiu vrstvu zabezpečenia + SMS + Overovacia aplikácia + Toto telefónne číslo je spojené s iným účtom + Vyžaduje sa overenie + Naskenujte QR kód pomocou overovacej aplikácie + Overovacia aplikácia je už nastavená + Naozaj chcete odstrániť túto metódu? + Metódu sa nepodarilo odstrániť + Metóda odstránená + Zadajte overovací kód + Profil sa nepodarilo aktualizovať + Profil aktualizovaný + Potvrďte svoju identitu a pokračujte + Vyžaduje sa opätovné overenie + Opätovné overenie bolo úspešné + Znova overiť + Uložil som si kódy na obnovenie + Odstrániť + Znova poslať overovací e-mail + Tajný kľúč + Odhlásiť sa + Prihlásený ako + Preskočiť + Použiť inú metódu + Overovací kód + E-mail overený + Overiť + Poslali sme overovací e-mail na adresu %1$s diff --git a/auth/src/main/res/values-sl/strings.xml b/auth/src/main/res/values-sl/strings.xml index 9aa9516ee..0cdd669fc 100755 --- a/auth/src/main/res/values-sl/strings.xml +++ b/auth/src/main/res/values-sl/strings.xml @@ -25,6 +25,8 @@ E-pošta Telefonska številka Država + Izberi državo + Iskanje države, npr. +1, "US" Geslo Novo geslo To polje ne sme biti prazno. @@ -73,10 +75,13 @@ Če želite nadaljevati s prijavo, potrdite e-poštni naslov Opusti Najprej ste želeli račun %1$s povezati z e-poštnim računom, vendar ste povezavo odprli v drugi napravi, kjer niste prijavljeni.\n\nČe želite še vedno povezati račun %1$s, odprite povezavo v napravi, v kateri ste začeli s prijavo. V nasprotnem primeru se dotaknite možnosti »Nadaljuj«, da se prijavite v tej napravi. + ali Nadaljuj z + Prijavite se z e-poštno povezavo + Prijavite se z geslom Vnesite telefonsko številko Vnesite veljavno telefonsko številko Vnesite 6-mestno kodo, ki smo jo poslali na številko - Ponovno pošlji kodo čez 0:%02d + Ponovno pošlji kodo čez %1$s Preverjanje telefonske številke Preverjanje… Napačna koda. Poskusite znova. @@ -87,6 +92,80 @@ Telefonska številka je bila samodejno preverjena Ponovno pošlji kodo Preverjanje telefonske številke + Use a different phone number Č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. + + + Izberite način preverjanja pristnosti + Nastavite preverjanje prek SMS + Nastavite aplikacijo za preverjanje pristnosti + Preverite svojo kodo + Shranite kode za obnovitev + + Izberite drug način preverjanja pristnosti za zaščito računa + Vnesite telefonsko številko za prejemanje kod za preverjanje + Skenirajte kodo QR z aplikacijo za preverjanje pristnosti + Vnesite kodo, poslano na vaš telefon + Vnesite kodo iz aplikacije za preverjanje pristnosti + Vnesite kodo za preverjanje + Shranite te kode na varno mesto. Uporabite jih lahko za prijavo, če izgubite dostop do načina preverjanja pristnosti. + + Potrdite geslo + Gesli se ne ujemata + Geslo mora vsebovati vsaj %1$d znakov + Geslo mora vsebovati vsaj eno veliko črko + Geslo mora vsebovati vsaj eno malo črko + Geslo mora vsebovati vsaj eno števko + Geslo mora vsebovati vsaj en poseben znak + + + Nastavi preverjanje pristnosti prek SMS-a + Preveri kodo SMS + + Ta operacija zahteva nedavno preverjanje pristnosti. Znova se prijavite in poskusite znova. + Potrditvena koda ni pravilna. Poskusite znova. + Prišlo je do omrežne napake. Preverite povezavo in poskusite znova. + Med registracijo je prišlo do napake. Poskusite znova. + + Nazaj + Identiteta preverjena. Poskusite znova izvesti dejanje. + Upravljanje dvofaktorskega preverjanja + Napačno geslo + Izberite način preverjanja + Dodajte dodatno raven varnosti + SMS + Aplikacija za preverjanje pristnosti + Ta telefonska številka je povezana z drugim računom + Preverjanje je obvezno + Skenirajte QR-kodo z aplikacijo za preverjanje pristnosti + Aplikacija za preverjanje pristnosti je že nastavljena + Ali ste prepričani, da želite odstraniti to metodo? + Metode ni mogoče odstraniti + Metoda odstranjena + Vnesite kodo za preverjanje + Profila ni mogoče posodobiti + Profil posodobljen + Potrdite svojo identiteto za nadaljevanje + Zahtevano je ponovno preverjanje pristnosti + Ponovno preverjanje pristnosti uspešno + Ponovno preveri pristnost + Shranil sem kode za obnovitev + Odstrani + Znova pošlji e-sporočilo za preverjanje + Skrivni ključ + Odjava + Prijavljen kot + Preskoči + Uporabi drugo metodo + Koda za preverjanje + E-pošta preverjena + Preveri + Poslali smo e-sporočilo za preverjanje na %1$s diff --git a/auth/src/main/res/values-sr/strings.xml b/auth/src/main/res/values-sr/strings.xml index 73d0f8fd2..29199fc20 100755 --- a/auth/src/main/res/values-sr/strings.xml +++ b/auth/src/main/res/values-sr/strings.xml @@ -25,6 +25,8 @@ Имејл Број телефона Земља + Изаберите земљу + Претражите земљу нпр. +1, "US" Лозинка Нова лозинка Ово поље не може да буде празно. @@ -73,10 +75,13 @@ Потврдите имејл адресу да бисте наставили са пријављивањем Одбаци Првобитна намера вам је била да повежете %1$s налог са имејл налогом, али сте отворили линк на другом уређају, на ком нисте пријављени.\n\nАко и даље желите да повежете %1$s налог, отворите линк на истом уређају на ком сте почели пријављивање. У супротном, додирните Настави да бисте се пријавили на овом уређају. + или Наставите са + Пријавите се помоћу имејл линка + Пријавите се помоћу лозинке Унесите број телефона Унесите важећи број телефона Унесите 6-цифрени кôд који смо послали на - Кôд ће бити поново послат за 0:%02d + Кôд ће бити поново послат за %1$s Верификујте број телефона Верификује се… Кôд није тачан. Пробајте поново. @@ -87,6 +92,80 @@ Број телефона је аутоматски верификован Поново пошаљи кôд Верификуј број телефона + Use a different phone number Ако додирнете „%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. + + + Изаберите метод аутентификације + Подесите SMS верификацију + Подесите апликацију за аутентификацију + Верификујте свој код + Сачувајте кодове за опоравак + + Изаберите други метод аутентификације да бисте заштитили свој налог + Унесите свој број телефона да бисте примали кодове за верификацију + Скенирајте QR код помоћу своје апликације за аутентификацију + Унесите код послат на ваш телефон + Унесите код из своје апликације за аутентификацију + Унесите свој код за верификацију + Похраните ове кодове на сигурно место. Можете их користити за пријаву ако изгубите приступ методу аутентификације. + + Потврди лозинку + Лозинке се не подударају + Лозинка мора да има најмање %1$d знакова + Лозинка мора да садржи најмање једно велико слово + Лозинка мора да садржи најмање једно мало слово + Лозинка мора да садржи најмање један број + Лозинка мора да садржи најмање један посебан знак + + + Подесите SMS аутентификацију + Потврдите SMS код + + За ову операцију је потребна недавна провера идентитета. Пријавите се поново и покушајте поново. + Код за потврду је нетачан. Покушајте поново. + Дошло је до грешке на мрежи. Проверите везу и покушајте поново. + Дошло је до грешке током регистрације. Покушајте поново. + + Назад + Идентитет је верификован. Покушајте поново своју радњу. + Управљај двофакторском аутентификацијом + Нетачна лозинка + Изаберите метод верификације + Додајте додатни слој безбедности + СМС + Апликација за аутентификацију + Овај број телефона је повезан са другим налогом + Потребна је верификација + Скенирајте QR код апликацијом за аутентификацију + Апликација за аутентификацију је већ подешена + Да ли сте сигурни да желите да уклоните овај метод? + Не може се уклонити метод + Метод је уклоњен + Унесите код за верификацију + Не може се ажурирати профил + Профил је ажуриран + Потврдите свој идентитет да бисте наставили + Потребна је поновна аутентификација + Поновна аутентификација је успешна + Поновна аутентификација + Сачувао сам кодове за опоравак + Уклони + Поново пошаљи имејл за верификацију + Тајни кључ + Одјава + Пријављен као + Прескочи + Користи други метод + Код за верификацију + Имејл је верификован + Верификуј + Послали смо имејл за верификацију на %1$s diff --git a/auth/src/main/res/values-sv/strings.xml b/auth/src/main/res/values-sv/strings.xml index efd16a99a..93cea3267 100755 --- a/auth/src/main/res/values-sv/strings.xml +++ b/auth/src/main/res/values-sv/strings.xml @@ -25,6 +25,8 @@ E-post Telefonnummer Land + Välj ett land + Sök land t.ex. +1, "US" Lösenord Nytt lösenord Du måste fylla i det här fältet. @@ -73,10 +75,13 @@ Bekräfta e-postadressen om du vill fortsätta med inloggningen Stäng Du skulle ansluta %1$s till ditt e-postkonto men har öppnat länken på en annan enhet där du inte är inloggad.\n\nOm du fortfarande vill ansluta ditt %1$s-konto ska du öppna länken på den enhet där du påbörjade inloggningen. Annars kan du klicka på Fortsätt för att logga in på den här enheten. + eller Fortsätt med + Logga in med e-postlänk + Logga in med lösenord Ange ditt telefonnummer Ange ett giltigt telefonnummer Ange den sexsiffriga koden vi skickade till - Skicka kod igen om 0:%02d + Skicka kod igen om %1$s Verifiera ditt telefonnummer Verifierar … Fel kod. Försök igen. @@ -87,6 +92,79 @@ Telefonnumret verifierades automatiskt Skicka koden igen Verifiera telefonnummer + Use a different phone number 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. + + + Välj autentiseringsmetod + Konfigurera SMS-verifiering + Konfigurera autentiseringsapp + Verifiera din kod + Spara dina återställningskoder + + Välj en andra autentiseringsmetod för att skydda ditt konto + Ange ditt telefonnummer för att ta emot verifieringskoder + Skanna QR-koden med din autentiseringsapp + Ange koden som skickades till din telefon + Ange koden från din autentiseringsapp + Ange din verifieringskod + Förvara dessa koder på en säker plats. Du kan använda dem för att logga in om du förlorar åtkomst till din autentiseringsmetod. + + Bekräfta lösenord + Lösenorden matchar inte + Lösenordet måste vara minst %1$d tecken långt + Lösenordet måste innehålla minst en stor bokstav + Lösenordet måste innehålla minst en liten bokstav + Lösenordet måste innehålla minst en siffra + Lösenordet måste innehålla minst ett specialtecken + + + Konfigurera SMS-autentisering + Verifiera SMS-kod + + Den här åtgärden kräver nylig autentisering. Logga in igen och försök igen. + Verifieringskoden är felaktig. Försök igen. + Ett nätverksfel uppstod. Kontrollera anslutningen och försök igen. + Ett fel uppstod vid registreringen. Försök igen. + + Tillbaka + Identitet verifierad. Försök din åtgärd igen. + Hantera tvåfaktorsautentisering + Felaktigt lösenord + Välj en verifieringsmetod + Lägg till ett extra säkerhetslager + SMS + Autentiseringsapp + Detta telefonnummer är kopplat till ett annat konto + Verifiering krävs + Skanna QR-koden med din autentiseringsapp + Autentiseringsapp är redan konfigurerad + Är du säker på att du vill ta bort denna metod? + Kan inte ta bort metoden + Metod borttagen + Ange verifieringskod + Kan inte uppdatera profil + Profil uppdaterad + Bekräfta din identitet för att fortsätta + Omautentisering krävs + Omautentisering lyckades + Omautentisera + Jag har sparat mina återställningskoder + Ta bort + Skicka verifieringsmail igen + Hemlig nyckel + Logga ut + Inloggad som + Hoppa över + Använd en annan metod + Verifieringskod + E-post verifierad + Verifiera + Vi har skickat ett verifieringsmail till %1$s diff --git a/auth/src/main/res/values-ta/strings.xml b/auth/src/main/res/values-ta/strings.xml index 6c44d19c8..c6a6a3227 100755 --- a/auth/src/main/res/values-ta/strings.xml +++ b/auth/src/main/res/values-ta/strings.xml @@ -25,6 +25,8 @@ மின்னஞ்சல் ஃபோன் எண் நாடு + நாட்டைத் தேர்வுசெய்க + நாட்டைத் தேடுக எ.கா. +1, "US" கடவுச்சொல் புதிய கடவுச்சொல் இதை காலியாக விடக்கூடாது. @@ -73,10 +75,13 @@ உள்நுழைவைத் தொடர, மின்னஞ்சலை உறுதிப்படுத்தவும் மூடு முதலில் %1$sஐ உங்கள் மின்னஞ்சல் கணக்குடன் இணைப்பதற்காக முயற்சித்தீர்கள், ஆனால் உள்நுழைந்திராத வேறொரு சாதனத்தில் இணைப்பைத் திறந்துள்ளீர்கள்.\n\nஉங்கள் %1$s கணக்கை இணைக்க விரும்பினால், நீங்கள் உள்நுழைய தொடங்கிய அதே சாதனத்தில் இணைப்பைத் திறக்கவும். இல்லையெனில் இந்தச் சாதனத்தில் உள்நுழைய தொடர்க என்பதைத் தட்டவும். + அல்லது இதனுடன் தொடரவும் + மின்னஞ்சல் இணைப்பு மூலம் உள்நுழைக + கடவுச்சொல் மூலம் உள்நுழைக ஃபோன் எண்ணை உள்ளிடவும் சரியான ஃபோன் எண்ணை உள்ளிடவும் இந்த எண்ணுக்கு நாங்கள் அனுப்பிய 6 இலக்கக் குறியீட்டை உள்ளிடவும்: - குறியீட்டை 0:%02d நேரத்தில் மீண்டும் அனுப்பவும் + குறியீட்டை %1$s நேரத்தில் மீண்டும் அனுப்பவும் ஃபோன் எண்ணைச் சரிபார் சரிபார்க்கிறது… தவறான குறியீடு. மீண்டும் முயலவும். @@ -87,6 +92,80 @@ ஃபோன் எண் தானாகவே சரிபார்க்கப்பட்டது குறியீட்டை மீண்டும் அனுப்பு ஃபோன் எண்ணைச் சரிபார் + Use a different phone number “%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. + + + அங்கீகார முறையைத் தேர்ந்தெடுக்கவும் + SMS சரிபார்ப்பை அமைக்கவும் + அங்கீகரிப்பு ஆப்ஸை அமைக்கவும் + உங்கள் குறியீட்டைச் சரிபார்க்கவும் + உங்கள் மீட்டெடுப்பு குறியீடுகளைச் சேமிக்கவும் + + உங்கள் கணக்கைப் பாதுகாக்க இரண்டாவது அங்கீகார முறையைத் தேர்ந்தெடுக்கவும் + சரிபார்ப்புக் குறியீடுகளைப் பெற உங்கள் தொலைபேசி எண்ணை உள்ளிடவும் + உங்கள் அங்கீகரிப்பு ஆப்ஸுடன் QR குறியீட்டை ஸ்கேன் செய்யவும் + உங்கள் தொலைபேசிக்கு அனுப்பப்பட்ட குறியீட்டை உள்ளிடவும் + உங்கள் அங்கீகரிப்பு ஆப்ஸிலிருந்து குறியீட்டை உள்ளிடவும் + உங்கள் சரிபார்ப்புக் குறியீட்டை உள்ளிடவும் + இந்தக் குறியீடுகளைப் பாதுகாப்பான இடத்தில் சேமிக்கவும். உங்கள் அங்கீகார முறைக்கான அணுகலை இழந்தால் உள்நுழைய இவற்றைப் பயன்படுத்தலாம். + + கடவுச்சொல்லை உறுதிப்படுத்தவும் + கடவுச்சொற்கள் பொருந்தவில்லை + கடவுச்சொல் குறைந்தது %1$d எழுத்துக்குறிகளைக் கொண்டிருக்க வேண்டும் + கடவுச்சொல்லில் குறைந்தது ஒரு பெரிய எழுத்து இருக்க வேண்டும் + கடவுச்சொல்லில் குறைந்தது ஒரு சிறிய எழுத்து இருக்க வேண்டும் + கடவுச்சொல்லில் குறைந்தது ஒரு எண் இருக்க வேண்டும் + கடவுச்சொல்லில் குறைந்தது ஒரு சிறப்பு எழுத்துக்குறி இருக்க வேண்டும் + + + SMS அங்கீகாரத்தை அமைக்கவும் + SMS குறியீட்டைச் சரிபார்க்கவும் + + இந்த செயல்பாட்டிற்கு சமீபத்திய அங்கீகாரம் தேவை. மீண்டும் உள்நுழைந்து மீண்டும் முயற்சிக்கவும். + சரிபார்ப்புக் குறியீடு தவறானது. மீண்டும் முயற்சிக்கவும். + நெட்வொர்க் பிழை ஏற்பட்டது. உங்கள் இணைப்பைச் சரிபார்த்து மீண்டும் முயற்சிக்கவும். + பதிவின் போது பிழை ஏற்பட்டது. மீண்டும் முயற்சிக்கவும். + + பின் + அடையாளம் சரிபார்க்கப்பட்டது. உங்கள் செயலை மீண்டும் முயற்சிக்கவும். + இரு-காரணி அங்கீகாரத்தை நிர்வகி + தவறான கடவுச்சொல் + சரிபார்ப்பு முறையைத் தேர்ந்தெடுக்கவும் + கூடுதல் பாதுகாப்பு அடுக்கைச் சேர்க்கவும் + SMS + அங்கீகார பயன்பாடு + இந்த தொலைபேசி எண் மற்றொரு கணக்குடன் தொடர்புடையது + சரிபார்ப்பு தேவை + உங்கள் அங்கீகார பயன்பாட்டில் QR குறியீட்டை ஸ்கேன் செய்யவும் + அங்கீகார பயன்பாடு ஏற்கனவே அமைக்கப்பட்டுள்ளது + இந்த முறையை அகற்ற விரும்புகிறீர்களா? + முறையை அகற்ற முடியவில்லை + முறை அகற்றப்பட்டது + சரிபார்ப்பு குறியீட்டை உள்ளிடவும் + சுயவிவரத்தைப் புதுப்பிக்க முடியவில்லை + சுயவிவரம் புதுப்பிக்கப்பட்டது + தொடர உங்கள் அடையாளத்தை உறுதிப்படுத்தவும் + மீண்டும் அங்கீகாரம் தேவை + மீண்டும் அங்கீகாரம் வெற்றிகரமாக + மீண்டும் அங்கீகரி + எனது மீட்பு குறியீடுகளைச் சேமித்துள்ளேன் + அகற்று + சரிபார்ப்பு மின்னஞ்சலை மீண்டும் அனுப்பு + ரகசிய திறவுகோல் + வெளியேறு + இவராக உள்நுழைந்துள்ளது + தவிர் + வேறு முறையைப் பயன்படுத்து + சரிபார்ப்பு குறியீடு + மின்னஞ்சல் சரிபார்க்கப்பட்டது + சரிபார் + %1$s க்கு சரிபார்ப்பு மின்னஞ்சலை அனுப்பியுள்ளோம் diff --git a/auth/src/main/res/values-th/strings.xml b/auth/src/main/res/values-th/strings.xml index 0f3762bc8..15d2edcc8 100755 --- a/auth/src/main/res/values-th/strings.xml +++ b/auth/src/main/res/values-th/strings.xml @@ -25,6 +25,8 @@ อีเมล หมายเลขโทรศัพท์ ประเทศ + เลือกประเทศ + ค้นหาประเทศ เช่น +1 "US" รหัสผ่าน รหัสผ่านใหม่ ต้องป้อนข้อมูลในช่องนี้ @@ -73,10 +75,13 @@ ยืนยันอีเมลเพื่อลงชื่อเข้าใช้ต่อ ปิด แต่เดิมคุณตั้งใจที่จะเชื่อม %1$s เข้ากับบัญชีอีเมลแต่ได้เปิดลิงก์บนอุปกรณ์อื่นที่ไม่ได้ลงชื่อเข้าใช้\n\nหากยังต้องการเชื่อมบัญชี %1$s ให้เปิดลิงก์บนอุปกรณ์เดียวกันกับที่เริ่มลงชื่อเข้าใช้ หรือแตะ \"ต่อไป\" เพื่อลงชื่อเข้าใช้บนอุปกรณ์นี้ + หรือดำเนินการต่อด้วย + ลงชื่อเข้าใช้ด้วยลิงก์อีเมล + ลงชื่อเข้าใช้ด้วยรหัสผ่าน ป้อนหมายเลขโทรศัพท์ของคุณ ป้อนหมายเลขโทรศัพท์ที่ถูกต้อง ป้อนรหัส 6 หลักที่เราส่งไปยัง - ส่งรหัสอีกครั้งใน 0:%02d + ส่งรหัสอีกครั้งใน %1$s ยืนยันหมายเลขโทรศัพท์ กำลังยืนยัน… รหัสไม่ถูกต้อง โปรดลองอีกครั้ง @@ -87,6 +92,80 @@ ยืนยันหมายเลขโทรศัพท์โดยอัตโนมัติแล้ว ส่งรหัสอีกครั้ง ยืนยันหมายเลขโทรศัพท์ + Use a different phone number เมื่อคุณแตะ “%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. + + + เลือกวิธีการตรวจสอบสิทธิ์ + ตั้งค่าการยืนยัน SMS + ตั้งค่าแอปตรวจสอบสิทธิ์ + ยืนยันรหัสของคุณ + บันทึกรหัสกู้คืนของคุณ + + เลือกวิธีการตรวจสอบสิทธิ์ที่สองเพื่อรักษาความปลอดภัยบัญชีของคุณ + ป้อนหมายเลขโทรศัพท์ของคุณเพื่อรับรหัสยืนยัน + สแกนรหัส QR ด้วยแอปตรวจสอบสิทธิ์ของคุณ + ป้อนรหัสที่ส่งไปยังโทรศัพท์ของคุณ + ป้อนรหัสจากแอปตรวจสอบสิทธิ์ของคุณ + ป้อนรหัสยืนยันของคุณ + เก็บรหัสเหล่านี้ไว้ในที่ปลอดภัย คุณสามารถใช้รหัสเหล่านี้ลงชื่อเข้าใช้หากคุณไม่สามารถเข้าถึงวิธีการตรวจสอบสิทธิ์ของคุณ + + ยืนยันรหัสผ่าน + รหัสผ่านไม่ตรงกัน + รหัสผ่านต้องมีความยาวอย่างน้อย %1$d อักขระ + รหัสผ่านต้องมีตัวพิมพ์ใหญ่อย่างน้อยหนึ่งตัว + รหัสผ่านต้องมีตัวพิมพ์เล็กอย่างน้อยหนึ่งตัว + รหัสผ่านต้องมีตัวเลขอย่างน้อยหนึ่งตัว + รหัสผ่านต้องมีอักขระพิเศษอย่างน้อยหนึ่งตัว + + + ตั้งค่าการยืนยันตัวตนผ่าน SMS + ยืนยันรหัส SMS + + การดำเนินการนี้จำเป็นต้องมีการตรวจสอบสิทธิ์ล่าสุด โปรดลงชื่อเข้าใช้อีกครั้งแล้วลองอีกครั้ง + รหัสยืนยันไม่ถูกต้อง โปรดลองอีกครั้ง + เกิดข้อผิดพลาดของเครือข่าย โปรดตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง + เกิดข้อผิดพลาดระหว่างการลงทะเบียน โปรดลองอีกครั้ง + + กลับ + ยืนยันตัวตนแล้ว ลองดำเนินการอีกครั้ง + จัดการการยืนยันตัวตนแบบสองปัจจัย + รหัสผ่านไม่ถูกต้อง + เลือกวิธีการยืนยัน + เพิ่มชั้นความปลอดภัยเพิ่มเติม + SMS + แอปยืนยันตัวตน + หมายเลขโทรศัพท์นี้เชื่อมโยงกับบัญชีอื่นอยู่แล้ว + ต้องการการยืนยัน + สแกนรหัส QR ด้วยแอปยืนยันตัวตนของคุณ + แอปยืนยันตัวตนได้รับการตั้งค่าแล้ว + คุณแน่ใจหรือไม่ว่าต้องการลบวิธีนี้ + ไม่สามารถลบวิธีได้ + ลบวิธีแล้ว + ป้อนรหัสยืนยัน + ไม่สามารถอัปเดตโปรไฟล์ได้ + อัปเดตโปรไฟล์แล้ว + ยืนยันตัวตนของคุณเพื่อดำเนินการต่อ + ต้องการการยืนยันตัวตนอีกครั้ง + ยืนยันตัวตนอีกครั้งสำเร็จ + ยืนยันตัวตนอีกครั้ง + ฉันได้บันทึกรหัสกู้คืนแล้ว + ลบ + ส่งอีเมลยืนยันอีกครั้ง + คีย์ลับ + ออกจากระบบ + ลงชื่อเข้าใช้ในฐานะ + ข้าม + ใช้วิธีอื่น + รหัสยืนยัน + ยืนยันอีเมลแล้ว + ยืนยัน + เราส่งอีเมลยืนยันไปที่ %1$s แล้ว diff --git a/auth/src/main/res/values-tl/strings.xml b/auth/src/main/res/values-tl/strings.xml index eb4768d02..e79d20f9b 100755 --- a/auth/src/main/res/values-tl/strings.xml +++ b/auth/src/main/res/values-tl/strings.xml @@ -25,6 +25,8 @@ Mag-email Numero ng Telepono Bansa + Pumili ng bansa + Maghanap ng bansa hal. +1, "US" Password Bagong password Hindi mo ito maaaring iwanan na walang laman. @@ -73,10 +75,13 @@ Kumpirmahin ang email para magpatuloy sa pag-sign in I-dismiss Orihinal na sinadya mong ikonekta ang %1$s sa iyong email account pero nabuksan ang link sa ibang device kung saan hindi ka naka-sign in.\n\nKung gusto mo pa ring ikonekta ang iyong %1$s account, buksan ang link sa parehong device kung saan ka nagsimulang mag-sign in. Kung hindi, i-tap ang Magpatuloy para makapag-sign in sa device na ito. + o Magpatuloy gamit ang + Mag-sign in gamit ang email link + Mag-sign in gamit ang password Ilagay ang numero ng iyong telepono Maglagay ng wastong numero ng telepono Ilagay ang 6-digit na code na ipinadala namin sa - Ipadala muli ang code sa loob ng 0:%02d + Ipadala muli ang code sa loob ng %1$s I-verify ang numero ng iyong telepono Bine-verify… Maling code. Subukang muli. @@ -87,6 +92,79 @@ Awtomatikong na-verify ang numero ng telepono Ipadala Muli ang Code I-verify ang Numero ng Telepono + Use a different phone number 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. + + + Piliin ang Paraan ng Authentication + I-set Up ang SMS Verification + I-set Up ang Authenticator App + I-verify ang Iyong Code + I-save ang Iyong Mga Recovery Code + + Pumili ng pangalawang paraan ng authentication para protektahan ang iyong account + Ilagay ang iyong numero ng telepono para makatanggap ng mga verification code + I-scan ang QR code gamit ang iyong authenticator app + Ilagay ang code na ipinadala sa iyong telepono + Ilagay ang code mula sa iyong authenticator app + Ilagay ang iyong verification code + I-imbak ang mga code na ito sa ligtas na lugar. Magagamit mo ang mga ito para mag-sign in kung mawawala ang access sa iyong paraan ng authentication. + + Kumpirmahin ang password + Hindi magkatugma ang mga password + Dapat ay may hindi bababa sa %1$d (na) character ang password + Dapat ay may hindi bababa sa isang malaking titik ang password + Dapat ay may hindi bababa sa isang maliit na titik ang password + Dapat ay may hindi bababa sa isang numero ang password + Dapat ay may hindi bababa sa isang espesyal na character ang password + + + I-set Up ang SMS Authentication + I-verify ang SMS Code + + Kailangan ng kamakailang pag-authenticate para sa operasyong ito. Mag-sign in muli at subukang muli. + Mali ang verification code. Subukang muli. + May naganap na error sa network. Tingnan ang iyong koneksyon at subukang muli. + May naganap na error habang nag-e-enroll. Subukang muli. + + Bumalik + Na-verify ang pagkakakilanlan. Subukan muli ang iyong aksyon. + Pamahalaan ang two-factor authentication + Maling password + Pumili ng paraan ng pag-verify + Magdagdag ng karagdagang layer ng seguridad + SMS + Authenticator app + Ang numerong ito ay nauugnay sa ibang account + Kinakailangan ang pag-verify + I-scan ang QR code gamit ang iyong authenticator app + Naka-set up na ang authenticator app + Sigurado ka bang gusto mong alisin ang paraang ito? + Hindi ma-alis ang paraan + Naalis ang paraan + Ilagay ang verification code + Hindi ma-update ang profile + Na-update ang profile + Kumpirmahin ang iyong pagkakakilanlan upang magpatuloy + Kinakailangan ang muling pag-authenticate + Matagumpay ang muling pag-authenticate + Mag-authenticate muli + Na-save ko ang aking mga recovery code + Alisin + Ipadala muli ang verification email + Secret key + Mag-sign out + Naka-sign in bilang + Laktawan + Gumamit ng ibang paraan + Verification code + Na-verify ang email + I-verify + Nagpadala kami ng verification email sa %1$s diff --git a/auth/src/main/res/values-tr/strings.xml b/auth/src/main/res/values-tr/strings.xml index c3e2cc019..0360f0db7 100755 --- a/auth/src/main/res/values-tr/strings.xml +++ b/auth/src/main/res/values-tr/strings.xml @@ -25,6 +25,8 @@ E-posta Telefon Numarası Ülke + Ülke seçin + Ülke arayın ör. +1, "US" Şifre Yeni şifre Bu alanı boş bırakamazsınız. @@ -73,10 +75,13 @@ Oturum açma işlemine devam etmek için e-posta adresini onaylayın Kapat Aslında %1$s adlı sağlayıcıyı e-posta hesabınıza bağlamak istediniz ancak bağlantıyı, oturum açmadığınız farklı bir cihazda açtınız.\n\nHâlâ %1$s hesabınızı bağlamak istiyorsanız bağlantıyı oturum açma işlemini başlattığınız cihazda açın. Aksi takdirde bu cihazda oturum açmak için Devam\'a dokunun. + veya Şununla Devam Et + E-posta bağlantısı ile oturum aç + Şifre ile oturum aç Telefon numaranızı girin Geçerli bir telefon numarası girin Şu telefon numarasına gönderdiğimiz 6 haneli kodu girin: - 0:%02d içinde kodu yeniden gönder + %1$s içinde kodu yeniden gönder Telefon numaranızı doğrulayın Doğrulanıyor… Yanlış kod. Tekrar deneyin. @@ -87,6 +92,80 @@ Telefon numarası otomatik olarak doğrulandı Kodu Yeniden Gönder Telefon Numarasını Doğrula + Use a different phone number “%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. + + + Kimlik Doğrulama Yöntemini Seçin + SMS Doğrulamasını Ayarlayın + Kimlik Doğrulayıcı Uygulamayı Ayarlayın + Kodunuzu Doğrulayın + Kurtarma Kodlarınızı Kaydedin + + Hesabınızı güvence altına almak için ikinci bir kimlik doğrulama yöntemi seçin + Doğrulama kodları almak için telefon numaranızı girin + Kimlik doğrulayıcı uygulamanızla QR kodunu tarayın + Telefonunuza gönderilen kodu girin + Kimlik doğrulayıcı uygulamanızdaki kodu girin + Doğrulama kodunuzu girin + Bu kodları güvenli bir yerde saklayın. Kimlik doğrulama yönteminize erişimi kaybederseniz oturum açmak için bunları kullanabilirsiniz. + + Şifreyi onayla + Şifreler eşleşmiyor + Şifre en az %1$d karakter uzunluğunda olmalıdır + Şifre en az bir büyük harf içermelidir + Şifre en az bir küçük harf içermelidir + Şifre en az bir rakam içermelidir + Şifre en az bir özel karakter içermelidir + + + SMS Kimlik Doğrulaması Kur + SMS Kodunu Doğrula + + Bu işlem için yakın zamanda kimlik doğrulama gerekiyor. Lütfen tekrar oturum açın ve tekrar deneyin. + Doğrulama kodu yanlış. Lütfen tekrar deneyin. + Bir ağ hatası oluştu. Lütfen bağlantınızı kontrol edin ve tekrar deneyin. + Kayıt sırasında bir hata oluştu. Lütfen tekrar deneyin. + + Geri + Kimlik doğrulandı. İşleminizi tekrar deneyin. + İki faktörlü kimlik doğrulamayı yönet + Yanlış şifre + Bir doğrulama yöntemi seçin + Ekstra bir güvenlik katmanı ekleyin + SMS + Kimlik doğrulama uygulaması + Bu telefon numarası başka bir hesapla ilişkilendirilmiş + Doğrulama gerekli + QR kodunu kimlik doğrulama uygulamanızla tarayın + Kimlik doğrulama uygulaması zaten ayarlanmış + Bu yöntemi kaldırmak istediğinizden emin misiniz? + Yöntem kaldırılamıyor + Yöntem kaldırıldı + Doğrulama kodunu girin + Profil güncellenemiyor + Profil güncellendi + Devam etmek için kimliğinizi onaylayın + Yeniden kimlik doğrulama gerekli + Yeniden kimlik doğrulama başarılı + Yeniden kimlik doğrula + Kurtarma kodlarımı kaydettim + Kaldır + Doğrulama e-postasını tekrar gönder + Gizli anahtar + Çıkış yap + Şu kullanıcı olarak oturum açıldı + Atla + Farklı bir yöntem kullan + Doğrulama kodu + E-posta doğrulandı + Doğrula + %1$s adresine bir doğrulama e-postası gönderdik diff --git a/auth/src/main/res/values-uk/strings.xml b/auth/src/main/res/values-uk/strings.xml index fd1b5e27a..c2be39d47 100755 --- a/auth/src/main/res/values-uk/strings.xml +++ b/auth/src/main/res/values-uk/strings.xml @@ -25,6 +25,8 @@ Електронна адреса Номер телефону Країна + Виберіть країну + Шукати країну, наприклад +1, "US" Пароль Новий пароль Потрібно заповнити це поле. @@ -73,10 +75,13 @@ Щоб продовжити, підтвердьте електронну адресу Закрити Ви збиралися зв’язати %1$s зі своєю електронною адресою, але відкрили надане посилання на пристрої, на якому не ввійшли в обліковий запис.\n\nЩоб зв’язати обліковий запис %1$s, відкрийте посилання на пристрої, де ви почали входити, або натисніть \"Продовжити\", щоб увійти на цьому пристрої. + або Продовжити з + Увійти за посиланням з електронного листа + Увійти з паролем Введіть свій номер телефону Введіть дійсний номер телефону Введіть 6-значний код, який ми надіслали на номер - Повторно надіслати код через 0:%02d + Повторно надіслати код через %1$s Підтвердити номер телефону Підтвердження… Неправильний код. Повторіть спробу. @@ -87,6 +92,80 @@ Номер телефону підтверджено автоматично Повторно надіслати код Підтвердити номер телефону + Use a different phone number Коли ви торкнетесь опції “%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. + + + Виберіть спосіб автентифікації + Налаштувати підтвердження через SMS + Налаштувати додаток автентифікації + Підтвердіть свій код + Збережіть коди відновлення + + Виберіть другий спосіб автентифікації для захисту облікового запису + Введіть номер телефону для отримання кодів підтвердження + Відскануйте QR-код за допомогою додатка автентифікації + Введіть код, надісланий на ваш телефон + Введіть код із додатка автентифікації + Введіть код підтвердження + Збережіть ці коди в безпечному місці. Ви можете використовувати їх для входу, якщо втратите доступ до способу автентифікації. + + Підтвердьте пароль + Паролі не збігаються + Пароль має містити щонайменше %1$d символів + Пароль має містити щонайменше одну велику літеру + Пароль має містити щонайменше одну малу літеру + Пароль має містити щонайменше одну цифру + Пароль має містити щонайменше один спеціальний символ + + + Налаштувати SMS-автентифікацію + Підтвердити SMS-код + + Для цієї операції потрібна недавня автентифікація. Увійдіть знову та повторіть спробу. + Код підтвердження неправильний. Повторіть спробу. + Сталася помилка мережі. Перевірте з\'єднання та повторіть спробу. + Сталася помилка під час реєстрації. Повторіть спробу. + + Назад + Особу підтверджено. Повторіть дію. + Керування двофакторною автентифікацією + Неправильний пароль + Виберіть спосіб підтвердження + Додайте додатковий рівень безпеки + SMS + Додаток для автентифікації + Цей номер телефону вже пов\'язаний з іншим обліковим записом + Потрібне підтвердження + Скануйте QR-код додатком для автентифікації + Додаток для автентифікації вже налаштовано + Ви впевнені, що хочете видалити цей спосіб? + Не вдалося видалити спосіб + Спосіб видалено + Введіть код підтвердження + Не вдалося оновити профіль + Профіль оновлено + Підтвердіть свою особу, щоб продовжити + Потрібна повторна автентифікація + Повторна автентифікація виконана + Повторна автентифікація + Я зберіг коди відновлення + Видалити + Надіслати лист підтвердження повторно + Секретний ключ + Вийти + Ви ввійшли як + Пропустити + Використати інший спосіб + Код підтвердження + Електронну пошту підтверджено + Підтвердити + Ми надіслали лист підтвердження на %1$s diff --git a/auth/src/main/res/values-ur/strings.xml b/auth/src/main/res/values-ur/strings.xml index a481e0447..3dc108e0b 100755 --- a/auth/src/main/res/values-ur/strings.xml +++ b/auth/src/main/res/values-ur/strings.xml @@ -25,6 +25,8 @@ ای میل فون نمبر ملک + ملک منتخب کریں + ملک تلاش کریں مثلاً +1، "US" پاس ورڈ نیا پاس ورڈ آپ اسے خالی نہیں چھوڑ سکتے۔ @@ -73,10 +75,13 @@ سائن ان جاری رکھنے کے لیے ای میل کی توثیق کریں برخاست کریں آپ نے اصل میں اپنے ای میل اکاؤنٹ کو %1$s کے ساتھ منسلک کرنا چاہتے تھے لیکن آپ نے لنک کو دوسرے آلہ پر کھولا ہے جہاں آپ سائن ان نہیں ہیں۔‎\n\nاگر آپ ابھی بھی اپنے %1$s اکاؤنٹ سے منسلک ہونا چاہتے ہیں، تو اسی آلہ پر لنک کو کھولیں جہاں سے آپ نے سائن ان شروع کیا تھا۔ بصورت دیگر، اس آلہ پر مسلسل سائن کرنے کے لیے تھپتھپائيں۔ + یا اس کے ساتھ جاری رکھیں + ای میل لنک سے سائن ان کریں + پاس ورڈ سے سائن ان کریں اپنا فون نمبر درج کریں براہ کرم ایک درست فون نمبر درج کریں ہماری جانب سے حسبِ ذیل کو بھیجا گیا 6 عدد کا کوڈ درج کریں - 0:%02d میں دوبارہ کوڈ بھیجیں + %1$s میں دوبارہ کوڈ بھیجیں اپنے فون نمبر کی توثیق کریں توثیق ہو رہی ہے… غلط کوڈ۔ دوبارہ کوشش کریں۔ @@ -87,6 +92,80 @@ فون نمبر کی خودکار طور پر توثیق ہو گئی کوڈ دوبارہ بھیجیں فون نمبر کی توثیق کریں + Use a different phone number %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. + + + تصدیقی طریقہ منتخب کریں + SMS تصدیق سیٹ اپ کریں + تصدیقی ایپ سیٹ اپ کریں + اپنا کوڈ تصدیق کریں + اپنے بازیافتی کوڈز محفوظ کریں + + اپنے اکاؤنٹ کو محفوظ بنانے کے لیے دوسرا تصدیقی طریقہ منتخب کریں + تصدیقی کوڈز وصول کرنے کے لیے اپنا فون نمبر درج کریں + اپنی تصدیقی ایپ سے QR کوڈ اسکین کریں + اپنے فون پر بھیجا گیا کوڈ درج کریں + اپنی تصدیقی ایپ سے کوڈ درج کریں + اپنا تصدیقی کوڈ درج کریں + ان کوڈز کو محفوظ جگہ پر اسٹور کریں۔ اگر آپ اپنے تصدیقی طریقے تک رسائی کھو دیتے ہیں تو آپ سائن ان کرنے کے لیے ان کا استعمال کر سکتے ہیں۔ + + پاس ورڈ کی تصدیق کریں + پاس ورڈز مماثل نہیں ہیں + پاس ورڈ کم از کم %1$d حروف پر مشتمل ہونا چاہیے + پاس ورڈ میں کم از کم ایک بڑا حرف ہونا چاہیے + پاس ورڈ میں کم از کم ایک چھوٹا حرف ہونا چاہیے + پاس ورڈ میں کم از کم ایک ہندسہ ہونا چاہیے + پاس ورڈ میں کم از کم ایک خاص حرف ہونا چاہیے + + + SMS تصدیق ترتیب دیں + SMS کوڈ کی تصدیق کریں + + اس آپریشن کے لیے حالیہ توثیق کی ضرورت ہے۔ براہ کرم دوبارہ سائن ان کریں اور دوبارہ کوشش کریں۔ + تصدیقی کوڈ غلط ہے۔ براہ کرم دوبارہ کوشش کریں۔ + نیٹ ورک کی خرابی واقع ہوئی۔ براہ کرم اپنا کنکشن چیک کریں اور دوبارہ کوشش کریں۔ + اندراج کے دوران ایک خرابی واقع ہوئی۔ براہ کرم دوبارہ کوشش کریں۔ + + واپس + شناخت تصدیق شدہ۔ اپنا عمل دوبارہ آزمائیں۔ + دو عنصری توثیق کا نظم کریں + غلط پاس ورڈ + تصدیق کا طریقہ منتخب کریں + سیکیورٹی کی ایک اضافی پرت شامل کریں + SMS + تصدیقی ایپ + یہ فون نمبر دوسرے اکاؤنٹ سے منسلک ہے + تصدیق درکار ہے + اپنی تصدیقی ایپ سے QR کوڈ اسکین کریں + تصدیقی ایپ پہلے ہی سیٹ اپ ہے + کیا آپ واقعی اس طریقے کو ہٹانا چاہتے ہیں؟ + طریقہ ہٹانے سے قاصر + طریقہ ہٹا دیا گیا + تصدیقی کوڈ درج کریں + پروفائل اپ ڈیٹ کرنے سے قاصر + پروفائل اپ ڈیٹ ہو گیا + جاری رکھنے کے لیے اپنی شناخت کی تصدیق کریں + دوبارہ توثیق درکار ہے + دوبارہ توثیق کامیاب + دوبارہ توثیق کریں + میں نے اپنے بحالی کے کوڈز محفوظ کر لیے ہیں + ہٹائیں + تصدیقی ای میل دوبارہ بھیجیں + خفیہ کلید + سائن آؤٹ + بطور سائن ان ہیں + چھوڑیں + ایک مختلف طریقہ استعمال کریں + تصدیقی کوڈ + ای میل تصدیق شدہ + تصدیق کریں + ہم نے %1$s کو تصدیقی ای میل بھیجی diff --git a/auth/src/main/res/values-vi/strings.xml b/auth/src/main/res/values-vi/strings.xml index 1de2f0a52..7c9877f5a 100755 --- a/auth/src/main/res/values-vi/strings.xml +++ b/auth/src/main/res/values-vi/strings.xml @@ -25,6 +25,8 @@ Email Số điện thoại Quốc gia + Chọn quốc gia + Tìm kiếm quốc gia, ví dụ: +1, "US" Mật khẩu Mật khẩu mới Bạn không được để trống trường này. @@ -73,10 +75,13 @@ Xác nhận email để tiếp tục đăng nhập Bỏ qua Ban đầu, bạn có ý định kết nối %1$s với tài khoản email của bạn nhưng đã mở đường liên kết trên một thiết bị khác mà bạn chưa đăng nhập.\n\nNếu bạn vẫn muốn kết nối với tài khoản %1$s của mình, hãy mở đường liên kết này trên cùng một thiết bị mà bạn đã đăng nhập từ đầu. Nếu không, hãy nhấn Tiếp tục để đăng nhập trên thiết bị này. + hoặc Tiếp tục với + Đăng nhập bằng liên kết email + Đăng nhập bằng mật khẩu Nhập số điện thoại của bạn Nhập số điện thoại hợp lệ Nhập mã 6 chữ số mà chúng tôi đã gửi cho bạn - Gửi lại mã sau 0:%02d + Gửi lại mã sau %1$s Xác minh số điện thoại của bạn Đang xác minh… Mã không chính xác. Hãy thử lại. @@ -87,6 +92,80 @@ Đã tự động xác minh số điện thoại Gửi lại mã Xác minh số điện thoại + Use a different phone number 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. + + + Chọn phương thức xác thực + Thiết lập xác minh qua SMS + Thiết lập ứng dụng xác thực + Xác minh mã của bạn + Lưu mã khôi phục của bạn + + Chọn phương thức xác thực thứ hai để bảo mật tài khoản của bạn + Nhập số điện thoại của bạn để nhận mã xác minh + Quét mã QR bằng ứng dụng xác thực của bạn + Nhập mã được gửi đến điện thoại của bạn + Nhập mã từ ứng dụng xác thực của bạn + Nhập mã xác minh của bạn + Lưu trữ các mã này ở nơi an toàn. Bạn có thể sử dụng chúng để đăng nhập nếu mất quyền truy cập vào phương thức xác thực của mình. + + Xác nhận mật khẩu + Mật khẩu không khớp + Mật khẩu phải có ít nhất %1$d ký tự + Mật khẩu phải chứa ít nhất một chữ cái viết hoa + Mật khẩu phải chứa ít nhất một chữ cái viết thường + Mật khẩu phải chứa ít nhất một chữ số + Mật khẩu phải chứa ít nhất một ký tự đặc biệt + + + Thiết lập xác thực SMS + Xác minh mã SMS + + Hoạt động này yêu cầu xác thực gần đây. Vui lòng đăng nhập lại và thử lại. + Mã xác minh không chính xác. Vui lòng thử lại. + Đã xảy ra lỗi mạng. Vui lòng kiểm tra kết nối của bạn và thử lại. + Đã xảy ra lỗi trong quá trình đăng ký. Vui lòng thử lại. + + Quay lại + Đã xác minh danh tính. Thử lại hành động của bạn. + Quản lý xác thực hai yếu tố + Mật khẩu không đúng + Chọn phương thức xác minh + Thêm lớp bảo mật bổ sung + SMS + Ứng dụng xác thực + Số điện thoại này được liên kết với tài khoản khác + Yêu cầu xác minh + Quét mã QR bằng ứng dụng xác thực của bạn + Ứng dụng xác thực đã được thiết lập + Bạn có chắc chắn muốn xóa phương thức này không? + Không thể xóa phương thức + Đã xóa phương thức + Nhập mã xác minh + Không thể cập nhật hồ sơ + Đã cập nhật hồ sơ + Xác nhận danh tính của bạn để tiếp tục + Yêu cầu xác thực lại + Xác thực lại thành công + Xác thực lại + Tôi đã lưu mã khôi phục của mình + Xóa + Gửi lại email xác minh + Khóa bí mật + Đăng xuất + Đã đăng nhập với tư cách + Bỏ qua + Sử dụng phương thức khác + Mã xác minh + Email đã xác minh + Xác minh + Chúng tôi đã gửi email xác minh đến %1$s diff --git a/auth/src/main/res/values-zh-rCN/strings.xml b/auth/src/main/res/values-zh-rCN/strings.xml index 38d69974b..6206fe03b 100755 --- a/auth/src/main/res/values-zh-rCN/strings.xml +++ b/auth/src/main/res/values-zh-rCN/strings.xml @@ -25,6 +25,8 @@ 电子邮件地址 电话号码 国家/地区 + 选择国家/地区 + 搜索国家/地区,例如 +1、"US" 密码 新密码 此处不能留空。 @@ -73,10 +75,13 @@ 确认电子邮件地址以继续登录 关闭 您最初是想将%1$s与您的电子邮件帐号关联起来,但是您换用其他未登录的设备打开了此链接。\n\n如果您仍希望关联您的%1$s帐号,请在您最初开始登录时所用的设备上打开此链接。否则,请点按“继续”以在当前设备上登录。 + 或继续使用 + 使用电子邮件链接登录 + 使用密码登录 输入您的电话号码 请输入有效的电话号码 输入我们发送至以下电话号码的 6 位数验证码 - %02d 后可重新发送验证码 + %1$s 后可重新发送验证码 验证您的电话号码 正在验证… 验证码有误,请重试。 @@ -87,6 +92,80 @@ 电话号码已自动验证 重新发送验证码 验证电话号码 + Use a different phone number 您点按“%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. + + + 选择身份验证方法 + 设置短信验证 + 设置身份验证器应用 + 验证代码 + 保存恢复代码 + + 选择第二种身份验证方法以保护您的帐号 + 输入您的电话号码以接收验证码 + 使用身份验证器应用扫描二维码 + 输入发送到您手机的验证码 + 输入身份验证器应用中的验证码 + 输入您的验证码 + 将这些代码保存在安全的地方。如果您无法访问身份验证方法,可以使用这些代码登录。 + + 确认密码 + 密码不匹配 + 密码长度至少为 %1$d 个字符 + 密码必须包含至少一个大写字母 + 密码必须包含至少一个小写字母 + 密码必须包含至少一个数字 + 密码必须包含至少一个特殊字符 + + + 设置短信验证 + 验证短信验证码 + + 此操作需要最近的身份验证。请重新登录并重试。 + 验证码不正确。请重试。 + 发生网络错误。请检查您的连接并重试。 + 注册期间发生错误。请重试。 + + 返回 + 身份已验证。请重试您的操作。 + 管理双重验证 + 密码不正确 + 选择验证方式 + 添加额外的安全保障 + 短信 + 身份验证器应用 + 该手机号码已与其他账户关联 + 需要验证 + 使用身份验证器应用扫描二维码 + 身份验证器应用已设置 + 确定要移除此验证方式吗? + 无法移除验证方式 + 验证方式已移除 + 输入验证码 + 无法更新个人资料 + 个人资料已更新 + 确认您的身份以继续 + 需要重新验证 + 重新验证成功 + 重新验证 + 我已保存恢复代码 + 移除 + 重新发送验证邮件 + 密钥 + 退出 + 登录身份 + 跳过 + 使用其他方式 + 验证码 + 邮箱已验证 + 验证 + 我们已向 %1$s 发送了验证邮件 diff --git a/auth/src/main/res/values-zh-rHK/strings.xml b/auth/src/main/res/values-zh-rHK/strings.xml index ec2f5229b..bf940e5d6 100755 --- a/auth/src/main/res/values-zh-rHK/strings.xml +++ b/auth/src/main/res/values-zh-rHK/strings.xml @@ -25,6 +25,8 @@ 電子郵件 電話號碼 國家/地區 + 選取國家/地區 + 搜尋國家/地區,例如 +1、"US" 密碼 新密碼 此欄位不可留空。 @@ -73,10 +75,13 @@ 如要繼續登入,請確認電子郵件地址 關閉 您原先想將 %1$s 連結至您的電子郵件帳戶,卻在另一部未登入帳戶的裝置上開啟了連結。\n\n如果您仍想連結您的 %1$s 帳戶,請在開始登入程序的同一部裝置上開啟連結。如不想連結帳戶,請輕觸 [繼續] 以在這部裝置上進行登入程序。 + 或繼續使用 + 使用電郵連結登入 + 使用密碼登入 請輸入您的電話號碼 請輸入有效的電話號碼 請輸入傳送至以下電話號碼的 6 位數驗證碼 - 0:%02d 秒後將重新發送驗證碼 + %1$s 秒後將重新發送驗證碼 驗證您的電話號碼 驗證中… 驗證碼錯誤,請再試一次。 @@ -87,6 +92,80 @@ 已自動驗證電話號碼 重新傳送驗證碼 驗證電話號碼 + Use a different phone number 輕觸 [%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. + + + 選擇驗證方法 + 設定短訊驗證 + 設定驗證器應用程式 + 驗證代碼 + 儲存復原代碼 + + 選擇第二種驗證方法以保護您的帳戶 + 輸入您的電話號碼以接收驗證碼 + 使用驗證器應用程式掃描 QR 碼 + 輸入傳送至您手機的驗證碼 + 輸入驗證器應用程式中的驗證碼 + 輸入您的驗證碼 + 將這些代碼儲存在安全的地方。如果您無法存取驗證方法,可以使用這些代碼登入。 + + 確認密碼 + 密碼不符 + 密碼長度必須至少為 %1$d 個字元 + 密碼必須包含至少一個大寫字母 + 密碼必須包含至少一個小寫字母 + 密碼必須包含至少一個數字 + 密碼必須包含至少一個特殊字元 + + + 設定短訊驗證 + 驗證短訊驗證碼 + + 此操作需要最近的身份驗證。請重新登入並重試。 + 驗證碼不正確。請重試。 + 發生網絡錯誤。請檢查您的連接並重試。 + 註冊期間發生錯誤。請重試。 + + 返回 + 身分已驗證。請重試您的操作。 + 管理雙重驗證 + 密碼不正確 + 選擇驗證方式 + 加入額外的安全保障 + 短訊 + 驗證器應用程式 + 此電話號碼已與其他帳戶關聯 + 需要驗證 + 使用驗證器應用程式掃描二維碼 + 驗證器應用程式已設定 + 確定要移除此驗證方式嗎? + 無法移除驗證方式 + 驗證方式已移除 + 輸入驗證碼 + 無法更新個人資料 + 個人資料已更新 + 確認您的身分以繼續 + 需要重新驗證 + 重新驗證成功 + 重新驗證 + 我已儲存復原碼 + 移除 + 重新發送驗證電郵 + 密鑰 + 登出 + 登入身分 + 略過 + 使用其他方式 + 驗證碼 + 電郵已驗證 + 驗證 + 我們已向 %1$s 發送了驗證電郵 diff --git a/auth/src/main/res/values-zh-rTW/strings.xml b/auth/src/main/res/values-zh-rTW/strings.xml index ec2f5229b..cdec5f4c7 100755 --- a/auth/src/main/res/values-zh-rTW/strings.xml +++ b/auth/src/main/res/values-zh-rTW/strings.xml @@ -25,6 +25,8 @@ 電子郵件 電話號碼 國家/地區 + 選取國家/地區 + 搜尋國家/地區,例如 +1、「US」 密碼 新密碼 此欄位不可留空。 @@ -73,10 +75,13 @@ 如要繼續登入,請確認電子郵件地址 關閉 您原先想將 %1$s 連結至您的電子郵件帳戶,卻在另一部未登入帳戶的裝置上開啟了連結。\n\n如果您仍想連結您的 %1$s 帳戶,請在開始登入程序的同一部裝置上開啟連結。如不想連結帳戶,請輕觸 [繼續] 以在這部裝置上進行登入程序。 + 或繼續使用 + 使用電子郵件連結登入 + 使用密碼登入 請輸入您的電話號碼 請輸入有效的電話號碼 請輸入傳送至以下電話號碼的 6 位數驗證碼 - 0:%02d 秒後將重新發送驗證碼 + %1$s 秒後將重新發送驗證碼 驗證您的電話號碼 驗證中… 驗證碼錯誤,請再試一次。 @@ -87,6 +92,80 @@ 已自動驗證電話號碼 重新傳送驗證碼 驗證電話號碼 + Use a different phone number 輕觸 [%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. + + + 選擇驗證方法 + 設定簡訊驗證 + 設定驗證器應用程式 + 驗證代碼 + 儲存復原代碼 + + 選擇第二種驗證方法以保護您的帳戶 + 輸入您的電話號碼以接收驗證碼 + 使用驗證器應用程式掃描 QR 碼 + 輸入傳送至您手機的驗證碼 + 輸入驗證器應用程式中的驗證碼 + 輸入您的驗證碼 + 將這些代碼儲存在安全的地方。如果您無法存取驗證方法,可以使用這些代碼登入。 + + 確認密碼 + 密碼不符 + 密碼長度必須至少為 %1$d 個字元 + 密碼必須包含至少一個大寫字母 + 密碼必須包含至少一個小寫字母 + 密碼必須包含至少一個數字 + 密碼必須包含至少一個特殊字元 + + + 設定簡訊驗證 + 驗證簡訊驗證碼 + + 此操作需要最近的身份驗證。請重新登入並重試。 + 驗證碼不正確。請重試。 + 發生網路錯誤。請檢查您的連線並重試。 + 註冊期間發生錯誤。請重試。 + + 返回 + 身分已驗證。請重試您的操作。 + 管理雙重驗證 + 密碼不正確 + 選擇驗證方式 + 新增額外的安全保障 + 簡訊 + 驗證器應用程式 + 此電話號碼已與其他帳號關聯 + 需要驗證 + 使用驗證器應用程式掃描 QR 碼 + 驗證器應用程式已設定 + 確定要移除此驗證方式嗎? + 無法移除驗證方式 + 驗證方式已移除 + 輸入驗證碼 + 無法更新個人資料 + 個人資料已更新 + 確認您的身分以繼續 + 需要重新驗證 + 重新驗證成功 + 重新驗證 + 我已儲存復原代碼 + 移除 + 重新傳送驗證郵件 + 密鑰 + 登出 + 登入身分 + 略過 + 使用其他方式 + 驗證碼 + 郵件已驗證 + 驗證 + 我們已傳送驗證郵件至 %1$s diff --git a/auth/src/main/res/values-zh/strings.xml b/auth/src/main/res/values-zh/strings.xml index 38d69974b..4f4059bbb 100755 --- a/auth/src/main/res/values-zh/strings.xml +++ b/auth/src/main/res/values-zh/strings.xml @@ -25,6 +25,8 @@ 电子邮件地址 电话号码 国家/地区 + 选择国家/地区 + 搜索国家/地区,例如 +1、"US" 密码 新密码 此处不能留空。 @@ -73,10 +75,13 @@ 确认电子邮件地址以继续登录 关闭 您最初是想将%1$s与您的电子邮件帐号关联起来,但是您换用其他未登录的设备打开了此链接。\n\n如果您仍希望关联您的%1$s帐号,请在您最初开始登录时所用的设备上打开此链接。否则,请点按“继续”以在当前设备上登录。 + 或继续使用 + 使用电子邮件链接登录 + 使用密码登录 输入您的电话号码 请输入有效的电话号码 输入我们发送至以下电话号码的 6 位数验证码 - %02d 后可重新发送验证码 + %1$s 后可重新发送验证码 验证您的电话号码 正在验证… 验证码有误,请重试。 @@ -87,6 +92,79 @@ 电话号码已自动验证 重新发送验证码 验证电话号码 + Use a different phone number 您点按“%1$s”后,系统会向您发送一条短信。这可能会产生短信费用和上网流量费。 - 点按“%1$s”即表示您接受我们的%2$s和%3$s。系统会向您发送一条短信。这可能会产生短信费用和上网流量费。 + 点按"%1$s"即表示您接受我们的%2$s和%3$s。系统会向您发送一条短信。这可能会产生短信费用和上网流量费。 + 身份验证错误 + 重试 + 需要额外的验证。请完成多重身份验证。 + 需要关联账户。请尝试其他登录方式。 + 身份验证已取消。准备好后请重试。 + + + 选择身份验证方法 + 设置短信验证 + 设置身份验证器应用 + 验证代码 + 保存恢复代码 + + 选择第二种身份验证方法以保护您的帐号 + 输入您的电话号码以接收验证码 + 使用身份验证器应用扫描二维码 + 输入发送到您手机的验证码 + 输入身份验证器应用中的验证码 + 输入您的验证码 + 将这些代码保存在安全的地方。如果您无法访问身份验证方法,可以使用这些代码登录。 + + 确认密码 + 密码不匹配 + 密码长度至少为 %1$d 个字符 + 密码必须包含至少一个大写字母 + 密码必须包含至少一个小写字母 + 密码必须包含至少一个数字 + 密码必须包含至少一个特殊字符 + + + 设置短信验证 + 验证短信验证码 + + 此操作需要最近的身份验证。请重新登录并重试。 + 验证码不正确。请重试。 + 发生网络错误。请检查您的连接并重试。 + 注册期间发生错误。请重试。 + + 返回 + 身份已验证。请重试您的操作。 + 管理双重验证 + 密码不正确 + 选择验证方式 + 添加额外的安全保障 + 短信 + 身份验证器应用 + 该手机号码已与其他账户关联 + 需要验证 + 使用身份验证器应用扫描二维码 + 身份验证器应用已设置 + 确定要移除此验证方式吗? + 无法移除验证方式 + 验证方式已移除 + 输入验证码 + 无法更新个人资料 + 个人资料已更新 + 确认您的身份以继续 + 需要重新验证 + 重新验证成功 + 重新验证 + 我已保存恢复代码 + 移除 + 重新发送验证邮件 + 密钥 + 退出 + 登录身份 + 跳过 + 使用其他方式 + 验证码 + 邮箱已验证 + 验证 + 我们已向 %1$s 发送了验证邮件 diff --git a/auth/src/main/res/values/config.xml b/auth/src/main/res/values/config.xml index ec03e4b3c..7aec5c460 100644 --- a/auth/src/main/res/values/config.xml +++ b/auth/src/main/res/values/config.xml @@ -21,6 +21,15 @@ --> fb_your_app_id + + CHANGE-ME + "Auth method picker logo" Sign in with Google Sign in with Facebook - Sign in with Twitter + Sign in with X Sign in with GitHub Sign in with email Sign in with phone @@ -30,12 +31,73 @@ Sign in with Microsoft Sign in with Yahoo + + Signed in as %1$s + Manage multi-factor authentication + Sign out + Please verify %1$s to continue. + Resend verification email + I\'ve verified my email + Please complete your profile information to continue. + Missing fields: %1$s + Skip for now + Remove + Back + Verify + Use a different method + I\'ve saved these codes + Secret key + Verification code + Identity verified. Please try your action again. + + + Manage two-factor authentication + Add or remove authentication methods for your account + Active methods + Add new method + All available authentication methods are enrolled + SMS authentication + Authenticator app + Unknown method + Enrolled on %1$s + Scan the QR code or enter the secret key in your authenticator app + Choose a verification method + Add an extra layer of security + SMS + Authenticator app + This phone number is associated with another account + Verification required + Scan the QR code with your authenticator app + Authenticator app is already set up + Are you sure you want to remove this method? + Unable to remove method + Method removed + Enter the verification code + + + Verify your identity + For your security, please re-enter your password to continue. + Account: %1$s + Incorrect password. Please try again. + Authentication failed. Please try again. + Confirm your identity to continue + Re-authentication required + Re-authentication successful + Re-authenticate + + + Unable to update profile + Profile updated + Next Email Phone Number Country + Select a country + Select for country e.g. +1, "US" Password + Confirm Password New password You can\'t leave this empty. That email address isn\'t correct @@ -93,11 +155,22 @@ 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 + or Continue with + Sign in with email link + Sign in with password Sign-in email sent\n A sign-in email with additional instructions was sent to %1$s. Check your email to complete sign-in. Trouble getting email? @@ -119,7 +192,7 @@ Enter your phone number Enter a valid phone number Enter the 6-digit code we sent to - Resend code in 0:%02d + Resend code in %1$s Verify your phone number Verifying… Wrong code. Try again. @@ -130,6 +203,38 @@ Phone number automatically verified 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. + Use a different 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. + + + 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. + + + Choose Authentication Method + Set Up SMS Verification + Set Up Authenticator App + Verify Your Code + Save Your Recovery Codes + + Select a second authentication method to secure your account + Enter your phone number to receive verification codes + Scan the QR code with your authenticator app + Enter the code sent to your phone + Enter the code from your authenticator app + Enter your verification code + Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method. + + Set Up SMS Authentication + Verify SMS Code + + + This operation requires recent authentication. Please sign in again and try again. + The verification code is incorrect. Please try again. + A network error occurred. Please check your connection and try again. + An error occurred during enrollment. Please try again. 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/AuthFlowControllerTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/AuthFlowControllerTest.kt new file mode 100644 index 000000000..2c80a8e0e --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/AuthFlowControllerTest.kt @@ -0,0 +1,478 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.MockitoAnnotations +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [AuthFlowController] covering lifecycle management, + * intent creation, state observation, and resource disposal. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class AuthFlowControllerTest { + + private lateinit var applicationContext: Context + private lateinit var authUI: FirebaseAuthUI + private lateinit var configuration: AuthUIConfiguration + + @Mock + private lateinit var mockActivity: Activity + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + applicationContext = ApplicationProvider.getApplicationContext() + + // Clear any existing Firebase apps + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + // Initialize default FirebaseApp + FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + + authUI = FirebaseAuthUI.getInstance() + configuration = AuthUIConfiguration( + context = applicationContext, + providers = listOf( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + ) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { app -> + try { + app.delete() + } catch (_: Exception) { + // Ignore if already deleted + } + } + } + + // ============================================================================================= + // Controller Creation Tests + // ============================================================================================= + + @Test + fun `createAuthFlow() returns new controller instance`() { + val controller = authUI.createAuthFlow(configuration) + + assertThat(controller).isNotNull() + assertThat(controller.isDisposed()).isFalse() + } + + @Test + fun `createAuthFlow() returns different instances each time`() { + val controller1 = authUI.createAuthFlow(configuration) + val controller2 = authUI.createAuthFlow(configuration) + + // Each call should return a new controller instance + assertThat(controller1).isNotEqualTo(controller2) + } + + // ============================================================================================= + // Intent Creation Tests + // ============================================================================================= + + @Test + fun `createIntent() returns valid Intent`() { + val controller = authUI.createAuthFlow(configuration) + val intent = controller.createIntent(applicationContext) + + assertThat(intent).isNotNull() + assertThat(intent.component?.className).contains("FirebaseAuthActivity") + } + + @Test + fun `createIntent() contains configuration key`() { + val controller = authUI.createAuthFlow(configuration) + val intent = controller.createIntent(applicationContext) + + // Intent should contain a configuration key extra + val configKey = intent.getStringExtra("com.firebase.ui.auth.compose.CONFIGURATION_KEY") + assertThat(configKey).isNotNull() + assertThat(configKey).isNotEmpty() + } + + @Test + fun `createIntent() throws IllegalStateException when disposed`() { + val controller = authUI.createAuthFlow(configuration) + controller.dispose() + + try { + controller.createIntent(applicationContext) + assertThat(false).isTrue() // Should not reach here + } catch (e: IllegalStateException) { + assertThat(e.message).contains("disposed") + } + } + + // ============================================================================================= + // Auth State Flow Tests + // ============================================================================================= + + @Test + fun `authStateFlow observes state changes from FirebaseAuthUI`() = runTest { + val controller = authUI.createAuthFlow(configuration) + + // Collect initial state + val initialState = controller.authStateFlow.first() + assertThat(initialState).isInstanceOf(AuthState.Idle::class.java) + } + + @Test + fun `authStateFlow emits Loading state when updated`() = runTest { + val controller = authUI.createAuthFlow(configuration) + + // Update state + authUI.updateAuthState(AuthState.Loading("Testing")) + + // Advance test scheduler to process all pending coroutines + testScheduler.advanceUntilIdle() + + // Collect first state after update + val state = controller.authStateFlow.first() + + // Should be Loading state + assertThat(state).isInstanceOf(AuthState.Loading::class.java) + assertThat((state as AuthState.Loading).message).isEqualTo("Testing") + } + + @Test + fun `authStateFlow throws IllegalStateException when disposed`() = runTest { + val controller = authUI.createAuthFlow(configuration) + controller.dispose() + + try { + controller.authStateFlow.first() + assertThat(false).isTrue() // Should not reach here + } catch (e: IllegalStateException) { + assertThat(e.message).contains("disposed") + } + } + + // ============================================================================================= + // Cancel Tests + // ============================================================================================= + + @Test + fun `cancel() updates state to Cancelled`() = runTest { + val controller = authUI.createAuthFlow(configuration) + + // Cancel the flow + controller.cancel() + + // Advance test scheduler to process all pending coroutines + testScheduler.advanceUntilIdle() + + // Collect first state after cancel + val state = controller.authStateFlow.first() + + // Should be Cancelled state + assertThat(state).isInstanceOf(AuthState.Cancelled::class.java) + } + + @Test + fun `cancel() throws IllegalStateException when disposed`() { + val controller = authUI.createAuthFlow(configuration) + controller.dispose() + + try { + controller.cancel() + assertThat(false).isTrue() // Should not reach here + } catch (e: IllegalStateException) { + assertThat(e.message).contains("disposed") + } + } + + // ============================================================================================= + // Dispose Tests + // ============================================================================================= + + @Test + fun `dispose() marks controller as disposed`() { + val controller = authUI.createAuthFlow(configuration) + + assertThat(controller.isDisposed()).isFalse() + + controller.dispose() + + assertThat(controller.isDisposed()).isTrue() + } + + @Test + fun `dispose() can be called multiple times safely`() { + val controller = authUI.createAuthFlow(configuration) + + controller.dispose() + controller.dispose() // Should not throw + + assertThat(controller.isDisposed()).isTrue() + } + + @Test + fun `dispose() prevents further operations`() { + val controller = authUI.createAuthFlow(configuration) + controller.dispose() + + // All operations should throw IllegalStateException + try { + controller.createIntent(applicationContext) + assertThat(false).isTrue() + } catch (e: IllegalStateException) { + assertThat(e.message).contains("disposed") + } + + try { + controller.cancel() + assertThat(false).isTrue() + } catch (e: IllegalStateException) { + assertThat(e.message).contains("disposed") + } + } + + @Test + fun `isDisposed() returns correct state`() { + val controller = authUI.createAuthFlow(configuration) + + assertThat(controller.isDisposed()).isFalse() + + controller.dispose() + + assertThat(controller.isDisposed()).isTrue() + } + + // ============================================================================================= + // Deprecated Start Method Tests + // ============================================================================================= + + @Suppress("DEPRECATION") + @Test + fun `start() launches activity with correct intent`() { + val controller = authUI.createAuthFlow(configuration) + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + + controller.start(activity, AuthFlowController.RC_SIGN_IN) + + // Verify an activity was started + val shadowActivity = org.robolectric.Shadows.shadowOf(activity) + val startedIntent = shadowActivity.nextStartedActivity + + assertThat(startedIntent).isNotNull() + assertThat(startedIntent.component?.className).contains("FirebaseAuthActivity") + } + + @Suppress("DEPRECATION") + @Test + fun `start() throws IllegalStateException when disposed`() { + val controller = authUI.createAuthFlow(configuration) + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + + controller.dispose() + + try { + controller.start(activity, AuthFlowController.RC_SIGN_IN) + assertThat(false).isTrue() // Should not reach here + } catch (e: IllegalStateException) { + assertThat(e.message).contains("disposed") + } + } + + @Suppress("DEPRECATION") + @Test + fun `start() uses default request code when not specified`() { + val controller = authUI.createAuthFlow(configuration) + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + + controller.start(activity) + + // Verify an activity was started (request code verification is internal) + val shadowActivity = org.robolectric.Shadows.shadowOf(activity) + val startedIntent = shadowActivity.nextStartedActivity + + assertThat(startedIntent).isNotNull() + } + + // ============================================================================================= + // Thread Safety Tests + // ============================================================================================= + + @Test + fun `dispose() is thread-safe`() { + val controller = authUI.createAuthFlow(configuration) + + val threads = List(10) { + Thread { + controller.dispose() + } + } + + // Start all threads concurrently + threads.forEach { it.start() } + + // Wait for all threads to complete + threads.forEach { it.join() } + + // Controller should be disposed exactly once + assertThat(controller.isDisposed()).isTrue() + } + + @Test + fun `multiple controllers can be created and disposed independently`() { + val controller1 = authUI.createAuthFlow(configuration) + val controller2 = authUI.createAuthFlow(configuration) + val controller3 = authUI.createAuthFlow(configuration) + + controller1.dispose() + + // Other controllers should still be usable + assertThat(controller1.isDisposed()).isTrue() + assertThat(controller2.isDisposed()).isFalse() + assertThat(controller3.isDisposed()).isFalse() + + // Can still create intents with non-disposed controllers + val intent2 = controller2.createIntent(applicationContext) + val intent3 = controller3.createIntent(applicationContext) + + assertThat(intent2).isNotNull() + assertThat(intent3).isNotNull() + + controller2.dispose() + controller3.dispose() + + assertThat(controller2.isDisposed()).isTrue() + assertThat(controller3.isDisposed()).isTrue() + } + + // ============================================================================================= + // Configuration Tests + // ============================================================================================= + + @Test + fun `controller preserves configuration for intent creation`() { + val customConfig = AuthUIConfiguration( + context = applicationContext, + providers = listOf( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ), + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ), + tosUrl = "https://example.com/tos", + privacyPolicyUrl = "https://example.com/privacy" + ) + + val controller = authUI.createAuthFlow(customConfig) + val intent = controller.createIntent(applicationContext) + + // Intent should be created successfully with custom config + assertThat(intent).isNotNull() + assertThat(intent.component?.className).contains("FirebaseAuthActivity") + } + + // ============================================================================================= + // Lifecycle Tests + // ============================================================================================= + + @Test + fun `typical lifecycle - create, start, cancel, dispose`() = runTest { + val controller = authUI.createAuthFlow(configuration) + + // Create intent + val intent = controller.createIntent(applicationContext) + assertThat(intent).isNotNull() + + // Cancel flow + controller.cancel() + + // Advance test scheduler to process all pending coroutines + testScheduler.advanceUntilIdle() + + // Verify cancelled state + val state = controller.authStateFlow.first() + assertThat(state).isInstanceOf(AuthState.Cancelled::class.java) + + // Dispose + controller.dispose() + assertThat(controller.isDisposed()).isTrue() + } + + @Test + fun `typical lifecycle - create, start, observe, dispose`() = runTest { + val controller = authUI.createAuthFlow(configuration) + + // Create intent + val intent = controller.createIntent(applicationContext) + assertThat(intent).isNotNull() + + // Update state + authUI.updateAuthState(AuthState.Loading("Signing in...")) + + // Advance test scheduler to process all pending coroutines + testScheduler.advanceUntilIdle() + + // Observe state change + val state = controller.authStateFlow.first() + assertThat(state).isInstanceOf(AuthState.Loading::class.java) + + // Dispose + controller.dispose() + assertThat(controller.isDisposed()).isTrue() + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthActivityTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthActivityTest.kt new file mode 100644 index 000000000..8b21965fa --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthActivityTest.kt @@ -0,0 +1,539 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorResolver +import android.os.Looper +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config + +/** + * Unit tests for [FirebaseAuthActivity] covering activity lifecycle, + * intent handling, state observation, and result handling. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class FirebaseAuthActivityTest { + + private lateinit var applicationContext: Context + private lateinit var authUI: FirebaseAuthUI + private lateinit var configuration: AuthUIConfiguration + + @Mock + private lateinit var mockFirebaseUser: FirebaseUser + + @Mock + private lateinit var mockMultiFactorResolver: MultiFactorResolver + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + applicationContext = ApplicationProvider.getApplicationContext() + + // Clear any existing Firebase apps + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + // Initialize default FirebaseApp + FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + + authUI = FirebaseAuthUI.getInstance() + authUI.auth.useEmulator("127.0.0.1", 9099) + + configuration = AuthUIConfiguration( + context = applicationContext, + providers = listOf( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + ) + + // Reset auth state before each test + authUI.updateAuthState(AuthState.Idle) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { app -> + try { + app.delete() + } catch (_: Exception) { + // Ignore if already deleted + } + } + } + + // ============================================================================================= + // Activity Launch Tests + // ============================================================================================= + + @Test + fun `activity launches successfully with valid configuration`() { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + assertThat(activity).isNotNull() + assertThat(activity.isFinishing).isFalse() + } + + @Test + fun `activity finishes immediately when configuration is missing`() { + // Create intent without configuration + val intent = Intent(applicationContext, FirebaseAuthActivity::class.java) + + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + val activity = controller.create().get() + + // Activity should finish immediately + assertThat(activity.isFinishing).isTrue() + + // Result should be RESULT_CANCELED + val shadowActivity = shadowOf(activity) + assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_CANCELED) + } + + // ============================================================================================= + // Configuration Extraction Tests + // ============================================================================================= + + @Test + fun `createIntent() stores configuration in cache`() { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + + // Intent should contain configuration key + val configKey = intent.getStringExtra("com.firebase.ui.auth.compose.CONFIGURATION_KEY") + assertThat(configKey).isNotNull() + assertThat(configKey).isNotEmpty() + } + + @Test + fun `activity extracts configuration from intent on onCreate`() { + val customConfig = AuthUIConfiguration( + context = applicationContext, + providers = listOf( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ), + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ), + tosUrl = "https://example.com/tos" + ) + + val intent = FirebaseAuthActivity.createIntent(applicationContext, customConfig) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().get() + + // Activity should have been created successfully (not finished) + assertThat(activity.isFinishing).isFalse() + } + + // ============================================================================================= + // Auth State Success Tests + // ============================================================================================= + + @Test + fun `activity finishes with RESULT_OK on Success state`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Mock user + `when`(mockFirebaseUser.uid).thenReturn("test-user-id") + + // Update to Success state + authUI.updateAuthState(AuthState.Success( + result = null, + user = mockFirebaseUser, + isNewUser = true + )) + + // Process pending tasks on main looper + shadowOf(Looper.getMainLooper()).idle() + + // Activity should finish + assertThat(activity.isFinishing).isTrue() + + // Result should be RESULT_OK + val shadowActivity = shadowOf(activity) + assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_OK) + + // Result intent should contain user data + val resultIntent = shadowActivity.resultIntent + assertThat(resultIntent).isNotNull() + assertThat(resultIntent.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID)) + .isEqualTo("test-user-id") + assertThat(resultIntent.getBooleanExtra(FirebaseAuthActivity.EXTRA_IS_NEW_USER, false)) + .isTrue() + } + + @Test + fun `activity returns correct user data on success`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Mock user with specific data + `when`(mockFirebaseUser.uid).thenReturn("user-123") + + // Update to Success state with isNewUser = false + authUI.updateAuthState(AuthState.Success( + result = null, + user = mockFirebaseUser, + isNewUser = false + )) + + shadowOf(Looper.getMainLooper()).idle() + + val shadowActivity = shadowOf(activity) + val resultIntent = shadowActivity.resultIntent + + assertThat(resultIntent.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID)) + .isEqualTo("user-123") + assertThat(resultIntent.getBooleanExtra(FirebaseAuthActivity.EXTRA_IS_NEW_USER, true)) + .isFalse() + } + + // ============================================================================================= + // Auth State Cancelled Tests + // ============================================================================================= + + @Test + fun `activity finishes with RESULT_CANCELED on Cancelled state`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Update to Cancelled state + authUI.updateAuthState(AuthState.Cancelled) + + shadowOf(Looper.getMainLooper()).idle() + + // Activity should finish + assertThat(activity.isFinishing).isTrue() + + // Result should be RESULT_CANCELED + val shadowActivity = shadowOf(activity) + assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_CANCELED) + } + + // ============================================================================================= + // Auth State Error Tests + // ============================================================================================= + + @Test + fun `activity sets RESULT_CANCELED on Error state but does not finish`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Update to Error state + val exception = AuthException.UnknownException("Test error") + authUI.updateAuthState(AuthState.Error(exception)) + + shadowOf(Looper.getMainLooper()).idle() + + // Activity should NOT finish (to let user see error and retry) + assertThat(activity.isFinishing).isFalse() + + // Result should be set to RESULT_CANCELED + val shadowActivity = shadowOf(activity) + assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_CANCELED) + + // Result intent should contain error + val resultIntent = shadowActivity.resultIntent + assertThat(resultIntent).isNotNull() + assertThat(resultIntent.getSerializableExtra(FirebaseAuthActivity.EXTRA_ERROR)) + .isInstanceOf(AuthException::class.java) + } + + @Test + fun `activity includes error in result intent on Error state`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Update to Error state with specific exception + val exception = AuthException.InvalidCredentialsException( + message = "Invalid credentials" + ) + authUI.updateAuthState(AuthState.Error(exception)) + + shadowOf(Looper.getMainLooper()).idle() + + val shadowActivity = shadowOf(activity) + val resultIntent = shadowActivity.resultIntent + val resultError = resultIntent.getSerializableExtra(FirebaseAuthActivity.EXTRA_ERROR) as AuthException + + assertThat(resultError).isInstanceOf(AuthException.InvalidCredentialsException::class.java) + assertThat(resultError.message).isEqualTo("Invalid credentials") + } + + // ============================================================================================= + // Activity Lifecycle Tests + // ============================================================================================= + + @Test + fun `activity resets auth state to Idle on destroy when not finishing`() { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Set some state + authUI.updateAuthState(AuthState.Loading("Testing")) + + // Destroy without finishing + controller.pause().stop().destroy() + + // Note: This test verifies the lifecycle hook is in place + // The actual state reset behavior depends on the isFinishing flag + } + + @Test + fun `activity handles rapid state transitions`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Simulate rapid state changes + authUI.updateAuthState(AuthState.Loading("Loading...")) + shadowOf(Looper.getMainLooper()).idle() + authUI.updateAuthState(AuthState.Loading("Signing in...")) + shadowOf(Looper.getMainLooper()).idle() + `when`(mockFirebaseUser.uid).thenReturn("test-user") + authUI.updateAuthState(AuthState.Success( + result = null, + user = mockFirebaseUser, + isNewUser = false + )) + + shadowOf(Looper.getMainLooper()).idle() + + // Activity should finish on final Success state + assertThat(activity.isFinishing).isTrue() + + val shadowActivity = shadowOf(activity) + assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_OK) + } + + // ============================================================================================= + // Intent Extras Constants Tests + // ============================================================================================= + + @Test + fun `activity exposes correct intent extra constants`() { + assertThat(FirebaseAuthActivity.EXTRA_USER_ID) + .isEqualTo("com.firebase.ui.auth.compose.USER_ID") + assertThat(FirebaseAuthActivity.EXTRA_IS_NEW_USER) + .isEqualTo("com.firebase.ui.auth.compose.IS_NEW_USER") + assertThat(FirebaseAuthActivity.EXTRA_ERROR) + .isEqualTo("com.firebase.ui.auth.compose.ERROR") + } + + // ============================================================================================= + // Configuration Cache Tests + // ============================================================================================= + + @Test + fun `configuration is removed from cache after onCreate`() { + val intent1 = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val configKey1 = intent1.getStringExtra("com.firebase.ui.auth.compose.CONFIGURATION_KEY") + + assertThat(configKey1).isNotNull() + + // Create activity - this should consume the configuration from cache + val controller1 = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent1) + controller1.create().get() + + // Create another intent + val intent2 = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val configKey2 = intent2.getStringExtra("com.firebase.ui.auth.compose.CONFIGURATION_KEY") + + // Should be a different key + assertThat(configKey2).isNotEqualTo(configKey1) + } + + @Test + fun `multiple activities can be launched with different configurations`() { + val config1 = AuthUIConfiguration( + context = applicationContext, + providers = listOf( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ), + tosUrl = "https://example.com/tos1" + ) + + val config2 = AuthUIConfiguration( + context = applicationContext, + providers = listOf( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ), + tosUrl = "https://example.com/tos2" + ) + + val intent1 = FirebaseAuthActivity.createIntent(applicationContext, config1) + val intent2 = FirebaseAuthActivity.createIntent(applicationContext, config2) + + // Both activities should launch successfully + val controller1 = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent1) + val activity1 = controller1.create().get() + assertThat(activity1.isFinishing).isFalse() + + val controller2 = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent2) + val activity2 = controller2.create().get() + assertThat(activity2.isFinishing).isFalse() + } + + // ============================================================================================= + // Other State Tests + // ============================================================================================= + + @Test + fun `activity continues showing UI on Loading state`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Update to Loading state + authUI.updateAuthState(AuthState.Loading("Signing in...")) + + shadowOf(Looper.getMainLooper()).idle() + + // Activity should NOT finish on Loading state + assertThat(activity.isFinishing).isFalse() + } + + @Test + fun `activity continues showing UI on RequiresMfa state`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Update to RequiresMfa state (mocked resolver) + authUI.updateAuthState(AuthState.RequiresMfa( + resolver = mockMultiFactorResolver, + hint = "Enter verification code" + )) + + shadowOf(Looper.getMainLooper()).idle() + + // Activity should NOT finish on RequiresMfa state + assertThat(activity.isFinishing).isFalse() + } + + @Test + fun `activity continues showing UI on RequiresEmailVerification state`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Update to RequiresEmailVerification state + `when`(mockFirebaseUser.email).thenReturn("test@example.com") + authUI.updateAuthState(AuthState.RequiresEmailVerification( + user = mockFirebaseUser, + email = "test@example.com" + )) + + shadowOf(Looper.getMainLooper()).idle() + + // Activity should NOT finish on RequiresEmailVerification state + assertThat(activity.isFinishing).isFalse() + } + + // ============================================================================================= + // Theme Tests + // ============================================================================================= + + @Test + fun `activity applies theme from configuration`() { + val customConfig = AuthUIConfiguration( + context = applicationContext, + providers = listOf( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ), + theme = com.firebase.ui.auth.compose.configuration.theme.AuthUITheme.Default + ) + + val intent = FirebaseAuthActivity.createIntent(applicationContext, customConfig) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().get() + + // Activity should launch successfully with custom theme + assertThat(activity.isFinishing).isFalse() + } +} diff --git a/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..893f69b85 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt @@ -0,0 +1,384 @@ +/* + * 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.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +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.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +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 RequiresEmailVerification for unverified password users`() = runBlocking { + // Given a signed-in user with unverified email using password authentication + 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 RequiresEmailVerification state + assertThat(state).isInstanceOf(AuthState.RequiresEmailVerification::class.java) + val verificationState = state as AuthState.RequiresEmailVerification + assertThat(verificationState.user).isEqualTo(mockFirebaseUser) + assertThat(verificationState.email).isEqualTo("test@example.com") + } + + @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) + + // Start collecting the flow to capture initial state + val states = mutableListOf() + val job = launch { + authUI.authStateFlow().take(3).toList(states) + } + + // Wait for initial state to be collected + delay(100) + + // When updating auth state internally + authUI.updateAuthState(AuthState.Loading("Signing in...")) + + // Wait for state update to propagate + delay(100) + + // Update state again + authUI.updateAuthState(AuthState.Cancelled) + + job.join() + + // Verify the emitted states + assertThat(states).hasSize(3) + assertThat(states[0]).isEqualTo(AuthState.Idle) // Initial state + assertThat(states[1]).isInstanceOf(AuthState.Loading::class.java) // After first update + assertThat(states[2]).isEqualTo(AuthState.Cancelled) // After second update + } + + // ============================================================================================= + // 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..c9025cece --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt @@ -0,0 +1,549 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseException +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException +import com.google.firebase.auth.FirebaseUser +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.anyString +import org.mockito.Mockito.doNothing +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 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) + } + } + + @Test + fun `canHandleIntent returns true when auth validates email link`() { + val emailLink = "https://example.com/__/auth/action?mode=signIn" + val intent = Intent().setData(Uri.parse(emailLink)) + val authUI = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + + `when`(mockFirebaseAuth.isSignInWithEmailLink(emailLink)).thenReturn(true) + + assertThat(authUI.canHandleIntent(intent)).isTrue() + } + + @Test + fun `canHandleIntent returns false when intent lacks data`() { + val authUI = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val intent = Intent() + + assertThat(authUI.canHandleIntent(intent)).isFalse() + verify(mockFirebaseAuth, never()).isSignInWithEmailLink(anyString()) + } +} 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..c98c9f17a --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -0,0 +1,473 @@ +/* + * 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.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset +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.passwordResetActionCodeSettings).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 customPasswordResetActionCodeSettings = actionCodeSettings { + url = "https://example.com/verify" + handleCodeInApp = true + } + val logoAsset = AuthUIAsset.Vector(Icons.Default.AccountCircle) + + 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 = logoAsset + passwordResetActionCodeSettings = customPasswordResetActionCodeSettings + 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(logoAsset) + assertThat(config.passwordResetActionCodeSettings) + .isEqualTo(customPasswordResetActionCodeSettings) + 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 // Intentionally not setting context + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "test_client_id" + ) + ) + } + } + assertThat(false).isTrue() // Should not reach here + } catch (e: IllegalArgumentException) { + assertThat(e.message).isEqualTo("Application context is required") + } + } + + @Test + fun `authUIConfiguration throws when no providers configured`() { + try { + authUIConfiguration { + context = applicationContext + // providers { ... } // Intentionally not configuring any providers + } + assertThat(false).isTrue() // Should not reach here + } catch (e: IllegalArgumentException) { + 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( + emailLinkActionCodeSettings = null, + passwordValidationRules = listOf() + ) + ) + } + } + assertThat(config.providers).hasSize(9) + } + + @Test + fun `validation accepts custom OIDC providers`() { + val linkedInProvider = AuthProvider.GenericOAuth( + providerName = "LinkedIn", + providerId = "oidc.linkedin", + scopes = listOf(), + customParameters = mapOf(), + buttonLabel = "Sign in with LinkedIn", + buttonIcon = null, + buttonColor = null, + contentColor = null, + ) + + val oktaProvider = AuthProvider.GenericOAuth( + providerName = "Okta", + providerId = "oidc.okta", + scopes = listOf(), + customParameters = mapOf(), + buttonLabel = "Sign in with Okta", + buttonIcon = null, + buttonColor = null, + contentColor = null, + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider(linkedInProvider) + provider(oktaProvider) + } + } + + assertThat(config.providers).hasSize(2) + assertThat(config.providers[0].providerId).isEqualTo("oidc.linkedin") + assertThat(config.providers[1].providerId).isEqualTo("oidc.okta") + } + + @Test + fun `validation throws for unsupported provider`() { + val mockProvider = AuthProvider.GenericOAuth( + providerName = "Generic Provider", + providerId = "unsupported.provider", + scopes = listOf(), + customParameters = mapOf(), + buttonLabel = "Test", + buttonIcon = null, + buttonColor = null, + contentColor = null, + ) + + try { + authUIConfiguration { + context = applicationContext + providers { + provider(mockProvider) + } + } + assertThat(false).isTrue() // Should not reach here + } catch (e: IllegalArgumentException) { + 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" + ) + ) + } + } + assertThat(false).isTrue() // Should not reach here + } catch (e: IllegalArgumentException) { + 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", + "passwordResetActionCodeSettings", + "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/MfaConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/MfaConfigurationTest.kt new file mode 100644 index 000000000..37e29d8ba --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/MfaConfigurationTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [MfaConfiguration] covering default values, custom configurations, + * and validation rules. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class MfaConfigurationTest { + + // ============================================================================================= + // Default Configuration Tests + // ============================================================================================= + + @Test + fun `MfaConfiguration with defaults uses correct values`() { + val config = MfaConfiguration() + + assertThat(config.allowedFactors).containsExactly(MfaFactor.Sms, MfaFactor.Totp) + assertThat(config.requireEnrollment).isFalse() + assertThat(config.enableRecoveryCodes).isTrue() + } + + @Test + fun `MfaConfiguration default allowedFactors includes both Sms and Totp`() { + val config = MfaConfiguration() + + assertThat(config.allowedFactors).hasSize(2) + assertThat(config.allowedFactors).contains(MfaFactor.Sms) + assertThat(config.allowedFactors).contains(MfaFactor.Totp) + } + + // ============================================================================================= + // Custom Configuration Tests + // ============================================================================================= + + @Test + fun `MfaConfiguration with custom allowedFactors only Sms`() { + val config = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms) + ) + + assertThat(config.allowedFactors).containsExactly(MfaFactor.Sms) + assertThat(config.allowedFactors).hasSize(1) + } + + @Test + fun `MfaConfiguration with custom allowedFactors only Totp`() { + val config = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Totp) + ) + + assertThat(config.allowedFactors).containsExactly(MfaFactor.Totp) + assertThat(config.allowedFactors).hasSize(1) + } + + @Test + fun `MfaConfiguration with requireEnrollment enabled`() { + val config = MfaConfiguration( + requireEnrollment = true + ) + + assertThat(config.requireEnrollment).isTrue() + } + + @Test + fun `MfaConfiguration with enableRecoveryCodes disabled`() { + val config = MfaConfiguration( + enableRecoveryCodes = false + ) + + assertThat(config.enableRecoveryCodes).isFalse() + } + + @Test + fun `MfaConfiguration with all custom values`() { + val config = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms), + requireEnrollment = true, + enableRecoveryCodes = false + ) + + assertThat(config.allowedFactors).containsExactly(MfaFactor.Sms) + assertThat(config.requireEnrollment).isTrue() + assertThat(config.enableRecoveryCodes).isFalse() + } + + // ============================================================================================= + // Validation Tests + // ============================================================================================= + + @Test + fun `MfaConfiguration throws when allowedFactors is empty`() { + try { + MfaConfiguration( + allowedFactors = emptyList() + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo("At least one MFA factor must be allowed") + } + } + + @Test + fun `MfaConfiguration allows both factors in any order`() { + val config1 = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp) + ) + val config2 = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Totp, MfaFactor.Sms) + ) + + assertThat(config1.allowedFactors).hasSize(2) + assertThat(config2.allowedFactors).hasSize(2) + assertThat(config1.allowedFactors).containsExactly(MfaFactor.Sms, MfaFactor.Totp) + assertThat(config2.allowedFactors).containsExactly(MfaFactor.Totp, MfaFactor.Sms) + } + + // ============================================================================================= + // MfaFactor Enum Tests + // ============================================================================================= + + @Test + fun `MfaFactor enum has exactly two values`() { + val factors = MfaFactor.entries + + assertThat(factors).hasSize(2) + assertThat(factors).containsExactly(MfaFactor.Sms, MfaFactor.Totp) + } + + @Test + fun `MfaFactor Sms has correct name`() { + assertThat(MfaFactor.Sms.name).isEqualTo("Sms") + } + + @Test + fun `MfaFactor Totp has correct name`() { + assertThat(MfaFactor.Totp.name).isEqualTo("Totp") + } +} 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/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt new file mode 100644 index 000000000..c36899a63 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt @@ -0,0 +1,316 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseNetworkException +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthUserCollisionException +import com.google.firebase.auth.FirebaseUser +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class AnonymousAuthProviderFirebaseAuthUITest { + + @Mock + private lateinit var mockFirebaseAuth: FirebaseAuth + + private lateinit var firebaseApp: FirebaseApp + private lateinit var applicationContext: Context + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + FirebaseAuthUI.clearInstanceCache() + + applicationContext = ApplicationProvider.getApplicationContext() + + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + firebaseApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + try { + firebaseApp.delete() + } catch (_: Exception) { + // Ignore if already deleted + } + } + + // ============================================================================================= + // signInAnonymously Tests + // ============================================================================================= + + @Test + fun `signInAnonymously - successful anonymous sign in`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.isAnonymous).thenReturn(true) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInAnonymously()) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + instance.signInAnonymously() + + verify(mockFirebaseAuth).signInAnonymously() + + val finalState = instance.authStateFlow().first { it is AuthState.Idle } + assertThat(finalState).isInstanceOf(AuthState.Idle::class.java) + } + + @Test + fun `signInAnonymously - handles network error`() = runTest { + val networkException = FirebaseNetworkException("Network error") + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(networkException) + `when`(mockFirebaseAuth.signInAnonymously()) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + try { + instance.signInAnonymously() + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.NetworkException) { + assertThat(e.cause).isEqualTo(networkException) + } + + val currentState = instance.authStateFlow().first { it is AuthState.Error } + assertThat(currentState).isInstanceOf(AuthState.Error::class.java) + val errorState = currentState as AuthState.Error + assertThat(errorState.exception).isInstanceOf(AuthException.NetworkException::class.java) + } + + @Test + fun `signInAnonymously - handles cancellation`() = runTest { + val cancellationException = CancellationException("Operation cancelled") + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(cancellationException) + `when`(mockFirebaseAuth.signInAnonymously()) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + try { + instance.signInAnonymously() + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.AuthCancelledException) { + assertThat(e.message).contains("cancelled") + assertThat(e.cause).isInstanceOf(CancellationException::class.java) + } + + val currentState = instance.authStateFlow().first { it is AuthState.Error } + assertThat(currentState).isInstanceOf(AuthState.Error::class.java) + val errorState = currentState as AuthState.Error + assertThat(errorState.exception).isInstanceOf(AuthException.AuthCancelledException::class.java) + } + + @Test + fun `signInAnonymously - handles generic exception`() = runTest { + val genericException = RuntimeException("Something went wrong") + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(genericException) + `when`(mockFirebaseAuth.signInAnonymously()) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + try { + instance.signInAnonymously() + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.UnknownException) { + assertThat(e.cause).isEqualTo(genericException) + } + + val currentState = instance.authStateFlow().first { it is AuthState.Error } + assertThat(currentState).isInstanceOf(AuthState.Error::class.java) + val errorState = currentState as AuthState.Error + assertThat(errorState.exception).isInstanceOf(AuthException.UnknownException::class.java) + } + + // ============================================================================================= + // Anonymous Account Upgrade Tests + // ============================================================================================= + + @Test + fun `Upgrade anonymous account with email and password when isAnonymousUpgradeEnabled`() = runTest { + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mock(AuthResult::class.java)) + `when`(mockAnonymousUser.linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java))) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(AuthProvider.Anonymous) + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + + verify(mockAnonymousUser).linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java)) + } + + @Test + fun `Upgrade anonymous account throws AccountLinkingRequiredException on collision`() = runTest { + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockAnonymousUser.email).thenReturn(null) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + + val collisionException = mock(FirebaseAuthUserCollisionException::class.java) + `when`(collisionException.errorCode).thenReturn("ERROR_EMAIL_ALREADY_IN_USE") + `when`(collisionException.email).thenReturn("test@example.com") + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(collisionException) + `when`(mockAnonymousUser.linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java))) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(AuthProvider.Anonymous) + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.AccountLinkingRequiredException) { + assertThat(e.cause).isEqualTo(collisionException) + assertThat(e.email).isEqualTo("test@example.com") + assertThat(e.credential).isNotNull() + } + + val currentState = instance.authStateFlow().first { it is AuthState.Error } + assertThat(currentState).isInstanceOf(AuthState.Error::class.java) + val errorState = currentState as AuthState.Error + assertThat(errorState.exception).isInstanceOf(AuthException.AccountLinkingRequiredException::class.java) + } + + @Test + fun `Upgrade anonymous account with credential when isAnonymousUpgradeEnabled`() = runTest { + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + + val credential = EmailAuthProvider.getCredential("test@example.com", "Pass@123") + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockAnonymousUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockAnonymousUser.linkWithCredential(credential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(AuthProvider.Anonymous) + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + val result = instance.signInAndLinkWithCredential( + config = config, + credential = credential + ) + + assertThat(result).isNotNull() + verify(mockAnonymousUser).linkWithCredential(credential) + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt new file mode 100644 index 000000000..99bd91766 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt @@ -0,0 +1,403 @@ +package com.firebase.ui.auth.compose.configuration.auth_provider + +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( + emailLinkActionCodeSettings = 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, + emailLinkActionCodeSettings = 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, + emailLinkActionCodeSettings = null, + passwordValidationRules = listOf() + ) + + try { + provider.validate() + assertThat(false).isTrue() // Should not reach here + } 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, + emailLinkActionCodeSettings = actionCodeSettings, + passwordValidationRules = listOf() + ) + + try { + provider.validate() + assertThat(false).isTrue() // Should not reach here + } 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() + assertThat(false).isTrue() // Should not reach here + } 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() + assertThat(false).isTrue() // Should not reach here + } 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() + assertThat(false).isTrue() // Should not reach here + } 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) + assertThat(false).isTrue() // Should not reach here + } 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) + assertThat(false).isTrue() // Should not reach here + } 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) + assertThat(false).isTrue() // Should not reach here + } 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) + assertThat(false).isTrue() // Should not reach here + } 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) + assertThat(false).isTrue() // Should not reach here + } 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( + emailLinkActionCodeSettings = null, + passwordValidationRules = listOf() + ) + ) + + AuthProvider.Anonymous.validate(providers) + } + + // ============================================================================================= + // GenericOAuth Provider Tests + // ============================================================================================= + + @Test + fun `generic oauth provider with valid configuration should succeed`() { + val provider = AuthProvider.GenericOAuth( + providerName = "Generic Provider", + 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( + providerName = "Generic Provider", + providerId = "", + scopes = listOf("read"), + customParameters = mapOf(), + buttonLabel = "Sign in with Custom", + buttonIcon = null, + buttonColor = null, + contentColor = null, + ) + + try { + provider.validate() + assertThat(false).isTrue() // Should not reach here + } 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( + providerName = "Generic Provider", + providerId = "custom.provider", + scopes = listOf("read"), + customParameters = mapOf(), + buttonLabel = "", + buttonIcon = null, + buttonColor = null, + contentColor = null, + ) + + try { + provider.validate() + assertThat(false).isTrue() // Should not reach here + } 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/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt new file mode 100644 index 000000000..9f5de770c --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt @@ -0,0 +1,1458 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.ActionCodeSettings +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import com.google.firebase.auth.FirebaseAuthInvalidUserException +import com.google.firebase.auth.FirebaseAuthUserCollisionException +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.GoogleAuthProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mock +import org.mockito.Mockito.anyString +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Comprehensive unit tests for Email Authentication provider methods in FirebaseAuthUI. + * + * Tests cover all email auth methods: + * - createOrLinkUserWithEmailAndPassword + * - signInWithEmailAndPassword + * - signInAndLinkWithCredential + * - sendSignInLinkToEmail + * - signInWithEmailLink + * - sendPasswordResetEmail + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class EmailAuthProviderFirebaseAuthUITest { + + @Mock + private lateinit var mockFirebaseAuth: FirebaseAuth + + @Mock + private lateinit var mockEmailAuthCredentialProvider: AuthProvider.Email.CredentialProvider + + private lateinit var firebaseApp: FirebaseApp + private lateinit var applicationContext: Context + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + FirebaseAuthUI.clearInstanceCache() + + applicationContext = ApplicationProvider.getApplicationContext() + + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + firebaseApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + try { + firebaseApp.delete() + } catch (_: Exception) { + // Ignore if already deleted + } + } + + // ============================================================================================= + // createOrLinkUserWithEmailAndPassword Tests + // ============================================================================================= + + @Test + fun `Create user with email and password without anonymous upgrade should succeed`() = runTest { + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`(mockFirebaseAuth.createUserWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + + verify(mockFirebaseAuth) + .createUserWithEmailAndPassword("test@example.com", "Pass@123") + } + + @Test + fun `Link user with email and password with anonymous upgrade should succeed`() = runTest { + val mockCredential = mock(AuthCredential::class.java) + `when`(mockEmailAuthCredentialProvider.getCredential("test@example.com", "Pass@123")) + .thenReturn(mockCredential) + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`( + mockFirebaseAuth.currentUser?.linkWithCredential( + ArgumentMatchers.any(AuthCredential::class.java) + ) + ).thenReturn(taskCompletionSource.task) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123", + credentialProvider = mockEmailAuthCredentialProvider + ) + + verify(mockEmailAuthCredentialProvider).getCredential("test@example.com", "Pass@123") + verify(mockAnonymousUser).linkWithCredential(mockCredential) + } + + @Test + fun `createOrLinkUserWithEmailAndPassword - rejects weak password`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "weak" + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: Exception) { + assertThat(e.message).contains( + applicationContext + .getString(R.string.fui_error_password_too_short) + .format(emailProvider.minimumPasswordLength) + ) + } + + verify(mockFirebaseAuth, never()) + .createUserWithEmailAndPassword(anyString(), anyString()) + } + + @Test + fun `createOrLinkUserWithEmailAndPassword - validates custom password rules`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = listOf(PasswordRule.RequireUppercase) + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "pass@123" + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: Exception) { + assertThat(e.message).isEqualTo(applicationContext.getString(R.string.fui_error_password_missing_uppercase)) + } + } + + @Test + fun `createOrLinkUserWithEmailAndPassword - respects isNewAccountsAllowed setting`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList(), + isNewAccountsAllowed = false + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: Exception) { + assertThat(e.message) + .isEqualTo(applicationContext.getString(R.string.fui_error_email_does_not_exist)) + } + } + + @Test + fun `createOrLinkUserWithEmailAndPassword - handles collision exception`() = runTest { + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockAnonymousUser.email).thenReturn("test@example.com") + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + + val collisionException = mock(FirebaseAuthUserCollisionException::class.java) + `when`(collisionException.errorCode).thenReturn("ERROR_EMAIL_ALREADY_IN_USE") + `when`(collisionException.email).thenReturn("test@example.com") + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(collisionException) + `when`(mockAnonymousUser.linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java))) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.AccountLinkingRequiredException) { + assertThat(e.cause).isEqualTo(collisionException) + assertThat(e.email).isNotNull() + assertThat(e.credential).isNotNull() + + val currentState = instance.authStateFlow().first { it is AuthState.Error } + assertThat(currentState).isInstanceOf(AuthState.Error::class.java) + val errorState = currentState as AuthState.Error + assertThat(errorState.exception).isInstanceOf(AuthException.AccountLinkingRequiredException::class.java) + } + } + + // ============================================================================================= + // signInWithEmailAndPassword Tests + // ============================================================================================= + + @Test + fun `signInWithEmailAndPassword - successful sign in`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + val result = instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123" + ) + + assertThat(result).isNotNull() + assertThat(result?.user).isEqualTo(mockUser) + verify(mockFirebaseAuth).signInWithEmailAndPassword("test@example.com", "Pass@123") + } + + @Test + fun `signInWithEmailAndPassword - handles invalid credentials`() = runTest { + val invalidCredentialsException = FirebaseAuthInvalidCredentialsException( + "ERROR_WRONG_PASSWORD", + "Wrong password" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(invalidCredentialsException) + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + try { + instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123" + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.InvalidCredentialsException) { + assertThat(e.cause).isEqualTo(invalidCredentialsException) + } + } + + @Test + fun `signInWithEmailAndPassword - handles user not found`() = runTest { + val userNotFoundException = FirebaseAuthInvalidUserException( + "ERROR_USER_NOT_FOUND", + "User not found" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(userNotFoundException) + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + try { + instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123" + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.UserNotFoundException) { + assertThat(e.cause).isEqualTo(userNotFoundException) + } + } + + @Test + fun `signInWithEmailAndPassword - links credential after sign in`() = runTest { + val googleCredential = GoogleAuthProvider.getCredential("google-id-token", null) + val mockUser = mock(FirebaseUser::class.java) + val signInAuthResult = mock(AuthResult::class.java) + `when`(signInAuthResult.user).thenReturn(mockUser) + val signInTask = TaskCompletionSource() + signInTask.setResult(signInAuthResult) + + val linkAuthResult = mock(AuthResult::class.java) + `when`(linkAuthResult.user).thenReturn(mockUser) + val linkTask = TaskCompletionSource() + linkTask.setResult(linkAuthResult) + + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(signInTask.task) + `when`(mockUser.linkWithCredential(googleCredential)) + .thenReturn(linkTask.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123", + credentialForLinking = googleCredential + ) + + verify(mockUser).linkWithCredential(googleCredential) + } + + // ============================================================================================= + // signInAndLinkWithCredential Tests + // ============================================================================================= + + @Test + fun `signInAndLinkWithCredential - successful sign in with credential`() = runTest { + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithCredential(credential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + } + + val result = instance.signInAndLinkWithCredential( + config = config, + credential = credential + ) + + assertThat(result).isNotNull() + assertThat(result?.user).isEqualTo(mockUser) + verify(mockFirebaseAuth).signInWithCredential(credential) + } + + @Test + fun `signInAndLinkWithCredential - handles anonymous upgrade`() = runTest { + val anonymousUser = mock(FirebaseUser::class.java) + `when`(anonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(anonymousUser) + + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(anonymousUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(anonymousUser.linkWithCredential(credential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + val result = instance.signInAndLinkWithCredential( + config = config, + credential = credential + ) + + assertThat(result).isNotNull() + verify(anonymousUser).linkWithCredential(credential) + verify(mockFirebaseAuth, never()).signInWithCredential(credential) + } + + @Test + fun `signInAndLinkWithCredential - handles collision and throws AccountLinkingRequiredException`() = runTest { + val anonymousUser = mock(FirebaseUser::class.java) + `when`(anonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(anonymousUser) + + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + val updatedCredential = mock(AuthCredential::class.java) + + val collisionException = mock(FirebaseAuthUserCollisionException::class.java) + `when`(collisionException.errorCode).thenReturn("ERROR_CREDENTIAL_ALREADY_IN_USE") + `when`(collisionException.updatedCredential).thenReturn(updatedCredential) + `when`(collisionException.email).thenReturn("test@example.com") + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(collisionException) + `when`(anonymousUser.linkWithCredential(credential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + isAnonymousUpgradeEnabled = true + } + + try { + instance.signInAndLinkWithCredential( + config = config, + credential = credential + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.AccountLinkingRequiredException) { + assertThat(e.email).isEqualTo("test@example.com") + assertThat(e.credential).isEqualTo(updatedCredential) + assertThat(e.cause).isEqualTo(collisionException) + } + + val currentState = instance.authStateFlow().first { it is AuthState.Error } + assertThat(currentState).isInstanceOf(AuthState.Error::class.java) + val errorState = currentState as AuthState.Error + assertThat(errorState.exception).isInstanceOf(AuthException.AccountLinkingRequiredException::class.java) + } + + // ============================================================================================= + // sendPasswordResetEmail Tests + // ============================================================================================= + + @Test + fun `sendPasswordResetEmail - successfully sends reset email`() = runTest { + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`(mockFirebaseAuth.sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + )).thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + instance.sendPasswordResetEmail("test@example.com") + + verify(mockFirebaseAuth).sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + ) + + val finalState = instance.authStateFlow().first { it is AuthState.PasswordResetLinkSent } + assertThat(finalState).isInstanceOf(AuthState.PasswordResetLinkSent::class.java) + } + + @Test + fun `sendPasswordResetEmail - sends with ActionCodeSettings`() = runTest { + val actionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://myapp.com/resetPassword") + .setHandleCodeInApp(false) + .build() + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`(mockFirebaseAuth.sendPasswordResetEmail("test@example.com", actionCodeSettings)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + instance.sendPasswordResetEmail("test@example.com", actionCodeSettings) + + verify(mockFirebaseAuth).sendPasswordResetEmail("test@example.com", actionCodeSettings) + + val finalState = instance.authStateFlow().first { it is AuthState.PasswordResetLinkSent } + assertThat(finalState).isInstanceOf(AuthState.PasswordResetLinkSent::class.java) + } + + @Test + fun `sendPasswordResetEmail - handles user not found`() = runTest { + val userNotFoundException = FirebaseAuthInvalidUserException( + "ERROR_USER_NOT_FOUND", + "User not found" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(userNotFoundException) + `when`(mockFirebaseAuth.sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + )).thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + try { + instance.sendPasswordResetEmail("test@example.com") + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.UserNotFoundException) { + assertThat(e.cause).isEqualTo(userNotFoundException) + } + } + + @Test + fun `sendPasswordResetEmail - handles invalid email`() = runTest { + val invalidEmailException = FirebaseAuthInvalidCredentialsException( + "ERROR_INVALID_EMAIL", + "Invalid email" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(invalidEmailException) + `when`(mockFirebaseAuth.sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + )).thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + try { + instance.sendPasswordResetEmail("test@example.com") + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.InvalidCredentialsException) { + assertThat(e.cause).isEqualTo(invalidEmailException) + } + } + + @Test + fun `sendPasswordResetEmail - handles cancellation`() = runTest { + val cancellationException = CancellationException("Operation cancelled") + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(cancellationException) + `when`(mockFirebaseAuth.sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + )).thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + try { + instance.sendPasswordResetEmail("test@example.com") + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.AuthCancelledException) { + assertThat(e.message).contains("cancelled") + assertThat(e.cause).isInstanceOf(CancellationException::class.java) + } + } + + // ============================================================================================= + // sendSignInLinkToEmail Tests + // ============================================================================================= + + @Test + fun `sendSignInLinkToEmail - normal flow - successfully sends email link`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.uid).thenReturn("test-uid") + `when`(mockUser.isAnonymous).thenReturn(false) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser) + + val actionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .setAndroidPackageName("com.test", true, null) + .build() + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = actionCodeSettings, + passwordValidationRules = emptyList() + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`(mockFirebaseAuth.sendSignInLinkToEmail(anyString(), any())) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + instance.sendSignInLinkToEmail( + context = applicationContext, + config = config, + provider = provider, + email = "test@example.com", + credentialForLinking = null + ) + + verify(mockFirebaseAuth).sendSignInLinkToEmail( + ArgumentMatchers.eq("test@example.com"), + any() + ) + + val finalState = instance.authStateFlow().first { it is AuthState.EmailSignInLinkSent } + assertThat(finalState).isInstanceOf(AuthState.EmailSignInLinkSent::class.java) + } + + @Test + fun `sendSignInLinkToEmail - with anonymous user - includes anonymous user ID in link`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.uid).thenReturn("anonymous-uid-123") + `when`(mockUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser) + + val actionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .setAndroidPackageName("com.test", true, null) + .build() + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = actionCodeSettings, + isEmailLinkForceSameDeviceEnabled = true, + passwordValidationRules = emptyList() + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + isAnonymousUpgradeEnabled = true + } + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`(mockFirebaseAuth.sendSignInLinkToEmail(anyString(), any())) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + instance.sendSignInLinkToEmail( + context = applicationContext, + config = config, + provider = provider, + email = "test@example.com", + credentialForLinking = null + ) + + verify(mockFirebaseAuth).sendSignInLinkToEmail( + ArgumentMatchers.eq("test@example.com"), + any() + ) + + val finalState = instance.authStateFlow().first { it is AuthState.EmailSignInLinkSent } + assertThat(finalState).isInstanceOf(AuthState.EmailSignInLinkSent::class.java) + } + + @Test + fun `sendSignInLinkToEmail - with credential for linking - includes provider ID in link`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.uid).thenReturn("test-uid") + `when`(mockUser.isAnonymous).thenReturn(false) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser) + + val actionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .setAndroidPackageName("com.test", true, null) + .build() + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = actionCodeSettings, + passwordValidationRules = emptyList() + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + + val googleCredential = GoogleAuthProvider.getCredential("id-token", null) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`(mockFirebaseAuth.sendSignInLinkToEmail(anyString(), any())) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + instance.sendSignInLinkToEmail( + context = applicationContext, + config = config, + provider = provider, + email = "test@example.com", + credentialForLinking = googleCredential + ) + + verify(mockFirebaseAuth).sendSignInLinkToEmail( + ArgumentMatchers.eq("test@example.com"), + any() + ) + + val finalState = instance.authStateFlow().first { it is AuthState.EmailSignInLinkSent } + assertThat(finalState).isInstanceOf(AuthState.EmailSignInLinkSent::class.java) + } + + @Test + fun `sendSignInLinkToEmail - handles network error`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.uid).thenReturn("test-uid") + `when`(mockUser.isAnonymous).thenReturn(false) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser) + + val actionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .setAndroidPackageName("com.test", true, null) + .build() + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = actionCodeSettings, + passwordValidationRules = emptyList() + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + + val networkException = Exception("Network error") + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(networkException) + `when`(mockFirebaseAuth.sendSignInLinkToEmail(anyString(), any())) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + try { + instance.sendSignInLinkToEmail( + context = applicationContext, + config = config, + provider = provider, + email = "test@example.com", + credentialForLinking = null + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException) { + assertThat(e).isNotNull() + } + } + + // ============================================================================================= + // signInWithEmailLink Tests - Same Device Flow + // ============================================================================================= + + @Test + fun `signInWithEmailLink - invalid link format - throws InvalidEmailLinkException`() = runTest { + `when`(mockFirebaseAuth.isSignInWithEmailLink(anyString())).thenReturn(false) + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .build(), + passwordValidationRules = emptyList() + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + try { + instance.signInWithEmailLink( + context = applicationContext, + config = config, + provider = provider, + email = "test@example.com", + emailLink = "https://invalid-link.com" + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.InvalidEmailLinkException) { + assertThat(e).isNotNull() + } + } + + @Test + fun `signInWithEmailLink - same device normal flow - successfully signs in`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.email).thenReturn("test@example.com") + `when`(mockUser.displayName).thenReturn("Test User") + `when`(mockUser.photoUrl).thenReturn(null) + `when`(mockUser.isAnonymous).thenReturn(false) + + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`(mockFirebaseAuth.isSignInWithEmailLink(anyString())).thenReturn(true) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithCredential(any())).thenReturn(taskCompletionSource.task) + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .build(), + passwordValidationRules = emptyList() + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + // Create mock persistence manager with matching session + val mockPersistence = com.firebase.ui.auth.compose.util.MockPersistenceManager() + mockPersistence.setSessionRecord( + com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager.SessionRecord( + sessionId = "session123", + email = "test@example.com", + anonymousUserId = null, + credentialForLinking = null + ) + ) + + val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code&continueUrl=https://example.com?ui_sid=session123" + + val result = instance.signInWithEmailLink( + context = applicationContext, + config = config, + provider = provider, + email = "test@example.com", + emailLink = emailLink, + persistenceManager = mockPersistence + ) + + assertThat(result).isNotNull() + assertThat(result?.user).isEqualTo(mockUser) + } + + @Test + fun `signInWithEmailLink - anonymous upgrade flow - successfully links credential`() = runTest { + val mockAnonUser = mock(FirebaseUser::class.java) + `when`(mockAnonUser.uid).thenReturn("anon-uid-123") + `when`(mockAnonUser.email).thenReturn(null) + `when`(mockAnonUser.isAnonymous).thenReturn(true) + + val mockLinkedUser = mock(FirebaseUser::class.java) + `when`(mockLinkedUser.uid).thenReturn("anon-uid-123") + `when`(mockLinkedUser.email).thenReturn("test@example.com") + `when`(mockLinkedUser.displayName).thenReturn("Test User") + `when`(mockLinkedUser.isAnonymous).thenReturn(false) + + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockLinkedUser) + + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonUser) + `when`(mockFirebaseAuth.isSignInWithEmailLink(anyString())).thenReturn(true) + + val linkTaskSource = TaskCompletionSource() + linkTaskSource.setResult(mockAuthResult) + `when`(mockAnonUser.linkWithCredential(any())).thenReturn(linkTaskSource.task) + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .build(), + passwordValidationRules = emptyList() + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + isAnonymousUpgradeEnabled = true + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + // Create mock persistence manager with matching session and anonymous user + val mockPersistence = com.firebase.ui.auth.compose.util.MockPersistenceManager() + mockPersistence.setSessionRecord( + com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager.SessionRecord( + sessionId = "session123", + email = "test@example.com", + anonymousUserId = "anon-uid-123", + credentialForLinking = null + ) + ) + + val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code&continueUrl=https://example.com?ui_sid=session123&ui_auid=anon-uid-123" + + val result = instance.signInWithEmailLink( + context = applicationContext, + config = config, + provider = provider, + email = "test@example.com", + emailLink = emailLink, + persistenceManager = mockPersistence + ) + + assertThat(result).isNotNull() + verify(mockAnonUser).linkWithCredential(any()) + } + + // ============================================================================================= + // signInWithEmailLink Tests - Cross-Device Flow + // ============================================================================================= + + @Test + fun `signInWithEmailLink - different device with no session - throws EmailLinkPromptForEmailException`() = runTest { + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`(mockFirebaseAuth.isSignInWithEmailLink(anyString())).thenReturn(true) + + val actionCodeTask = TaskCompletionSource() + actionCodeTask.setResult(mock(com.google.firebase.auth.ActionCodeResult::class.java)) + `when`(mockFirebaseAuth.checkActionCode(anyString())).thenReturn(actionCodeTask.task) + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .build(), + passwordValidationRules = emptyList() + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + // Create mock persistence with no session (cross-device) + val mockPersistence = com.firebase.ui.auth.compose.util.MockPersistenceManager() + + // Email link with different session ID (cross-device) + val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code123&continueUrl=https://example.com?ui_sid=different-session" + + try { + instance.signInWithEmailLink( + context = applicationContext, + config = config, + provider = provider, + email = "", // Empty email triggers prompt + emailLink = emailLink, + persistenceManager = mockPersistence + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.EmailLinkPromptForEmailException) { + assertThat(e).isNotNull() + } + + verify(mockFirebaseAuth).checkActionCode("code123") + } + + @Test + fun `signInWithEmailLink - different device with provider linking - throws EmailLinkCrossDeviceLinkingException`() = runTest { + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`(mockFirebaseAuth.isSignInWithEmailLink(anyString())).thenReturn(true) + + val actionCodeTask = TaskCompletionSource() + actionCodeTask.setResult(mock(com.google.firebase.auth.ActionCodeResult::class.java)) + `when`(mockFirebaseAuth.checkActionCode(anyString())).thenReturn(actionCodeTask.task) + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .build(), + passwordValidationRules = emptyList() + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + // Create mock persistence with no session (cross-device) + val mockPersistence = com.firebase.ui.auth.compose.util.MockPersistenceManager() + + // Email link with provider ID (cross-device linking) + val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code123&continueUrl=https://example.com?ui_sid=different-session&ui_pid=google.com" + + try { + instance.signInWithEmailLink( + context = applicationContext, + config = config, + provider = provider, + email = "", // Empty email triggers prompt (which detects provider linking) + emailLink = emailLink, + persistenceManager = mockPersistence + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.EmailLinkCrossDeviceLinkingException) { + assertThat(e).isNotNull() + assertThat(e.providerName).isEqualTo("Google") + } + + verify(mockFirebaseAuth).checkActionCode("code123") + } + + @Test + fun `signInWithEmailLink - force same device on different device - throws EmailLinkWrongDeviceException`() = runTest { + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`(mockFirebaseAuth.isSignInWithEmailLink(anyString())).thenReturn(true) + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + isEmailLinkForceSameDeviceEnabled = true, + emailLinkActionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .build(), + passwordValidationRules = emptyList() + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + // Email link with force same device bit + val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code&continueUrl=https://example.com?ui_sid=different-session&ui_sd=1" + + try { + instance.signInWithEmailLink( + context = applicationContext, + config = config, + provider = provider, + email = "test@example.com", + emailLink = emailLink + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.EmailLinkWrongDeviceException) { + assertThat(e).isNotNull() + } + } + + @Test + fun `signInWithEmailLink - different anonymous user - throws EmailLinkDifferentAnonymousUserException`() = runTest { + val mockAnonUser = mock(FirebaseUser::class.java) + `when`(mockAnonUser.uid).thenReturn("current-anon-uid") + `when`(mockAnonUser.isAnonymous).thenReturn(true) + + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonUser) + `when`(mockFirebaseAuth.isSignInWithEmailLink(anyString())).thenReturn(true) + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .build(), + passwordValidationRules = emptyList() + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + isAnonymousUpgradeEnabled = true + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + // Create mock persistence with session for different anonymous user + val mockPersistence = com.firebase.ui.auth.compose.util.MockPersistenceManager() + mockPersistence.setSessionRecord( + com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager.SessionRecord( + sessionId = "session123", + email = "test@example.com", + anonymousUserId = "different-anon-uid", + credentialForLinking = null + ) + ) + + val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code&continueUrl=https://example.com?ui_sid=session123&ui_auid=different-anon-uid" + + try { + instance.signInWithEmailLink( + context = applicationContext, + config = config, + provider = provider, + email = "test@example.com", + emailLink = emailLink, + persistenceManager = mockPersistence + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.EmailLinkDifferentAnonymousUserException) { + assertThat(e).isNotNull() + } + } + + @Test + fun `signInWithEmailLink - empty email on same device - throws EmailMismatchException`() = runTest { + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`(mockFirebaseAuth.isSignInWithEmailLink(anyString())).thenReturn(true) + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .build(), + passwordValidationRules = emptyList() + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + // Create mock persistence with session but check email parameter is empty + val mockPersistence = com.firebase.ui.auth.compose.util.MockPersistenceManager() + mockPersistence.setSessionRecord( + com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager.SessionRecord( + sessionId = "session123", + email = "stored@example.com", + anonymousUserId = null, + credentialForLinking = null + ) + ) + + val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code&continueUrl=https://example.com?ui_sid=session123" + + try { + instance.signInWithEmailLink( + context = applicationContext, + config = config, + provider = provider, + email = "", // Empty email + emailLink = emailLink, + persistenceManager = mockPersistence + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.EmailMismatchException) { + assertThat(e).isNotNull() + } + } + + @Test + fun `signInWithEmailLink - invalid action code on different device - throws InvalidEmailLinkException`() = runTest { + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`(mockFirebaseAuth.isSignInWithEmailLink(anyString())).thenReturn(true) + + val actionCodeTask = TaskCompletionSource() + actionCodeTask.setException(Exception("Invalid action code")) + `when`(mockFirebaseAuth.checkActionCode(anyString())).thenReturn(actionCodeTask.task) + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .build(), + passwordValidationRules = emptyList() + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + // Create mock persistence with different session (cross-device) + val mockPersistence = com.firebase.ui.auth.compose.util.MockPersistenceManager() + mockPersistence.setSessionRecord( + com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager.SessionRecord( + sessionId = "local-session", + email = "test@example.com", + anonymousUserId = null, + credentialForLinking = null + ) + ) + + val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=invalid-code&continueUrl=https://example.com?ui_sid=different-session" + + try { + instance.signInWithEmailLink( + context = applicationContext, + config = config, + provider = provider, + email = "", // Empty email triggers validation which will fail + emailLink = emailLink, + persistenceManager = mockPersistence + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.InvalidEmailLinkException) { + assertThat(e).isNotNull() + } + + verify(mockFirebaseAuth).checkActionCode("invalid-code") + } + + @Test + fun `signInWithEmailLink - no session ID in link - throws InvalidEmailLinkException`() = runTest { + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`(mockFirebaseAuth.isSignInWithEmailLink(anyString())).thenReturn(true) + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .build(), + passwordValidationRules = emptyList() + ) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + // Create mock persistence (can be null since we expect validation error) + val mockPersistence = com.firebase.ui.auth.compose.util.MockPersistenceManager() + + val emailLink = "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code&continueUrl=https://example.com" + + try { + instance.signInWithEmailLink( + context = applicationContext, + config = config, + provider = provider, + email = "test@example.com", + emailLink = emailLink, + persistenceManager = mockPersistence + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.InvalidEmailLinkException) { + assertThat(e).isNotNull() + } + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt new file mode 100644 index 000000000..c1df4ef3a --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt @@ -0,0 +1,277 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import com.facebook.AccessToken +import com.facebook.FacebookException +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider.Facebook.FacebookProfileData +import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.FirebaseAuth.AuthStateListener +import com.google.firebase.auth.FirebaseAuthUserCollisionException +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for Facebook Authentication provider methods in FirebaseAuthUI. + **/ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class FacebookAuthProviderFirebaseAuthUITest { + + @Mock + private lateinit var mockFirebaseAuth: FirebaseAuth + + @Mock + private lateinit var mockFBAuthCredentialProvider: AuthProvider.Facebook.CredentialProvider + + private lateinit var firebaseApp: FirebaseApp + private lateinit var applicationContext: Context + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + FirebaseAuthUI.clearInstanceCache() + + applicationContext = ApplicationProvider.getApplicationContext() + + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + firebaseApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + try { + firebaseApp.delete() + } catch (_: Exception) { + // Ignore if already deleted + } + } + + @Test + fun `signInWithFacebook - successful sign in signs user in and emits Success authState`() = runTest { + val authStateListeners = mutableListOf() + doAnswer { invocation -> + val listener = invocation.getArgument(0) + authStateListeners += listener + null + }.whenever(mockFirebaseAuth).addAuthStateListener(any()) + doAnswer { invocation -> + val listener = invocation.getArgument(0) + authStateListeners -= listener + null + }.whenever(mockFirebaseAuth).removeAuthStateListener(any()) + whenever(mockFirebaseAuth.currentUser).thenReturn(null) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val provider = spy(AuthProvider.Facebook( + applicationId = "000000000000" + )) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + + val mockAccessToken = mock { + on { token } doReturn "random-token" + } + val mockCredential = mock() + val mockUser = mock() + val mockAuthResult = mock() + whenever(mockAuthResult.user).thenReturn(mockUser) + whenever(mockUser.isEmailVerified).thenReturn(true) + whenever(mockUser.providerData).thenReturn(emptyList()) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + whenever(mockFirebaseAuth.signInWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + doReturn( + FacebookProfileData( + displayName = "Test User", + email = "test@example.com", + photoUrl = Uri.parse("https://someurl.com/photo.png") + ) + ).whenever(provider).fetchFacebookProfile(any()) + whenever(mockFBAuthCredentialProvider.getCredential("random-token")) + .thenReturn(mockCredential) + + val successStateDeferred = async { + instance.authStateFlow().first { it is AuthState.Success } + } + + instance.signInWithFacebook( + context = applicationContext, + config = config, + provider = provider, + accessToken = mockAccessToken, + credentialProvider = mockFBAuthCredentialProvider + ) + + whenever(mockFirebaseAuth.currentUser).thenReturn(mockUser) + authStateListeners.forEach { listener -> + listener.onAuthStateChanged(mockFirebaseAuth) + } + + val successState = successStateDeferred.await() as AuthState.Success + assertThat(successState.user).isEqualTo(mockUser) + verify(mockFBAuthCredentialProvider).getCredential("random-token") + verify(mockFirebaseAuth).signInWithCredential(mockCredential) + } + + @Test + fun `signInWithFacebook - handles account collision by saving credential and emitting error`() = runTest { + EmailLinkPersistenceManager.default.clear(applicationContext) + EmailLinkPersistenceManager.default.saveEmail( + context = applicationContext, + email = "link@example.com", + sessionId = "session-id", + anonymousUserId = null + ) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val provider = spy(AuthProvider.Facebook( + applicationId = "000000000000" + )) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + + val mockAccessToken = mock { + on { token } doReturn "collision-token" + } + val mockCredential = mock() + val collisionException = mock { + on { errorCode } doReturn "ERROR_ACCOUNT_EXISTS_WITH_DIFFERENT_CREDENTIAL" + on { email } doReturn "existing@example.com" + } + val failingTask = TaskCompletionSource() + failingTask.setException(collisionException) + whenever(mockFirebaseAuth.signInWithCredential(mockCredential)) + .thenReturn(failingTask.task) + doReturn(null).whenever(provider).fetchFacebookProfile(any()) + whenever(mockFBAuthCredentialProvider.getCredential("collision-token")) + .thenReturn(mockCredential) + + try { + instance.signInWithFacebook( + context = applicationContext, + config = config, + provider = provider, + accessToken = mockAccessToken, + credentialProvider = mockFBAuthCredentialProvider + ) + assertThat(false).isTrue() + } catch (e: AuthException.AccountLinkingRequiredException) { + assertThat(e.cause).isEqualTo(collisionException) + val currentState = instance.authStateFlow().first { it is AuthState.Error } + assertThat(currentState).isInstanceOf(AuthState.Error::class.java) + val errorState = currentState as AuthState.Error + assertThat(errorState.exception).isInstanceOf(AuthException.AccountLinkingRequiredException::class.java) + + val sessionRecord = EmailLinkPersistenceManager.default.retrieveSessionRecord(applicationContext) + assertThat(sessionRecord).isNotNull() + assertThat(sessionRecord?.credentialForLinking).isNotNull() + assertThat(sessionRecord?.credentialForLinking?.provider) + .isEqualTo(provider.providerId) + } finally { + EmailLinkPersistenceManager.default.clear(applicationContext) + } + } + + @Test + fun `signInWithFacebook - converts FacebookException into AuthException`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val provider = spy(AuthProvider.Facebook( + applicationId = "000000000000" + )) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(provider) + } + } + + val mockAccessToken = mock { + on { token } doReturn "error-token" + } + doAnswer { + throw FacebookException("Graph error") + }.whenever(provider).fetchFacebookProfile(any()) + + try { + instance.signInWithFacebook( + context = applicationContext, + config = config, + provider = provider, + accessToken = mockAccessToken, + credentialProvider = mockFBAuthCredentialProvider + ) + assertThat(false).isTrue() + } catch (e: AuthException) { + val currentState = instance.authStateFlow().first { it is AuthState.Error } + assertThat(currentState).isInstanceOf(AuthState.Error::class.java) + val errorState = currentState as AuthState.Error + assertThat(errorState.exception).isEqualTo(e) + assertThat(e).isInstanceOf(AuthException.UnknownException::class.java) + } + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt new file mode 100644 index 000000000..ce24ac55a --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt @@ -0,0 +1,666 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.content.Context +import androidx.core.net.toUri +import androidx.credentials.CredentialManager +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.google.android.gms.common.api.Scope +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import com.google.firebase.auth.FirebaseUser +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Comprehensive unit tests for Google Sign-In provider methods in FirebaseAuthUI. + * + * Tests cover: + * - signInWithGoogle with and without OAuth scopes + * - Authorization flow testing + * - Credential Manager flow testing + * - Error handling for various scenarios + * - Anonymous account upgrade + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class GoogleAuthProviderFirebaseAuthUITest { + + @Mock + private lateinit var mockFirebaseAuth: FirebaseAuth + + @Mock + private lateinit var mockAuthorizationProvider: AuthProvider.Google.AuthorizationProvider + + @Mock + private lateinit var mockCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider + + private lateinit var firebaseApp: FirebaseApp + private lateinit var applicationContext: Context + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + FirebaseAuthUI.clearInstanceCache() + + applicationContext = ApplicationProvider.getApplicationContext() + + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + firebaseApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + try { + firebaseApp.delete() + } catch (_: Exception) { + // Ignore if already deleted + } + } + + // ============================================================================================= + // signInWithGoogle - Success Cases + // ============================================================================================= + + @Test + fun `Sign in with Google without scopes should succeed`() = runTest { + val mockCredential = mock(AuthCredential::class.java) + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + + val googleSignInResult = AuthProvider.Google.GoogleSignInResult( + credential = mockCredential, + idToken = "test-id-token", + displayName = "Test User", + photoUrl = "https://example.com/photo.jpg".toUri() + ) + + `when`( + mockCredentialManagerProvider.getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + ).thenReturn(googleSignInResult) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(googleProvider) + } + } + + instance.signInWithGoogle( + context = applicationContext, + config = config, + provider = googleProvider, + authorizationProvider = mockAuthorizationProvider, + credentialManagerProvider = mockCredentialManagerProvider + ) + + // Verify authorization was NOT called (no scopes) + verify(mockAuthorizationProvider, never()).authorize(any(), any()) + + // Verify credential manager was called + verify(mockCredentialManagerProvider).getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + + // Verify Firebase sign-in was called + verify(mockFirebaseAuth).signInWithCredential(mockCredential) + + // Verify state is Idle after success + val finalState = instance.authStateFlow().first() + assertThat(finalState).isEqualTo(AuthState.Idle) + } + + @Test + fun `Sign in with Google with scopes should request authorization first`() = runTest { + val mockCredential = mock(AuthCredential::class.java) + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + + val googleSignInResult = AuthProvider.Google.GoogleSignInResult( + credential = mockCredential, + idToken = "test-id-token", + displayName = "Test User", + photoUrl = "https://example.com/photo.jpg".toUri() + ) + + `when`( + mockCredentialManagerProvider.getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + ).thenReturn(googleSignInResult) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = listOf("https://www.googleapis.com/auth/drive.readonly") + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(googleProvider) + } + } + + instance.signInWithGoogle( + context = applicationContext, + config = config, + provider = googleProvider, + authorizationProvider = mockAuthorizationProvider, + credentialManagerProvider = mockCredentialManagerProvider + ) + + // Verify authorization was called with correct scopes + val scopesCaptor = argumentCaptor>() + verify(mockAuthorizationProvider).authorize( + eq(applicationContext), + scopesCaptor.capture() + ) + assertThat(scopesCaptor.firstValue).hasSize(1) + assertThat(scopesCaptor.firstValue[0].scopeUri).isEqualTo("https://www.googleapis.com/auth/drive.readonly") + + // Verify credential manager was called after authorization + verify(mockCredentialManagerProvider).getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + + // Verify Firebase sign-in was called + verify(mockFirebaseAuth).signInWithCredential(mockCredential) + } + + @Test + fun `Sign in with Google should pass displayName and photoUrl to linking`() = runTest { + val mockCredential = mock(AuthCredential::class.java) + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + + val expectedDisplayName = "John Doe" + val expectedPhotoUrl = "https://example.com/john.jpg".toUri() + + val googleSignInResult = AuthProvider.Google.GoogleSignInResult( + credential = mockCredential, + idToken = "test-id-token", + displayName = expectedDisplayName, + photoUrl = expectedPhotoUrl + ) + + `when`( + mockCredentialManagerProvider.getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + ).thenReturn(googleSignInResult) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(googleProvider) + } + } + + instance.signInWithGoogle( + context = applicationContext, + config = config, + provider = googleProvider, + authorizationProvider = mockAuthorizationProvider, + credentialManagerProvider = mockCredentialManagerProvider + ) + + // Note: Testing the actual values passed would require mocking signInAndLinkWithCredential + // which is an internal function. This test verifies the flow completes successfully + // with the displayName and photoUrl available in GoogleSignInResult. + verify(mockFirebaseAuth).signInWithCredential(mockCredential) + } + + // ============================================================================================= + // signInWithGoogle - Error Handling + // ============================================================================================= + + @Test + fun `Sign in with Google when authorization fails should update state to error but continue`() = runTest { + val authorizationException = RuntimeException("Authorization failed") + `when`( + mockAuthorizationProvider.authorize( + eq(applicationContext), + any() + ) + ).thenThrow(authorizationException) + + val mockCredential = mock(AuthCredential::class.java) + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + + val googleSignInResult = AuthProvider.Google.GoogleSignInResult( + credential = mockCredential, + idToken = "test-id-token", + displayName = "Test User", + photoUrl = null + ) + + `when`( + mockCredentialManagerProvider.getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + ).thenReturn(googleSignInResult) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = listOf("https://www.googleapis.com/auth/drive") + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(googleProvider) + } + } + + instance.signInWithGoogle( + context = applicationContext, + config = config, + provider = googleProvider, + authorizationProvider = mockAuthorizationProvider, + credentialManagerProvider = mockCredentialManagerProvider + ) + + // Verify authorization was attempted + verify(mockAuthorizationProvider).authorize(eq(applicationContext), any()) + + // Verify sign-in continued despite authorization failure + verify(mockCredentialManagerProvider).getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + verify(mockFirebaseAuth).signInWithCredential(mockCredential) + } + + @Test + fun `Sign in with Google when credential manager fails should throw AuthException`() = runTest { + val credentialException = RuntimeException("No credentials available") + `when`( + mockCredentialManagerProvider.getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + ).thenThrow(credentialException) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(googleProvider) + } + } + + try { + instance.signInWithGoogle( + context = applicationContext, + config = config, + provider = googleProvider, + authorizationProvider = mockAuthorizationProvider, + credentialManagerProvider = mockCredentialManagerProvider + ) + throw AssertionError("Expected exception to be thrown") + } catch (e: AuthException) { + assertThat(e).isInstanceOf(AuthException.UnknownException::class.java) + } + + // Verify state is Error + val finalState = instance.authStateFlow().first() + assertThat(finalState).isInstanceOf(AuthState.Error::class.java) + val errorState = finalState as AuthState.Error + assertThat(errorState.exception).isInstanceOf(AuthException.UnknownException::class.java) + } + + @Test + fun `Sign in with Google when Firebase sign-in fails should throw AuthException`() = runTest { + val mockCredential = mock(AuthCredential::class.java) + val googleSignInResult = AuthProvider.Google.GoogleSignInResult( + credential = mockCredential, + idToken = "test-id-token", + displayName = "Test User", + photoUrl = null + ) + + `when`( + mockCredentialManagerProvider.getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + ).thenReturn(googleSignInResult) + + val taskCompletionSource = TaskCompletionSource() + val firebaseException = FirebaseAuthInvalidCredentialsException("invalid_credential", "Invalid credential") + taskCompletionSource.setException(firebaseException) + `when`(mockFirebaseAuth.signInWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(googleProvider) + } + } + + try { + instance.signInWithGoogle( + context = applicationContext, + config = config, + provider = googleProvider, + authorizationProvider = mockAuthorizationProvider, + credentialManagerProvider = mockCredentialManagerProvider + ) + throw AssertionError("Expected exception to be thrown") + } catch (e: AuthException) { + assertThat(e).isInstanceOf(AuthException.InvalidCredentialsException::class.java) + } + + // Verify state is Error + val finalState = instance.authStateFlow().first() + assertThat(finalState).isInstanceOf(AuthState.Error::class.java) + } + + @Test + fun `Sign in with Google when cancelled should throw AuthCancelledException`() = runTest { + val cancellationException = CancellationException("User cancelled") + `when`( + mockCredentialManagerProvider.getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + ).thenThrow(cancellationException) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(googleProvider) + } + } + + try { + instance.signInWithGoogle( + context = applicationContext, + config = config, + provider = googleProvider, + authorizationProvider = mockAuthorizationProvider, + credentialManagerProvider = mockCredentialManagerProvider + ) + throw AssertionError("Expected AuthCancelledException to be thrown") + } catch (e: AuthException.AuthCancelledException) { + assertThat(e.message).isEqualTo("Sign in with google was cancelled") + } + + // Verify state is Error with AuthCancelledException + val finalState = instance.authStateFlow().first() + assertThat(finalState).isInstanceOf(AuthState.Error::class.java) + val errorState = finalState as AuthState.Error + assertThat(errorState.exception).isInstanceOf(AuthException.AuthCancelledException::class.java) + } + + // ============================================================================================= + // signInWithGoogle - Anonymous Upgrade + // ============================================================================================= + + @Test + fun `Sign in with Google with anonymous user and upgrade enabled should link credentials`() = runTest { + val mockCredential = mock(AuthCredential::class.java) + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockAnonymousUser.uid).thenReturn("anonymous-uid") + + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockAnonymousUser) + + val googleSignInResult = AuthProvider.Google.GoogleSignInResult( + credential = mockCredential, + idToken = "test-id-token", + displayName = "Test User", + photoUrl = null + ) + + `when`( + mockCredentialManagerProvider.getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + ).thenReturn(googleSignInResult) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + `when`(mockAnonymousUser.linkWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + isAnonymousUpgradeEnabled = true + providers { + provider(googleProvider) + } + } + + instance.signInWithGoogle( + context = applicationContext, + config = config, + provider = googleProvider, + authorizationProvider = mockAuthorizationProvider, + credentialManagerProvider = mockCredentialManagerProvider + ) + + // Verify link was called instead of sign-in + verify(mockAnonymousUser).linkWithCredential(mockCredential) + verify(mockFirebaseAuth, never()).signInWithCredential(any()) + } + + // ============================================================================================= + // signInWithGoogle - State Management + // ============================================================================================= + + @Test + fun `Sign in with Google should update state to Loading then Idle on success`() = runTest { + val mockCredential = mock(AuthCredential::class.java) + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + + val googleSignInResult = AuthProvider.Google.GoogleSignInResult( + credential = mockCredential, + idToken = "test-id-token", + displayName = "Test User", + photoUrl = null + ) + + `when`( + mockCredentialManagerProvider.getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + ).thenReturn(googleSignInResult) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(googleProvider) + } + } + + // Verify initial state + assertThat(instance.authStateFlow().first()).isEqualTo(AuthState.Idle) + + instance.signInWithGoogle( + context = applicationContext, + config = config, + provider = googleProvider, + authorizationProvider = mockAuthorizationProvider, + credentialManagerProvider = mockCredentialManagerProvider + ) + + // Verify final state + val finalState = instance.authStateFlow().first() + assertThat(finalState).isEqualTo(AuthState.Idle) + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt new file mode 100644 index 000000000..2e2fc1cd7 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt @@ -0,0 +1,289 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.app.Activity +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthUserCollisionException +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.OAuthCredential +import com.google.firebase.auth.OAuthProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for OAuth provider sign-in methods in FirebaseAuthUI. + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE) +class OAuthProviderFirebaseAuthUITest { + + @Mock + private lateinit var mockFirebaseAuth: FirebaseAuth + + @Mock + private lateinit var mockActivity: Activity + + private lateinit var firebaseApp: FirebaseApp + private lateinit var applicationContext: Context + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + FirebaseAuthUI.clearInstanceCache() + + applicationContext = ApplicationProvider.getApplicationContext() + + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + firebaseApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + try { + firebaseApp.delete() + } catch (_: Exception) { + // Ignore if already deleted + } + } + + // ============================================================================================= + // signInWithProvider - Success Case + // ============================================================================================= + + @Test + fun `Sign in with OAuth provider should succeed`() = runTest { + val mockOAuthCredential = mock(OAuthCredential::class.java) + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + `when`(mockAuthResult.credential).thenReturn(mockOAuthCredential) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + + `when`(mockFirebaseAuth.pendingAuthResult).thenReturn(null) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`(mockFirebaseAuth.startActivityForSignInWithProvider(any(), any())) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val githubProvider = AuthProvider.Github(customParameters = emptyMap()) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(githubProvider) + } + } + + instance.signInWithProvider( + config = config, + activity = mockActivity, + provider = githubProvider + ) + + // Verify OAuth provider was built and used + verify(mockFirebaseAuth).startActivityForSignInWithProvider( + eq(mockActivity), + any() + ) + + // Verify state is Idle after success + val finalState = instance.authStateFlow().first() + assertThat(finalState).isEqualTo(AuthState.Idle) + } + + // ============================================================================================= + // signInWithProvider - Anonymous Upgrade + // ============================================================================================= + + @Test + fun `Sign in with provider should upgrade anonymous user when enabled`() = runTest { + val mockOAuthCredential = mock(OAuthCredential::class.java) + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockAnonymousUser.uid).thenReturn("anonymous-uid") + `when`(mockAnonymousUser.displayName).thenReturn(null) + `when`(mockAnonymousUser.photoUrl).thenReturn(null) + + val mockLinkedUser = mock(FirebaseUser::class.java) + `when`(mockLinkedUser.isAnonymous).thenReturn(false) + + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockLinkedUser) + `when`(mockAuthResult.credential).thenReturn(mockOAuthCredential) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + + `when`(mockFirebaseAuth.pendingAuthResult).thenReturn(null) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + `when`(mockAnonymousUser.startActivityForLinkWithProvider(any(), any())) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val yahooProvider = AuthProvider.Yahoo(customParameters = emptyMap()) + val config = authUIConfiguration { + context = applicationContext + isAnonymousUpgradeEnabled = true + providers { + provider(yahooProvider) + } + } + + instance.signInWithProvider( + config = config, + activity = mockActivity, + provider = yahooProvider + ) + + // Verify link was called instead of sign-in + verify(mockAnonymousUser).startActivityForLinkWithProvider(eq(mockActivity), any()) + verify(mockFirebaseAuth, never()).startActivityForSignInWithProvider(any(), any()) + + // Verify the operation completed (state should be Idle after successful link) + val finalState = instance.authStateFlow().first() + assertThat(finalState).isNotNull() + } + + // ============================================================================================= + // signInWithProvider - Error Handling + // ============================================================================================= + + @Test + fun `Sign in with provider should throw AccountLinkingRequiredException on collision`() = runTest { + val collisionEmail = "test@example.com" + val mockCredential = mock(AuthCredential::class.java) + val collisionException = mock(FirebaseAuthUserCollisionException::class.java) + `when`(collisionException.email).thenReturn(collisionEmail) + `when`(collisionException.updatedCredential).thenReturn(mockCredential) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(collisionException) + + `when`(mockFirebaseAuth.pendingAuthResult).thenReturn(null) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`(mockFirebaseAuth.startActivityForSignInWithProvider(any(), any())) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val githubProvider = AuthProvider.Github(customParameters = emptyMap()) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(githubProvider) + } + } + + try { + instance.signInWithProvider( + config = config, + activity = mockActivity, + provider = githubProvider + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.AccountLinkingRequiredException) { + // Verify it's the right exception type with expected fields + assertThat(e).isNotNull() + assertThat(e).isInstanceOf(AuthException.AccountLinkingRequiredException::class.java) + } + + // Verify state is Error + val finalState = instance.authStateFlow().first() + assertThat(finalState).isInstanceOf(AuthState.Error::class.java) + val errorState = finalState as AuthState.Error + assertThat(errorState.exception).isInstanceOf(AuthException.AccountLinkingRequiredException::class.java) + } + + @Test + fun `Sign in with provider should throw AuthCancelledException when cancelled`() = runTest { + val cancellationException = CancellationException("User cancelled") + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(cancellationException) + + `when`(mockFirebaseAuth.pendingAuthResult).thenReturn(null) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`(mockFirebaseAuth.startActivityForSignInWithProvider(any(), any())) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val microsoftProvider = AuthProvider.Microsoft(tenant = null, customParameters = emptyMap()) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(microsoftProvider) + } + } + + try { + instance.signInWithProvider( + config = config, + activity = mockActivity, + provider = microsoftProvider + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.AuthCancelledException) { + assertThat(e.message).contains("Signing in with Microsoft was cancelled") + } + + // Verify state is Error + val finalState = instance.authStateFlow().first() + assertThat(finalState).isInstanceOf(AuthState.Error::class.java) + val errorState = finalState as AuthState.Error + assertThat(errorState.exception).isInstanceOf(AuthException.AuthCancelledException::class.java) + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt new file mode 100644 index 000000000..ad39e96cf --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt @@ -0,0 +1,410 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.timeout +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Comprehensive unit tests for Phone Authentication provider methods in FirebaseAuthUI. + * + * Tests cover all phone auth methods: + * - verifyPhoneNumber (instant verification, manual verification, resend) + * - submitVerificationCode + * - signInWithPhoneAuthCredential + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class PhoneAuthProviderFirebaseAuthUITest { + + @Mock + private lateinit var mockFirebaseAuth: FirebaseAuth + + @Mock + private lateinit var mockPhoneAuthVerifier: AuthProvider.Phone.Verifier + + @Mock + private lateinit var mockPhoneAuthCredentialProvider: AuthProvider.Phone.CredentialProvider + + private lateinit var firebaseApp: FirebaseApp + private lateinit var applicationContext: Context + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + FirebaseAuthUI.clearInstanceCache() + + applicationContext = ApplicationProvider.getApplicationContext() + + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + firebaseApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + try { + firebaseApp.delete() + } catch (_: Exception) { + // Ignore if already deleted + } + } + + // ============================================================================================= + // verifyPhoneNumber Tests + // ============================================================================================= + + @Test + fun `verifyPhoneNumber - instant verification succeeds and emits SMSAutoVerified`() = runTest { + val mockCredential = mock(PhoneAuthCredential::class.java) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + + `when`( + mockPhoneAuthVerifier.verifyPhoneNumber( + auth = any(), + activity = anyOrNull(), + phoneNumber = any(), + timeout = eq(60L), + forceResendingToken = anyOrNull(), + multiFactorSession = anyOrNull(), + isInstantVerificationEnabled = eq(true) + ) + ).thenReturn(AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified(mockCredential)) + + instance.verifyPhoneNumber( + provider = phoneProvider, + activity = null, + phoneNumber = "+1234567890", + verifier = mockPhoneAuthVerifier + ) + + val finalState = instance.authStateFlow().first { it is AuthState.SMSAutoVerified } + assertThat(finalState).isInstanceOf(AuthState.SMSAutoVerified::class.java) + val autoVerifiedState = finalState as AuthState.SMSAutoVerified + assertThat(autoVerifiedState.credential).isEqualTo(mockCredential) + } + + @Test + fun `verifyPhoneNumber - manual verification emits PhoneNumberVerificationRequired`() = + runTest { + val mockToken = mock(PhoneAuthProvider.ForceResendingToken::class.java) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + + `when`( + mockPhoneAuthVerifier.verifyPhoneNumber( + auth = any(), + activity = anyOrNull(), + phoneNumber = any(), + timeout = eq(60L), + forceResendingToken = anyOrNull(), + multiFactorSession = anyOrNull(), + isInstantVerificationEnabled = eq(true) + ) + ).thenReturn( + AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification( + "test-verification-id", + mockToken + ) + ) + + instance.verifyPhoneNumber( + provider = phoneProvider, + activity = null, + phoneNumber = "+1234567890", + verifier = mockPhoneAuthVerifier + ) + + val finalState = + instance.authStateFlow().first { it is AuthState.PhoneNumberVerificationRequired } + assertThat(finalState).isInstanceOf(AuthState.PhoneNumberVerificationRequired::class.java) + val verificationState = finalState as AuthState.PhoneNumberVerificationRequired + assertThat(verificationState.verificationId).isEqualTo("test-verification-id") + assertThat(verificationState.forceResendingToken).isEqualTo(mockToken) + } + + @Test + fun `verifyPhoneNumber - with forceResendingToken resends code`() = runTest { + val mockToken = mock(PhoneAuthProvider.ForceResendingToken::class.java) + val newMockToken = mock(PhoneAuthProvider.ForceResendingToken::class.java) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + + `when`( + mockPhoneAuthVerifier.verifyPhoneNumber( + auth = any(), + activity = anyOrNull(), + phoneNumber = any(), + timeout = eq(60L), + forceResendingToken = eq(mockToken), + multiFactorSession = anyOrNull(), + isInstantVerificationEnabled = eq(true) + ) + ).thenReturn( + AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification( + "new-verification-id", + newMockToken + ) + ) + + instance.verifyPhoneNumber( + provider = phoneProvider, + activity = null, + phoneNumber = "+1234567890", + forceResendingToken = mockToken, + verifier = mockPhoneAuthVerifier + ) + + val finalState = + instance.authStateFlow().first { it is AuthState.PhoneNumberVerificationRequired } + assertThat(finalState).isInstanceOf(AuthState.PhoneNumberVerificationRequired::class.java) + val verificationState = finalState as AuthState.PhoneNumberVerificationRequired + assertThat(verificationState.verificationId).isEqualTo("new-verification-id") + assertThat(verificationState.forceResendingToken).isEqualTo(newMockToken) + } + + @Test + fun `verifyPhoneNumber - respects isInstantVerificationEnabled flag`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = false // Disabled + ) + + `when`( + mockPhoneAuthVerifier.verifyPhoneNumber( + auth = any(), + activity = anyOrNull(), + phoneNumber = any(), + timeout = eq(60L), + forceResendingToken = anyOrNull(), + multiFactorSession = anyOrNull(), + isInstantVerificationEnabled = eq(false) + ) + ).thenReturn( + AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification( + "test-id", + mock() + ) + ) + + instance.verifyPhoneNumber( + provider = phoneProvider, + activity = null, + phoneNumber = "+1234567890", + verifier = mockPhoneAuthVerifier + ) + + verify(mockPhoneAuthVerifier).verifyPhoneNumber( + any(), + anyOrNull(), + any(), + eq(60L), + anyOrNull(), + anyOrNull(), + eq(false) + ) + } + + // ============================================================================================= + // submitVerificationCode Tests + // ============================================================================================= + + @Test + fun `submitVerificationCode - creates credential and signs in successfully`() = runTest { + val mockCredential = mock(PhoneAuthCredential::class.java) + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + + `when`(mockPhoneAuthCredentialProvider.getCredential("test-verification-id", "123456")) + .thenReturn(mockCredential) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(phoneProvider) + } + } + + val result = instance.submitVerificationCode( + config = config, + verificationId = "test-verification-id", + code = "123456", + credentialProvider = mockPhoneAuthCredentialProvider + ) + + assertThat(result).isNotNull() + assertThat(result?.user).isEqualTo(mockUser) + verify(mockFirebaseAuth).signInWithCredential(mockCredential) + } + + // ============================================================================================= + // signInWithPhoneAuthCredential Tests + // ============================================================================================= + + @Test + fun `signInWithPhoneAuthCredential - successful sign in with credential`() = runTest { + val mockCredential = mock(PhoneAuthCredential::class.java) + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(phoneProvider) + } + } + + val result = instance.signInWithPhoneAuthCredential( + config = config, + credential = mockCredential + ) + + assertThat(result).isNotNull() + assertThat(result?.user).isEqualTo(mockUser) + verify(mockFirebaseAuth).signInWithCredential(mockCredential) + } + + @Test + fun `signInWithPhoneAuthCredential - handles anonymous upgrade`() = runTest { + val anonymousUser = mock(FirebaseUser::class.java) + `when`(anonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(anonymousUser) + + val mockCredential = mock(PhoneAuthCredential::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(anonymousUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(anonymousUser.linkWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(phoneProvider) + } + isAnonymousUpgradeEnabled = true + } + + val result = instance.signInWithPhoneAuthCredential( + config = config, + credential = mockCredential + ) + + assertThat(result).isNotNull() + verify(anonymousUser).linkWithCredential(mockCredential) + } + +} \ 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..af36c1e18 --- /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.PasswordRule +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 [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/credentialmanager/PasswordCredentialHandlerTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/credentialmanager/PasswordCredentialHandlerTest.kt new file mode 100644 index 000000000..f98ed6128 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/credentialmanager/PasswordCredentialHandlerTest.kt @@ -0,0 +1,272 @@ +/* + * 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.credentialmanager + +import android.content.Context +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.PasswordCredential as AndroidPasswordCredential +import androidx.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.NoCredentialException +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class PasswordCredentialHandlerTest { + + private lateinit var context: Context + private lateinit var handler: PasswordCredentialHandler + + @Mock + private lateinit var mockCredentialManager: CredentialManager + + @Mock + private lateinit var mockGetCredentialResponse: GetCredentialResponse + + @Mock + private lateinit var mockAndroidPasswordCredential: AndroidPasswordCredential + + @Captor + private lateinit var createPasswordRequestCaptor: ArgumentCaptor + + @Captor + private lateinit var getCredentialRequestCaptor: ArgumentCaptor + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + context = ApplicationProvider.getApplicationContext() + handler = PasswordCredentialHandler(context) + } + + // savePassword tests + + @Test + fun `savePassword with valid credentials succeeds`() = runTest { + val username = "test@example.com" + val password = "securePassword123" + + // This test verifies the handler can be created and called without throwing + // In a real scenario with a mocked CredentialManager, we would verify the interaction + try { + handler.savePassword(username, password) + } catch (e: PasswordCredentialException) { + // Expected in test environment without real credential manager + } + } + + @Test + fun `savePassword with blank username throws IllegalArgumentException`() { + val exception = assertThrows(IllegalArgumentException::class.java) { + runTest { + handler.savePassword("", "password123") + } + } + assertEquals("Username cannot be blank", exception.message) + } + + @Test + fun `savePassword with blank password throws IllegalArgumentException`() { + val exception = assertThrows(IllegalArgumentException::class.java) { + runTest { + handler.savePassword("user@example.com", "") + } + } + assertEquals("Password cannot be blank", exception.message) + } + + @Test + fun `savePassword with whitespace-only username throws IllegalArgumentException`() { + val exception = assertThrows(IllegalArgumentException::class.java) { + runTest { + handler.savePassword(" ", "password123") + } + } + assertEquals("Username cannot be blank", exception.message) + } + + @Test + fun `savePassword with whitespace-only password throws IllegalArgumentException`() { + val exception = assertThrows(IllegalArgumentException::class.java) { + runTest { + handler.savePassword("user@example.com", " ") + } + } + assertEquals("Password cannot be blank", exception.message) + } + + // getPassword tests + + @Test + fun `getPassword returns PasswordCredential when successful`() = runTest { + // This test verifies the handler structure + // In a real scenario, we would mock CredentialManager to return a credential + try { + val credential = handler.getPassword() + // If we get here, verify the structure + assert(credential.username.isNotEmpty() || credential.password.isNotEmpty()) + } catch (e: PasswordCredentialException) { + // Expected in test environment + } + } + + // Exception handling tests + + @Test + fun `PasswordCredentialException has correct message and cause`() { + val cause = RuntimeException("Test cause") + val exception = PasswordCredentialException("Test message", cause) + + assertEquals("Test message", exception.message) + assertEquals(cause, exception.cause) + } + + @Test + fun `PasswordCredentialCancelledException has correct message and cause`() { + val cause = RuntimeException("Test cause") + val exception = PasswordCredentialCancelledException("User cancelled", cause) + + assertEquals("User cancelled", exception.message) + assertEquals(cause, exception.cause) + } + + @Test + fun `PasswordCredentialNotFoundException has correct message and cause`() { + val cause = RuntimeException("Test cause") + val exception = PasswordCredentialNotFoundException("Not found", cause) + + assertEquals("Not found", exception.message) + assertEquals(cause, exception.cause) + } + + @Test + fun `PasswordCredentialCancelledException is instance of PasswordCredentialException`() { + val exception = PasswordCredentialCancelledException("Cancelled") + assert(exception is PasswordCredentialException) + } + + @Test + fun `PasswordCredentialNotFoundException is instance of PasswordCredentialException`() { + val exception = PasswordCredentialNotFoundException("Not found") + assert(exception is PasswordCredentialException) + } + + // PasswordCredential data class tests + + @Test + fun `PasswordCredential holds username and password`() { + val username = "test@example.com" + val password = "securePassword123" + + val credential = PasswordCredential(username, password) + + assertEquals(username, credential.username) + assertEquals(password, credential.password) + } + + @Test + fun `PasswordCredential equality works correctly`() { + val credential1 = PasswordCredential("user@test.com", "pass123") + val credential2 = PasswordCredential("user@test.com", "pass123") + val credential3 = PasswordCredential("other@test.com", "pass123") + + assertEquals(credential1, credential2) + assert(credential1 != credential3) + } + + @Test + fun `PasswordCredential copy works correctly`() { + val original = PasswordCredential("user@test.com", "pass123") + val copied = original.copy(password = "newPass456") + + assertEquals("user@test.com", copied.username) + assertEquals("newPass456", copied.password) + assertEquals("pass123", original.password) // Original unchanged + } + + @Test + fun `PasswordCredential component destructuring works`() { + val credential = PasswordCredential("user@test.com", "pass123") + val (username, password) = credential + + assertEquals("user@test.com", username) + assertEquals("pass123", password) + } + + @Test + fun `PasswordCredential toString contains field names`() { + val credential = PasswordCredential("user@test.com", "pass123") + val toString = credential.toString() + + assert(toString.contains("username")) + assert(toString.contains("password")) + assert(toString.contains("user@test.com")) + assert(toString.contains("pass123")) + } + + // Integration-style tests (would work with real credential manager) + + @Test + fun `handler can be created with application context`() { + val handler = PasswordCredentialHandler(context) + assert(handler != null) + } + + @Test + fun `multiple handlers can be created independently`() { + val handler1 = PasswordCredentialHandler(context) + val handler2 = PasswordCredentialHandler(context) + + assert(handler1 != handler2) + } + + @Test + fun `handler operations are independent`() = runTest { + val handler1 = PasswordCredentialHandler(context) + val handler2 = PasswordCredentialHandler(context) + + // Both handlers should be able to attempt operations independently + try { + handler1.savePassword("user1@test.com", "pass1") + } catch (e: PasswordCredentialException) { + // Expected in test + } + + try { + handler2.savePassword("user2@test.com", "pass2") + } catch (e: PasswordCredentialException) { + // Expected in test + } + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/data/CountryDataTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/data/CountryDataTest.kt new file mode 100644 index 000000000..8c7064f8f --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/data/CountryDataTest.kt @@ -0,0 +1,347 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.data + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [CountryData] and related utilities. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class CountryDataTest { + + // ============================================================================================= + // CountryData Tests + // ============================================================================================= + + @Test + fun `CountryData has correct properties`() { + val country = CountryData( + name = "United States", + dialCode = "+1", + countryCode = "US", + flagEmoji = "🇺🇸" + ) + + assertThat(country.name).isEqualTo("United States") + assertThat(country.dialCode).isEqualTo("+1") + assertThat(country.countryCode).isEqualTo("US") + assertThat(country.flagEmoji).isEqualTo("🇺🇸") + } + + @Test + fun `getDisplayName returns formatted name with flag`() { + val country = CountryData( + name = "United Kingdom", + dialCode = "+44", + countryCode = "GB", + flagEmoji = "🇬🇧" + ) + + assertThat(country.getDisplayName()).isEqualTo("🇬🇧 United Kingdom") + } + + @Test + fun `getDisplayNameWithDialCode returns formatted name with flag and dial code`() { + val country = CountryData( + name = "France", + dialCode = "+33", + countryCode = "FR", + flagEmoji = "🇫🇷" + ) + + assertThat(country.getDisplayNameWithDialCode()).isEqualTo("🇫🇷 France (+33)") + } + + // ============================================================================================= + // Flag Emoji Tests + // ============================================================================================= + + @Test + fun `countryCodeToFlagEmoji generates correct emoji for US`() { + val emoji = countryCodeToFlagEmoji("US") + assertThat(emoji).isEqualTo("🇺🇸") + } + + @Test + fun `countryCodeToFlagEmoji generates correct emoji for GB`() { + val emoji = countryCodeToFlagEmoji("GB") + assertThat(emoji).isEqualTo("🇬🇧") + } + + @Test + fun `countryCodeToFlagEmoji generates correct emoji for FR`() { + val emoji = countryCodeToFlagEmoji("FR") + assertThat(emoji).isEqualTo("🇫🇷") + } + + @Test + fun `countryCodeToFlagEmoji works with lowercase codes`() { + val emoji = countryCodeToFlagEmoji("de") + assertThat(emoji).isEqualTo("🇩🇪") + } + + @Test + fun `countryCodeToFlagEmoji returns empty string for invalid code length`() { + val emoji = countryCodeToFlagEmoji("USA") + assertThat(emoji).isEmpty() + } + + @Test + fun `countryCodeToFlagEmoji returns empty string for empty code`() { + val emoji = countryCodeToFlagEmoji("") + assertThat(emoji).isEmpty() + } + + @Test + fun `countryCodeToFlagEmoji returns empty string for single character`() { + val emoji = countryCodeToFlagEmoji("U") + assertThat(emoji).isEmpty() + } + + // ============================================================================================= + // Country List Tests + // ============================================================================================= + + @Test + fun `ALL_COUNTRIES contains expected number of countries`() { + assertThat(ALL_COUNTRIES).isNotEmpty() + assertThat(ALL_COUNTRIES.size).isGreaterThan(200) + } + + @Test + fun `ALL_COUNTRIES contains United States`() { + val us = ALL_COUNTRIES.find { it.countryCode == "US" } + assertThat(us).isNotNull() + assertThat(us?.name).isEqualTo("United States") + assertThat(us?.dialCode).isEqualTo("+1") + } + + @Test + fun `ALL_COUNTRIES contains United Kingdom`() { + val uk = ALL_COUNTRIES.find { it.countryCode == "GB" } + assertThat(uk).isNotNull() + assertThat(uk?.name).isEqualTo("United Kingdom") + assertThat(uk?.dialCode).isEqualTo("+44") + } + + @Test + fun `ALL_COUNTRIES contains France`() { + val france = ALL_COUNTRIES.find { it.countryCode == "FR" } + assertThat(france).isNotNull() + assertThat(france?.name).isEqualTo("France") + assertThat(france?.dialCode).isEqualTo("+33") + } + + @Test + fun `ALL_COUNTRIES has no duplicate country codes`() { + val countryCodes = ALL_COUNTRIES.map { it.countryCode } + val uniqueCodes = countryCodes.toSet() + assertThat(countryCodes.size).isEqualTo(uniqueCodes.size) + } + + @Test + fun `ALL_COUNTRIES all entries have valid flag emojis`() { + ALL_COUNTRIES.forEach { country -> + assertThat(country.flagEmoji).isNotEmpty() + } + } + + @Test + fun `ALL_COUNTRIES all entries have dial codes starting with plus`() { + ALL_COUNTRIES.forEach { country -> + assertThat(country.dialCode).startsWith("+") + } + } + + @Test + fun `ALL_COUNTRIES all entries have two-letter country codes`() { + ALL_COUNTRIES.forEach { country -> + assertThat(country.countryCode).hasLength(2) + } + } + + // ============================================================================================= + // CountryUtils - Lookup Tests + // ============================================================================================= + + @Test + fun `findByCountryCode returns correct country for US`() { + val country = CountryUtils.findByCountryCode("US") + assertThat(country).isNotNull() + assertThat(country?.name).isEqualTo("United States") + assertThat(country?.dialCode).isEqualTo("+1") + } + + @Test + fun `findByCountryCode is case insensitive`() { + val country = CountryUtils.findByCountryCode("us") + assertThat(country).isNotNull() + assertThat(country?.countryCode).isEqualTo("US") + } + + @Test + fun `findByCountryCode returns null for invalid code`() { + val country = CountryUtils.findByCountryCode("XX") + assertThat(country).isNull() + } + + @Test + fun `findByDialCode returns countries with +1 dial code`() { + val countries = CountryUtils.findByDialCode("+1") + assertThat(countries).isNotEmpty() + assertThat(countries.map { it.countryCode }).contains("US") + assertThat(countries.map { it.countryCode }).contains("CA") + } + + @Test + fun `findByDialCode returns countries with +44 dial code`() { + val countries = CountryUtils.findByDialCode("+44") + assertThat(countries).isNotEmpty() + val countryCodes = countries.map { it.countryCode } + assertThat(countryCodes).contains("GB") + } + + @Test + fun `findByDialCode returns empty list for non-existent dial code`() { + val countries = CountryUtils.findByDialCode("+9999") + assertThat(countries).isEmpty() + } + + // ============================================================================================= + // CountryUtils - Search Tests + // ============================================================================================= + + @Test + fun `searchByName finds United States`() { + val countries = CountryUtils.searchByName("United States") + assertThat(countries).isNotEmpty() + assertThat(countries[0].countryCode).isEqualTo("US") + } + + @Test + fun `searchByName finds countries with partial match`() { + val countries = CountryUtils.searchByName("United") + assertThat(countries).isNotEmpty() + val names = countries.map { it.name } + assertThat(names).contains("United States") + assertThat(names).contains("United Kingdom") + assertThat(names).contains("United Arab Emirates") + } + + @Test + fun `searchByName is case insensitive`() { + val countries = CountryUtils.searchByName("france") + assertThat(countries).isNotEmpty() + assertThat(countries[0].countryCode).isEqualTo("FR") + } + + @Test + fun `searchByName handles diacritics`() { + val countries = CountryUtils.searchByName("Cote d'Ivoire") + assertThat(countries).isNotEmpty() + assertThat(countries[0].countryCode).isEqualTo("CI") + } + + @Test + fun `searchByName returns empty list for empty query`() { + val countries = CountryUtils.searchByName("") + assertThat(countries).isEmpty() + } + + @Test + fun `searchByName returns empty list for whitespace query`() { + val countries = CountryUtils.searchByName(" ") + assertThat(countries).isEmpty() + } + + // ============================================================================================= + // CountryUtils - Filter Tests + // ============================================================================================= + + @Test + fun `filterByAllowedCountries returns only allowed countries`() { + val allowedCodes = setOf("US", "GB", "FR") + val filtered = CountryUtils.filterByAllowedCountries(allowedCodes) + + assertThat(filtered).hasSize(3) + val countryCodes = filtered.map { it.countryCode } + assertThat(countryCodes).containsExactly("US", "GB", "FR") + } + + @Test + fun `filterByAllowedCountries is case insensitive`() { + val allowedCodes = setOf("us", "gb") + val filtered = CountryUtils.filterByAllowedCountries(allowedCodes) + + assertThat(filtered).hasSize(2) + val countryCodes = filtered.map { it.countryCode } + assertThat(countryCodes).containsExactly("US", "GB") + } + + @Test + fun `filterByAllowedCountries returns all countries when set is empty`() { + val filtered = CountryUtils.filterByAllowedCountries(emptySet()) + assertThat(filtered).hasSize(ALL_COUNTRIES.size) + } + + @Test + fun `filterByAllowedCountries returns empty list for non-existent codes`() { + val allowedCodes = setOf("XX", "YY") + val filtered = CountryUtils.filterByAllowedCountries(allowedCodes) + assertThat(filtered).isEmpty() + } + + // ============================================================================================= + // CountryUtils - Default Country Tests + // ============================================================================================= + + @Test + fun `getDefaultCountry returns a valid country`() { + val country = CountryUtils.getDefaultCountry() + assertThat(country).isNotNull() + assertThat(country.countryCode).isNotEmpty() + assertThat(country.dialCode).isNotEmpty() + } + + // ============================================================================================= + // CountryUtils - Formatting Tests + // ============================================================================================= + + @Test + fun `formatPhoneNumber combines dial code and phone number`() { + val formatted = CountryUtils.formatPhoneNumber("+1", "5551234567") + assertThat(formatted).isEqualTo("+15551234567") + } + + @Test + fun `formatPhoneNumber removes non-numeric characters from phone number`() { + val formatted = CountryUtils.formatPhoneNumber("+1", "(555) 123-4567") + assertThat(formatted).isEqualTo("+15551234567") + } + + @Test + fun `formatPhoneNumber handles phone number with spaces`() { + val formatted = CountryUtils.formatPhoneNumber("+44", "20 1234 5678") + assertThat(formatted).isEqualTo("+442012345678") + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/mfa/MfaChallengeContentStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/mfa/MfaChallengeContentStateTest.kt new file mode 100644 index 000000000..a8b4d5c0d --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/mfa/MfaChallengeContentStateTest.kt @@ -0,0 +1,236 @@ +/* + * 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.mfa + +import com.firebase.ui.auth.compose.configuration.MfaFactor +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MfaChallengeContentStateTest { + + @Test + fun `state holds all properties correctly for SMS`() { + val state = MfaChallengeContentState( + factorType = MfaFactor.Sms, + maskedPhoneNumber = "+1••••••890", + isLoading = false, + error = null, + verificationCode = "123456" + ) + + assertEquals(MfaFactor.Sms, state.factorType) + assertEquals("+1••••••890", state.maskedPhoneNumber) + assertFalse(state.isLoading) + assertNull(state.error) + assertEquals("123456", state.verificationCode) + } + + @Test + fun `state holds all properties correctly for TOTP`() { + val state = MfaChallengeContentState( + factorType = MfaFactor.Totp, + maskedPhoneNumber = null, + isLoading = true, + error = "Test error", + verificationCode = "654321" + ) + + assertEquals(MfaFactor.Totp, state.factorType) + assertNull(state.maskedPhoneNumber) + assertTrue(state.isLoading) + assertEquals("Test error", state.error) + assertEquals("654321", state.verificationCode) + } + + @Test + fun `isValid returns true for valid 6-digit code`() { + val state = MfaChallengeContentState( + factorType = MfaFactor.Sms, + verificationCode = "123456" + ) + + assertTrue(state.isValid) + } + + @Test + fun `isValid returns false for code that is too short`() { + val state = MfaChallengeContentState( + factorType = MfaFactor.Sms, + verificationCode = "12345" + ) + + assertFalse(state.isValid) + } + + @Test + fun `isValid returns false for code that is too long`() { + val state = MfaChallengeContentState( + factorType = MfaFactor.Sms, + verificationCode = "1234567" + ) + + assertFalse(state.isValid) + } + + @Test + fun `isValid returns false for code with non-digits`() { + val state = MfaChallengeContentState( + factorType = MfaFactor.Sms, + verificationCode = "12345a" + ) + + assertFalse(state.isValid) + } + + @Test + fun `isValid returns false for empty code`() { + val state = MfaChallengeContentState( + factorType = MfaFactor.Sms, + verificationCode = "" + ) + + assertFalse(state.isValid) + } + + @Test + fun `hasError returns true when error is present`() { + val state = MfaChallengeContentState( + factorType = MfaFactor.Sms, + error = "Invalid code" + ) + + assertTrue(state.hasError) + } + + @Test + fun `hasError returns false when error is null`() { + val state = MfaChallengeContentState( + factorType = MfaFactor.Sms, + error = null + ) + + assertFalse(state.hasError) + } + + @Test + fun `hasError returns false when error is blank`() { + val state = MfaChallengeContentState( + factorType = MfaFactor.Sms, + error = " " + ) + + assertFalse(state.hasError) + } + + @Test + fun `canResend returns true for SMS when callback is provided`() { + val state = MfaChallengeContentState( + factorType = MfaFactor.Sms, + onResendCodeClick = {} + ) + + assertTrue(state.canResend) + } + + @Test + fun `canResend returns false for SMS when callback is null`() { + val state = MfaChallengeContentState( + factorType = MfaFactor.Sms, + onResendCodeClick = null + ) + + assertFalse(state.canResend) + } + + @Test + fun `canResend returns false for TOTP even with callback`() { + val state = MfaChallengeContentState( + factorType = MfaFactor.Totp, + onResendCodeClick = {} + ) + + assertFalse(state.canResend) + } + + @Test + fun `callbacks are invoked correctly`() { + var verificationCodeChanged = false + var verifyClicked = false + var resendClicked = false + var cancelClicked = false + + val state = MfaChallengeContentState( + factorType = MfaFactor.Sms, + onVerificationCodeChange = { verificationCodeChanged = true }, + onVerifyClick = { verifyClicked = true }, + onResendCodeClick = { resendClicked = true }, + onCancelClick = { cancelClicked = true } + ) + + state.onVerificationCodeChange("123456") + assertTrue(verificationCodeChanged) + + state.onVerifyClick() + assertTrue(verifyClicked) + + state.onResendCodeClick?.invoke() + assertTrue(resendClicked) + + state.onCancelClick() + assertTrue(cancelClicked) + } + + @Test + fun `state equality works correctly`() { + val state1 = MfaChallengeContentState( + factorType = MfaFactor.Sms, + maskedPhoneNumber = "+1••••••890", + verificationCode = "123456" + ) + + val state2 = MfaChallengeContentState( + factorType = MfaFactor.Sms, + maskedPhoneNumber = "+1••••••890", + verificationCode = "123456" + ) + + val state3 = MfaChallengeContentState( + factorType = MfaFactor.Totp, + maskedPhoneNumber = null, + verificationCode = "123456" + ) + + assertEquals(state1, state2) + assertFalse(state1 == state3) + } + + @Test + fun `state copy works correctly`() { + val original = MfaChallengeContentState( + factorType = MfaFactor.Sms, + verificationCode = "123456", + isLoading = false + ) + + val copied = original.copy(isLoading = true) + + assertTrue(copied.isLoading) + assertEquals("123456", copied.verificationCode) + assertFalse(original.isLoading) + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/mfa/MfaEnrollmentContentStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/mfa/MfaEnrollmentContentStateTest.kt new file mode 100644 index 000000000..b08c84bf3 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/mfa/MfaEnrollmentContentStateTest.kt @@ -0,0 +1,291 @@ +/* + * 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.mfa + +import com.firebase.ui.auth.compose.configuration.MfaFactor +import com.firebase.ui.auth.compose.data.CountryData +import com.google.firebase.auth.TotpSecret as FirebaseTotpSecret +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class MfaEnrollmentContentStateTest { + + @Test + fun `isValid returns true for SelectFactor with available factors`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.SelectFactor, + availableFactors = listOf(MfaFactor.Sms, MfaFactor.Totp) + ) + + // When & Then + assertTrue(state.isValid) + } + + @Test + fun `isValid returns false for SelectFactor with no available factors`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.SelectFactor, + availableFactors = emptyList() + ) + + // When & Then + assertFalse(state.isValid) + } + + @Test + fun `isValid returns true for ConfigureSms with valid phone number`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.ConfigureSms, + phoneNumber = "1234567890" + ) + + // When & Then + assertTrue(state.isValid) + } + + @Test + fun `isValid returns false for ConfigureSms with empty phone number`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.ConfigureSms, + phoneNumber = "" + ) + + // When & Then + assertFalse(state.isValid) + } + + @Test + fun `isValid returns true for ConfigureTotp with secret and QR URL`() { + // Given + val mockFirebaseTotpSecret = mock(FirebaseTotpSecret::class.java) + val mockTotpSecret = TotpSecret.from(mockFirebaseTotpSecret) + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.ConfigureTotp, + totpSecret = mockTotpSecret, + totpQrCodeUrl = "otpauth://totp/test" + ) + + // When & Then + assertTrue(state.isValid) + } + + @Test + fun `isValid returns false for ConfigureTotp without secret`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.ConfigureTotp, + totpSecret = null, + totpQrCodeUrl = "otpauth://totp/test" + ) + + // When & Then + assertFalse(state.isValid) + } + + @Test + fun `isValid returns true for VerifyFactor with 6-digit code`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.VerifyFactor, + verificationCode = "123456" + ) + + // When & Then + assertTrue(state.isValid) + } + + @Test + fun `isValid returns false for VerifyFactor with invalid code length`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.VerifyFactor, + verificationCode = "12345" + ) + + // When & Then + assertFalse(state.isValid) + } + + @Test + fun `isValid returns true for ShowRecoveryCodes with codes`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.ShowRecoveryCodes, + recoveryCodes = listOf("code1", "code2", "code3") + ) + + // When & Then + assertTrue(state.isValid) + } + + @Test + fun `isValid returns false for ShowRecoveryCodes without codes`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.ShowRecoveryCodes, + recoveryCodes = null + ) + + // When & Then + assertFalse(state.isValid) + } + + @Test + fun `hasError returns true when error is present`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.SelectFactor, + error = "Something went wrong" + ) + + // When & Then + assertTrue(state.hasError) + } + + @Test + fun `hasError returns false when error is null`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.SelectFactor, + error = null + ) + + // When & Then + assertFalse(state.hasError) + } + + @Test + fun `hasError returns false when error is blank`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.SelectFactor, + error = " " + ) + + // When & Then + assertFalse(state.hasError) + } + + @Test + fun `canSkip returns true when on SelectFactor step with skip callback`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.SelectFactor, + onSkipClick = {} + ) + + // When & Then + assertTrue(state.canSkip) + } + + @Test + fun `canSkip returns false when skip callback is null`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.SelectFactor, + onSkipClick = null + ) + + // When & Then + assertFalse(state.canSkip) + } + + @Test + fun `canSkip returns false when not on SelectFactor step`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.ConfigureTotp, + onSkipClick = {} + ) + + // When & Then + assertFalse(state.canSkip) + } + + @Test + fun `canGoBack returns false on SelectFactor step`() { + // Given + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.SelectFactor + ) + + // When & Then + assertFalse(state.canGoBack) + } + + @Test + fun `canGoBack returns true on other steps`() { + // Given + val steps = listOf( + MfaEnrollmentStep.ConfigureSms, + MfaEnrollmentStep.ConfigureTotp, + MfaEnrollmentStep.VerifyFactor, + MfaEnrollmentStep.ShowRecoveryCodes + ) + + // When & Then + steps.forEach { step -> + val state = MfaEnrollmentContentState(step = step) + assertTrue("Expected canGoBack to be true for step $step", state.canGoBack) + } + } + + + @Test + fun `data class properties are accessible`() { + // Given + val mockFirebaseTotpSecret = mock(FirebaseTotpSecret::class.java) + val mockTotpSecret = TotpSecret.from(mockFirebaseTotpSecret) + val mockCountry = CountryData("United States", "+1", "us", "🇺🇸") + val state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.ConfigureTotp, + isLoading = true, + error = "Error message", + phoneNumber = "1234567890", + selectedCountry = mockCountry, + totpSecret = mockTotpSecret, + totpQrCodeUrl = "otpauth://totp/test", + verificationCode = "123456", + selectedFactor = MfaFactor.Totp, + recoveryCodes = listOf("code1", "code2"), + availableFactors = listOf(MfaFactor.Sms, MfaFactor.Totp) + ) + + // When & Then + assertEquals(MfaEnrollmentStep.ConfigureTotp, state.step) + assertTrue(state.isLoading) + assertEquals("Error message", state.error) + assertEquals("1234567890", state.phoneNumber) + assertEquals(mockCountry, state.selectedCountry) + assertEquals(mockTotpSecret, state.totpSecret) + assertEquals("otpauth://totp/test", state.totpQrCodeUrl) + assertEquals("123456", state.verificationCode) + assertEquals(MfaFactor.Totp, state.selectedFactor) + assertEquals(listOf("code1", "code2"), state.recoveryCodes) + assertEquals(listOf(MfaFactor.Sms, MfaFactor.Totp), state.availableFactors) + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/mfa/MfaEnrollmentStepTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/mfa/MfaEnrollmentStepTest.kt new file mode 100644 index 000000000..5ffa2dab5 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/mfa/MfaEnrollmentStepTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.mfa + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.configuration.MfaFactor +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class MfaEnrollmentStepTest { + + private lateinit var stringProvider: DefaultAuthUIStringProvider + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + } + + @Test + fun `enum has all expected values`() { + val values = MfaEnrollmentStep.entries.toTypedArray() + + assertEquals(5, values.size) + assertEquals(MfaEnrollmentStep.SelectFactor, values[0]) + assertEquals(MfaEnrollmentStep.ConfigureSms, values[1]) + assertEquals(MfaEnrollmentStep.ConfigureTotp, values[2]) + assertEquals(MfaEnrollmentStep.VerifyFactor, values[3]) + assertEquals(MfaEnrollmentStep.ShowRecoveryCodes, values[4]) + } + + @Test + fun `valueOf works correctly`() { + assertEquals(MfaEnrollmentStep.SelectFactor, MfaEnrollmentStep.valueOf("SelectFactor")) + assertEquals(MfaEnrollmentStep.ConfigureSms, MfaEnrollmentStep.valueOf("ConfigureSms")) + assertEquals(MfaEnrollmentStep.ConfigureTotp, MfaEnrollmentStep.valueOf("ConfigureTotp")) + assertEquals(MfaEnrollmentStep.VerifyFactor, MfaEnrollmentStep.valueOf("VerifyFactor")) + assertEquals(MfaEnrollmentStep.ShowRecoveryCodes, MfaEnrollmentStep.valueOf("ShowRecoveryCodes")) + } + + @Test + fun `enum ordinals are in expected order`() { + assertEquals(0, MfaEnrollmentStep.SelectFactor.ordinal) + assertEquals(1, MfaEnrollmentStep.ConfigureSms.ordinal) + assertEquals(2, MfaEnrollmentStep.ConfigureTotp.ordinal) + assertEquals(3, MfaEnrollmentStep.VerifyFactor.ordinal) + assertEquals(4, MfaEnrollmentStep.ShowRecoveryCodes.ordinal) + } + + @Test + fun `getTitle returns correct values for each step`() { + assertEquals("Choose Authentication Method", MfaEnrollmentStep.SelectFactor.getTitle(stringProvider)) + assertEquals("Set Up SMS Verification", MfaEnrollmentStep.ConfigureSms.getTitle(stringProvider)) + assertEquals("Set Up Authenticator App", MfaEnrollmentStep.ConfigureTotp.getTitle(stringProvider)) + assertEquals("Verify Your Code", MfaEnrollmentStep.VerifyFactor.getTitle(stringProvider)) + assertEquals("Save Your Recovery Codes", MfaEnrollmentStep.ShowRecoveryCodes.getTitle(stringProvider)) + } + + @Test + fun `getHelperText returns correct text for SelectFactor`() { + assertEquals( + "Select a second authentication method to secure your account", + MfaEnrollmentStep.SelectFactor.getHelperText(stringProvider) + ) + } + + @Test + fun `getHelperText returns correct text for ConfigureSms`() { + assertEquals( + "Enter your phone number to receive verification codes", + MfaEnrollmentStep.ConfigureSms.getHelperText(stringProvider) + ) + } + + @Test + fun `getHelperText returns correct text for ConfigureTotp`() { + assertEquals( + "Scan the QR code with your authenticator app", + MfaEnrollmentStep.ConfigureTotp.getHelperText(stringProvider) + ) + } + + @Test + fun `getHelperText returns correct text for VerifyFactor with SMS`() { + assertEquals( + "Enter the code sent to your phone", + MfaEnrollmentStep.VerifyFactor.getHelperText(stringProvider, MfaFactor.Sms) + ) + } + + @Test + fun `getHelperText returns correct text for VerifyFactor with TOTP`() { + assertEquals( + "Enter the code from your authenticator app", + MfaEnrollmentStep.VerifyFactor.getHelperText(stringProvider, MfaFactor.Totp) + ) + } + + @Test + fun `getHelperText returns generic text for VerifyFactor with no factor`() { + assertEquals( + "Enter your verification code", + MfaEnrollmentStep.VerifyFactor.getHelperText(stringProvider, null) + ) + } + + @Test + fun `getHelperText returns generic text for VerifyFactor without parameter`() { + assertEquals( + "Enter your verification code", + MfaEnrollmentStep.VerifyFactor.getHelperText(stringProvider) + ) + } + + @Test + fun `getHelperText returns correct text for ShowRecoveryCodes`() { + val helperText = MfaEnrollmentStep.ShowRecoveryCodes.getHelperText(stringProvider) + assertTrue(helperText.contains("Store these codes in a safe place")) + } + + @Test + fun `getHelperText ignores factor parameter for non-VerifyFactor steps`() { + // These should return the same result regardless of the factor parameter + assertEquals( + MfaEnrollmentStep.SelectFactor.getHelperText(stringProvider), + MfaEnrollmentStep.SelectFactor.getHelperText(stringProvider, MfaFactor.Sms) + ) + assertEquals( + MfaEnrollmentStep.ConfigureSms.getHelperText(stringProvider), + MfaEnrollmentStep.ConfigureSms.getHelperText(stringProvider, MfaFactor.Totp) + ) + assertEquals( + MfaEnrollmentStep.ConfigureTotp.getHelperText(stringProvider), + MfaEnrollmentStep.ConfigureTotp.getHelperText(stringProvider, MfaFactor.Sms) + ) + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/mfa/SmsEnrollmentHandlerTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/mfa/SmsEnrollmentHandlerTest.kt new file mode 100644 index 000000000..014a33f2e --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/mfa/SmsEnrollmentHandlerTest.kt @@ -0,0 +1,404 @@ +/* + * 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.mfa + +import android.app.Activity +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactor +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.PhoneMultiFactorGenerator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [SmsEnrollmentHandler]. + * + * Note: Full integration tests for SMS sending and enrollment require + * mocking static Firebase methods and Android components, which is complex. + * These tests focus on validation logic, constants, and utility functions. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class SmsEnrollmentHandlerTest { + + @Mock + private lateinit var mockActivity: Activity + @Mock + private lateinit var mockAuth: FirebaseAuth + @Mock + private lateinit var mockUser: FirebaseUser + @Mock + private lateinit var mockMultiFactor: MultiFactor + private lateinit var handler: SmsEnrollmentHandler + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + `when`(mockUser.multiFactor).thenReturn(mockMultiFactor) + handler = SmsEnrollmentHandler(mockActivity, mockAuth, mockUser) + } + + // isValidCodeFormat tests + + @Test + fun `isValidCodeFormat returns true for valid 6-digit codes`() { + assertTrue(handler.isValidCodeFormat("123456")) + assertTrue(handler.isValidCodeFormat("000000")) + assertTrue(handler.isValidCodeFormat("999999")) + assertTrue(handler.isValidCodeFormat("654321")) + } + + @Test + fun `isValidCodeFormat returns false for empty or blank codes`() { + assertFalse(handler.isValidCodeFormat("")) + assertFalse(handler.isValidCodeFormat(" ")) + assertFalse(handler.isValidCodeFormat(" ")) + } + + @Test + fun `isValidCodeFormat returns false for codes with wrong length`() { + assertFalse(handler.isValidCodeFormat("12345")) // Too short + assertFalse(handler.isValidCodeFormat("1234567")) // Too long + assertFalse(handler.isValidCodeFormat("1")) // Way too short + assertFalse(handler.isValidCodeFormat("12345678901234567890")) // Way too long + } + + @Test + fun `isValidCodeFormat returns false for codes with non-digit characters`() { + assertFalse(handler.isValidCodeFormat("12345a")) // Contains letter + assertFalse(handler.isValidCodeFormat("12 34 56")) // Contains spaces + assertFalse(handler.isValidCodeFormat("abc123")) // Contains letters + assertFalse(handler.isValidCodeFormat("12-345")) // Contains dash + assertFalse(handler.isValidCodeFormat("12.345")) // Contains dot + assertFalse(handler.isValidCodeFormat("123!56")) // Contains special char + } + + // isValidPhoneNumber tests + + @Test + fun `isValidPhoneNumber returns true for valid E164 phone numbers`() { + assertTrue(handler.isValidPhoneNumber("+1234567890")) // US + assertTrue(handler.isValidPhoneNumber("+447911123456")) // UK + assertTrue(handler.isValidPhoneNumber("+33612345678")) // France + assertTrue(handler.isValidPhoneNumber("+861234567890")) // China + assertTrue(handler.isValidPhoneNumber("+5511987654321")) // Brazil + } + + @Test + fun `isValidPhoneNumber returns false for numbers without plus sign`() { + assertFalse(handler.isValidPhoneNumber("1234567890")) + assertFalse(handler.isValidPhoneNumber("447911123456")) + } + + @Test + fun `isValidPhoneNumber returns false for numbers starting with zero after plus`() { + assertFalse(handler.isValidPhoneNumber("+0234567890")) + assertFalse(handler.isValidPhoneNumber("+0447911123456")) + } + + @Test + fun `isValidPhoneNumber returns false for numbers that are too short`() { + assertFalse(handler.isValidPhoneNumber("+1")) // Too short + assertFalse(handler.isValidPhoneNumber("+12")) // Still too short + } + + @Test + fun `isValidPhoneNumber returns false for numbers that are too long`() { + assertFalse(handler.isValidPhoneNumber("+12345678901234567")) // More than 15 digits + } + + @Test + fun `isValidPhoneNumber returns false for numbers with non-digit characters`() { + assertFalse(handler.isValidPhoneNumber("+1 234 567 890")) // Spaces + assertFalse(handler.isValidPhoneNumber("+1-234-567-890")) // Dashes + assertFalse(handler.isValidPhoneNumber("+1(234)567890")) // Parentheses + assertFalse(handler.isValidPhoneNumber("+1.234.567.890")) // Dots + } + + @Test + fun `isValidPhoneNumber returns false for empty or blank numbers`() { + assertFalse(handler.isValidPhoneNumber("")) + assertFalse(handler.isValidPhoneNumber(" ")) + } + + // Constants tests + + @Test + fun `constants have expected values`() { + assertEquals(6, SmsEnrollmentHandler.SMS_CODE_LENGTH) + assertEquals(60L, SmsEnrollmentHandler.VERIFICATION_TIMEOUT_SECONDS) + assertEquals(30, SmsEnrollmentHandler.RESEND_DELAY_SECONDS) + assertEquals(PhoneMultiFactorGenerator.FACTOR_ID, SmsEnrollmentHandler.FACTOR_ID) + } + + @Test + fun `handler is created with correct auth and user references`() { + // Verify handler can be instantiated + val newHandler = SmsEnrollmentHandler(mockActivity, mockAuth, mockUser) + // Basic smoke test - if we get here, construction succeeded + assertTrue(newHandler.isValidCodeFormat("123456")) + } + + // maskPhoneNumber tests + + @Test + fun `maskPhoneNumber masks US phone numbers correctly`() { + val masked = maskPhoneNumber("+1234567890") + assertTrue(masked.startsWith("+1")) + assertTrue(masked.endsWith("890")) + assertTrue(masked.contains("•")) + assertEquals("+1••••••890", masked) + } + + @Test + fun `maskPhoneNumber masks UK phone numbers correctly`() { + val masked = maskPhoneNumber("+447911123456") + assertTrue(masked.startsWith("+44")) + assertTrue(masked.endsWith("456")) + assertTrue(masked.contains("•")) + // UK number: 13 chars, shows last 3 digits + assertEquals("+44•••••••456", masked) + } + + @Test + fun `maskPhoneNumber masks French phone numbers correctly`() { + val masked = maskPhoneNumber("+33612345678") + assertTrue(masked.startsWith("+33")) + assertTrue(masked.endsWith("678")) + assertTrue(masked.contains("•")) + // French number: 12 chars, shows last 3 digits + assertEquals("+33••••••678", masked) + } + + @Test + fun `maskPhoneNumber handles short phone numbers`() { + val short = "+1234567" + val masked = maskPhoneNumber(short) + assertTrue(masked.startsWith("+1")) + assertTrue(masked.contains("•")) + } + + @Test + fun `maskPhoneNumber returns original for invalid numbers`() { + assertEquals("1234567890", maskPhoneNumber("1234567890")) // No + + assertEquals("abc", maskPhoneNumber("abc")) // Not a number + assertEquals("+123", maskPhoneNumber("+123")) // Too short + } + + @Test + fun `maskPhoneNumber masks different country codes correctly`() { + // Single-digit country code (US) + val us = maskPhoneNumber("+1234567890") + assertTrue(us.startsWith("+1")) + + // Two-digit country code (UK) + val uk = maskPhoneNumber("+447911123456") + assertTrue(uk.startsWith("+44")) + + // Three-digit country code (less common, but handled) + val threeDigit = maskPhoneNumber("+8861234567890") + assertTrue(threeDigit.startsWith("+88")) + } +} + +/** + * Unit tests for [SmsEnrollmentSession]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class SmsEnrollmentSessionTest { + + @Mock + private lateinit var mockForceResendingToken: PhoneAuthProvider.ForceResendingToken + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + } + + @Test + fun `session holds all properties correctly`() { + val session = SmsEnrollmentSession( + verificationId = "test-id-123", + phoneNumber = "+1234567890", + forceResendingToken = mockForceResendingToken, + sentAt = 1000000L + ) + + assertEquals("test-id-123", session.verificationId) + assertEquals("+1234567890", session.phoneNumber) + assertEquals(mockForceResendingToken, session.forceResendingToken) + assertEquals(1000000L, session.sentAt) + } + + @Test + fun `getMaskedPhoneNumber returns masked version`() { + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = null, + sentAt = System.currentTimeMillis() + ) + + val masked = session.getMaskedPhoneNumber() + // 11 char number shows last 3 digits + assertEquals("+1••••••890", masked) + } + + @Test + fun `canResend returns false immediately after sending`() { + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = mockForceResendingToken, + sentAt = System.currentTimeMillis() + ) + + assertFalse(session.canResend()) + } + + @Test + fun `canResend returns true after delay has passed`() { + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = mockForceResendingToken, + sentAt = System.currentTimeMillis() - 31_000 // 31 seconds ago + ) + + assertTrue(session.canResend()) + } + + @Test + fun `canResend works with custom delay`() { + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = mockForceResendingToken, + sentAt = System.currentTimeMillis() - 5_000 // 5 seconds ago + ) + + assertFalse(session.canResend(10)) // 10 second delay + assertTrue(session.canResend(4)) // 4 second delay + } + + @Test + fun `getRemainingResendSeconds returns correct value`() { + val now = System.currentTimeMillis() + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = mockForceResendingToken, + sentAt = now - 10_000 // 10 seconds ago + ) + + val remaining = session.getRemainingResendSeconds(30) + // Should be around 20 seconds (30 - 10) + assertTrue(remaining in 19..21) + } + + @Test + fun `getRemainingResendSeconds returns 0 when resend is allowed`() { + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = mockForceResendingToken, + sentAt = System.currentTimeMillis() - 35_000 // 35 seconds ago + ) + + assertEquals(0, session.getRemainingResendSeconds(30)) + } + + @Test + fun `getRemainingResendSeconds works with custom delay`() { + val now = System.currentTimeMillis() + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = mockForceResendingToken, + sentAt = now - 3_000 // 3 seconds ago + ) + + val remaining = session.getRemainingResendSeconds(10) + // Should be around 7 seconds (10 - 3) + assertTrue(remaining in 6..8) + } + + @Test + fun `session without forceResendingToken can be created`() { + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = null, + sentAt = System.currentTimeMillis() + ) + + assertEquals("test-id", session.verificationId) + assertEquals(null, session.forceResendingToken) + } + + @Test + fun `session equality works correctly`() { + val session1 = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = null, + sentAt = 1000000L + ) + + val session2 = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = null, + sentAt = 1000000L + ) + + val session3 = SmsEnrollmentSession( + verificationId = "different-id", + phoneNumber = "+1234567890", + forceResendingToken = null, + sentAt = 1000000L + ) + + assertEquals(session1, session2) + assertFalse(session1 == session3) + } + + @Test + fun `session copy works correctly`() { + val original = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = null, + sentAt = 1000000L + ) + + val copied = original.copy(verificationId = "new-id") + + assertEquals("new-id", copied.verificationId) + assertEquals("+1234567890", copied.phoneNumber) + assertEquals("test-id", original.verificationId) // Original unchanged + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/mfa/TotpEnrollmentHandlerTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/mfa/TotpEnrollmentHandlerTest.kt new file mode 100644 index 000000000..d0218d450 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/mfa/TotpEnrollmentHandlerTest.kt @@ -0,0 +1,107 @@ +/* + * 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.mfa + +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactor +import com.google.firebase.auth.TotpMultiFactorGenerator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [TotpEnrollmentHandler]. + * + * Note: Full integration tests for secret generation and enrollment require + * mocking static Firebase methods, which is complex with Mockito. + * These tests focus on validation logic and constants. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class TotpEnrollmentHandlerTest { + + @Mock + private lateinit var mockAuth: FirebaseAuth + @Mock + private lateinit var mockUser: FirebaseUser + @Mock + private lateinit var mockMultiFactor: MultiFactor + private lateinit var handler: TotpEnrollmentHandler + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + `when`(mockUser.multiFactor).thenReturn(mockMultiFactor) + handler = TotpEnrollmentHandler(mockAuth, mockUser) + } + + @Test + fun `isValidCodeFormat returns true for valid 6-digit codes`() { + // Valid codes + assertTrue(handler.isValidCodeFormat("123456")) + assertTrue(handler.isValidCodeFormat("000000")) + assertTrue(handler.isValidCodeFormat("999999")) + assertTrue(handler.isValidCodeFormat("123456")) + } + + @Test + fun `isValidCodeFormat returns false for empty or blank codes`() { + assertFalse(handler.isValidCodeFormat("")) + assertFalse(handler.isValidCodeFormat(" ")) + assertFalse(handler.isValidCodeFormat(" ")) + } + + @Test + fun `isValidCodeFormat returns false for codes with wrong length`() { + assertFalse(handler.isValidCodeFormat("12345")) // Too short + assertFalse(handler.isValidCodeFormat("1234567")) // Too long + assertFalse(handler.isValidCodeFormat("1")) // Way too short + assertFalse(handler.isValidCodeFormat("12345678901234567890")) // Way too long + } + + @Test + fun `isValidCodeFormat returns false for codes with non-digit characters`() { + assertFalse(handler.isValidCodeFormat("12345a")) // Contains letter + assertFalse(handler.isValidCodeFormat("12 34 56")) // Contains spaces + assertFalse(handler.isValidCodeFormat("abc123")) // Contains letters + assertFalse(handler.isValidCodeFormat("12-345")) // Contains dash + assertFalse(handler.isValidCodeFormat("12.345")) // Contains dot + assertFalse(handler.isValidCodeFormat("123!56")) // Contains special char + } + + @Test + fun `constants have expected values`() { + assertEquals(6, TotpEnrollmentHandler.TOTP_CODE_LENGTH) + assertEquals(30, TotpEnrollmentHandler.TOTP_TIME_INTERVAL_SECONDS) + assertEquals(TotpMultiFactorGenerator.FACTOR_ID, TotpEnrollmentHandler.FACTOR_ID) + } + + @Test + fun `handler is created with correct auth and user references`() { + // Verify handler can be instantiated + val newHandler = TotpEnrollmentHandler(mockAuth, mockUser) + // Basic smoke test - if we get here, construction succeeded + assertTrue(newHandler.isValidCodeFormat("123456")) + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/mfa/TotpSecretTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/mfa/TotpSecretTest.kt new file mode 100644 index 000000000..9a28d5c77 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/mfa/TotpSecretTest.kt @@ -0,0 +1,103 @@ +/* + * 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.mfa + +import com.google.firebase.auth.TotpSecret as FirebaseTotpSecret +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class TotpSecretTest { + + @Mock + private lateinit var mockFirebaseTotpSecret: FirebaseTotpSecret + private lateinit var totpSecret: TotpSecret + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + totpSecret = TotpSecret.from(mockFirebaseTotpSecret) + } + + @Test + fun `sharedSecretKey returns value from Firebase TOTP secret`() { + // Given + val expectedSecret = "JBSWY3DPEHPK3PXP" + `when`(mockFirebaseTotpSecret.sharedSecretKey).thenReturn(expectedSecret) + + // When + val result = totpSecret.sharedSecretKey + + // Then + assertEquals(expectedSecret, result) + } + + @Test + fun `generateQrCodeUrl generates correct URL format`() { + // Given + val accountName = "user@example.com" + val issuer = "MyApp" + val expectedUrl = "otpauth://totp/MyApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp&algorithm=SHA1&digits=6&period=30" + `when`(mockFirebaseTotpSecret.generateQrCodeUrl(accountName, issuer)).thenReturn(expectedUrl) + + // When + val result = totpSecret.generateQrCodeUrl(accountName, issuer) + + // Then + assertEquals(expectedUrl, result) + verify(mockFirebaseTotpSecret).generateQrCodeUrl(accountName, issuer) + } + + @Test + fun `openInOtpApp calls Firebase TOTP secret method`() { + // Given + val qrCodeUrl = "otpauth://totp/MyApp:user@example.com?secret=JBSWY3DPEHPK3PXP" + + // When + totpSecret.openInOtpApp(qrCodeUrl) + + // Then + verify(mockFirebaseTotpSecret).openInOtpApp(qrCodeUrl) + } + + @Test + fun `getFirebaseTotpSecret returns the underlying Firebase TOTP secret`() { + // When + val result = totpSecret.getFirebaseTotpSecret() + + // Then + assertEquals(mockFirebaseTotpSecret, result) + } + + @Test + fun `from creates TotpSecret instance from Firebase TOTP secret`() { + // When + val result = TotpSecret.from(mockFirebaseTotpSecret) + + // Then + assertNotNull(result) + assertEquals(mockFirebaseTotpSecret, result.getFirebaseTotpSecret()) + } +} 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..6382bf6dd --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt @@ -0,0 +1,546 @@ +/* + * 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.R +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.auth_provider.Provider +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset +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 + +/** + * 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( + emailLinkActionCodeSettings = 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 Apple provider with custom locale correctly`() { + val provider = AuthProvider.Apple( + locale = "es", // Spanish locale + customParameters = emptyMap() + ) + + composeTestRule.setContent { + AuthProviderButton( + provider = provider, + onClick = { clickedProvider = provider }, + stringProvider = stringProvider // Default stringProvider (English) + ) + } + + // Should display Spanish text despite English stringProvider + composeTestRule + .onNodeWithText("Iniciar sesión con Apple") + .assertIsDisplayed() + .assertHasClickAction() + .assertIsEnabled() + } + + @Test + fun `AuthProviderButton displays GenericOAuth provider with custom label`() { + val customLabel = "Sign in with Custom Provider" + val provider = AuthProvider.GenericOAuth( + providerName = "Generic Provider", + 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( + providerName = "Generic Provider", + 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( + providerName = "Generic Provider", + 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(providerId = "unknown.provider", providerName = "Generic 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( + providerName = "Generic Provider", + 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( + providerName = "Generic Provider", + 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(providerId = "unknown.provider", providerName = "Generic 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/AuthTextFieldTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt new file mode 100644 index 000000000..8c22818c3 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt @@ -0,0 +1,451 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.components + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [AuthTextField] covering UI interactions, validation, + * password visibility toggle, and error states. + * + * @suppress Internal test class + */ +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class AuthTextFieldTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var context: Context + private lateinit var stringProvider: AuthUIStringProvider + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + } + + // ============================================================================================= + // Basic UI Tests + // ============================================================================================= + + @Test + fun `AuthTextField displays correctly with basic configuration`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Name") } + ) + } + + composeTestRule + .onNodeWithText("Name") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField displays initial value`() { + composeTestRule.setContent { + AuthTextField( + value = "test@example.com", + onValueChange = { }, + label = { Text("Email") } + ) + } + + composeTestRule + .onNodeWithText("Email") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("test@example.com") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField updates value on text input`() { + composeTestRule.setContent { + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Email") } + ) + } + + composeTestRule + .onNodeWithText("Email") + .performTextInput("test@example.com") + + composeTestRule + .onNodeWithText("Email") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("test@example.com") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField respects enabled state`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") }, + enabled = false + ) + } + + composeTestRule + .onNodeWithText("Email") + .assertIsNotEnabled() + } + + @Test + fun `AuthTextField is enabled by default`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") } + ) + } + + composeTestRule + .onNodeWithText("Email") + .assertIsEnabled() + } + + @Test + fun `AuthTextField displays leading icon when provided`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Email Icon" + ) + } + ) + } + + composeTestRule + .onNodeWithContentDescription("Email Icon") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField displays custom trailing icon when provided`() { + composeTestRule.setContent { + AuthTextField( + value = "", + onValueChange = { }, + label = { Text("Email") }, + trailingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Custom Trailing Icon" + ) + } + ) + } + + composeTestRule + .onNodeWithContentDescription("Custom Trailing Icon") + .assertIsDisplayed() + } + + // ============================================================================================= + // Validation Tests + // ============================================================================================= + + @Test + fun `AuthTextField validates email correctly with EmailValidator`() { + composeTestRule.setContent { + val emailValidator = remember { + EmailValidator(stringProvider = stringProvider) + } + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Email") }, + validator = emailValidator + ) + } + + composeTestRule + .onNodeWithText("Email") + .performTextInput("invalid-email") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.invalidEmailAddress) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Email") + .performTextClearance() + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.missingEmailAddress) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Email") + .performTextInput("valid@example.com") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.missingEmailAddress) + .assertIsNotDisplayed() + composeTestRule + .onNodeWithText(stringProvider.invalidEmailAddress) + .assertIsNotDisplayed() + } + + @Test + fun `AuthTextField displays custom error message when provided`() { + composeTestRule.setContent { + val emailValidator = remember { + EmailValidator(stringProvider = stringProvider) + } + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Email") }, + validator = emailValidator, + errorMessage = "Custom error message" + ) + } + + composeTestRule + .onNodeWithText("Email") + .performTextInput("invalid") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText("Custom error message") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField validates password with PasswordValidator`() { + composeTestRule.setContent { + val passwordValidator = remember { + PasswordValidator( + stringProvider = stringProvider, + rules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireUppercase, + PasswordRule.RequireLowercase + ) + ) + } + val textValue = remember { mutableStateOf("") } + AuthTextField( + value = textValue.value, + onValueChange = { textValue.value = it }, + label = { Text("Password") }, + validator = passwordValidator + ) + } + + composeTestRule + .onNodeWithText("Password") + .performTextInput("short") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.passwordTooShort(8)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Password") + .performTextClearance() + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.invalidPassword) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Password") + .performTextInput("pass@1234") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.passwordMissingUppercase) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Password") + .performTextInput("ValidPass123") + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(stringProvider.passwordTooShort(8)) + .assertIsNotDisplayed() + composeTestRule + .onNodeWithText(stringProvider.passwordMissingLowercase) + .assertIsNotDisplayed() + composeTestRule + .onNodeWithText(stringProvider.passwordMissingUppercase) + .assertIsNotDisplayed() + } + + // ============================================================================================= + // Password Visibility Toggle Tests + // ============================================================================================= + + @Test + fun `AuthTextField shows password visibility toggle when isSecureTextField`() { + composeTestRule.setContent { + AuthTextField( + value = "password123", + onValueChange = { }, + label = { Text("Password") }, + isSecureTextField = true, + validator = PasswordValidator( + stringProvider = stringProvider, + rules = emptyList() + ) + ) + } + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField toggles password visibility when icon is clicked`() { + composeTestRule.setContent { + AuthTextField( + value = "password123", + onValueChange = { }, + label = { Text("Password") }, + isSecureTextField = true, + validator = PasswordValidator( + stringProvider = stringProvider, + rules = emptyList() + ) + ) + } + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertIsDisplayed() + .performClick() + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithContentDescription("Hide password") + .assertIsDisplayed() + } + + @Test + fun `AuthTextField hides password visibility toggle for non-password fields`() { + composeTestRule.setContent { + AuthTextField( + value = "test@example.com", + onValueChange = { }, + label = { Text("Email") }, + ) + } + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertDoesNotExist() + + composeTestRule + .onNodeWithContentDescription("Hide password") + .assertDoesNotExist() + } + + @Test + fun `AuthTextField respects custom trailing icon over password toggle`() { + composeTestRule.setContent { + AuthTextField( + value = "password123", + onValueChange = { }, + label = { Text("Password") }, + validator = PasswordValidator( + stringProvider = stringProvider, + rules = emptyList() + ), + trailingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Custom Icon" + ) + } + ) + } + + composeTestRule + .onNodeWithContentDescription("Custom Icon") + .assertIsDisplayed() + + composeTestRule + .onNodeWithContentDescription("Show password") + .assertDoesNotExist() + } +} diff --git a/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..4c9ce634b --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt @@ -0,0 +1,328 @@ +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.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +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 + } + + private fun setContentWithStringProvider(content: @Composable () -> Unit) { + composeTestRule.setContent { + CompositionLocalProvider( + LocalAuthUIStringProvider provides DefaultAuthUIStringProvider(context) + ) { + content() + } + } + } + + // ============================================================================================= + // Basic UI Tests + // ============================================================================================= + + @Test + fun `AuthMethodPicker displays all providers`() { + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null), + AuthProvider.Facebook(), + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + + setContentWithStringProvider { + 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) + ) + + setContentWithStringProvider { + 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) + ) + + setContentWithStringProvider { + 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) + ) + + setContentWithStringProvider { + 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) + ) + + setContentWithStringProvider { + 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) + + setContentWithStringProvider { + 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 + + setContentWithStringProvider { + 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 + + setContentWithStringProvider { + 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) + + setContentWithStringProvider { + 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( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ), + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ), + AuthProvider.Anonymous + ) + + setContentWithStringProvider { + 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/auth/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreenTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreenTest.kt new file mode 100644 index 000000000..a28dae928 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreenTest.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.ui.screens + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.createComposeRule +import com.firebase.ui.auth.compose.configuration.MfaFactor +import com.firebase.ui.auth.compose.mfa.MfaChallengeContentState +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.MultiFactorResolver +import com.google.firebase.auth.PhoneMultiFactorInfo +import com.google.firebase.auth.TotpMultiFactorInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * Unit tests for [MfaChallengeScreen]. + * + * These tests focus on the state management logic and callbacks provided + * through the content slot for MFA challenge flow. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [28]) +class MfaChallengeScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Mock + private lateinit var mockAuth: FirebaseAuth + + @Mock + private lateinit var mockResolver: MultiFactorResolver + + @Mock + private lateinit var mockPhoneMultiFactorInfo: PhoneMultiFactorInfo + + @Mock + private lateinit var mockTotpMultiFactorInfo: TotpMultiFactorInfo + + @Mock + private lateinit var mockFirebaseApp: FirebaseApp + + private lateinit var capturedState: MfaChallengeContentState + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + FirebaseApp.initializeApp(RuntimeEnvironment.getApplication()) + `when`(mockAuth.app).thenReturn(mockFirebaseApp) + } + + @Test + fun `screen detects SMS factor type from phone hint`() { + `when`(mockResolver.hints).thenReturn(listOf(mockPhoneMultiFactorInfo)) + `when`(mockPhoneMultiFactorInfo.factorId).thenReturn("phone") + `when`(mockPhoneMultiFactorInfo.phoneNumber).thenReturn("+1234567890") + + composeTestRule.setContent { + MfaChallengeScreen( + resolver = mockResolver, + auth = mockAuth, + onSuccess = {}, + onCancel = {} + ) { state -> + capturedState = state + } + } + + composeTestRule.waitForIdle() + assertEquals(MfaFactor.Sms, capturedState.factorType) + } + + @Test + fun `screen detects TOTP factor type from totp hint`() { + `when`(mockResolver.hints).thenReturn(listOf(mockTotpMultiFactorInfo)) + `when`(mockTotpMultiFactorInfo.factorId).thenReturn("totp") + + composeTestRule.setContent { + MfaChallengeScreen( + resolver = mockResolver, + auth = mockAuth, + onSuccess = {}, + onCancel = {} + ) { state -> + capturedState = state + } + } + + composeTestRule.waitForIdle() + assertEquals(MfaFactor.Totp, capturedState.factorType) + } + + @Test + fun `screen shows masked phone number for SMS factor`() { + `when`(mockResolver.hints).thenReturn(listOf(mockPhoneMultiFactorInfo)) + `when`(mockPhoneMultiFactorInfo.factorId).thenReturn("phone") + `when`(mockPhoneMultiFactorInfo.phoneNumber).thenReturn("+1234567890") + + composeTestRule.setContent { + MfaChallengeScreen( + resolver = mockResolver, + auth = mockAuth, + onSuccess = {}, + onCancel = {} + ) { state -> + capturedState = state + } + } + + composeTestRule.waitForIdle() + assertNotNull(capturedState.maskedPhoneNumber) + assertTrue(capturedState.maskedPhoneNumber!!.contains("•")) + } + + @Test + fun `screen shows null masked phone for TOTP factor`() { + `when`(mockResolver.hints).thenReturn(listOf(mockTotpMultiFactorInfo)) + `when`(mockTotpMultiFactorInfo.factorId).thenReturn("totp") + + composeTestRule.setContent { + MfaChallengeScreen( + resolver = mockResolver, + auth = mockAuth, + onSuccess = {}, + onCancel = {} + ) { state -> + capturedState = state + } + } + + composeTestRule.waitForIdle() + assertNull(capturedState.maskedPhoneNumber) + } + + @Test + fun `verification code change updates state`() { + `when`(mockResolver.hints).thenReturn(listOf(mockTotpMultiFactorInfo)) + `when`(mockTotpMultiFactorInfo.factorId).thenReturn("totp") + + var currentState by mutableStateOf(null) + + composeTestRule.setContent { + MfaChallengeScreen( + resolver = mockResolver, + auth = mockAuth, + onSuccess = {}, + onCancel = {} + ) { state -> + currentState = state + } + } + + composeTestRule.waitForIdle() + assertEquals("", currentState?.verificationCode) + + composeTestRule.runOnUiThread { + currentState?.onVerificationCodeChange?.invoke("123456") + } + + composeTestRule.waitForIdle() + assertEquals("123456", currentState?.verificationCode) + } + + @Test + fun `resend callback is available for SMS factor`() { + `when`(mockResolver.hints).thenReturn(listOf(mockPhoneMultiFactorInfo)) + `when`(mockPhoneMultiFactorInfo.factorId).thenReturn("phone") + `when`(mockPhoneMultiFactorInfo.phoneNumber).thenReturn("+1234567890") + + composeTestRule.setContent { + MfaChallengeScreen( + resolver = mockResolver, + auth = mockAuth, + onSuccess = {}, + onCancel = {} + ) { state -> + capturedState = state + } + } + + composeTestRule.waitForIdle() + assertNotNull(capturedState.onResendCodeClick) + assertTrue(capturedState.canResend) + } + + @Test + fun `resend callback is null for TOTP factor`() { + `when`(mockResolver.hints).thenReturn(listOf(mockTotpMultiFactorInfo)) + `when`(mockTotpMultiFactorInfo.factorId).thenReturn("totp") + + composeTestRule.setContent { + MfaChallengeScreen( + resolver = mockResolver, + auth = mockAuth, + onSuccess = {}, + onCancel = {} + ) { state -> + capturedState = state + } + } + + composeTestRule.waitForIdle() + assertNull(capturedState.onResendCodeClick) + assertFalse(capturedState.canResend) + } + + @Test + fun `state validation works correctly`() { + `when`(mockResolver.hints).thenReturn(listOf(mockTotpMultiFactorInfo)) + `when`(mockTotpMultiFactorInfo.factorId).thenReturn("totp") + + var currentState by mutableStateOf(null) + + composeTestRule.setContent { + MfaChallengeScreen( + resolver = mockResolver, + auth = mockAuth, + onSuccess = {}, + onCancel = {} + ) { state -> + currentState = state + } + } + + composeTestRule.waitForIdle() + + // Invalid when code is empty + assertFalse(currentState?.isValid ?: true) + + composeTestRule.runOnUiThread { + currentState?.onVerificationCodeChange?.invoke("12345") + } + + composeTestRule.waitForIdle() + + // Invalid when code is too short + assertFalse(currentState?.isValid ?: true) + + composeTestRule.runOnUiThread { + currentState?.onVerificationCodeChange?.invoke("123456") + } + + composeTestRule.waitForIdle() + + // Valid when code is 6 digits + assertTrue(currentState?.isValid ?: false) + } + + @Test + fun `cancel callback is invoked correctly`() { + `when`(mockResolver.hints).thenReturn(listOf(mockTotpMultiFactorInfo)) + `when`(mockTotpMultiFactorInfo.factorId).thenReturn("totp") + + var cancelCalled = false + + composeTestRule.setContent { + MfaChallengeScreen( + resolver = mockResolver, + auth = mockAuth, + onSuccess = {}, + onCancel = { cancelCalled = true } + ) { state -> + capturedState = state + } + } + + composeTestRule.waitForIdle() + + composeTestRule.runOnUiThread { + capturedState.onCancelClick() + } + + composeTestRule.waitForIdle() + assertTrue(cancelCalled) + } + + @Test + fun `error clears when verification code changes`() { + `when`(mockResolver.hints).thenReturn(listOf(mockTotpMultiFactorInfo)) + `when`(mockTotpMultiFactorInfo.factorId).thenReturn("totp") + + var currentState by mutableStateOf(null) + + composeTestRule.setContent { + MfaChallengeScreen( + resolver = mockResolver, + auth = mockAuth, + onSuccess = {}, + onCancel = {} + ) { state -> + currentState = state + } + } + + composeTestRule.waitForIdle() + + // Initially no error + assertNull(currentState?.error) + assertFalse(currentState?.hasError ?: true) + + // Change verification code + composeTestRule.runOnUiThread { + currentState?.onVerificationCodeChange?.invoke("123456") + } + + composeTestRule.waitForIdle() + + // Error should still be null + assertNull(currentState?.error) + assertFalse(currentState?.hasError ?: true) + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentScreenTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentScreenTest.kt new file mode 100644 index 000000000..fda9586cf --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentScreenTest.kt @@ -0,0 +1,361 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.screens + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.createComposeRule +import com.firebase.ui.auth.compose.configuration.MfaConfiguration +import com.firebase.ui.auth.compose.configuration.MfaFactor +import com.firebase.ui.auth.compose.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.compose.mfa.MfaEnrollmentStep +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * Unit tests for [MfaEnrollmentScreen]. + * + * These tests focus on the state management logic and callbacks provided + * through the content slot. UI rendering is not tested here. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [28]) +class MfaEnrollmentScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Mock + private lateinit var mockAuth: FirebaseAuth + + @Mock + private lateinit var mockUser: FirebaseUser + + @Mock + private lateinit var mockFirebaseApp: FirebaseApp + + @Mock + private lateinit var mockMultiFactor: com.google.firebase.auth.MultiFactor + + private lateinit var capturedState: MfaEnrollmentContentState + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + FirebaseApp.initializeApp(RuntimeEnvironment.getApplication()) + `when`(mockAuth.app).thenReturn(mockFirebaseApp) + `when`(mockFirebaseApp.name).thenReturn("TestApp") + `when`(mockUser.email).thenReturn("test@example.com") + `when`(mockUser.multiFactor).thenReturn(mockMultiFactor) + `when`(mockMultiFactor.enrolledFactors).thenReturn(emptyList()) + } + + @Test + fun `screen starts at SelectFactor step with multiple factors`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp), + requireEnrollment = false + ) + + composeTestRule.setContent { + MfaEnrollmentScreen( + user = mockUser, + auth = mockAuth, + configuration = configuration, + onComplete = {}, + onSkip = {} + ) { state -> + capturedState = state + } + } + + composeTestRule.waitForIdle() + assertEquals(MfaEnrollmentStep.SelectFactor, capturedState.step) + assertEquals(2, capturedState.availableFactors.size) + assertNotNull(capturedState.onSkipClick) + } + + @Test + fun `screen skips SelectFactor with single SMS factor`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms), + requireEnrollment = false + ) + + composeTestRule.setContent { + MfaEnrollmentScreen( + user = mockUser, + auth = mockAuth, + configuration = configuration, + onComplete = {}, + onSkip = {} + ) { state -> + capturedState = state + } + } + + composeTestRule.waitForIdle() + assertEquals(MfaEnrollmentStep.ConfigureSms, capturedState.step) + } + + @Test + fun `skip button is null when enrollment is required`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp), + requireEnrollment = true + ) + + composeTestRule.setContent { + MfaEnrollmentScreen( + user = mockUser, + auth = mockAuth, + configuration = configuration, + onComplete = {}, + onSkip = {} + ) { state -> + capturedState = state + } + } + + composeTestRule.waitForIdle() + assertNull(capturedState.onSkipClick) + assertFalse(capturedState.canSkip) + } + + @Test + fun `selecting SMS factor navigates to ConfigureSms step`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp) + ) + + var currentState by mutableStateOf(null) + + composeTestRule.setContent { + MfaEnrollmentScreen( + user = mockUser, + auth = mockAuth, + configuration = configuration, + onComplete = {} + ) { state -> + currentState = state + } + } + + composeTestRule.waitForIdle() + assertEquals(MfaEnrollmentStep.SelectFactor, currentState?.step) + + composeTestRule.runOnUiThread { + currentState?.onFactorSelected?.invoke(MfaFactor.Sms) + } + + composeTestRule.waitForIdle() + assertEquals(MfaEnrollmentStep.ConfigureSms, currentState?.step) + } + + @Test + fun `phone number change updates state`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms) + ) + + var currentState by mutableStateOf(null) + + composeTestRule.setContent { + MfaEnrollmentScreen( + user = mockUser, + auth = mockAuth, + configuration = configuration, + onComplete = {} + ) { state -> + currentState = state + } + } + + composeTestRule.waitForIdle() + assertEquals("", currentState?.phoneNumber) + + composeTestRule.runOnUiThread { + currentState?.onPhoneNumberChange?.invoke("1234567890") + } + + composeTestRule.waitForIdle() + assertEquals("1234567890", currentState?.phoneNumber) + } + + @Test + fun `verification code change updates state`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms) + ) + + var currentState by mutableStateOf(null) + + composeTestRule.setContent { + MfaEnrollmentScreen( + user = mockUser, + auth = mockAuth, + configuration = configuration, + onComplete = {} + ) { state -> + currentState = state + } + } + + composeTestRule.waitForIdle() + + // Navigate to verify step manually by updating state + composeTestRule.runOnUiThread { + currentState?.onPhoneNumberChange?.invoke("1234567890") + } + + composeTestRule.waitForIdle() + + composeTestRule.runOnUiThread { + currentState?.onVerificationCodeChange?.invoke("123456") + } + + composeTestRule.waitForIdle() + assertEquals("123456", currentState?.verificationCode) + } + + @Test + fun `back navigation works from ConfigureSms to SelectFactor`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp) + ) + + var currentState by mutableStateOf(null) + + composeTestRule.setContent { + MfaEnrollmentScreen( + user = mockUser, + auth = mockAuth, + configuration = configuration, + onComplete = {} + ) { state -> + currentState = state + } + } + + composeTestRule.waitForIdle() + + composeTestRule.runOnUiThread { + currentState?.onFactorSelected?.invoke(MfaFactor.Sms) + } + + composeTestRule.waitForIdle() + assertEquals(MfaEnrollmentStep.ConfigureSms, currentState?.step) + + composeTestRule.runOnUiThread { + currentState?.onBackClick?.invoke() + } + + composeTestRule.waitForIdle() + assertEquals(MfaEnrollmentStep.SelectFactor, currentState?.step) + } + + @Test + fun `state validation works correctly`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms) + ) + + var currentState by mutableStateOf(null) + + composeTestRule.setContent { + MfaEnrollmentScreen( + user = mockUser, + auth = mockAuth, + configuration = configuration, + onComplete = {} + ) { state -> + currentState = state + } + } + + composeTestRule.waitForIdle() + + // ConfigureSms step - invalid when phone is blank + assertFalse(currentState?.isValid ?: true) + + composeTestRule.runOnUiThread { + currentState?.onPhoneNumberChange?.invoke("1234567890") + } + + composeTestRule.waitForIdle() + + // ConfigureSms step - valid when phone is not blank + assertTrue(currentState?.isValid ?: false) + } + + @Test + fun `canGoBack returns false for SelectFactor step`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp) + ) + + composeTestRule.setContent { + MfaEnrollmentScreen( + user = mockUser, + auth = mockAuth, + configuration = configuration, + onComplete = {} + ) { state -> + capturedState = state + } + } + + composeTestRule.waitForIdle() + assertFalse(capturedState.canGoBack) + } + + @Test + fun `canGoBack returns true for ConfigureSms step`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms) + ) + + composeTestRule.setContent { + MfaEnrollmentScreen( + user = mockUser, + auth = mockAuth, + configuration = configuration, + onComplete = {} + ) { state -> + capturedState = state + } + } + + composeTestRule.waitForIdle() + assertTrue(capturedState.canGoBack) + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/util/MockPersistenceManager.kt b/auth/src/test/java/com/firebase/ui/auth/compose/util/MockPersistenceManager.kt new file mode 100644 index 000000000..be374502d --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/util/MockPersistenceManager.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.util + +import android.content.Context +import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager.SessionRecord + +/** + * Mock implementation of [PersistenceManager] for testing. + * Uses in-memory storage instead of DataStore. + */ +class MockPersistenceManager : PersistenceManager { + + private var sessionRecord: SessionRecord? = null + + override suspend fun saveEmail( + context: Context, + email: String, + sessionId: String, + anonymousUserId: String? + ) { + sessionRecord = SessionRecord( + sessionId = sessionId, + email = email, + anonymousUserId = anonymousUserId, + credentialForLinking = sessionRecord?.credentialForLinking + ) + } + + override suspend fun saveCredentialForLinking( + context: Context, + providerType: String, + idToken: String?, + accessToken: String? + ) { + // For mock, we don't reconstruct the credential - just store nulls + // Real tests can override this method if needed + } + + override suspend fun retrieveSessionRecord(context: Context): SessionRecord? { + return sessionRecord + } + + override suspend fun clear(context: Context) { + sessionRecord = null + } + + /** + * Helper method to set a custom session record for testing. + */ + fun setSessionRecord(record: SessionRecord?) { + sessionRecord = record + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java b/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java index 2efed02da..2311a45b8 100644 --- a/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java +++ b/auth/src/test/java/com/firebase/ui/auth/testhelpers/TestHelper.java @@ -45,6 +45,7 @@ import static com.firebase.ui.auth.AuthUI.EMAIL_LINK_PROVIDER; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; @@ -75,11 +76,19 @@ public static void initialize() { } private static void spyContextAndResources() { - CONTEXT = spy(CONTEXT); - when(CONTEXT.getApplicationContext()) - .thenReturn(CONTEXT); - Resources spiedResources = spy(CONTEXT.getResources()); - when(CONTEXT.getResources()).thenReturn(spiedResources); + // In Mockito 5.x with inline mock maker, we can spy on final classes + // Use doReturn().when() pattern to avoid calling real methods during stubbing + if (!org.mockito.Mockito.mockingDetails(CONTEXT).isSpy()) { + CONTEXT = spy(CONTEXT); + } + doReturn(CONTEXT).when(CONTEXT).getApplicationContext(); + + // Get and spy on Resources, ensuring the spy is properly returned + Resources originalResources = CONTEXT.getResources(); + if (!org.mockito.Mockito.mockingDetails(originalResources).isSpy()) { + Resources spiedResources = spy(originalResources); + doReturn(spiedResources).when(CONTEXT).getResources(); + } } private static void initializeApp(Context context) { @@ -94,9 +103,9 @@ private static void initializeApp(Context context) { } private static void initializeProviders() { - when(CONTEXT.getString(R.string.firebase_web_host)).thenReturn("abc"); - when(CONTEXT.getString(R.string.default_web_client_id)).thenReturn("abc"); - when(CONTEXT.getString(R.string.facebook_application_id)).thenReturn("abc"); + doReturn("abc").when(CONTEXT).getString(R.string.firebase_web_host); + doReturn("abc").when(CONTEXT).getString(R.string.default_web_client_id); + doReturn("abc").when(CONTEXT).getString(R.string.facebook_application_id); } public static FirebaseUser getMockFirebaseUser() { diff --git a/build.gradle b/build.gradle index e75ec13b3..b3a5a9caa 100644 --- a/build.gradle +++ b/build.gradle @@ -1,2 +1,10 @@ -// This empty file along with settings.gradle help Android Studio recognize the project +buildscript { + dependencies { + classpath libs.kotlin.gradle.plugin + } +} +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.compose.compiler) apply false +}// This empty file along with settings.gradle help Android Studio recognize the project // as a gradle project, despite the use of .gradle.kts scripts. \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index c87edeee9..41393496e 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" + id("org.jetbrains.kotlin.plugin.compose") version Config.kotlinVersion apply false } allprojects { diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index e4aed3d45..159e9e60b 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -1,17 +1,18 @@ 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" + const val kotlinVersion = "2.2.0" + const val kotlinSerializationVersion = "1.9.0" object SdkVersions { - const val compile = 34 - const val target = 34 + const val compile = 36 + const val target = 36 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 +41,31 @@ 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" + + const val datastorePreferences = "androidx.datastore:datastore-preferences:1.1.1" + const val credentials = "androidx.credentials:credentials:1.5.0" + const val credentialsPlayServices = "androidx.credentials:credentials-play-services-auth:1.5.0" + + object Compose { + const val bom = "androidx.compose:compose-bom:2025.10.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 materialIconsExtended = "androidx.compose.material:material-icons-extended" + const val activityCompose = "androidx.activity:activity-compose:1.11.0" + } + + object Navigation { + const val nav3Runtime = "androidx.navigation3:navigation3-runtime:1.0.0-alpha08" + const val nav3UI = "androidx.navigation3:navigation3-ui:1.0.0-alpha08" + const val lifecycleViewmodelNav3 = "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha04" + } + + const val kotlinxSerialization = "org.jetbrains.kotlinx:kotlinx-serialization-core:$kotlinSerializationVersion" } object Firebase { @@ -53,11 +77,11 @@ object Config { } object PlayServices { - const val auth = "com.google.android.gms:play-services-auth:21.3.0" + const val auth = "com.google.android.gms:play-services-auth:21.4.0" } object Provider { - const val facebook = "com.facebook.android:facebook-login:8.1.0" + const val facebook = "com.facebook.android:facebook-login:18.0.3" } object Misc { @@ -70,6 +94,9 @@ object Config { const val glideCompiler = "com.github.bumptech.glide:compiler:$glideVersion" const val permissions = "pub.devrel:easypermissions:3.0.0" + const val libphonenumber = "com.googlecode.libphonenumber:libphonenumber:9.0.16" + + const val googleid = "com.google.android.libraries.identity.googleid:googleid:1.1.1" } object Test { @@ -77,12 +104,18 @@ object Config { const val junitExt = "androidx.test.ext:junit:1.1.5" const val truth = "com.google.truth:truth:0.42" const val mockito = "org.mockito:mockito-android:2.21.0" - const val robolectric = "org.robolectric:robolectric:4.14" + const val mockitoCore = "org.mockito:mockito-core:5.19.0" + const val mockitoInline = "org.mockito:mockito-inline:5.2.0" + const val mockitoKotlin = "org.mockito.kotlin:mockito-kotlin:6.0.0" + const val robolectric = "org.robolectric:robolectric:4.15.1" const val core = "androidx.test:core:1.5.0" 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" + const val composeUiTestJunit4 = "androidx.compose.ui:ui-test-junit4" } object Lint { diff --git a/composeapp/.gitignore b/composeapp/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/composeapp/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/composeapp/build.gradle.kts b/composeapp/build.gradle.kts new file mode 100644 index 000000000..c76f2ed86 --- /dev/null +++ b/composeapp/build.gradle.kts @@ -0,0 +1,77 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("com.google.gms.google-services") apply false +} + +android { + namespace = "com.firebase.composeapp" + compileSdk = Config.SdkVersions.compile + + defaultConfig { + applicationId = "com.firebase.composeapp" + minSdk = Config.SdkVersions.min + targetSdk = Config.SdkVersions.target + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + // Only sign with debug keystore if it exists (for local testing) + val debugKeystoreFile = file("${System.getProperty("user.home")}/.android/debug.keystore") + if (debugKeystoreFile.exists()) { + signingConfig = signingConfigs.getByName("debug") + } + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":auth")) + + implementation(Config.Libs.Kotlin.jvm) + implementation(Config.Libs.Androidx.lifecycleRuntime) + implementation(Config.Libs.Androidx.Compose.activityCompose) + implementation(platform(Config.Libs.Androidx.Compose.bom)) + implementation(Config.Libs.Androidx.Compose.ui) + implementation(Config.Libs.Androidx.Compose.uiGraphics) + implementation(Config.Libs.Androidx.Compose.toolingPreview) + implementation(Config.Libs.Androidx.Compose.material3) + + + // Facebook + implementation(Config.Libs.Provider.facebook) + + testImplementation(Config.Libs.Test.junit) + androidTestImplementation(Config.Libs.Test.junitExt) + androidTestImplementation(platform(Config.Libs.Androidx.Compose.bom)) + androidTestImplementation(Config.Libs.Test.composeUiTestJunit4) + + debugImplementation(Config.Libs.Androidx.Compose.tooling) + + implementation(platform(Config.Libs.Firebase.bom)) +} + +// Only apply google-services plugin if the google-services.json file exists +if (rootProject.file("composeapp/google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") +} diff --git a/composeapp/proguard-rules.pro b/composeapp/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/composeapp/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/composeapp/public/.well-known/assetlinks.json b/composeapp/public/.well-known/assetlinks.json new file mode 100644 index 000000000..12a76bc24 --- /dev/null +++ b/composeapp/public/.well-known/assetlinks.json @@ -0,0 +1,10 @@ +[{ + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.firebase.composeapp", + "sha256_cert_fingerprints": [ + "EB:06:B9:FC:79:32:D6:01:04:2D:2E:FD:AF:7D:74:6E:04:74:D4:57:DF:27:0E:B7:B2:82:56:6F:9A:CB:FD:81" + ] + } +}] diff --git a/composeapp/public/index.html b/composeapp/public/index.html new file mode 100644 index 000000000..0dcd71095 --- /dev/null +++ b/composeapp/public/index.html @@ -0,0 +1,12 @@ + + + + + + FirebaseUI Android + + +

FirebaseUI Android

+

This page is for App Links verification only.

+ + diff --git a/composeapp/src/main/AndroidManifest.xml b/composeapp/src/main/AndroidManifest.xml new file mode 100644 index 000000000..0139f125b --- /dev/null +++ b/composeapp/src/main/AndroidManifest.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeapp/src/main/ic_launcher-playstore.png b/composeapp/src/main/ic_launcher-playstore.png new file mode 100644 index 000000000..2612b90d2 Binary files /dev/null and b/composeapp/src/main/ic_launcher-playstore.png differ diff --git a/composeapp/src/main/java/com/firebase/composeapp/AuthFlowControllerDemoActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/AuthFlowControllerDemoActivity.kt new file mode 100644 index 000000000..dfdcb0cab --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/AuthFlowControllerDemoActivity.kt @@ -0,0 +1,337 @@ +package com.firebase.composeapp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.AuthFlowController +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthActivity +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.actionCodeSettings +import kotlinx.coroutines.launch + +/** + * Demo activity showcasing the AuthFlowController API for managing + * Firebase authentication with lifecycle-safe control. + * + * This demonstrates: + * - Creating an AuthFlowController with configuration + * - Starting the auth flow using ActivityResultLauncher + * - Observing auth state changes + * - Handling results (success, cancelled, error) + * - Proper lifecycle management with dispose() + */ +class AuthFlowControllerDemoActivity : ComponentActivity() { + + private lateinit var authController: AuthFlowController + + // Modern ActivityResultLauncher for auth flow + private val authLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + when (result.resultCode) { + Activity.RESULT_OK -> { + // Get user data from result + val userId = result.data?.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID) + val isNewUser = result.data?.getBooleanExtra( + FirebaseAuthActivity.EXTRA_IS_NEW_USER, + false + ) ?: false + + val user = FirebaseAuth.getInstance().currentUser + val message = if (isNewUser) { + "Welcome new user! ${user?.email ?: userId}" + } else { + "Welcome back! ${user?.email ?: userId}" + } + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + } + Activity.RESULT_CANCELED -> { + Toast.makeText(this, "Auth cancelled", Toast.LENGTH_SHORT).show() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Initialize FirebaseAuthUI + val authUI = FirebaseAuthUI.getInstance() + + // Create auth configuration + val configuration = AuthUIConfiguration( + context = applicationContext, + providers = listOf( + AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkForceSameDeviceEnabled = true, + isEmailLinkSignInEnabled = false, + emailLinkActionCodeSettings = actionCodeSettings { + url = "https://temp-test-aa342.firebaseapp.com" + handleCodeInApp = true + setAndroidPackageName( + "com.firebase.composeapp", + true, + null + ) + }, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireLowercase, + PasswordRule.RequireUppercase, + ) + ), + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = emptyList(), + smsCodeLength = 6, + timeout = 120L, + isInstantVerificationEnabled = true + ), + AuthProvider.Facebook( + applicationId = "792556260059222" + ) + ), + tosUrl = "https://policies.google.com/terms?hl=en-NG&fg=1", + privacyPolicyUrl = "https://policies.google.com/privacy?hl=en-NG&fg=1" + ) + + // Create AuthFlowController + authController = authUI.createAuthFlow(configuration) + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + AuthFlowDemo( + authController = authController, + onStartAuth = { startAuthFlow() }, + onCancelAuth = { cancelAuthFlow() } + ) + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + // Clean up resources + authController.dispose() + } + + private fun startAuthFlow() { + val intent = authController.createIntent(this) + authLauncher.launch(intent) + } + + private fun cancelAuthFlow() { + authController.cancel() + Toast.makeText(this, "Auth flow cancelled", Toast.LENGTH_SHORT).show() + } + + companion object { + fun createIntent(context: Context): Intent { + return Intent(context, AuthFlowControllerDemoActivity::class.java) + } + } +} + +@Composable +fun AuthFlowDemo( + authController: AuthFlowController, + onStartAuth: () -> Unit, + onCancelAuth: () -> Unit +) { + val authState by authController.authStateFlow.collectAsState(AuthState.Idle) + var currentUser by remember { mutableStateOf(FirebaseAuth.getInstance().currentUser) } + + // Observe Firebase auth state changes + DisposableEffect(Unit) { + val authStateListener = FirebaseAuth.AuthStateListener { auth -> + currentUser = auth.currentUser + } + FirebaseAuth.getInstance().addAuthStateListener(authStateListener) + + onDispose { + FirebaseAuth.getInstance().removeAuthStateListener(authStateListener) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically) + ) { + Text( + text = "⚙️ Low-Level API Demo", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Text( + text = "AuthFlowController with ActivityResultLauncher", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary + ) + + Text( + text = "This demonstrates manual control over the authentication flow with lifecycle-safe management.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Current Auth State Card + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Current State:", + style = MaterialTheme.typography.labelLarge + ) + Text( + text = when (authState) { + is AuthState.Idle -> "Idle" + is AuthState.Loading -> "Loading: ${(authState as AuthState.Loading).message}" + is AuthState.Success -> "Success - User: ${(authState as AuthState.Success).user.email}" + is AuthState.Error -> "Error: ${(authState as AuthState.Error).exception.message}" + is AuthState.Cancelled -> "Cancelled" + is AuthState.RequiresMfa -> "MFA Required" + is AuthState.RequiresEmailVerification -> "Email Verification Required" + else -> "Unknown" + }, + style = MaterialTheme.typography.bodyMedium, + color = when (authState) { + is AuthState.Success -> MaterialTheme.colorScheme.primary + is AuthState.Error -> MaterialTheme.colorScheme.error + is AuthState.Loading -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } + + // Current User Card + currentUser?.let { user -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Signed In User:", + style = MaterialTheme.typography.labelLarge + ) + Text( + text = "Email: ${user.email ?: "N/A"}", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "UID: ${user.uid}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Action Buttons + if (currentUser == null) { + Button( + onClick = onStartAuth, + modifier = Modifier.fillMaxWidth() + ) { + Text("Start Auth Flow") + } + + if (authState is AuthState.Loading) { + OutlinedButton( + onClick = onCancelAuth, + modifier = Modifier.fillMaxWidth() + ) { + Text("Cancel Auth Flow") + } + } + } else { + Button( + onClick = { + FirebaseAuth.getInstance().signOut() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Sign Out") + } + } + + // Info Card + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Features:", + style = MaterialTheme.typography.labelLarge + ) + Text( + text = "• Lifecycle-safe auth flow management", + style = MaterialTheme.typography.bodySmall + ) + Text( + text = "• Observable auth state with Flow", + style = MaterialTheme.typography.bodySmall + ) + Text( + text = "• Modern ActivityResultLauncher API", + style = MaterialTheme.typography.bodySmall + ) + Text( + text = "• Automatic resource cleanup", + style = MaterialTheme.typography.bodySmall + ) + } + } + } +} diff --git a/composeapp/src/main/java/com/firebase/composeapp/CustomSlotsThemingDemoActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/CustomSlotsThemingDemoActivity.kt new file mode 100644 index 000000000..75e9cb7d5 --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/CustomSlotsThemingDemoActivity.kt @@ -0,0 +1,825 @@ +package com.firebase.composeapp + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.ui.screens.EmailAuthContentState +import com.firebase.ui.auth.compose.ui.screens.EmailAuthMode +import com.firebase.ui.auth.compose.ui.screens.EmailAuthScreen +import com.firebase.ui.auth.compose.ui.screens.phone.PhoneAuthContentState +import com.firebase.ui.auth.compose.ui.screens.phone.PhoneAuthScreen +import com.firebase.ui.auth.compose.ui.screens.phone.PhoneAuthStep +import com.google.firebase.auth.AuthResult + +/** + * Demo activity showcasing custom slots and theming capabilities: + * - EmailAuthScreen with custom slot UI + * - PhoneAuthScreen with custom slot UI + * - AuthUITheme.fromMaterialTheme() with custom ProviderStyle overrides + */ +class CustomSlotsThemingDemoActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val authUI = FirebaseAuthUI.getInstance() + val appContext = applicationContext + + // Configuration for email authentication + val emailConfiguration = authUIConfiguration { + context = appContext + providers { + provider( + AuthProvider.Email( + isDisplayNameRequired = true, + isNewAccountsAllowed = true, + isEmailLinkSignInEnabled = false, + emailLinkActionCodeSettings = null, + isEmailLinkForceSameDeviceEnabled = false, + minimumPasswordLength = 8, + passwordValidationRules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireLowercase, + PasswordRule.RequireUppercase, + PasswordRule.RequireDigit + ) + ) + ) + } + tosUrl = "https://policies.google.com/terms" + privacyPolicyUrl = "https://policies.google.com/privacy" + } + + // Configuration for phone authentication + val phoneConfiguration = authUIConfiguration { + context = appContext + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = "US", + allowedCountries = emptyList(), + smsCodeLength = 6, + timeout = 60L, + isInstantVerificationEnabled = true + ) + ) + } + } + + setContent { + // Custom theme using fromMaterialTheme() with custom provider styles + CustomAuthUITheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + var selectedDemo by remember { mutableStateOf(DemoType.Email) } + + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + // Demo selector tabs + DemoSelector( + selectedDemo = selectedDemo, + onDemoSelected = { selectedDemo = it } + ) + + // Show selected demo + when (selectedDemo) { + DemoType.Email -> EmailAuthDemo( + authUI = authUI, + configuration = emailConfiguration, + context = appContext + ) + DemoType.Phone -> PhoneAuthDemo( + authUI = authUI, + configuration = phoneConfiguration, + context = appContext + ) + } + } + } + } + } + } +} + +enum class DemoType { + Email, + Phone +} + +@Composable +fun CustomAuthUITheme(content: @Composable () -> Unit) { + // Use Material Theme colors + MaterialTheme { + val customProviderStyles = mapOf( + "google.com" to AuthUITheme.ProviderStyle( + icon = null, // Would use actual Google icon in production + backgroundColor = Color(0xFFFFFFFF), + contentColor = Color(0xFF757575), + iconTint = null, + shape = RoundedCornerShape(8.dp), + elevation = 1.dp + ), + "facebook.com" to AuthUITheme.ProviderStyle( + icon = null, // Would use actual Facebook icon in production + backgroundColor = Color(0xFF1877F2), + contentColor = Color.White, + iconTint = null, + shape = RoundedCornerShape(8.dp), + elevation = 2.dp + ), + "password" to AuthUITheme.ProviderStyle( + icon = null, + backgroundColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + iconTint = null, + shape = RoundedCornerShape(12.dp), + elevation = 3.dp + ) + ) + + // Apply custom theme using fromMaterialTheme + val authTheme = AuthUITheme.fromMaterialTheme(providerStyles = customProviderStyles) + + AuthUITheme(theme = authTheme) { + content() + } + } +} + +@Composable +fun DemoSelector( + selectedDemo: DemoType, + onDemoSelected: (DemoType) -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Custom Slots & Theming Demo", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = "Select a demo to see custom UI implementations using slot APIs", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = selectedDemo == DemoType.Email, + onClick = { onDemoSelected(DemoType.Email) }, + label = { Text("Email Auth") }, + modifier = Modifier.weight(1f) + ) + FilterChip( + selected = selectedDemo == DemoType.Phone, + onClick = { onDemoSelected(DemoType.Phone) }, + label = { Text("Phone Auth") }, + modifier = Modifier.weight(1f) + ) + } + } + } +} + +@Composable +fun EmailAuthDemo( + authUI: FirebaseAuthUI, + configuration: com.firebase.ui.auth.compose.configuration.AuthUIConfiguration, + context: android.content.Context +) { + var currentUser by remember { mutableStateOf(authUI.getCurrentUser()) } + + // Monitor auth state changes + LaunchedEffect(Unit) { + authUI.authStateFlow().collect { _ -> + currentUser = authUI.getCurrentUser() + } + } + + if (currentUser != null) { + // Show success screen + val successScrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(successScrollState) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "✓", + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Successfully Authenticated!", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = currentUser?.email ?: "Signed in", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(32.dp)) + Button(onClick = { + authUI.auth.signOut() + }) { + Text("Sign Out") + } + } + } else { + // Show custom email auth UI using slot API + // Provide the string provider required by EmailAuthScreen + CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) { + EmailAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + onSuccess = { result: AuthResult -> + Log.d("CustomSlotsDemo", "Email auth success: ${result.user?.uid}") + }, + onError = { exception: AuthException -> + Log.e("CustomSlotsDemo", "Email auth error", exception) + }, + onCancel = { + Log.d("CustomSlotsDemo", "Email auth cancelled") + } + ) { state: EmailAuthContentState -> + // Custom UI using the slot API + CustomEmailAuthUI(state) + } + } + } +} + +@Composable +fun CustomEmailAuthUI(state: EmailAuthContentState) { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + + // Title based on mode + Text( + text = when (state.mode) { + EmailAuthMode.SignIn, EmailAuthMode.EmailLinkSignIn -> "📧 Welcome Back" + EmailAuthMode.SignUp -> "📧 Create Account" + EmailAuthMode.ResetPassword -> "📧 Reset Password" + }, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Error display + state.error?.let { errorMessage -> + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = errorMessage, + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall + ) + } + } + + // Render UI based on mode + when (state.mode) { + EmailAuthMode.SignIn, EmailAuthMode.EmailLinkSignIn -> SignInUI(state) + EmailAuthMode.SignUp -> SignUpUI(state) + EmailAuthMode.ResetPassword -> ResetPasswordUI(state) + } + } +} + +@Composable +fun SignInUI(state: EmailAuthContentState) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = state.email, + onValueChange = state.onEmailChange, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + OutlinedTextField( + value = state.password, + onValueChange = state.onPasswordChange, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + if (state.emailSignInLinkSent) { + Text( + text = "✓ Sign-in link sent! Check your email.", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = state.onSignInClick, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Sign In") + } + } + + TextButton( + onClick = state.onGoToResetPassword, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Text("Forgot Password?") + } + + HorizontalDivider() + + TextButton( + onClick = state.onGoToSignUp, + modifier = Modifier.fillMaxWidth() + ) { + Text("Don't have an account? Sign Up") + } + } +} + +@Composable +fun SignUpUI(state: EmailAuthContentState) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = state.displayName, + onValueChange = state.onDisplayNameChange, + label = { Text("Display Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + OutlinedTextField( + value = state.email, + onValueChange = state.onEmailChange, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + OutlinedTextField( + value = state.password, + onValueChange = state.onPasswordChange, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + OutlinedTextField( + value = state.confirmPassword, + onValueChange = state.onConfirmPasswordChange, + label = { Text("Confirm Password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = state.onSignUpClick, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Create Account") + } + } + + HorizontalDivider() + + TextButton( + onClick = state.onGoToSignIn, + modifier = Modifier.fillMaxWidth() + ) { + Text("Already have an account? Sign In") + } + } +} + +@Composable +fun ResetPasswordUI(state: EmailAuthContentState) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Enter your email address and we'll send you a link to reset your password.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = state.email, + onValueChange = state.onEmailChange, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + if (state.resetLinkSent) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "✓ Password reset link sent! Check your email.", + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.bodyMedium + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = state.onSendResetLinkClick, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading && !state.resetLinkSent + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Send Reset Link") + } + } + + HorizontalDivider() + + TextButton( + onClick = state.onGoToSignIn, + modifier = Modifier.fillMaxWidth() + ) { + Text("Back to Sign In") + } + } +} + +@Composable +fun PhoneAuthDemo( + authUI: FirebaseAuthUI, + configuration: com.firebase.ui.auth.compose.configuration.AuthUIConfiguration, + context: android.content.Context +) { + var currentUser by remember { mutableStateOf(authUI.getCurrentUser()) } + + // Monitor auth state changes + LaunchedEffect(Unit) { + authUI.authStateFlow().collect { _ -> + currentUser = authUI.getCurrentUser() + } + } + + if (currentUser != null) { + // Show success screen + val successScrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(successScrollState) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "📱", + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Phone Verified!", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = currentUser?.phoneNumber ?: "Signed in", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(32.dp)) + Button(onClick = { + authUI.auth.signOut() + }) { + Text("Sign Out") + } + } + } else { + // Show custom phone auth UI using slot API + // Provide the string provider required by PhoneAuthScreen + CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) { + PhoneAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + onSuccess = { result: AuthResult -> + Log.d("CustomSlotsDemo", "Phone auth success: ${result.user?.uid}") + }, + onError = { exception: AuthException -> + Log.e("CustomSlotsDemo", "Phone auth error", exception) + }, + onCancel = { + Log.d("CustomSlotsDemo", "Phone auth cancelled") + } + ) { state: PhoneAuthContentState -> + // Custom UI using the slot API + CustomPhoneAuthUI(state) + } + } + } +} + +@Composable +fun CustomPhoneAuthUI(state: PhoneAuthContentState) { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + + // Title based on step + Text( + text = when (state.step) { + PhoneAuthStep.EnterPhoneNumber -> "📱 Phone Verification" + PhoneAuthStep.EnterVerificationCode -> "📱 Enter Code" + }, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Error display + state.error?.let { errorMessage -> + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = errorMessage, + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall + ) + } + } + + // Render UI based on step + when (state.step) { + PhoneAuthStep.EnterPhoneNumber -> EnterPhoneNumberUI(state) + PhoneAuthStep.EnterVerificationCode -> EnterVerificationCodeUI(state) + } + } +} + +@Composable +fun EnterPhoneNumberUI(state: PhoneAuthContentState) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Enter your phone number to receive a verification code", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Country selector (simplified for demo) + OutlinedCard( + onClick = { /* In real app, open country selector */ }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${state.selectedCountry.flagEmoji} ${state.selectedCountry.dialCode}", + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = state.selectedCountry.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + OutlinedTextField( + value = state.phoneNumber, + onValueChange = state.onPhoneNumberChange, + label = { Text("Phone Number") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = state.onSendCodeClick, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading && state.phoneNumber.isNotBlank() + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Send Code") + } + } + } +} + +@Composable +fun EnterVerificationCodeUI(state: PhoneAuthContentState) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "We sent a verification code to:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Text( + text = state.fullPhoneNumber, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = state.verificationCode, + onValueChange = state.onVerificationCodeChange, + label = { Text("6-Digit Code") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !state.isLoading + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = state.onVerifyCodeClick, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading && state.verificationCode.length == 6 + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Verify Code") + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = state.onChangeNumberClick) { + Text("Change Number") + } + + TextButton( + onClick = state.onResendCodeClick, + enabled = state.resendTimer == 0 + ) { + Text( + if (state.resendTimer > 0) + "Resend (${state.resendTimer}s)" + else + "Resend Code" + ) + } + } + } +} diff --git a/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt new file mode 100644 index 000000000..3ec0d3a1e --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt @@ -0,0 +1,287 @@ +package com.firebase.composeapp + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.ui.screens.AuthSuccessUiContext +import com.firebase.ui.auth.compose.ui.screens.FirebaseAuthScreen +import com.firebase.ui.auth.compose.util.EmailLinkConstants +import com.google.firebase.auth.actionCodeSettings + +class HighLevelApiDemoActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val authUI = FirebaseAuthUI.getInstance() + val emailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK) + + val configuration = authUIConfiguration { + context = applicationContext + logo = AuthUIAsset.Resource(R.drawable.firebase_auth) + tosUrl = "https://policies.google.com/terms" + privacyPolicyUrl = "https://policies.google.com/privacy" + isAnonymousUpgradeEnabled = false + providers { + provider(AuthProvider.Anonymous) + provider( + AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "771411398215-o39fujhds88bs4mb5ai7u6o73g86fspp.apps.googleusercontent.com", + ) + ) + provider( + AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkForceSameDeviceEnabled = false, + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = actionCodeSettings { + url = "https://temp-test-aa342.firebaseapp.com" + handleCodeInApp = true + setAndroidPackageName( + "com.firebase.composeapp", + true, + null + ) + }, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireLowercase, + PasswordRule.RequireUppercase, + ), + ) + ) + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = emptyList(), + smsCodeLength = 6, + timeout = 120L, + isInstantVerificationEnabled = true + ) + ) + provider( + AuthProvider.Facebook( + applicationId = "792556260059222" + ) + ) + provider( + AuthProvider.Twitter( + customParameters = emptyMap() + ) + ) + provider( + AuthProvider.Apple( + customParameters = emptyMap(), + locale = null + ) + ) + provider( + AuthProvider.Microsoft( + scopes = emptyList(), + tenant = "", + customParameters = emptyMap(), + ) + ) + provider( + AuthProvider.Github( + scopes = emptyList(), + customParameters = emptyMap(), + ) + ) + provider( + AuthProvider.Yahoo( + scopes = emptyList(), + customParameters = emptyMap(), + ) + ) + provider( + AuthProvider.GenericOAuth( + providerName = "LINE", + providerId = "oidc.line", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = "Sign in with LINE", + buttonIcon = AuthUIAsset.Resource(R.drawable.ic_line_logo_24dp), + buttonColor = Color(0xFF06C755), + contentColor = Color.White + ) + ) + provider( + AuthProvider.GenericOAuth( + providerName = "Discord", + providerId = "oidc.discord", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = "Sign in with Discord", + buttonIcon = AuthUIAsset.Resource(R.drawable.ic_discord_24dp), + buttonColor = Color(0xFF5865F2), + contentColor = Color.White + ) + ) + } + } + + setContent { + AuthUITheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + emailLink = emailLink, + onSignInSuccess = { result -> + Log.d("HighLevelApiDemoActivity", "Authentication success: ${result.user?.uid}") + }, + onSignInFailure = { exception: AuthException -> + Log.e("HighLevelApiDemoActivity", "Authentication failed", exception) + }, + onSignInCancelled = { + Log.d("HighLevelApiDemoActivity", "Authentication cancelled") + }, + authenticatedContent = { state, uiContext -> + AppAuthenticatedContent(state, uiContext) + } + ) + } + } + } + } +} + +@Composable +private fun AppAuthenticatedContent( + state: AuthState, + uiContext: AuthSuccessUiContext +) { + val stringProvider = uiContext.stringProvider + when (state) { + is AuthState.Success -> { + val user = uiContext.authUI.getCurrentUser() + val identifier = user?.email ?: user?.phoneNumber ?: user?.uid.orEmpty() + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + if (identifier.isNotBlank()) { + Text( + text = stringProvider.signedInAs(identifier), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + } + Text( + "isAnonymous - ${state.user.isAnonymous}", + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Providers - ${state.user.providerData.map { it.providerId }}", + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = uiContext.onManageMfa) { + Text(stringProvider.manageMfaAction) + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = uiContext.onSignOut) { + Text(stringProvider.signOutAction) + } + } + } + + is AuthState.RequiresEmailVerification -> { + val email = uiContext.authUI.getCurrentUser()?.email ?: stringProvider.emailProvider + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringProvider.verifyEmailInstruction(email), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { uiContext.authUI.getCurrentUser()?.sendEmailVerification() }) { + Text(stringProvider.resendVerificationEmailAction) + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = uiContext.onReloadUser) { + Text(stringProvider.verifiedEmailAction) + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = uiContext.onSignOut) { + Text(stringProvider.signOutAction) + } + } + } + + is AuthState.RequiresProfileCompletion -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringProvider.profileCompletionMessage, + textAlign = TextAlign.Center + ) + if (state.missingFields.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringProvider.profileMissingFieldsMessage(state.missingFields.joinToString()), + textAlign = TextAlign.Center + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = uiContext.onSignOut) { + Text(stringProvider.signOutAction) + } + } + } + + else -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + } + } + } +} diff --git a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt new file mode 100644 index 000000000..039e20115 --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt @@ -0,0 +1,273 @@ +package com.firebase.composeapp + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.util.EmailLinkConstants +import com.google.firebase.FirebaseApp + +/** + * Main launcher activity that allows users to choose between different + * authentication API demonstrations. + */ +class MainActivity : ComponentActivity() { + companion object { + private const val USE_AUTH_EMULATOR = false + private const val AUTH_EMULATOR_HOST = "10.0.2.2" + private const val AUTH_EMULATOR_PORT = 9099 + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Initialize Firebase and configure emulator if needed + FirebaseApp.initializeApp(applicationContext) + val authUI = FirebaseAuthUI.getInstance() + + if (USE_AUTH_EMULATOR) { + authUI.auth.useEmulator(AUTH_EMULATOR_HOST, AUTH_EMULATOR_PORT) + } + + var pendingEmailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK) + + if (pendingEmailLink.isNullOrEmpty() && authUI.canHandleIntent(intent)) { + pendingEmailLink = intent.data?.toString() + } + + Log.d("MainActivity", "Pending email link: $pendingEmailLink") + + fun launchHighLevelDemo() { + val demoIntent = Intent( + this, + HighLevelApiDemoActivity::class.java + ).apply { + pendingEmailLink?.let { link -> + putExtra(EmailLinkConstants.EXTRA_EMAIL_LINK, link) + pendingEmailLink = null + } + } + startActivity(demoIntent) + } + + if (savedInstanceState == null && !pendingEmailLink.isNullOrEmpty()) { + launchHighLevelDemo() + finish() + return + } + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + ChooserScreen( + onHighLevelApiClick = ::launchHighLevelDemo, + onLowLevelApiClick = { + startActivity(Intent(this, AuthFlowControllerDemoActivity::class.java)) + }, + onCustomSlotsClick = { + startActivity(Intent(this, CustomSlotsThemingDemoActivity::class.java)) + } + ) + } + } + } + } +} + +@Composable +fun ChooserScreen( + onHighLevelApiClick: () -> Unit, + onLowLevelApiClick: () -> Unit, + onCustomSlotsClick: () -> Unit +) { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .systemBarsPadding() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + // Header + Text( + text = "Firebase Auth UI Compose", + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center + ) + + Text( + text = "Choose a demo to explore different authentication APIs", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // High-Level API Card + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onHighLevelApiClick + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "🎨 High-Level API", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "FirebaseAuthScreen Composable", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Best for: Pure Compose applications that want a complete, ready-to-use authentication UI with minimal setup.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Features:", + style = MaterialTheme.typography.labelLarge + ) + Text( + text = "• Drop-in Composable\n• Automatic navigation\n• State management included\n• Customizable content", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Low-Level API Card + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onLowLevelApiClick + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "⚙️ Low-Level API", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "AuthFlowController", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Best for: Applications that need fine-grained control over the authentication flow with ActivityResultLauncher integration.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Features:", + style = MaterialTheme.typography.labelLarge + ) + Text( + text = "• Lifecycle-safe controller\n• ActivityResultLauncher\n• Observable state with Flow\n• Manual flow control", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Custom Slots & Theming Card + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onCustomSlotsClick + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "🎨 Custom Slots & Theming", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Slot APIs & Theme Customization", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Best for: Applications that need fully custom UI while leveraging the authentication logic and state management.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Features:", + style = MaterialTheme.typography.labelLarge + ) + Text( + text = "• Custom email auth UI via slots\n• Custom phone auth UI via slots\n• AuthUITheme.fromMaterialTheme()\n• Custom ProviderStyle examples", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Info card + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "💡 Tip", + style = MaterialTheme.typography.labelLarge + ) + Text( + text = "Both APIs provide the same authentication capabilities. Choose based on your app's architecture and control requirements.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Color.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Color.kt new file mode 100644 index 000000000..994915613 --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.firebase.composeapp.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Theme.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Theme.kt new file mode 100644 index 000000000..08bd9441a --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.firebase.composeapp.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun FirebaseUIAndroidTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Type.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Type.kt new file mode 100644 index 000000000..ea0657654 --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.firebase.composeapp.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/composeapp/src/main/res/drawable-hdpi/firebase_auth.png b/composeapp/src/main/res/drawable-hdpi/firebase_auth.png new file mode 100644 index 000000000..fecbcb6dd Binary files /dev/null and b/composeapp/src/main/res/drawable-hdpi/firebase_auth.png differ diff --git a/composeapp/src/main/res/drawable-mdpi/firebase_auth.png b/composeapp/src/main/res/drawable-mdpi/firebase_auth.png new file mode 100644 index 000000000..bc9af3cc0 Binary files /dev/null and b/composeapp/src/main/res/drawable-mdpi/firebase_auth.png differ diff --git a/composeapp/src/main/res/drawable-v24/ic_launcher_foreground.xml b/composeapp/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..fde1368fc --- /dev/null +++ b/composeapp/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/composeapp/src/main/res/drawable-xhdpi/firebase_auth.png b/composeapp/src/main/res/drawable-xhdpi/firebase_auth.png new file mode 100644 index 000000000..8a93e39a6 Binary files /dev/null and b/composeapp/src/main/res/drawable-xhdpi/firebase_auth.png differ diff --git a/composeapp/src/main/res/drawable-xxhdpi/firebase_auth.png b/composeapp/src/main/res/drawable-xxhdpi/firebase_auth.png new file mode 100644 index 000000000..c01b18b14 Binary files /dev/null and b/composeapp/src/main/res/drawable-xxhdpi/firebase_auth.png differ diff --git a/composeapp/src/main/res/drawable-xxxhdpi/firebase_auth.png b/composeapp/src/main/res/drawable-xxxhdpi/firebase_auth.png new file mode 100644 index 000000000..221da4d3a Binary files /dev/null and b/composeapp/src/main/res/drawable-xxxhdpi/firebase_auth.png differ diff --git a/composeapp/src/main/res/drawable/ic_discord_24dp.xml b/composeapp/src/main/res/drawable/ic_discord_24dp.xml new file mode 100644 index 000000000..6b7ee0dae --- /dev/null +++ b/composeapp/src/main/res/drawable/ic_discord_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeapp/src/main/res/drawable/ic_launcher_background.xml b/composeapp/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..1e4408cae --- /dev/null +++ b/composeapp/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeapp/src/main/res/drawable/ic_line_logo_24dp.xml b/composeapp/src/main/res/drawable/ic_line_logo_24dp.xml new file mode 100644 index 000000000..b5402cc99 --- /dev/null +++ b/composeapp/src/main/res/drawable/ic_line_logo_24dp.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..036d09bc5 --- /dev/null +++ b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..036d09bc5 --- /dev/null +++ b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/composeapp/src/main/res/mipmap-hdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..b69b810b8 Binary files /dev/null and b/composeapp/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/composeapp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/composeapp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 000000000..522e8b49f Binary files /dev/null and b/composeapp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/composeapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/composeapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 000000000..42ff206c7 Binary files /dev/null and b/composeapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/composeapp/src/main/res/mipmap-mdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..5e6619368 Binary files /dev/null and b/composeapp/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/composeapp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/composeapp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 000000000..aaaadd665 Binary files /dev/null and b/composeapp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/composeapp/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/composeapp/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9f69d9f78 Binary files /dev/null and b/composeapp/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/composeapp/src/main/res/mipmap-xhdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..590ed7e41 Binary files /dev/null and b/composeapp/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/composeapp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/composeapp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 000000000..e47c73a65 Binary files /dev/null and b/composeapp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/composeapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/composeapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..c3797b9df Binary files /dev/null and b/composeapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/composeapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..fee730f87 Binary files /dev/null and b/composeapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/composeapp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/composeapp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 000000000..d0d291335 Binary files /dev/null and b/composeapp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/composeapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/composeapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..a3c17df51 Binary files /dev/null and b/composeapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..d6bcfe1bb Binary files /dev/null and b/composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 000000000..125eace5d Binary files /dev/null and b/composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..bb69db44c Binary files /dev/null and b/composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/composeapp/src/main/res/values/colors.xml b/composeapp/src/main/res/values/colors.xml new file mode 100644 index 000000000..f8c6127d3 --- /dev/null +++ b/composeapp/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/composeapp/src/main/res/values/ic_launcher_background.xml b/composeapp/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..c5d5899fd --- /dev/null +++ b/composeapp/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/composeapp/src/main/res/values/strings.xml b/composeapp/src/main/res/values/strings.xml new file mode 100644 index 000000000..318d7fb0f --- /dev/null +++ b/composeapp/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + ComposeApp + + temp-test-aa342.firebaseapp.com + + + 1131506989188007 + fb1131506989188007 + e3968638d7751ba83063e2a78bc27e4e + \ No newline at end of file diff --git a/composeapp/src/main/res/values/themes.xml b/composeapp/src/main/res/values/themes.xml new file mode 100644 index 000000000..1f225670b --- /dev/null +++ b/composeapp/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +