From 55449eece2da92c22052d8469d965b53bb25983d Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 17 Oct 2025 09:24:47 +0200 Subject: [PATCH 01/13] feat: MFA Screens --- .../ui/auth/compose/FirebaseAuthUI.kt | 13 +- .../string_provider/AuthUIStringProvider.kt | 20 + .../DefaultAuthUIStringProvider.kt | 16 + .../compose/mfa/MfaChallengeContentState.kt | 115 +++ .../compose/mfa/MfaEnrollmentContentState.kt | 8 + .../ui/auth/compose/mfa/MfaErrorMapper.kt | 39 + .../compose/ui/screens/MfaChallengeScreen.kt | 263 +++++++ .../compose/ui/screens/MfaEnrollmentScreen.kt | 350 +++++++++ .../ui/screens/phone/EnterPhoneNumberUI.kt | 3 +- .../screens/phone/EnterVerificationCodeUI.kt | 3 +- auth/src/main/res/values-ar/strings.xml | 9 + auth/src/main/res/values-b+es+419/strings.xml | 9 + auth/src/main/res/values-bg/strings.xml | 9 + auth/src/main/res/values-bn/strings.xml | 9 + auth/src/main/res/values-ca/strings.xml | 9 + auth/src/main/res/values-cs/strings.xml | 9 + auth/src/main/res/values-da/strings.xml | 9 + auth/src/main/res/values-de-rAT/strings.xml | 9 + auth/src/main/res/values-de-rCH/strings.xml | 9 + auth/src/main/res/values-de/strings.xml | 9 + auth/src/main/res/values-el/strings.xml | 9 + auth/src/main/res/values-en-rAU/strings.xml | 9 + auth/src/main/res/values-en-rCA/strings.xml | 9 + auth/src/main/res/values-en-rGB/strings.xml | 9 + auth/src/main/res/values-en-rIE/strings.xml | 9 + auth/src/main/res/values-en-rIN/strings.xml | 9 + auth/src/main/res/values-en-rSG/strings.xml | 9 + auth/src/main/res/values-en-rZA/strings.xml | 9 + auth/src/main/res/values-es-rAR/strings.xml | 9 + auth/src/main/res/values-es-rBO/strings.xml | 9 + auth/src/main/res/values-es-rCL/strings.xml | 9 + auth/src/main/res/values-es-rCO/strings.xml | 9 + auth/src/main/res/values-es-rCR/strings.xml | 9 + auth/src/main/res/values-es-rDO/strings.xml | 9 + auth/src/main/res/values-es-rEC/strings.xml | 9 + auth/src/main/res/values-es-rGT/strings.xml | 9 + auth/src/main/res/values-es-rHN/strings.xml | 9 + auth/src/main/res/values-es-rMX/strings.xml | 9 + auth/src/main/res/values-es-rNI/strings.xml | 9 + auth/src/main/res/values-es-rPA/strings.xml | 9 + auth/src/main/res/values-es-rPE/strings.xml | 9 + auth/src/main/res/values-es-rPR/strings.xml | 9 + auth/src/main/res/values-es-rPY/strings.xml | 9 + auth/src/main/res/values-es-rSV/strings.xml | 9 + auth/src/main/res/values-es-rUS/strings.xml | 9 + auth/src/main/res/values-es-rUY/strings.xml | 9 + auth/src/main/res/values-es-rVE/strings.xml | 9 + auth/src/main/res/values-es/strings.xml | 9 + auth/src/main/res/values-fa/strings.xml | 9 + auth/src/main/res/values-fi/strings.xml | 9 + auth/src/main/res/values-fil/strings.xml | 9 + auth/src/main/res/values-fr-rCH/strings.xml | 9 + auth/src/main/res/values-fr/strings.xml | 9 + auth/src/main/res/values-gsw/strings.xml | 9 + auth/src/main/res/values-gu/strings.xml | 9 + auth/src/main/res/values-hi/strings.xml | 9 + auth/src/main/res/values-hr/strings.xml | 9 + auth/src/main/res/values-hu/strings.xml | 9 + auth/src/main/res/values-in/strings.xml | 9 + auth/src/main/res/values-it/strings.xml | 9 + auth/src/main/res/values-iw/strings.xml | 9 + auth/src/main/res/values-ja/strings.xml | 9 + auth/src/main/res/values-kn/strings.xml | 9 + auth/src/main/res/values-ko/strings.xml | 9 + auth/src/main/res/values-ln/strings.xml | 9 + auth/src/main/res/values-lt/strings.xml | 9 + auth/src/main/res/values-lv/strings.xml | 9 + auth/src/main/res/values-mo/strings.xml | 9 + auth/src/main/res/values-mr/strings.xml | 9 + auth/src/main/res/values-ms/strings.xml | 9 + auth/src/main/res/values-nb/strings.xml | 9 + auth/src/main/res/values-nl/strings.xml | 9 + auth/src/main/res/values-no/strings.xml | 9 + auth/src/main/res/values-pl/strings.xml | 9 + auth/src/main/res/values-pt-rBR/strings.xml | 9 + auth/src/main/res/values-pt-rPT/strings.xml | 9 + auth/src/main/res/values-pt/strings.xml | 9 + auth/src/main/res/values-ro/strings.xml | 9 + auth/src/main/res/values-ru/strings.xml | 9 + auth/src/main/res/values-sk/strings.xml | 9 + auth/src/main/res/values-sl/strings.xml | 9 + auth/src/main/res/values-sr/strings.xml | 9 + auth/src/main/res/values-sv/strings.xml | 9 + auth/src/main/res/values-ta/strings.xml | 9 + auth/src/main/res/values-th/strings.xml | 9 + auth/src/main/res/values-tl/strings.xml | 9 + auth/src/main/res/values-tr/strings.xml | 9 + auth/src/main/res/values-uk/strings.xml | 9 + auth/src/main/res/values-ur/strings.xml | 9 + auth/src/main/res/values-vi/strings.xml | 9 + auth/src/main/res/values-zh-rCN/strings.xml | 9 + auth/src/main/res/values-zh-rHK/strings.xml | 9 + auth/src/main/res/values-zh-rTW/strings.xml | 9 + auth/src/main/res/values-zh/strings.xml | 9 + auth/src/main/res/values/strings.xml | 9 + .../mfa/MfaChallengeContentStateTest.kt | 236 ++++++ .../ui/screens/MfaChallengeScreenTest.kt | 341 +++++++++ .../ui/screens/MfaEnrollmentScreenTest.kt | 356 +++++++++ .../com/firebase/composeapp/MainActivity.kt | 67 +- .../ui/components/ReauthenticationDialog.kt | 214 ++++++ .../composeapp/ui/screens/EmailAuthMain.kt | 59 +- .../ui/screens/MfaEnrollmentMain.kt | 676 ++++++++++++++++++ .../ui/screens/MfaChallengeScreenTest.kt | 316 ++++++++ .../ui/screens/MfaEnrollmentScreenTest.kt | 457 ++++++++++++ 104 files changed, 4312 insertions(+), 5 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/mfa/MfaChallengeContentState.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/mfa/MfaErrorMapper.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreen.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentScreen.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/mfa/MfaChallengeContentStateTest.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreenTest.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentScreenTest.kt create mode 100644 composeapp/src/main/java/com/firebase/composeapp/ui/components/ReauthenticationDialog.kt create mode 100644 composeapp/src/main/java/com/firebase/composeapp/ui/screens/MfaEnrollmentMain.kt create mode 100644 e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreenTest.kt create mode 100644 e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentScreenTest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt index cf6b54b50..9551759aa 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt @@ -163,7 +163,18 @@ class FirebaseAuthUI private constructor( val firebaseAuthFlow = callbackFlow { // Set initial state based on current auth state val initialState = auth.currentUser?.let { user -> - AuthState.Success(result = null, user = user, isNewUser = false) + // 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) 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 index 67b24fb1d..90ca68407 100644 --- 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 @@ -319,4 +319,24 @@ interface AuthUIStringProvider { /** 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 } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt index 130eb74fe..32733ef96 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -303,4 +303,20 @@ class DefaultAuthUIStringProvider( 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) } 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 index 796900cb6..788ec413f 100644 --- 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 @@ -16,6 +16,7 @@ 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 @@ -66,6 +67,7 @@ import com.firebase.ui.auth.compose.data.CountryData * @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`. @@ -89,8 +91,12 @@ data class MfaEnrollmentContentState( // SelectFactor step val availableFactors: List = emptyList(), + val enrolledFactors: List = emptyList(), + val onFactorSelected: (MfaFactor) -> Unit = {}, + val onUnenrollFactor: (MultiFactorInfo) -> Unit = {}, + val onSkipClick: (() -> Unit)? = null, // ConfigureSms step @@ -120,6 +126,8 @@ data class MfaEnrollmentContentState( val selectedFactor: MfaFactor? = null, + val resendTimer: Int = 0, + val onResendCodeClick: (() -> Unit)? = null, // ShowRecoveryCodes step 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/ui/screens/MfaChallengeScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreen.kt new file mode 100644 index 000000000..64917c14f --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreen.kt @@ -0,0 +1,263 @@ +/* + * 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 +) { + 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 + ) + + content(state) +} 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..e661af2ae --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentScreen.kt @@ -0,0 +1,350 @@ +/* + * 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.MfaConfiguration +import com.firebase.ui.auth.compose.configuration.MfaFactor +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, + onComplete: () -> Unit, + onSkip: () -> Unit = {}, + onError: (Exception) -> Unit = {}, + content: @Composable (MfaEnrollmentContentState) -> Unit +) { + val activity = requireNotNull(LocalActivity.current) { + "MfaEnrollmentScreen must be used within an Activity context for SMS verification" + } + val coroutineScope = rememberCoroutineScope() + + 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 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) } + + // 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 + } catch (e: Exception) { + error.value = e.message + onError(e) + } finally { + isLoading.value = false + } + } + null -> {} + } + } + } + + val state = MfaEnrollmentContentState( + step = currentStep.value, + isLoading = isLoading.value, + error = error.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 + }, + 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 + } catch (e: Exception) { + error.value = e.message + 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 { onError(it) } + } + isLoading.value = false + } + } catch (e: Exception) { + error.value = e.message + 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 + } catch (e: Exception) { + error.value = e.message + 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 + } catch (e: Exception) { + error.value = e.message + 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 + } catch (e: Exception) { + error.value = e.message + onError(e) + } finally { + isLoading.value = false + } + } + } + } + } + } else null, + recoveryCodes = recoveryCodes.value, + onCodesSavedClick = { + onComplete() + } + ) + + content(state) +} + +/** + * 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/phone/EnterPhoneNumberUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/EnterPhoneNumberUI.kt index 54b65af8a..b2097fbb4 100644 --- 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 @@ -69,6 +69,7 @@ fun EnterPhoneNumberUI( onPhoneNumberChange: (String) -> Unit, onCountrySelected: (CountryData) -> Unit, onSendCodeClick: () -> Unit, + title: String? = null, ) { val context = LocalContext.current val provider = configuration.providers.filterIsInstance().first() @@ -88,7 +89,7 @@ fun EnterPhoneNumberUI( topBar = { TopAppBar( title = { - Text(stringProvider.signInWithPhone) + Text(title ?: stringProvider.signInWithPhone) }, colors = AuthUITheme.topAppBarColors ) 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 index 00c64d614..a1f5f6285 100644 --- 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 @@ -65,6 +65,7 @@ fun EnterVerificationCodeUI( onVerifyCodeClick: () -> Unit, onResendCodeClick: () -> Unit, onChangeNumberClick: () -> Unit, + title: String? = null, ) { val context = LocalContext.current val stringProvider = DefaultAuthUIStringProvider(context) @@ -85,7 +86,7 @@ fun EnterVerificationCodeUI( topBar = { TopAppBar( title = { - Text(stringProvider.verifyPhoneNumber) + Text(title ?: stringProvider.verifyPhoneNumber) }, colors = AuthUITheme.topAppBarColors ) diff --git a/auth/src/main/res/values-ar/strings.xml b/auth/src/main/res/values-ar/strings.xml index a4a45814b..ffe387165 100755 --- a/auth/src/main/res/values-ar/strings.xml +++ b/auth/src/main/res/values-ar/strings.xml @@ -120,4 +120,13 @@ يجب أن تحتوي كلمة المرور على حرف صغير واحد على الأقل يجب أن تحتوي كلمة المرور على رقم واحد على الأقل يجب أن تحتوي كلمة المرور على حرف خاص واحد على الأقل + + + إعداد مصادقة الرسائل القصيرة + التحقق من رمز الرسائل القصيرة + + تتطلب هذه العملية مصادقة حديثة. يُرجى تسجيل الدخول مرة أخرى والمحاولة مرة أخرى. + رمز التحقق غير صحيح. يُرجى المحاولة مرة أخرى. + حدث خطأ في الشبكة. يُرجى التحقق من اتصالك والمحاولة مرة أخرى. + حدث خطأ أثناء التسجيل. يُرجى المحاولة مرة أخرى. 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 a54686189..1ca5e1f0f 100755 --- a/auth/src/main/res/values-b+es+419/strings.xml +++ b/auth/src/main/res/values-b+es+419/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-bg/strings.xml b/auth/src/main/res/values-bg/strings.xml index afd9dff14..4491a4a19 100755 --- a/auth/src/main/res/values-bg/strings.xml +++ b/auth/src/main/res/values-bg/strings.xml @@ -120,4 +120,13 @@ Паролата трябва да съдържа поне една малка буква Паролата трябва да съдържа поне една цифра Паролата трябва да съдържа поне един специален знак + + + Настройка на SMS удостоверяване + Потвърждаване на SMS код + + Тази операция изисква скорошно удостоверяване. Моля, влезте отново и опитайте отново. + Кодът за потвърждение е неправилен. Моля, опитайте отново. + Възникна мрежова грешка. Моля, проверете връзката си и опитайте отново. + Възникна грешка по време на регистрацията. Моля, опитайте отново. diff --git a/auth/src/main/res/values-bn/strings.xml b/auth/src/main/res/values-bn/strings.xml index 8c8309b18..90cc7a895 100755 --- a/auth/src/main/res/values-bn/strings.xml +++ b/auth/src/main/res/values-bn/strings.xml @@ -121,4 +121,13 @@ পাসওয়ার্ডে অন্তত একটি ছোট হাতের অক্ষর থাকতে হবে পাসওয়ার্ডে অন্তত একটি সংখ্যা থাকতে হবে পাসওয়ার্ডে অন্তত একটি বিশেষ অক্ষর থাকতে হবে + + + এসএমএস প্রমাণীকরণ সেট আপ করুন + এসএমএস কোড যাচাই করুন + + এই কাজটির জন্য সাম্প্রতিক প্রমাণীকরণ প্রয়োজন৷ আবার সাইন-ইন করুন এবং আবার চেষ্টা করুন৷ + যাচাইকরণ কোডটি ভুল৷ আবার চেষ্টা করুন৷ + একটি নেটওয়ার্ক ত্রুটি হয়েছে৷ আপনার সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন৷ + নথিভুক্তির সময় একটি ত্রুটি ঘটেছে৷ আবার চেষ্টা করুন৷ diff --git a/auth/src/main/res/values-ca/strings.xml b/auth/src/main/res/values-ca/strings.xml index 587a85f98..ecb0a3490 100755 --- a/auth/src/main/res/values-ca/strings.xml +++ b/auth/src/main/res/values-ca/strings.xml @@ -121,4 +121,13 @@ 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. diff --git a/auth/src/main/res/values-cs/strings.xml b/auth/src/main/res/values-cs/strings.xml index bbb83bf4d..d2e0ab838 100755 --- a/auth/src/main/res/values-cs/strings.xml +++ b/auth/src/main/res/values-cs/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-da/strings.xml b/auth/src/main/res/values-da/strings.xml index a0e8ea23a..daa2f2c78 100755 --- a/auth/src/main/res/values-da/strings.xml +++ b/auth/src/main/res/values-da/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-de-rAT/strings.xml b/auth/src/main/res/values-de-rAT/strings.xml index 967785909..68bb18038 100755 --- a/auth/src/main/res/values-de-rAT/strings.xml +++ b/auth/src/main/res/values-de-rAT/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-de-rCH/strings.xml b/auth/src/main/res/values-de-rCH/strings.xml index 2274dae5d..7b148718a 100755 --- a/auth/src/main/res/values-de-rCH/strings.xml +++ b/auth/src/main/res/values-de-rCH/strings.xml @@ -121,4 +121,13 @@ 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. diff --git a/auth/src/main/res/values-de/strings.xml b/auth/src/main/res/values-de/strings.xml index 1fad657aa..2bcd40222 100755 --- a/auth/src/main/res/values-de/strings.xml +++ b/auth/src/main/res/values-de/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-el/strings.xml b/auth/src/main/res/values-el/strings.xml index 724c6aae6..5a7f8d2df 100755 --- a/auth/src/main/res/values-el/strings.xml +++ b/auth/src/main/res/values-el/strings.xml @@ -121,4 +121,13 @@ Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα μικρό γράμμα Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον έναν αριθμό Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον έναν ειδικό χαρακτήρα + + + Ρύθμιση ελέγχου ταυτότητας SMS + Επαλήθευση κωδικού SMS + + Αυτή η λειτουργία απαιτεί πρόσφατο έλεγχο ταυτότητας. Συνδεθείτε ξανά και δοκιμάστε ξανά. + Ο κωδικός επαλήθευσης είναι εσφαλμένος. Δοκιμάστε ξανά. + Παρουσιάστηκε σφάλμα δικτύου. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά. + Παρουσιάστηκε σφάλμα κατά την εγγραφή. Δοκιμάστε ξανά. diff --git a/auth/src/main/res/values-en-rAU/strings.xml b/auth/src/main/res/values-en-rAU/strings.xml index 635f9271b..1afbc0242 100755 --- a/auth/src/main/res/values-en-rAU/strings.xml +++ b/auth/src/main/res/values-en-rAU/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-en-rCA/strings.xml b/auth/src/main/res/values-en-rCA/strings.xml index 672733c8f..1f5b936ad 100755 --- a/auth/src/main/res/values-en-rCA/strings.xml +++ b/auth/src/main/res/values-en-rCA/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-en-rGB/strings.xml b/auth/src/main/res/values-en-rGB/strings.xml index 672733c8f..81c445d64 100755 --- a/auth/src/main/res/values-en-rGB/strings.xml +++ b/auth/src/main/res/values-en-rGB/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-en-rIE/strings.xml b/auth/src/main/res/values-en-rIE/strings.xml index 5323807c8..9035ca4f2 100755 --- a/auth/src/main/res/values-en-rIE/strings.xml +++ b/auth/src/main/res/values-en-rIE/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-en-rIN/strings.xml b/auth/src/main/res/values-en-rIN/strings.xml index 5323807c8..9035ca4f2 100755 --- a/auth/src/main/res/values-en-rIN/strings.xml +++ b/auth/src/main/res/values-en-rIN/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-en-rSG/strings.xml b/auth/src/main/res/values-en-rSG/strings.xml index 5323807c8..9035ca4f2 100755 --- a/auth/src/main/res/values-en-rSG/strings.xml +++ b/auth/src/main/res/values-en-rSG/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-en-rZA/strings.xml b/auth/src/main/res/values-en-rZA/strings.xml index 5323807c8..9035ca4f2 100755 --- a/auth/src/main/res/values-en-rZA/strings.xml +++ b/auth/src/main/res/values-en-rZA/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rAR/strings.xml b/auth/src/main/res/values-es-rAR/strings.xml index abe63a0e8..e2505cf23 100755 --- a/auth/src/main/res/values-es-rAR/strings.xml +++ b/auth/src/main/res/values-es-rAR/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rBO/strings.xml b/auth/src/main/res/values-es-rBO/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rBO/strings.xml +++ b/auth/src/main/res/values-es-rBO/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rCL/strings.xml b/auth/src/main/res/values-es-rCL/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rCL/strings.xml +++ b/auth/src/main/res/values-es-rCL/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rCO/strings.xml b/auth/src/main/res/values-es-rCO/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rCO/strings.xml +++ b/auth/src/main/res/values-es-rCO/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rCR/strings.xml b/auth/src/main/res/values-es-rCR/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rCR/strings.xml +++ b/auth/src/main/res/values-es-rCR/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rDO/strings.xml b/auth/src/main/res/values-es-rDO/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rDO/strings.xml +++ b/auth/src/main/res/values-es-rDO/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rEC/strings.xml b/auth/src/main/res/values-es-rEC/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rEC/strings.xml +++ b/auth/src/main/res/values-es-rEC/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rGT/strings.xml b/auth/src/main/res/values-es-rGT/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rGT/strings.xml +++ b/auth/src/main/res/values-es-rGT/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rHN/strings.xml b/auth/src/main/res/values-es-rHN/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rHN/strings.xml +++ b/auth/src/main/res/values-es-rHN/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rMX/strings.xml b/auth/src/main/res/values-es-rMX/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rMX/strings.xml +++ b/auth/src/main/res/values-es-rMX/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rNI/strings.xml b/auth/src/main/res/values-es-rNI/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rNI/strings.xml +++ b/auth/src/main/res/values-es-rNI/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rPA/strings.xml b/auth/src/main/res/values-es-rPA/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rPA/strings.xml +++ b/auth/src/main/res/values-es-rPA/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rPE/strings.xml b/auth/src/main/res/values-es-rPE/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rPE/strings.xml +++ b/auth/src/main/res/values-es-rPE/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rPR/strings.xml b/auth/src/main/res/values-es-rPR/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rPR/strings.xml +++ b/auth/src/main/res/values-es-rPR/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rPY/strings.xml b/auth/src/main/res/values-es-rPY/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rPY/strings.xml +++ b/auth/src/main/res/values-es-rPY/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rSV/strings.xml b/auth/src/main/res/values-es-rSV/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rSV/strings.xml +++ b/auth/src/main/res/values-es-rSV/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rUS/strings.xml b/auth/src/main/res/values-es-rUS/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rUS/strings.xml +++ b/auth/src/main/res/values-es-rUS/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rUY/strings.xml b/auth/src/main/res/values-es-rUY/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rUY/strings.xml +++ b/auth/src/main/res/values-es-rUY/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es-rVE/strings.xml b/auth/src/main/res/values-es-rVE/strings.xml index abe63a0e8..001251ab0 100755 --- a/auth/src/main/res/values-es-rVE/strings.xml +++ b/auth/src/main/res/values-es-rVE/strings.xml @@ -113,4 +113,13 @@ 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. diff --git a/auth/src/main/res/values-es/strings.xml b/auth/src/main/res/values-es/strings.xml index 516b1233d..0e4b9a6f4 100755 --- a/auth/src/main/res/values-es/strings.xml +++ b/auth/src/main/res/values-es/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-fa/strings.xml b/auth/src/main/res/values-fa/strings.xml index 9d3f7d53e..d51bfce6e 100755 --- a/auth/src/main/res/values-fa/strings.xml +++ b/auth/src/main/res/values-fa/strings.xml @@ -121,4 +121,13 @@ گذرواژه باید حداقل یک حرف کوچک داشته باشد گذرواژه باید حداقل یک عدد داشته باشد گذرواژه باید حداقل یک نویسه خاص داشته باشد + + + راه‌اندازی احراز هویت پیامک + تأیید کد پیامک + + این عملیات نیاز به احراز هویت اخیر دارد. لطفاً دوباره وارد سیستم شوید و دوباره امتحان کنید. + کد تأیید نادرست است. لطفاً دوباره امتحان کنید. + خطای شبکه رخ داد. لطفاً اتصال خود را بررسی کنید و دوباره امتحان کنید. + در طول ثبت‌نام خطایی رخ داد. لطفاً دوباره امتحان کنید. diff --git a/auth/src/main/res/values-fi/strings.xml b/auth/src/main/res/values-fi/strings.xml index 32ef14a70..8e155a9fd 100755 --- a/auth/src/main/res/values-fi/strings.xml +++ b/auth/src/main/res/values-fi/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-fil/strings.xml b/auth/src/main/res/values-fil/strings.xml index 17947fc00..00460c8cc 100755 --- a/auth/src/main/res/values-fil/strings.xml +++ b/auth/src/main/res/values-fil/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-fr-rCH/strings.xml b/auth/src/main/res/values-fr-rCH/strings.xml index e1c7a6c29..3594f8bcd 100755 --- a/auth/src/main/res/values-fr-rCH/strings.xml +++ b/auth/src/main/res/values-fr-rCH/strings.xml @@ -114,4 +114,13 @@ 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. diff --git a/auth/src/main/res/values-fr/strings.xml b/auth/src/main/res/values-fr/strings.xml index 3b1397b40..694a19ae4 100755 --- a/auth/src/main/res/values-fr/strings.xml +++ b/auth/src/main/res/values-fr/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-gsw/strings.xml b/auth/src/main/res/values-gsw/strings.xml index 4b9290422..a22cb614a 100755 --- a/auth/src/main/res/values-gsw/strings.xml +++ b/auth/src/main/res/values-gsw/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-gu/strings.xml b/auth/src/main/res/values-gu/strings.xml index f8bef2158..4b3ae9c47 100755 --- a/auth/src/main/res/values-gu/strings.xml +++ b/auth/src/main/res/values-gu/strings.xml @@ -121,4 +121,13 @@ પાસવર્ડમાં ઓછામાં ઓછો એક લોઅરકેસ અક્ષર હોવો આવશ્યક છે પાસવર્ડમાં ઓછામાં ઓછો એક નંબર હોવો આવશ્યક છે પાસવર્ડમાં ઓછામાં ઓછો એક વિશેષ અક્ષર હોવો આવશ્યક છે + + + SMS પ્રમાણીકરણ સેટ કરો + SMS કોડ ચકાસો + + આ કામગીરી માટે તાજેતરના પ્રમાણીકરણની જરૂર છે. કૃપા કરીને ફરીથી સાઇન ઇન કરો અને ફરી પ્રયાસ કરો. + ચકાસણી કોડ ખોટો છે. કૃપા કરીને ફરી પ્રયાસ કરો. + નેટવર્ક ભૂલ આવી. કૃપા કરીને તમારું કનેક્શન તપાસો અને ફરી પ્રયાસ કરો. + નોંધણી દરમિયાન એક ભૂલ આવી. કૃપા કરીને ફરી પ્રયાસ કરો. diff --git a/auth/src/main/res/values-hi/strings.xml b/auth/src/main/res/values-hi/strings.xml index faf556212..84887fd89 100755 --- a/auth/src/main/res/values-hi/strings.xml +++ b/auth/src/main/res/values-hi/strings.xml @@ -121,4 +121,13 @@ पासवर्ड में कम से कम एक छोटा अक्षर होना चाहिए પાસવર્ડમાં ઓછામાં ઓછો એક નંબર હોવો આવશ્યક છે પાસવર્ડમાં ઓછામાં ઓછો એક વિશેષ અક્ષર હોવો આવશ્યક છે + + + एसएमएस प्रमाणीकरण सेट करें + एसएमएस कोड सत्यापित करें + + इस ऑपरेशन के लिए हाल ही की पुष्टि की ज़रूरत है. कृपया फिर से साइन इन करें और फिर से कोशिश करें. + पुष्टि करने वाला कोड गलत है. कृपया फिर से कोशिश करें. + नेटवर्क में कोई गड़बड़ी हुई. कृपया अपने कनेक्शन की जांच करें और फिर से कोशिश करें. + नामांकन के दौरान एक गड़बड़ी हुई. कृपया फिर से कोशिश करें. diff --git a/auth/src/main/res/values-hr/strings.xml b/auth/src/main/res/values-hr/strings.xml index 963bf1d4e..314710d13 100755 --- a/auth/src/main/res/values-hr/strings.xml +++ b/auth/src/main/res/values-hr/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-hu/strings.xml b/auth/src/main/res/values-hu/strings.xml index f9e5cca93..b38d3afb8 100755 --- a/auth/src/main/res/values-hu/strings.xml +++ b/auth/src/main/res/values-hu/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-in/strings.xml b/auth/src/main/res/values-in/strings.xml index 1db30540a..7a2874840 100755 --- a/auth/src/main/res/values-in/strings.xml +++ b/auth/src/main/res/values-in/strings.xml @@ -121,4 +121,13 @@ 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. diff --git a/auth/src/main/res/values-it/strings.xml b/auth/src/main/res/values-it/strings.xml index d53baebe3..d7bca9b55 100755 --- a/auth/src/main/res/values-it/strings.xml +++ b/auth/src/main/res/values-it/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-iw/strings.xml b/auth/src/main/res/values-iw/strings.xml index 34ec20c99..d60ce8311 100755 --- a/auth/src/main/res/values-iw/strings.xml +++ b/auth/src/main/res/values-iw/strings.xml @@ -121,4 +121,13 @@ הסיסמה חייבת להכיל לפחות אות קטנה אחת הסיסמה חייבת להכיל לפחות מספר אחד הסיסמה חייבת להכיל לפחות תו מיוחד אחד + + + הגדרת אימות SMS + אימות קוד SMS + + הפעולה הזו דורשת אימות עדכני. יש להיכנס שוב ולנסות שוב. + קוד האימות שגוי. יש לנסות שוב. + אירעה שגיאת רשת. יש לבדוק את החיבור ולנסות שוב. + אירעה שגיאה במהלך ההרשמה. יש לנסות שוב. diff --git a/auth/src/main/res/values-ja/strings.xml b/auth/src/main/res/values-ja/strings.xml index c3cf61755..4f6c33435 100755 --- a/auth/src/main/res/values-ja/strings.xml +++ b/auth/src/main/res/values-ja/strings.xml @@ -120,4 +120,13 @@ パスワードには小文字が1文字以上必要です パスワードには数字が1文字以上必要です パスワードには特殊文字が1文字以上必要です + + + SMS認証を設定 + SMSコードを確認 + + この操作には最近の認証が必要です。再度ログインして、もう一度お試しください。 + 確認コードが正しくありません。もう一度お試しください。 + ネットワークエラーが発生しました。接続を確認して、もう一度お試しください。 + 登録中にエラーが発生しました。もう一度お試しください。 diff --git a/auth/src/main/res/values-kn/strings.xml b/auth/src/main/res/values-kn/strings.xml index 61db2f14b..5577e4e0c 100755 --- a/auth/src/main/res/values-kn/strings.xml +++ b/auth/src/main/res/values-kn/strings.xml @@ -121,4 +121,13 @@ ಪಾಸ್‌ವರ್ಡ್ ಕನಿಷ್ಠ ಒಂದು ಸಣ್ಣಕ್ಷರವನ್ನು ಹೊಂದಿರಬೇಕು ಪಾಸ್‌ವರ್ಡ್ ಕನಿಷ್ಠ ಒಂದು ಸಂಖ್ಯೆಯನ್ನು ಹೊಂದಿರಬೇಕು ಪಾಸ್‌ವರ್ಡ್ ಕನಿಷ್ಠ ಒಂದು ವಿಶೇಷ ಅಕ್ಷರವನ್ನು ಹೊಂದಿರಬೇಕು + + + SMS ದೃಢೀಕರಣವನ್ನು ಹೊಂದಿಸಿ + SMS ಕೋಡ್ ಅನ್ನು ಪರಿಶೀಲಿಸಿ + + ಈ ಕಾರ್ಯಕ್ಕೆ ಇತ್ತೀಚಿನ ದೃಢೀಕರಣದ ಅಗತ್ಯವಿದೆ. ದಯವಿಟ್ಟು ಮತ್ತೆ ಸೈನ್ ಇನ್ ಮಾಡಿ ಮತ್ತು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ. + ಪರಿಶೀಲನಾ ಕೋಡ್ ತಪ್ಪಾಗಿದೆ. ದಯವಿಟ್ಟು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ. + ನೆಟ್‌ವರ್ಕ್ ದೋಷ ಸಂಭವಿಸಿದೆ. ದಯವಿಟ್ಟು ನಿಮ್ಮ ಸಂಪರ್ಕವನ್ನು ಪರಿಶೀಲಿಸಿ ಮತ್ತು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ. + ದಾಖಲಾತಿ ಸಮಯದಲ್ಲಿ ದೋಷ ಸಂಭವಿಸಿದೆ. ದಯವಿಟ್ಟು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ. diff --git a/auth/src/main/res/values-ko/strings.xml b/auth/src/main/res/values-ko/strings.xml index c239c3b44..a6d39dbad 100755 --- a/auth/src/main/res/values-ko/strings.xml +++ b/auth/src/main/res/values-ko/strings.xml @@ -119,4 +119,13 @@ 비밀번호에 소문자가 하나 이상 포함되어야 합니다 비밀번호에 숫자가 하나 이상 포함되어야 합니다 비밀번호에 특수문자가 하나 이상 포함되어야 합니다 + + + SMS 인증 설정 + SMS 코드 확인 + + 이 작업을 수행하려면 최근 인증이 필요합니다. 다시 로그인한 후 다시 시도하세요. + 인증 코드가 잘못되었습니다. 다시 시도하세요. + 네트워크 오류가 발생했습니다. 연결 상태를 확인한 후 다시 시도하세요. + 등록 중 오류가 발생했습니다. 다시 시도하세요. diff --git a/auth/src/main/res/values-ln/strings.xml b/auth/src/main/res/values-ln/strings.xml index 9c110a7d9..36e9bdacc 100755 --- a/auth/src/main/res/values-ln/strings.xml +++ b/auth/src/main/res/values-ln/strings.xml @@ -121,4 +121,13 @@ 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. diff --git a/auth/src/main/res/values-lt/strings.xml b/auth/src/main/res/values-lt/strings.xml index a4ef4278e..fb5045a4a 100755 --- a/auth/src/main/res/values-lt/strings.xml +++ b/auth/src/main/res/values-lt/strings.xml @@ -121,4 +121,13 @@ 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ą. diff --git a/auth/src/main/res/values-lv/strings.xml b/auth/src/main/res/values-lv/strings.xml index c3bd47ae6..b539b9019 100755 --- a/auth/src/main/res/values-lv/strings.xml +++ b/auth/src/main/res/values-lv/strings.xml @@ -121,4 +121,13 @@ 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. diff --git a/auth/src/main/res/values-mo/strings.xml b/auth/src/main/res/values-mo/strings.xml index 8ca92a1c2..f047fb9f7 100755 --- a/auth/src/main/res/values-mo/strings.xml +++ b/auth/src/main/res/values-mo/strings.xml @@ -121,4 +121,13 @@ 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. diff --git a/auth/src/main/res/values-mr/strings.xml b/auth/src/main/res/values-mr/strings.xml index 6d6821196..2158386cf 100755 --- a/auth/src/main/res/values-mr/strings.xml +++ b/auth/src/main/res/values-mr/strings.xml @@ -121,4 +121,13 @@ पासवर्डमध्ये किमान एक लहान अक्षर असावे पासवर्डमध्ये किमान एक अंक असावा पासवर्डमध्ये किमान एक विशेष वर्ण असावा + + + एसएमएस प्रमाणीकरण सेट करा + एसएमएस कोड सत्यापित करा + + या ऑपरेशनसाठी अलीकडील प्रमाणीकरण आवश्यक आहे. कृपया पुन्हा साइन इन करा आणि पुन्हा प्रयत्न करा. + पडताळणी कोड चुकीचा आहे. कृपया पुन्हा प्रयत्न करा. + नेटवर्क एरर आली. कृपया तुमचे कनेक्शन तपासा आणि पुन्हा प्रयत्न करा. + नोंदणी दरम्यान एरर आली. कृपया पुन्हा प्रयत्न करा. diff --git a/auth/src/main/res/values-ms/strings.xml b/auth/src/main/res/values-ms/strings.xml index 9edeae853..22f306d46 100755 --- a/auth/src/main/res/values-ms/strings.xml +++ b/auth/src/main/res/values-ms/strings.xml @@ -121,4 +121,13 @@ 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. diff --git a/auth/src/main/res/values-nb/strings.xml b/auth/src/main/res/values-nb/strings.xml index 13d7e7990..cc720cc65 100755 --- a/auth/src/main/res/values-nb/strings.xml +++ b/auth/src/main/res/values-nb/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-nl/strings.xml b/auth/src/main/res/values-nl/strings.xml index a5e9be4d1..365c6bfb3 100755 --- a/auth/src/main/res/values-nl/strings.xml +++ b/auth/src/main/res/values-nl/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-no/strings.xml b/auth/src/main/res/values-no/strings.xml index ad3a91c76..57b7abfdf 100755 --- a/auth/src/main/res/values-no/strings.xml +++ b/auth/src/main/res/values-no/strings.xml @@ -121,4 +121,13 @@ 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. diff --git a/auth/src/main/res/values-pl/strings.xml b/auth/src/main/res/values-pl/strings.xml index eed13d63f..385a8702f 100755 --- a/auth/src/main/res/values-pl/strings.xml +++ b/auth/src/main/res/values-pl/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-pt-rBR/strings.xml b/auth/src/main/res/values-pt-rBR/strings.xml index 5996720fe..049ebd61a 100755 --- a/auth/src/main/res/values-pt-rBR/strings.xml +++ b/auth/src/main/res/values-pt-rBR/strings.xml @@ -121,4 +121,13 @@ 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. diff --git a/auth/src/main/res/values-pt-rPT/strings.xml b/auth/src/main/res/values-pt-rPT/strings.xml index ed0ad3ffc..f685b7703 100755 --- a/auth/src/main/res/values-pt-rPT/strings.xml +++ b/auth/src/main/res/values-pt-rPT/strings.xml @@ -121,4 +121,13 @@ 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. diff --git a/auth/src/main/res/values-pt/strings.xml b/auth/src/main/res/values-pt/strings.xml index f84a82b3a..bbf565648 100755 --- a/auth/src/main/res/values-pt/strings.xml +++ b/auth/src/main/res/values-pt/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-ro/strings.xml b/auth/src/main/res/values-ro/strings.xml index 96967a80a..22099af76 100755 --- a/auth/src/main/res/values-ro/strings.xml +++ b/auth/src/main/res/values-ro/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-ru/strings.xml b/auth/src/main/res/values-ru/strings.xml index 7c0cbb98a..85148cbaf 100755 --- a/auth/src/main/res/values-ru/strings.xml +++ b/auth/src/main/res/values-ru/strings.xml @@ -120,4 +120,13 @@ Пароль должен содержать хотя бы одну строчную букву Пароль должен содержать хотя бы одну цифру Пароль должен содержать хотя бы один специальный символ + + + Настроить SMS-аутентификацию + Подтвердить SMS-код + + Для этой операции требуется недавняя аутентификация. Войдите снова и повторите попытку. + Код подтверждения неверен. Повторите попытку. + Произошла ошибка сети. Проверьте соединение и повторите попытку. + Произошла ошибка при регистрации. Повторите попытку. diff --git a/auth/src/main/res/values-sk/strings.xml b/auth/src/main/res/values-sk/strings.xml index 6caff4502..83acd7545 100755 --- a/auth/src/main/res/values-sk/strings.xml +++ b/auth/src/main/res/values-sk/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-sl/strings.xml b/auth/src/main/res/values-sl/strings.xml index 3190eecfb..b84520b76 100755 --- a/auth/src/main/res/values-sl/strings.xml +++ b/auth/src/main/res/values-sl/strings.xml @@ -121,4 +121,13 @@ 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. diff --git a/auth/src/main/res/values-sr/strings.xml b/auth/src/main/res/values-sr/strings.xml index 00b9f020e..06d63adba 100755 --- a/auth/src/main/res/values-sr/strings.xml +++ b/auth/src/main/res/values-sr/strings.xml @@ -121,4 +121,13 @@ Лозинка мора да садржи најмање једно мало слово Лозинка мора да садржи најмање један број Лозинка мора да садржи најмање један посебан знак + + + Подесите SMS аутентификацију + Потврдите SMS код + + За ову операцију је потребна недавна провера идентитета. Пријавите се поново и покушајте поново. + Код за потврду је нетачан. Покушајте поново. + Дошло је до грешке на мрежи. Проверите везу и покушајте поново. + Дошло је до грешке током регистрације. Покушајте поново. diff --git a/auth/src/main/res/values-sv/strings.xml b/auth/src/main/res/values-sv/strings.xml index 35aff8fa9..b586a1c7f 100755 --- a/auth/src/main/res/values-sv/strings.xml +++ b/auth/src/main/res/values-sv/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-ta/strings.xml b/auth/src/main/res/values-ta/strings.xml index 2ed52463b..844c5baae 100755 --- a/auth/src/main/res/values-ta/strings.xml +++ b/auth/src/main/res/values-ta/strings.xml @@ -121,4 +121,13 @@ கடவுச்சொல்லில் குறைந்தது ஒரு சிறிய எழுத்து இருக்க வேண்டும் கடவுச்சொல்லில் குறைந்தது ஒரு எண் இருக்க வேண்டும் கடவுச்சொல்லில் குறைந்தது ஒரு சிறப்பு எழுத்துக்குறி இருக்க வேண்டும் + + + SMS அங்கீகாரத்தை அமைக்கவும் + SMS குறியீட்டைச் சரிபார்க்கவும் + + இந்த செயல்பாட்டிற்கு சமீபத்திய அங்கீகாரம் தேவை. மீண்டும் உள்நுழைந்து மீண்டும் முயற்சிக்கவும். + சரிபார்ப்புக் குறியீடு தவறானது. மீண்டும் முயற்சிக்கவும். + நெட்வொர்க் பிழை ஏற்பட்டது. உங்கள் இணைப்பைச் சரிபார்த்து மீண்டும் முயற்சிக்கவும். + பதிவின் போது பிழை ஏற்பட்டது. மீண்டும் முயற்சிக்கவும். diff --git a/auth/src/main/res/values-th/strings.xml b/auth/src/main/res/values-th/strings.xml index 9d24cba7f..9d454c3dc 100755 --- a/auth/src/main/res/values-th/strings.xml +++ b/auth/src/main/res/values-th/strings.xml @@ -121,4 +121,13 @@ รหัสผ่านต้องมีตัวพิมพ์เล็กอย่างน้อยหนึ่งตัว รหัสผ่านต้องมีตัวเลขอย่างน้อยหนึ่งตัว รหัสผ่านต้องมีอักขระพิเศษอย่างน้อยหนึ่งตัว + + + ตั้งค่าการยืนยันตัวตนผ่าน SMS + ยืนยันรหัส SMS + + การดำเนินการนี้จำเป็นต้องมีการตรวจสอบสิทธิ์ล่าสุด โปรดลงชื่อเข้าใช้อีกครั้งแล้วลองอีกครั้ง + รหัสยืนยันไม่ถูกต้อง โปรดลองอีกครั้ง + เกิดข้อผิดพลาดของเครือข่าย โปรดตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง + เกิดข้อผิดพลาดระหว่างการลงทะเบียน โปรดลองอีกครั้ง diff --git a/auth/src/main/res/values-tl/strings.xml b/auth/src/main/res/values-tl/strings.xml index 19d2527a7..15b21d85f 100755 --- a/auth/src/main/res/values-tl/strings.xml +++ b/auth/src/main/res/values-tl/strings.xml @@ -120,4 +120,13 @@ 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. diff --git a/auth/src/main/res/values-tr/strings.xml b/auth/src/main/res/values-tr/strings.xml index bc0e197f7..d996772db 100755 --- a/auth/src/main/res/values-tr/strings.xml +++ b/auth/src/main/res/values-tr/strings.xml @@ -121,4 +121,13 @@ Ş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. diff --git a/auth/src/main/res/values-uk/strings.xml b/auth/src/main/res/values-uk/strings.xml index e08e214df..81cbb2719 100755 --- a/auth/src/main/res/values-uk/strings.xml +++ b/auth/src/main/res/values-uk/strings.xml @@ -121,4 +121,13 @@ Пароль має містити щонайменше одну малу літеру Пароль має містити щонайменше одну цифру Пароль має містити щонайменше один спеціальний символ + + + Налаштувати SMS-автентифікацію + Підтвердити SMS-код + + Для цієї операції потрібна недавня автентифікація. Увійдіть знову та повторіть спробу. + Код підтвердження неправильний. Повторіть спробу. + Сталася помилка мережі. Перевірте з\'єднання та повторіть спробу. + Сталася помилка під час реєстрації. Повторіть спробу. diff --git a/auth/src/main/res/values-ur/strings.xml b/auth/src/main/res/values-ur/strings.xml index 321f63854..878e78d29 100755 --- a/auth/src/main/res/values-ur/strings.xml +++ b/auth/src/main/res/values-ur/strings.xml @@ -121,4 +121,13 @@ پاس ورڈ میں کم از کم ایک چھوٹا حرف ہونا چاہیے پاس ورڈ میں کم از کم ایک ہندسہ ہونا چاہیے پاس ورڈ میں کم از کم ایک خاص حرف ہونا چاہیے + + + SMS تصدیق ترتیب دیں + SMS کوڈ کی تصدیق کریں + + اس آپریشن کے لیے حالیہ توثیق کی ضرورت ہے۔ براہ کرم دوبارہ سائن ان کریں اور دوبارہ کوشش کریں۔ + تصدیقی کوڈ غلط ہے۔ براہ کرم دوبارہ کوشش کریں۔ + نیٹ ورک کی خرابی واقع ہوئی۔ براہ کرم اپنا کنکشن چیک کریں اور دوبارہ کوشش کریں۔ + اندراج کے دوران ایک خرابی واقع ہوئی۔ براہ کرم دوبارہ کوشش کریں۔ diff --git a/auth/src/main/res/values-vi/strings.xml b/auth/src/main/res/values-vi/strings.xml index 98b73434f..d6b85385a 100755 --- a/auth/src/main/res/values-vi/strings.xml +++ b/auth/src/main/res/values-vi/strings.xml @@ -121,4 +121,13 @@ 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. diff --git a/auth/src/main/res/values-zh-rCN/strings.xml b/auth/src/main/res/values-zh-rCN/strings.xml index b8220c70c..868a12fae 100755 --- a/auth/src/main/res/values-zh-rCN/strings.xml +++ b/auth/src/main/res/values-zh-rCN/strings.xml @@ -121,4 +121,13 @@ 密码必须包含至少一个小写字母 密码必须包含至少一个数字 密码必须包含至少一个特殊字符 + + + 设置短信验证 + 验证短信验证码 + + 此操作需要最近的身份验证。请重新登录并重试。 + 验证码不正确。请重试。 + 发生网络错误。请检查您的连接并重试。 + 注册期间发生错误。请重试。 diff --git a/auth/src/main/res/values-zh-rHK/strings.xml b/auth/src/main/res/values-zh-rHK/strings.xml index dd047e28b..f302630cf 100755 --- a/auth/src/main/res/values-zh-rHK/strings.xml +++ b/auth/src/main/res/values-zh-rHK/strings.xml @@ -121,4 +121,13 @@ 密碼必須包含至少一個小寫字母 密碼必須包含至少一個數字 密碼必須包含至少一個特殊字元 + + + 設定短訊驗證 + 驗證短訊驗證碼 + + 此操作需要最近的身份驗證。請重新登入並重試。 + 驗證碼不正確。請重試。 + 發生網絡錯誤。請檢查您的連接並重試。 + 註冊期間發生錯誤。請重試。 diff --git a/auth/src/main/res/values-zh-rTW/strings.xml b/auth/src/main/res/values-zh-rTW/strings.xml index 3a00351a2..68eec499e 100755 --- a/auth/src/main/res/values-zh-rTW/strings.xml +++ b/auth/src/main/res/values-zh-rTW/strings.xml @@ -121,4 +121,13 @@ 密碼必須包含至少一個小寫字母 密碼必須包含至少一個數字 密碼必須包含至少一個特殊字元 + + + 設定簡訊驗證 + 驗證簡訊驗證碼 + + 此操作需要最近的身份驗證。請重新登入並重試。 + 驗證碼不正確。請重試。 + 發生網路錯誤。請檢查您的連線並重試。 + 註冊期間發生錯誤。請重試。 diff --git a/auth/src/main/res/values-zh/strings.xml b/auth/src/main/res/values-zh/strings.xml index c9fc58194..915780242 100755 --- a/auth/src/main/res/values-zh/strings.xml +++ b/auth/src/main/res/values-zh/strings.xml @@ -120,4 +120,13 @@ 密码必须包含至少一个小写字母 密码必须包含至少一个数字 密码必须包含至少一个特殊字符 + + + 设置短信验证 + 验证短信验证码 + + 此操作需要最近的身份验证。请重新登录并重试。 + 验证码不正确。请重试。 + 发生网络错误。请检查您的连接并重试。 + 注册期间发生错误。请重试。 diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 96df2a488..b6e05106e 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -167,4 +167,13 @@ 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/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/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..67341510b --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentScreenTest.kt @@ -0,0 +1,356 @@ +/* + * 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 + + 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") + } + + @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/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt index 33423b1a5..2b3a8948a 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt @@ -17,6 +17,7 @@ import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay import com.firebase.composeapp.ui.screens.EmailAuthMain +import com.firebase.composeapp.ui.screens.MfaEnrollmentMain import com.firebase.composeapp.ui.screens.PhoneAuthMain import com.firebase.ui.auth.compose.FirebaseAuthUI import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration @@ -43,6 +44,9 @@ sealed class Route : NavKey { @Serializable object PhoneAuth : Route() + + @Serializable + object MfaEnrollment : Route() } class MainActivity : ComponentActivity() { @@ -138,7 +142,7 @@ class MainActivity : ComponentActivity() { val emailProvider = configuration.providers .filterIsInstance() .first() - LaunchEmailAuth(authUI, configuration, emailProvider) + LaunchEmailAuth(authUI, configuration, emailProvider, backStack) } is Route.PhoneAuth -> NavEntry(entry) { @@ -147,6 +151,10 @@ class MainActivity : ComponentActivity() { .first() LaunchPhoneAuth(authUI, configuration, phoneProvider) } + + is Route.MfaEnrollment -> NavEntry(entry) { + LaunchMfaEnrollment(authUI, backStack) + } } } ) @@ -160,6 +168,7 @@ class MainActivity : ComponentActivity() { authUI: FirebaseAuthUI, configuration: AuthUIConfiguration, selectedProvider: AuthProvider.Email, + backStack: androidx.compose.runtime.snapshots.SnapshotStateList ) { // Check if this is an email link sign-in flow val emailLink = intent.getStringExtra( @@ -195,6 +204,9 @@ class MainActivity : ComponentActivity() { context = applicationContext, configuration = configuration, authUI = authUI, + onSetupMfa = { + backStack.add(Route.MfaEnrollment) + } ) } @@ -210,4 +222,57 @@ class MainActivity : ComponentActivity() { authUI = authUI, ) } + + @Composable + private fun LaunchMfaEnrollment( + authUI: FirebaseAuthUI, + backStack: androidx.compose.runtime.snapshots.SnapshotStateList + ) { + val user = authUI.getCurrentUser() + if (user != null) { + val authConfiguration = authUIConfiguration { + context = applicationContext + providers { + provider( + com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = emptyList(), + smsCodeLength = 6, + timeout = 120L, + isInstantVerificationEnabled = true + ) + ) + } + } + + val mfaConfiguration = com.firebase.ui.auth.compose.configuration.MfaConfiguration( + allowedFactors = listOf( + com.firebase.ui.auth.compose.configuration.MfaFactor.Sms, + com.firebase.ui.auth.compose.configuration.MfaFactor.Totp + ), + requireEnrollment = false, + enableRecoveryCodes = true + ) + + MfaEnrollmentMain( + context = applicationContext, + authUI = authUI, + user = user, + authConfiguration = authConfiguration, + mfaConfiguration = mfaConfiguration, + onComplete = { + // Navigate back to the previous screen after successful enrollment + backStack.removeLastOrNull() + }, + onSkip = { + // Navigate back if user skips enrollment + backStack.removeLastOrNull() + } + ) + } else { + // No user signed in, navigate back + backStack.removeLastOrNull() + } + } } \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/components/ReauthenticationDialog.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/components/ReauthenticationDialog.kt new file mode 100644 index 000000000..4deac4ed7 --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/components/ReauthenticationDialog.kt @@ -0,0 +1,214 @@ +/* + * 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.composeapp.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.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.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FirebaseUser +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +/** + * Dialog that prompts the user to re-authenticate with their password. + * This is required when performing sensitive operations like 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() } + + // Auto-focus the password field when dialog opens + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + AlertDialog( + onDismissRequest = { if (!isLoading) onDismiss() }, + title = { + Text( + text = "Verify Your Identity", + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "For your security, please re-enter your password to continue.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (user.email != null) { + Text( + text = "Account: ${user.email}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + OutlinedTextField( + value = password, + onValueChange = { + password = it + errorMessage = null + }, + label = { Text("Password") }, + 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 = when { + error.message?.contains("password", ignoreCase = true) == true -> + "Incorrect password. Please try again." + error.message?.contains("network", ignoreCase = true) == true -> + "Network error. Please check your connection." + else -> "Authentication failed. Please try again." + } + onError(error) + } + ) + } + } + } + ), + enabled = !isLoading, + isError = errorMessage != null, + supportingText = errorMessage?.let { { Text(it) } }, + 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 = when { + error.message?.contains("password", ignoreCase = true) == true -> + "Incorrect password. Please try again." + error.message?.contains("network", ignoreCase = true) == true -> + "Network error. Please check your connection." + else -> "Authentication failed. Please try again." + } + onError(error) + } + ) + } + }, + enabled = password.isNotBlank() && !isLoading + ) { + Text("Verify") + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + enabled = !isLoading + ) { + Text("Cancel") + } + } + ) +} + +private suspend fun reauthenticate( + user: FirebaseUser, + password: String, + onLoading: (Boolean) -> Unit, + onSuccess: () -> Unit, + onError: (Exception) -> Unit +) { + try { + onLoading(true) + + val email = user.email + if (email == null) { + throw IllegalStateException("User email not available") + } + + val credential = EmailAuthProvider.getCredential(email, password) + user.reauthenticate(credential).await() + + onSuccess() + } catch (e: Exception) { + onError(e) + } finally { + onLoading(false) + } +} diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/EmailAuthMain.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/EmailAuthMain.kt index 33938a0ff..a191da5f0 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/EmailAuthMain.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/EmailAuthMain.kt @@ -32,6 +32,7 @@ fun EmailAuthMain( context: Context, configuration: AuthUIConfiguration, authUI: FirebaseAuthUI, + onSetupMfa: () -> Unit = {}, ) { val coroutineScope = rememberCoroutineScope() val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) @@ -48,6 +49,12 @@ fun EmailAuthMain( textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = onSetupMfa + ) { + Text("Setup MFA") + } + Spacer(modifier = Modifier.height(8.dp)) Button( onClick = { coroutineScope.launch { @@ -61,6 +68,7 @@ fun EmailAuthMain( } is AuthState.RequiresEmailVerification -> { + val verificationState = authState as AuthState.RequiresEmailVerification Column( modifier = Modifier .fillMaxSize(), @@ -70,10 +78,59 @@ fun EmailAuthMain( Text( "Authenticated User - " + "(RequiresEmailVerification): " + - "${(authState as AuthState.RequiresEmailVerification).user.email}", + "${verificationState.user.email}", textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(8.dp)) + Text( + "Please verify your email to continue.", + textAlign = TextAlign.Center, + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { + coroutineScope.launch { + try { + verificationState.user.sendEmailVerification() + .addOnCompleteListener { task -> + if (task.isSuccessful) { + android.util.Log.d("EmailAuthMain", "Verification email sent") + } else { + android.util.Log.e("EmailAuthMain", "Failed to send verification email", task.exception) + } + } + } catch (e: Exception) { + android.util.Log.e("EmailAuthMain", "Error sending verification email", e) + } + } + } + ) { + Text("Send Verification Email") + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + coroutineScope.launch { + try { + // Reload the user to refresh the authentication token + verificationState.user.reload().addOnCompleteListener { task -> + if (task.isSuccessful) { + android.util.Log.d("EmailAuthMain", "User reloaded. isEmailVerified: ${verificationState.user.isEmailVerified}") + // The auth state listener will automatically update the state + } else { + android.util.Log.e("EmailAuthMain", "Failed to reload user", task.exception) + } + } + } catch (e: Exception) { + android.util.Log.e("EmailAuthMain", "Error reloading user", e) + } + } + } + ) { + Text("Check Verification Status") + } + Spacer(modifier = Modifier.height(8.dp)) Button( onClick = { coroutineScope.launch { diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/MfaEnrollmentMain.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/MfaEnrollmentMain.kt new file mode 100644 index 000000000..380cffcff --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/MfaEnrollmentMain.kt @@ -0,0 +1,676 @@ +/* + * 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.composeapp.ui.screens + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.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.CircularProgressIndicator +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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.FirebaseAuthUI +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.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.mfa.MfaEnrollmentStep +import com.firebase.ui.auth.compose.mfa.getHelperText +import com.firebase.ui.auth.compose.mfa.getTitle +import com.firebase.ui.auth.compose.mfa.toMfaErrorMessage +import com.firebase.ui.auth.compose.ui.components.CountrySelector +import com.firebase.ui.auth.compose.ui.screens.MfaEnrollmentScreen +import com.firebase.ui.auth.compose.ui.screens.phone.EnterPhoneNumberUI +import com.firebase.ui.auth.compose.ui.screens.phone.EnterVerificationCodeUI +import com.firebase.composeapp.ui.components.ReauthenticationDialog +import com.google.firebase.auth.FirebaseAuthException +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 +fun MfaEnrollmentMain( + context: Context, + authUI: FirebaseAuthUI, + user: FirebaseUser, + authConfiguration: AuthUIConfiguration, + mfaConfiguration: MfaConfiguration, + onComplete: () -> Unit, + onSkip: () -> Unit = {}, +) { + val stringProvider = DefaultAuthUIStringProvider(LocalContext.current) + val snackbarHostState = remember { SnackbarHostState() } + val currentError = remember { androidx.compose.runtime.mutableStateOf(null) } + val showReauthDialog = remember { androidx.compose.runtime.mutableStateOf(false) } + val retryAction = remember { androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null) } + val successMessage = remember { androidx.compose.runtime.mutableStateOf(null) } + val reauthErrorMessage = remember { androidx.compose.runtime.mutableStateOf(null) } + + // Show error in snackbar when error occurs + LaunchedEffect(currentError.value) { + currentError.value?.let { exception -> + // Don't show snackbar for recent login required - we'll show re-auth dialog instead + if (exception !is FirebaseAuthRecentLoginRequiredException) { + val errorMessage = exception.toMfaErrorMessage(stringProvider) + snackbarHostState.showSnackbar(errorMessage) + } + currentError.value = null // Clear error after showing + } + } + + // Show success message after re-authentication + LaunchedEffect(successMessage.value) { + successMessage.value?.let { message -> + snackbarHostState.showSnackbar(message) + successMessage.value = null + } + } + + // Show re-auth error message + LaunchedEffect(reauthErrorMessage.value) { + reauthErrorMessage.value?.let { message -> + snackbarHostState.showSnackbar(message) + reauthErrorMessage.value = null + } + } + + // Show re-authentication dialog when needed + if (showReauthDialog.value) { + ReauthenticationDialog( + user = user, + onDismiss = { + showReauthDialog.value = false + retryAction.value = null + }, + onSuccess = { + showReauthDialog.value = false + // Trigger success message + successMessage.value = "Identity verified. Please try your action again." + retryAction.value = null + }, + onError = { exception -> + android.util.Log.e("MfaEnrollmentMain", "Re-authentication failed", exception) + // Trigger error message + reauthErrorMessage.value = when { + exception.message?.contains("password", ignoreCase = true) == true -> + "Incorrect password. Please try again." + else -> "Re-authentication failed. Please try again." + } + } + ) + } + + MfaEnrollmentScreen( + user = user, + auth = authUI.auth, + configuration = mfaConfiguration, + onComplete = onComplete, + onSkip = onSkip, + onError = { exception -> + android.util.Log.e("MfaEnrollmentMain", "MFA enrollment error", exception) + + // Check if re-authentication is required + if (exception is FirebaseAuthRecentLoginRequiredException) { + showReauthDialog.value = true + // Store the retry action - we'll need to trigger it manually from state + // For now, we'll just show the dialog and let the user know to try again + } else if (exception is FirebaseAuthException && + exception.message?.contains("already enrolled", ignoreCase = true) == true) { + // Handle "already enrolled" error with a friendlier message + currentError.value = Exception("This authentication method is already enrolled. Please go back to remove it first or choose a different method.") + } else { + currentError.value = exception + } + } + ) { state -> + androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) { + // Step-specific UI - EnterPhoneNumberUI and EnterVerificationCodeUI have their own Scaffold + 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 + ) + } + + MfaEnrollmentStep.ConfigureSms -> { + state.selectedCountry?.let { country -> + val stringProvider = DefaultAuthUIStringProvider(LocalContext.current) + 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 + ) + } + + MfaEnrollmentStep.VerifyFactor -> { + when (state.selectedFactor) { + MfaFactor.Sms -> { + val stringProvider = DefaultAuthUIStringProvider(LocalContext.current) + EnterVerificationCodeUI( + configuration = authConfiguration, + isLoading = state.isLoading, + verificationCode = state.verificationCode, + fullPhoneNumber = "${state.selectedCountry?.dialCode ?: ""}${state.phoneNumber}", + 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 + ) + } + null -> {} + } + } + + MfaEnrollmentStep.ShowRecoveryCodes -> { + ShowRecoveryCodesUI( + recoveryCodes = state.recoveryCodes ?: emptyList(), + onDoneClick = state.onCodesSavedClick, + isLoading = state.isLoading, + error = state.error + ) + } + } + + // Snackbar for error messages + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + } +} + +@Composable +private fun SelectFactorUI( + availableFactors: List, + enrolledFactors: List, + onFactorSelected: (MfaFactor) -> Unit, + onUnenrollFactor: (MultiFactorInfo) -> Unit, + onSkipClick: (() -> Unit)?, + isLoading: Boolean, + error: String? +) { + // Filter out already enrolled factors + val enrolledFactorIds = enrolledFactors.map { + when (it) { + is PhoneMultiFactorInfo -> MfaFactor.Sms + is TotpMultiFactorInfo -> MfaFactor.Totp + else -> null + } + }.filterNotNull().toSet() + + val factorsToEnroll = availableFactors.filter { it !in enrolledFactorIds } + + Scaffold { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Manage Two-Factor Authentication", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Text( + text = "Add or remove authentication methods for your account", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + + // Show enrolled factors + if (enrolledFactors.isNotEmpty()) { + Text( + text = "Active Methods", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + + enrolledFactors.forEach { factorInfo -> + EnrolledFactorItem( + factorInfo = factorInfo, + onRemove = { onUnenrollFactor(factorInfo) }, + enabled = !isLoading + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + } + + // Show available factors to enroll + if (factorsToEnroll.isNotEmpty()) { + Text( + text = "Add New Method", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + + factorsToEnroll.forEach { factor -> + Button( + onClick = { onFactorSelected(factor) }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Text( + when (factor) { + MfaFactor.Sms -> "Add SMS Authentication" + MfaFactor.Totp -> "Add Authenticator App" + } + ) + } + } + } + + if (factorsToEnroll.isEmpty() && enrolledFactors.isNotEmpty()) { + Text( + text = "All available authentication methods are enrolled", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + + onSkipClick?.let { + TextButton( + onClick = it, + enabled = !isLoading + ) { + Text("Skip for now") + } + } + } + } +} + +@Composable +private fun EnrolledFactorItem( + factorInfo: MultiFactorInfo, + onRemove: () -> Unit, + enabled: Boolean +) { + androidx.compose.material3.Card( + modifier = Modifier.fillMaxWidth(), + colors = androidx.compose.material3.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 -> "SMS Authentication" + is TotpMultiFactorInfo -> "Authenticator App" + else -> "Unknown Method" + }, + style = MaterialTheme.typography.titleSmall + ) + Text( + text = when (factorInfo) { + is PhoneMultiFactorInfo -> factorInfo.phoneNumber ?: "Phone" + is TotpMultiFactorInfo -> factorInfo.displayName ?: "TOTP" + else -> "" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Enrolled on ${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 = androidx.compose.material3.ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Remove") + } + } + } +} + +@Composable +private fun ConfigureTotpUI( + totpSecret: String?, + totpQrCodeUrl: String?, + onContinueClick: () -> Unit, + onBackClick: () -> Unit, + isLoading: Boolean, + isValid: Boolean, + error: String? +) { + Scaffold { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Setup Authenticator App", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Text( + text = "Scan the QR code or enter the secret key in your authenticator app", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + + totpSecret?.let { secret -> + Text( + text = "Secret Key:", + style = MaterialTheme.typography.labelMedium + ) + Text( + text = secret, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + textAlign = TextAlign.Center + ) + } + + totpQrCodeUrl?.let { url -> + Text( + text = "Scan this with your authenticator app:", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + Text( + text = "(QR code would be displayed here)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onBackClick, + enabled = !isLoading, + modifier = Modifier.weight(1f) + ) { + Text("Back") + } + + Button( + onClick = onContinueClick, + enabled = !isLoading && isValid, + modifier = Modifier.weight(1f) + ) { + Text("Continue") + } + } + } + } +} + +@Composable +private fun VerifyTotpUI( + verificationCode: String, + onVerificationCodeChange: (String) -> Unit, + onVerifyClick: () -> Unit, + onBackClick: () -> Unit, + isLoading: Boolean, + isValid: Boolean, + error: String? +) { + Scaffold { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Verify Your Code", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Text( + text = "Enter the code from your authenticator app", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + + OutlinedTextField( + value = verificationCode, + onValueChange = onVerificationCodeChange, + label = { Text("Verification code") }, + 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("Back") + } + + Button( + onClick = onVerifyClick, + enabled = !isLoading && isValid, + modifier = Modifier.weight(1f) + ) { + Text("Verify") + } + } + } + } +} + +@Composable +private fun ShowRecoveryCodesUI( + recoveryCodes: List, + onDoneClick: () -> Unit, + isLoading: Boolean, + error: String? +) { + Scaffold { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Recovery Codes", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Text( + text = "Save these recovery codes in a safe place. You can use them to sign in if you lose access to your authentication method.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error + ) + + Spacer(modifier = Modifier.height(16.dp)) + + 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("I've saved these codes") + } + } + } +} diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreenTest.kt new file mode 100644 index 000000000..3fd795d87 --- /dev/null +++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreenTest.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.ui.screens + +import android.content.Context +import androidx.compose.runtime.Composable +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.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.MfaFactor +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.mfa.MfaChallengeContentState +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.MultiFactorInfo +import com.google.firebase.auth.MultiFactorResolver +import com.google.firebase.auth.MultiFactorSession +import com.google.firebase.auth.PhoneMultiFactorInfo +import com.google.firebase.auth.TotpMultiFactorInfo +import org.junit.After +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.annotation.Config + +/** + * E2E tests for [MfaChallengeScreen]. + * + * These tests verify the MFA challenge flow including UI interactions and state transitions. + * + * Note: Firebase Auth Emulator has limited MFA support, so these tests use mocked + * MultiFactorResolver to test the UI flow. + */ +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class MfaChallengeScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var applicationContext: Context + private lateinit var stringProvider: AuthUIStringProvider + private lateinit var authUI: FirebaseAuthUI + + @Mock + private lateinit var mockResolver: MultiFactorResolver + + @Mock + private lateinit var mockSession: MultiFactorSession + + @Mock + private lateinit var mockPhoneHint: PhoneMultiFactorInfo + + @Mock + private lateinit var mockTotpHint: TotpMultiFactorInfo + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + applicationContext = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(applicationContext) + + // 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) + + // Setup mock resolver + `when`(mockResolver.session).thenReturn(mockSession) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + } + + @Test + fun `screen detects SMS factor and shows masked phone number`() { + `when`(mockPhoneHint.factorId).thenReturn("phone") + `when`(mockPhoneHint.phoneNumber).thenReturn("+1234567890") + `when`(mockResolver.hints).thenReturn(listOf(mockPhoneHint)) + + var capturedState: MfaChallengeContentState? = null + + composeTestRule.setContent { + TestMfaChallengeScreen( + resolver = mockResolver, + onStateChange = { capturedState = it } + ) + } + + composeTestRule.waitForIdle() + + assertThat(capturedState?.factorType).isEqualTo(MfaFactor.Sms) + assertThat(capturedState?.maskedPhoneNumber).isNotNull() + assertThat(capturedState?.maskedPhoneNumber).contains("•") + composeTestRule.onNodeWithText(capturedState?.maskedPhoneNumber ?: "") + .assertIsDisplayed() + } + + @Test + fun `screen detects TOTP factor and shows no masked phone`() { + `when`(mockTotpHint.factorId).thenReturn("totp") + `when`(mockResolver.hints).thenReturn(listOf(mockTotpHint)) + + var capturedState: MfaChallengeContentState? = null + + composeTestRule.setContent { + TestMfaChallengeScreen( + resolver = mockResolver, + onStateChange = { capturedState = it } + ) + } + + composeTestRule.waitForIdle() + + assertThat(capturedState?.factorType).isEqualTo(MfaFactor.Totp) + assertThat(capturedState?.maskedPhoneNumber).isNull() + } + + @Test + fun `verification code input enables verify button`() { + `when`(mockTotpHint.factorId).thenReturn("totp") + `when`(mockResolver.hints).thenReturn(listOf(mockTotpHint)) + + composeTestRule.setContent { + TestMfaChallengeScreen(resolver = mockResolver) + } + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("VERIFY") + .assertIsNotEnabled() + + composeTestRule.onNodeWithText("Verification code") + .performTextInput("123456") + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("VERIFY") + .assertIsEnabled() + } + + @Test + fun `resend button is available for SMS factor`() { + `when`(mockPhoneHint.factorId).thenReturn("phone") + `when`(mockPhoneHint.phoneNumber).thenReturn("+1234567890") + `when`(mockResolver.hints).thenReturn(listOf(mockPhoneHint)) + + composeTestRule.setContent { + TestMfaChallengeScreen(resolver = mockResolver) + } + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("RESEND CODE") + .assertIsDisplayed() + } + + @Test + fun `resend button is not available for TOTP factor`() { + `when`(mockTotpHint.factorId).thenReturn("totp") + `when`(mockResolver.hints).thenReturn(listOf(mockTotpHint)) + + composeTestRule.setContent { + TestMfaChallengeScreen(resolver = mockResolver) + } + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("RESEND CODE") + .assertDoesNotExist() + } + + @Test + fun `cancel button invokes callback`() { + `when`(mockTotpHint.factorId).thenReturn("totp") + `when`(mockResolver.hints).thenReturn(listOf(mockTotpHint)) + + var cancelClicked = false + + composeTestRule.setContent { + TestMfaChallengeScreen( + resolver = mockResolver, + onCancel = { cancelClicked = true } + ) + } + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("CANCEL") + .performClick() + + composeTestRule.waitForIdle() + assertThat(cancelClicked).isTrue() + } + + @Test + fun `verification code must be 6 digits to enable verify button`() { + `when`(mockTotpHint.factorId).thenReturn("totp") + `when`(mockResolver.hints).thenReturn(listOf(mockTotpHint)) + + composeTestRule.setContent { + TestMfaChallengeScreen(resolver = mockResolver) + } + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Verification code") + .performTextInput("12345") + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("VERIFY") + .assertIsNotEnabled() + + composeTestRule.onNodeWithText("Verification code") + .performTextInput("6") + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("VERIFY") + .assertIsEnabled() + } + + @Composable + private fun TestMfaChallengeScreen( + resolver: MultiFactorResolver, + onSuccess: () -> Unit = {}, + onCancel: () -> Unit = {}, + onStateChange: (MfaChallengeContentState) -> Unit = {} + ) { + MfaChallengeScreen( + resolver = resolver, + auth = authUI.auth, + onSuccess = { onSuccess() }, + onCancel = onCancel, + onError = { /* Ignore errors in test UI */ } + ) { state -> + onStateChange(state) + TestMfaChallengeUI(state = state) + } + } + + @Composable + private fun TestMfaChallengeUI(state: MfaChallengeContentState) { + androidx.compose.foundation.layout.Column { + androidx.compose.material3.Text("MFA Challenge") + + state.maskedPhoneNumber?.let { + androidx.compose.material3.Text(it) + } + + androidx.compose.material3.TextField( + value = state.verificationCode, + onValueChange = state.onVerificationCodeChange, + label = { androidx.compose.material3.Text("Verification code") } + ) + + androidx.compose.material3.Button( + onClick = state.onVerifyClick, + enabled = state.isValid && !state.isLoading + ) { + androidx.compose.material3.Text("VERIFY") + } + + state.onResendCodeClick?.let { + androidx.compose.material3.Button(onClick = it) { + androidx.compose.material3.Text("RESEND CODE") + } + } + + androidx.compose.material3.Button(onClick = state.onCancelClick) { + androidx.compose.material3.Text("CANCEL") + } + } + } +} diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentScreenTest.kt new file mode 100644 index 000000000..f27c3ebd7 --- /dev/null +++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaEnrollmentScreenTest.kt @@ -0,0 +1,457 @@ +/* + * 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 androidx.compose.runtime.Composable +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.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.MfaConfiguration +import com.firebase.ui.auth.compose.configuration.MfaFactor +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.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.compose.mfa.MfaEnrollmentStep +import com.firebase.ui.auth.compose.mfa.getHelperText +import com.firebase.ui.auth.compose.mfa.getTitle +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseUser +import org.junit.After +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.annotation.Config + +/** + * E2E tests for [MfaEnrollmentScreen]. + * + * These tests verify the UI state management and transitions for the MFA enrollment flow. + * + * **Important Note**: Firebase Auth Emulator has **limited MFA support**, so these tests + * use mocked Firebase users and focus on UI flow validation. Actual MFA operations + * (enrollment, verification) will fail with the emulator and are caught/ignored in tests. + * + * For full integration testing of MFA functionality, use a real Firebase project. + */ +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class MfaEnrollmentScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var applicationContext: Context + private lateinit var stringProvider: AuthUIStringProvider + private lateinit var authUI: FirebaseAuthUI + private lateinit var testUser: FirebaseUser + + @Mock + private lateinit var mockFirebaseUser: FirebaseUser + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + applicationContext = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(applicationContext) + + // 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() + + // Use mock user instead of real Firebase user + `when`(mockFirebaseUser.email).thenReturn("mfatest@example.com") + `when`(mockFirebaseUser.uid).thenReturn("test-uid-123") + testUser = mockFirebaseUser + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + } + + @Test + fun `screen starts at SelectFactor step with multiple factors`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp), + requireEnrollment = false + ) + + var capturedState: MfaEnrollmentContentState? = null + + composeTestRule.setContent { + TestMfaEnrollmentScreen( + configuration = configuration, + onStateChange = { capturedState = it } + ) + } + + composeTestRule.waitForIdle() + + assertThat(capturedState?.step).isEqualTo(MfaEnrollmentStep.SelectFactor) + assertThat(capturedState?.availableFactors).containsExactly(MfaFactor.Sms, MfaFactor.Totp) + composeTestRule.onNodeWithText(MfaEnrollmentStep.SelectFactor.getTitle(stringProvider)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(MfaEnrollmentStep.SelectFactor.getHelperText(stringProvider)) + .assertIsDisplayed() + } + + @Test + fun `skip button is available when enrollment is not required`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp), + requireEnrollment = false + ) + + var skipClicked = false + + composeTestRule.setContent { + TestMfaEnrollmentScreen( + configuration = configuration, + onSkip = { skipClicked = true } + ) + } + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("SKIP") + .assertIsDisplayed() + .performClick() + + composeTestRule.waitForIdle() + assertThat(skipClicked).isTrue() + } + + @Test + fun `selecting SMS factor navigates to ConfigureSms step`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp) + ) + + var capturedState: MfaEnrollmentContentState? = null + + composeTestRule.setContent { + TestMfaEnrollmentScreen( + configuration = configuration, + onStateChange = { capturedState = it } + ) + } + + composeTestRule.waitForIdle() + assertThat(capturedState?.step).isEqualTo(MfaEnrollmentStep.SelectFactor) + + composeTestRule.onNodeWithText("SMS") + .performClick() + + composeTestRule.waitForIdle() + assertThat(capturedState?.step).isEqualTo(MfaEnrollmentStep.ConfigureSms) + composeTestRule.onNodeWithText(MfaEnrollmentStep.ConfigureSms.getTitle(stringProvider)) + .assertIsDisplayed() + } + + @Test + fun `selecting TOTP factor navigates to ConfigureTotp step`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp) + ) + + var capturedState: MfaEnrollmentContentState? = null + + composeTestRule.setContent { + TestMfaEnrollmentScreen( + configuration = configuration, + onStateChange = { capturedState = it } + ) + } + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("TOTP") + .performClick() + + composeTestRule.waitForIdle() + assertThat(capturedState?.step).isEqualTo(MfaEnrollmentStep.ConfigureTotp) + composeTestRule.onNodeWithText(MfaEnrollmentStep.ConfigureTotp.getTitle(stringProvider)) + .assertIsDisplayed() + } + + @Test + fun `phone number input enables send button`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms) + ) + + composeTestRule.setContent { + TestMfaEnrollmentScreen(configuration = configuration) + } + + composeTestRule.waitForIdle() + + // Initially at ConfigureSms since only one factor + composeTestRule.onNodeWithText("SEND CODE") + .assertIsNotEnabled() + + composeTestRule.onNodeWithText("Phone number") + .performTextInput("1234567890") + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("SEND CODE") + .assertIsEnabled() + } + + @Test + fun `back navigation works from ConfigureSms to SelectFactor`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp) + ) + + var capturedState: MfaEnrollmentContentState? = null + + composeTestRule.setContent { + TestMfaEnrollmentScreen( + configuration = configuration, + onStateChange = { capturedState = it } + ) + } + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("SMS") + .performClick() + + composeTestRule.waitForIdle() + assertThat(capturedState?.step).isEqualTo(MfaEnrollmentStep.ConfigureSms) + + composeTestRule.onNodeWithText("BACK") + .performClick() + + composeTestRule.waitForIdle() + assertThat(capturedState?.step).isEqualTo(MfaEnrollmentStep.SelectFactor) + } + + @Test + fun `TOTP secret and QR code URL are generated on ConfigureTotp step`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Totp) + ) + + var capturedState: MfaEnrollmentContentState? = null + var errorOccurred = false + + composeTestRule.setContent { + TestMfaEnrollmentScreen( + configuration = configuration, + onStateChange = { capturedState = it }, + onError = { errorOccurred = true } + ) + } + + composeTestRule.waitForIdle() + + // Should be at ConfigureTotp since only one factor + assertThat(capturedState?.step).isEqualTo(MfaEnrollmentStep.ConfigureTotp) + + // If no error occurred (rare in emulator), verify TOTP setup + if (!errorOccurred && capturedState?.totpSecret != null) { + assertThat(capturedState?.totpQrCodeUrl).isNotNull() + assertThat(capturedState?.totpQrCodeUrl).startsWith("otpauth://totp/") + } + } + + @Test + fun `verification code input enables verify button`() { + val configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Totp) + ) + + var capturedState: MfaEnrollmentContentState? = null + + composeTestRule.setContent { + TestMfaEnrollmentScreen( + configuration = configuration, + onStateChange = { capturedState = it } + ) + } + + composeTestRule.waitForIdle() + + // If TOTP generation failed (common with mocked user), skip the step navigation test + if (capturedState?.totpSecret == null) { + // Test would require real Firebase user with MFA support + return + } + + composeTestRule.onNodeWithText("CONTINUE") + .performClick() + + composeTestRule.waitForIdle() + assertThat(capturedState?.step).isEqualTo(MfaEnrollmentStep.VerifyFactor) + + composeTestRule.onNodeWithText("VERIFY") + .assertIsNotEnabled() + + composeTestRule.onNodeWithText("Verification code") + .performTextInput("123456") + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("VERIFY") + .assertIsEnabled() + } + + @Composable + private fun TestMfaEnrollmentScreen( + configuration: MfaConfiguration, + onComplete: () -> Unit = {}, + onSkip: () -> Unit = {}, + onError: (Exception) -> Unit = {}, + onStateChange: (MfaEnrollmentContentState) -> Unit = {} + ) { + MfaEnrollmentScreen( + user = testUser, + auth = authUI.auth, + configuration = configuration, + onComplete = onComplete, + onSkip = onSkip, + onError = onError + ) { state -> + onStateChange(state) + TestMfaEnrollmentUI(state = state) + } + } + + @Composable + private fun TestMfaEnrollmentUI(state: MfaEnrollmentContentState) { + androidx.compose.foundation.layout.Column { + // Title + androidx.compose.material3.Text(state.step.getTitle(stringProvider)) + androidx.compose.material3.Text(state.step.getHelperText(stringProvider, state.selectedFactor)) + + when (state.step) { + MfaEnrollmentStep.SelectFactor -> { + state.availableFactors.forEach { factor -> + androidx.compose.material3.Button( + onClick = { state.onFactorSelected(factor) } + ) { + androidx.compose.material3.Text(factor.name.uppercase()) + } + } + state.onSkipClick?.let { + androidx.compose.material3.Button(onClick = it) { + androidx.compose.material3.Text("SKIP") + } + } + } + + MfaEnrollmentStep.ConfigureSms -> { + androidx.compose.material3.TextField( + value = state.phoneNumber, + onValueChange = state.onPhoneNumberChange, + label = { androidx.compose.material3.Text("Phone number") } + ) + androidx.compose.material3.Button( + onClick = state.onSendSmsCodeClick, + enabled = state.isValid && !state.isLoading + ) { + androidx.compose.material3.Text("SEND CODE") + } + androidx.compose.material3.Button(onClick = state.onBackClick) { + androidx.compose.material3.Text("BACK") + } + } + + MfaEnrollmentStep.ConfigureTotp -> { + state.totpSecret?.let { + androidx.compose.material3.Text("Secret: ${it.sharedSecretKey}") + } + state.totpQrCodeUrl?.let { + androidx.compose.material3.Text("QR: $it") + } + androidx.compose.material3.Button( + onClick = state.onContinueToVerifyClick, + enabled = state.isValid && !state.isLoading + ) { + androidx.compose.material3.Text("CONTINUE") + } + androidx.compose.material3.Button(onClick = state.onBackClick) { + androidx.compose.material3.Text("BACK") + } + } + + MfaEnrollmentStep.VerifyFactor -> { + androidx.compose.material3.TextField( + value = state.verificationCode, + onValueChange = state.onVerificationCodeChange, + label = { androidx.compose.material3.Text("Verification code") } + ) + androidx.compose.material3.Button( + onClick = state.onVerifyClick, + enabled = state.isValid && !state.isLoading + ) { + androidx.compose.material3.Text("VERIFY") + } + state.onResendCodeClick?.let { + androidx.compose.material3.Button(onClick = it) { + androidx.compose.material3.Text("RESEND") + } + } + androidx.compose.material3.Button(onClick = state.onBackClick) { + androidx.compose.material3.Text("BACK") + } + } + + MfaEnrollmentStep.ShowRecoveryCodes -> { + state.recoveryCodes?.forEach { code -> + androidx.compose.material3.Text(code) + } + androidx.compose.material3.Button( + onClick = state.onCodesSavedClick, + enabled = !state.isLoading + ) { + androidx.compose.material3.Text("DONE") + } + } + } + } + } + +} From e0c74731540cbefc66677d47026773f636a8d3fa Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 17 Oct 2025 09:25:55 +0200 Subject: [PATCH 02/13] update gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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 From 401d96e55bf9b88ed1769c152c110803de8dc59a Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 17 Oct 2025 11:06:10 +0200 Subject: [PATCH 03/13] QR code generation --- composeapp/build.gradle.kts | 3 + .../com/firebase/composeapp/MainActivity.kt | 2 +- .../composeapp/ui/components/QrCodeImage.kt | 138 ++++++++++++++++++ .../composeapp/ui/screens/EmailAuthMain.kt | 18 ++- .../ui/screens/MfaEnrollmentMain.kt | 10 +- 5 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 composeapp/src/main/java/com/firebase/composeapp/ui/components/QrCodeImage.kt diff --git a/composeapp/build.gradle.kts b/composeapp/build.gradle.kts index fd3193475..05bc052af 100644 --- a/composeapp/build.gradle.kts +++ b/composeapp/build.gradle.kts @@ -59,6 +59,9 @@ dependencies { implementation(Config.Libs.Androidx.Navigation.lifecycleViewmodelNav3) implementation(Config.Libs.Androidx.kotlinxSerialization) + // QR Code generation for TOTP + implementation("com.google.zxing:core:3.5.3") + testImplementation(Config.Libs.Test.junit) androidTestImplementation(Config.Libs.Test.junitExt) androidTestImplementation(platform(Config.Libs.Androidx.Compose.bom)) diff --git a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt index 2b3a8948a..d28cd64f7 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt @@ -55,7 +55,7 @@ class MainActivity : ComponentActivity() { FirebaseApp.initializeApp(applicationContext) val authUI = FirebaseAuthUI.getInstance() - authUI.auth.useEmulator("10.0.2.2", 9099) + // authUI.auth.useEmulator("10.0.2.2", 9099) val configuration = authUIConfiguration { context = applicationContext diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/components/QrCodeImage.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/components/QrCodeImage.kt new file mode 100644 index 000000000..f0c83930e --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/components/QrCodeImage.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.composeapp.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.material3.MaterialTheme +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 + +/** + * Composable that displays a QR code image generated from the provided content. + * + * @param content The string content to encode in the QR code (e.g., TOTP URI) + * @param modifier Modifier to be applied to the QR code image + * @param size The size (width and height) of the QR code image + * @param foregroundColor The color of the QR code pixels (default: black) + * @param backgroundColor The background color of the QR code (default: 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(), // 2x for better resolution + foregroundColor = foregroundColor, + backgroundColor = backgroundColor + ) + } + + Box( + modifier = modifier + .size(size) + .background(backgroundColor), + contentAlignment = Alignment.Center + ) { + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "QR Code for $content", + modifier = Modifier.size(size) + ) + } + } +} + +/** + * Generates a QR code bitmap from the provided content. + * + * @param content The string to encode + * @param sizePx The size of the bitmap in pixels + * @param foregroundColor The color for the QR code pixels + * @param backgroundColor The background color + * @return A Bitmap containing the QR code, or null if generation fails + */ +private fun generateQrCodeBitmap( + content: String, + sizePx: Int, + foregroundColor: Color, + backgroundColor: Color +): Bitmap? { + return try { + val qrCodeWriter = QRCodeWriter() + val hints = mapOf( + EncodeHintType.MARGIN to 1 // Minimal margin + ) + + 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) { + e.printStackTrace() + null + } +} diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/EmailAuthMain.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/EmailAuthMain.kt index a191da5f0..f63505b00 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/EmailAuthMain.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/EmailAuthMain.kt @@ -114,12 +114,20 @@ fun EmailAuthMain( coroutineScope.launch { try { // Reload the user to refresh the authentication token - verificationState.user.reload().addOnCompleteListener { task -> - if (task.isSuccessful) { - android.util.Log.d("EmailAuthMain", "User reloaded. isEmailVerified: ${verificationState.user.isEmailVerified}") - // The auth state listener will automatically update the state + verificationState.user.reload().addOnCompleteListener { reloadTask -> + if (reloadTask.isSuccessful) { + // Force a token refresh to trigger the AuthStateListener + verificationState.user.getIdToken(true).addOnCompleteListener { tokenTask -> + if (tokenTask.isSuccessful) { + val currentUser = authUI.getCurrentUser() + android.util.Log.d("EmailAuthMain", "User reloaded. isEmailVerified: ${currentUser?.isEmailVerified}") + // The AuthStateListener should fire automatically after token refresh + } else { + android.util.Log.e("EmailAuthMain", "Failed to refresh token", tokenTask.exception) + } + } } else { - android.util.Log.e("EmailAuthMain", "Failed to reload user", task.exception) + android.util.Log.e("EmailAuthMain", "Failed to reload user", reloadTask.exception) } } } catch (e: Exception) { diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/MfaEnrollmentMain.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/MfaEnrollmentMain.kt index 380cffcff..7805bb5f7 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/MfaEnrollmentMain.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/MfaEnrollmentMain.kt @@ -496,11 +496,11 @@ private fun ConfigureTotpUI( style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center ) - Text( - text = "(QR code would be displayed here)", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + Spacer(modifier = Modifier.height(8.dp)) + com.firebase.composeapp.ui.components.QrCodeImage( + content = url, + modifier = Modifier.padding(16.dp), + size = 250.dp ) } From 2e23da5d02b75750fb5c36e46663ea54867aefdd Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 17 Oct 2025 11:06:50 +0200 Subject: [PATCH 04/13] remove emulator --- .../src/main/java/com/firebase/composeapp/MainActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt index d28cd64f7..2b3a8948a 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt @@ -55,7 +55,7 @@ class MainActivity : ComponentActivity() { FirebaseApp.initializeApp(applicationContext) val authUI = FirebaseAuthUI.getInstance() - // authUI.auth.useEmulator("10.0.2.2", 9099) + authUI.auth.useEmulator("10.0.2.2", 9099) val configuration = authUIConfiguration { context = applicationContext From 12d7db16fb8e3e707a25edd8d98ad7a9b6a9f5cd Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Fri, 17 Oct 2025 11:03:09 +0100 Subject: [PATCH 05/13] chore: upgrade facebook login to latest --- buildSrc/src/main/kotlin/Config.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 393af44dc..7d0ade670 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -79,7 +79,7 @@ object Config { } 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 { From c8e4fd67545609312b4b52892cad5fcc7478bb44 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Fri, 17 Oct 2025 11:06:09 +0100 Subject: [PATCH 06/13] feat: facebook client token for fb login sdk v13.0 and above --- auth/src/main/AndroidManifest.xml | 4 ++++ auth/src/main/res/values/config.xml | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml index a883affca..cd9101b3d 100644 --- a/auth/src/main/AndroidManifest.xml +++ b/auth/src/main/AndroidManifest.xml @@ -27,6 +27,10 @@ android:name="com.facebook.sdk.ApplicationId" android:value="@string/facebook_application_id" /> + + fb_your_app_id + + CHANGE-ME + 1131506989188007 + fb1131506989188007 + e3968638d7751ba83063e2a78bc27e4e \ No newline at end of file From d0efad3427f0de9f0fb2f124b6442cd6ece4b2d1 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Sat, 18 Oct 2025 01:38:10 +0100 Subject: [PATCH 09/13] feat: Facebook Sign in integration --- .firebase/hosting.cHVibGlj.cache | 2 + auth/src/main/AndroidManifest.xml | 20 +++-- .../auth_provider/AuthProvider.kt | 32 +++++-- .../EmailAuthProvider+FirebaseAuthUI.kt | 13 +-- .../FacebookAuthProvider+FirebaseAuthUI.kt | 61 ++++++++++--- .../ui/components/AuthProviderButton.kt | 3 + .../configuration/AuthUIConfigurationTest.kt | 1 + .../auth_provider/AuthProviderTest.kt | 3 + .../ui/components/AuthProviderButtonTest.kt | 9 +- composeapp/public/.well-known/assetlinks.json | 10 +++ composeapp/public/index.html | 12 +++ composeapp/src/main/AndroidManifest.xml | 18 ---- .../com/firebase/composeapp/MainActivity.kt | 52 +++++++----- .../ui/screens/FirebaseAuthScreen.kt | 35 ++++++-- composeapp/src/main/res/values/strings.xml | 2 + .../ui/auth/compose/testutil/EmulatorApi.kt | 2 +- .../ui/auth/compose/testutil/TestHelpers.kt | 84 ++++++++++++++++++ .../compose/ui/screens/EmailAuthScreenTest.kt | 85 ++----------------- 18 files changed, 287 insertions(+), 157 deletions(-) create mode 100644 .firebase/hosting.cHVibGlj.cache create mode 100644 composeapp/public/.well-known/assetlinks.json create mode 100644 composeapp/public/index.html create mode 100644 e2eTest/src/test/java/com/firebase/ui/auth/compose/testutil/TestHelpers.kt 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/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml index cd9101b3d..16055e162 100644 --- a/auth/src/main/AndroidManifest.xml +++ b/auth/src/main/AndroidManifest.xml @@ -124,14 +124,24 @@ - - - + + + + android:exported="true" + android:theme="@style/FirebaseUI.Transparent"> + + + + + + + = emptyList(), open val customParameters: Map = emptyMap(), -) : AuthProvider(providerId) +) : AuthProvider(providerId = providerId, name = name) /** * Base abstract class for authentication providers. */ -abstract class AuthProvider(open val providerId: String) { +abstract class AuthProvider(open val providerId: String, open val name: String) { companion object { internal fun canUpgradeAnonymous(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean { @@ -203,7 +207,7 @@ abstract class AuthProvider(open val providerId: String) { * A list of custom password validation rules. */ val passwordValidationRules: List, - ) : AuthProvider(providerId = Provider.EMAIL.id) { + ) : AuthProvider(providerId = Provider.EMAIL.id, name = "Email") { companion object { const val SESSION_ID_LENGTH = 10 val KEY_EMAIL = stringPreferencesKey("com.firebase.ui.auth.data.client.email") @@ -329,7 +333,7 @@ abstract class AuthProvider(open val providerId: String) { * Enables instant verification of the phone number. Defaults to true. */ val isInstantVerificationEnabled: Boolean = true, - ) : AuthProvider(providerId = Provider.PHONE.id) { + ) : AuthProvider(providerId = Provider.PHONE.id, name = "Phone") { /** * Sealed class representing the result of phone number verification. * @@ -552,6 +556,7 @@ abstract class AuthProvider(open val providerId: String) { override val customParameters: Map = emptyMap(), ) : OAuthProvider( providerId = Provider.GOOGLE.id, + name = "Google", scopes = scopes, customParameters = customParameters ) { @@ -599,6 +604,7 @@ abstract class AuthProvider(open val providerId: String) { override val customParameters: Map = emptyMap(), ) : OAuthProvider( providerId = Provider.FACEBOOK.id, + name = "Facebook", scopes = scopes, customParameters = customParameters ) { @@ -629,7 +635,7 @@ abstract class AuthProvider(open val providerId: String) { * An interface to wrap the static `FacebookAuthProvider.getCredential` method to make it testable. * @suppress */ - // TODO(demolaf): make this internal after testing + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) interface CredentialProvider { fun getCredential(token: String): AuthCredential } @@ -743,6 +749,7 @@ abstract class AuthProvider(open val providerId: String) { override val customParameters: Map, ) : OAuthProvider( providerId = Provider.TWITTER.id, + name = "Twitter", customParameters = customParameters ) @@ -761,6 +768,7 @@ abstract class AuthProvider(open val providerId: String) { override val customParameters: Map, ) : OAuthProvider( providerId = Provider.GITHUB.id, + name = "Github", scopes = scopes, customParameters = customParameters ) @@ -785,6 +793,7 @@ abstract class AuthProvider(open val providerId: String) { override val customParameters: Map, ) : OAuthProvider( providerId = Provider.MICROSOFT.id, + name = "Microsoft", scopes = scopes, customParameters = customParameters ) @@ -804,6 +813,7 @@ abstract class AuthProvider(open val providerId: String) { override val customParameters: Map, ) : OAuthProvider( providerId = Provider.YAHOO.id, + name = "Yahoo", scopes = scopes, customParameters = customParameters ) @@ -828,6 +838,7 @@ abstract class AuthProvider(open val providerId: String) { override val customParameters: Map, ) : OAuthProvider( providerId = Provider.APPLE.id, + name = "Apple", scopes = scopes, customParameters = customParameters ) @@ -835,7 +846,10 @@ abstract class AuthProvider(open val providerId: String) { /** * Anonymous authentication provider. It has no configurable properties. */ - object Anonymous : AuthProvider(providerId = Provider.ANONYMOUS.id) { + object Anonymous : AuthProvider( + providerId = Provider.ANONYMOUS.id, + name = "Anonymous" + ) { internal fun validate(providers: List) { if (providers.size == 1 && providers.first() is Anonymous) { throw IllegalStateException( @@ -850,6 +864,11 @@ abstract class AuthProvider(open val providerId: String) { * A generic OAuth provider for any unsupported provider. */ class GenericOAuth( + /** + * The provider name. + */ + override val name: String, + /** * The provider ID as configured in the Firebase console. */ @@ -886,6 +905,7 @@ abstract class AuthProvider(open val providerId: String) { val contentColor: Color?, ) : OAuthProvider( providerId = providerId, + name = name, scopes = scopes, customParameters = customParameters ) { diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index 50bb9c506..c537e4909 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -487,17 +487,10 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( // Non-anonymous collision: account already exists with different sign-in method // Create AccountLinkingRequiredException with credential for linking val email = e.email - // TODO(demolaf): make this cleaner! - val providerName = when (provider) { - is AuthProvider.Facebook -> "Facebook" - is AuthProvider.Google -> "Google" - is AuthProvider.Phone -> "Phone" - is AuthProvider.Email -> "Email" - else -> "this provider" - } - val accountLinkingException = AuthException.AccountLinkingRequiredException( - message = "An account already exists with the email ${email ?: ""}. Please sign in with your existing account to link your $providerName account.", + message = "An account already exists with the email ${email ?: ""}. " + + "Please sign in with your existing account to link " + + "your ${provider?.name ?: "this provider"} account.", email = email, credential = credential, cause = e 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 index 30336075b..6864d9926 100644 --- 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 @@ -1,3 +1,17 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.firebase.ui.auth.compose.configuration.auth_provider import android.util.Log @@ -16,10 +30,23 @@ 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.FirebaseAuthUserCollisionException 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 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 fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( config: AuthUIConfiguration, @@ -89,18 +116,26 @@ fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( } } -// Your app can only have one person at a time logged in, and LoginManager sets the current -// AccessToken and Profile for that person. The FacebookSDK saves this data in shared preferences -// and sets at the beginning of the session. You can see if a person is already logged in by -// checking AccessToken.getCurrentAccessToken() and Profile.getCurrentProfile(). -// -// You can load AccessToken.getCurrentAccessToken with the SDK from cache or from an app book -// mark when your app launches from a cold start. You should check its validity in your Activity's -// onCreate method: -// -// AccessToken accessToken = AccessToken.getCurrentAccessToken(); -// boolean isLoggedIn = accessToken != null && !accessToken.isExpired(); - +/** + * 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 throwing + * [AuthException.AccountLinkingRequiredException] with the credential for later 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( config: AuthUIConfiguration, provider: AuthProvider.Facebook, diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt index fb6b9098f..0a3e074c2 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt @@ -250,6 +250,7 @@ private fun PreviewAuthProviderButton() { ) AuthProviderButton( provider = AuthProvider.GenericOAuth( + name = "Generic Provider", providerId = "google.com", scopes = emptyList(), customParameters = emptyMap(), @@ -263,6 +264,7 @@ private fun PreviewAuthProviderButton() { ) AuthProviderButton( provider = AuthProvider.GenericOAuth( + name = "Generic Provider", providerId = "google.com", scopes = emptyList(), customParameters = emptyMap(), @@ -284,6 +286,7 @@ private fun PreviewAuthProviderButton() { ) AuthProviderButton( provider = AuthProvider.GenericOAuth( + name = "Generic Provider", providerId = "unknown_provider", scopes = emptyList(), customParameters = emptyMap(), diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt index 39316e2ff..4190a7bf0 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -321,6 +321,7 @@ class AuthUIConfigurationTest { @Test fun `validation throws for unsupported provider`() { val mockProvider = AuthProvider.GenericOAuth( + name = "Generic Provider", providerId = "unsupported.provider", scopes = listOf(), customParameters = mapOf(), diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt index a07a8abc0..5f7558852 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt @@ -334,6 +334,7 @@ class AuthProviderTest { @Test fun `generic oauth provider with valid configuration should succeed`() { val provider = AuthProvider.GenericOAuth( + name = "Generic Provider", providerId = "custom.provider", scopes = listOf("read"), customParameters = mapOf(), @@ -349,6 +350,7 @@ class AuthProviderTest { @Test fun `generic oauth provider with blank provider id should throw`() { val provider = AuthProvider.GenericOAuth( + name = "Generic Provider", providerId = "", scopes = listOf("read"), customParameters = mapOf(), @@ -369,6 +371,7 @@ class AuthProviderTest { @Test fun `generic oauth provider with blank button label should throw`() { val provider = AuthProvider.GenericOAuth( + name = "Generic Provider", providerId = "custom.provider", scopes = listOf("read"), customParameters = mapOf(), diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt index c68f1e6e9..9f583a818 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt @@ -271,6 +271,7 @@ class AuthProviderButtonTest { fun `AuthProviderButton displays GenericOAuth provider with custom label`() { val customLabel = "Sign in with Custom Provider" val provider = AuthProvider.GenericOAuth( + name = "Generic Provider", providerId = "google.com", scopes = emptyList(), customParameters = emptyMap(), @@ -375,6 +376,7 @@ class AuthProviderButtonTest { val customIcon = AuthUIAsset.Vector(Icons.Default.Star) val provider = AuthProvider.GenericOAuth( + name = "Generic Provider", providerId = "google.com", scopes = emptyList(), customParameters = emptyMap(), @@ -412,6 +414,7 @@ class AuthProviderButtonTest { fun `GenericOAuth provider falls back to default style when custom properties are null`() { val customLabel = "Custom Provider" val provider = AuthProvider.GenericOAuth( + name = "Generic Provider", providerId = "google.com", scopes = emptyList(), customParameters = emptyMap(), @@ -447,7 +450,7 @@ class AuthProviderButtonTest { @Test fun `AuthProviderButton provides fallback for unknown provider`() { - val provider = object : AuthProvider("unknown.provider") {} + val provider = object : AuthProvider(providerId = "unknown.provider", name = "Generic Provider",) {} composeTestRule.setContent { AuthProviderButton( @@ -470,6 +473,7 @@ class AuthProviderButtonTest { val customContentColor = Color.White val provider = AuthProvider.GenericOAuth( + name = "Generic Provider", providerId = "google.com", scopes = emptyList(), customParameters = emptyMap(), @@ -489,6 +493,7 @@ class AuthProviderButtonTest { @Test fun `resolveProviderStyle handles GenericOAuth without icon`() { val provider = AuthProvider.GenericOAuth( + name = "Generic Provider", providerId = "custom.provider", scopes = emptyList(), customParameters = emptyMap(), @@ -508,7 +513,7 @@ class AuthProviderButtonTest { @Test fun `resolveProviderStyle provides fallback for unknown provider`() { - val provider = object : AuthProvider("unknown.provider") {} + val provider = object : AuthProvider(providerId = "unknown.provider", name = "Generic Provider") {} val resolvedStyle = resolveProviderStyle(provider, null) 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 index e9e190d98..fccb57d77 100644 --- a/composeapp/src/main/AndroidManifest.xml +++ b/composeapp/src/main/AndroidManifest.xml @@ -23,24 +23,6 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt index 525c1c44b..3d89fc4e1 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt @@ -30,6 +30,7 @@ import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager import com.google.firebase.FirebaseApp import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.actionCodeSettings +import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable @Serializable @@ -37,8 +38,10 @@ sealed class Route : NavKey { @Serializable object MethodPicker : Route() - class EmailAuth(val credentialForLinking: AuthCredential? = null) : Route() + @Serializable + class EmailAuth(@Contextual val credentialForLinking: AuthCredential? = null) : Route() + @Serializable object PhoneAuth : Route() @Serializable @@ -53,6 +56,9 @@ class MainActivity : ComponentActivity() { val authUI = FirebaseAuthUI.getInstance() // authUI.auth.useEmulator("10.0.2.2", 9099) + // Check if this activity was launched from an email link deep link + val emailLink = intent.getStringExtra(EmailSignInLinkHandlerActivity.EXTRA_EMAIL_LINK) + val configuration = authUIConfiguration { context = applicationContext providers { @@ -60,6 +66,7 @@ class MainActivity : ComponentActivity() { AuthProvider.Email( isDisplayNameRequired = true, isEmailLinkForceSameDeviceEnabled = true, + isEmailLinkSignInEnabled = true, emailLinkActionCodeSettings = actionCodeSettings { // The continue URL - where to redirect after email link is clicked url = "https://temp-test-aa342.firebaseapp.com" @@ -100,7 +107,14 @@ class MainActivity : ComponentActivity() { } setContent { - val backStack = rememberNavBackStack(Route.MethodPicker) + // If there's an email link, navigate to EmailAuth screen + val initialRoute = if (emailLink != null) { + Route.EmailAuth(credentialForLinking = null) + } else { + Route.MethodPicker + } + + val backStack = rememberNavBackStack(initialRoute) AuthUITheme { Surface( @@ -126,23 +140,20 @@ class MainActivity : ComponentActivity() { } is Route.EmailAuth -> NavEntry(entry) { - val emailProvider = configuration.providers - .filterIsInstance() - .first() LaunchEmailAuth( authUI = authUI, configuration = configuration, - selectedProvider = emailProvider, backStack = backStack, - credentialForLinking = route.credentialForLinking + credentialForLinking = route.credentialForLinking, + emailLink = emailLink ) } is Route.PhoneAuth -> NavEntry(entry) { - val phoneProvider = configuration.providers - .filterIsInstance() - .first() - LaunchPhoneAuth(authUI, configuration, phoneProvider) + LaunchPhoneAuth( + authUI = authUI, + configuration = configuration, + ) } is Route.MfaEnrollment -> NavEntry(entry) { @@ -160,15 +171,15 @@ class MainActivity : ComponentActivity() { private fun LaunchEmailAuth( authUI: FirebaseAuthUI, configuration: AuthUIConfiguration, - selectedProvider: AuthProvider.Email, credentialForLinking: AuthCredential? = null, - backStack: NavBackStack + backStack: NavBackStack, + emailLink: String? = null ) { - // Check if this is an email link sign-in flow - val emailLink = intent.getStringExtra( - EmailSignInLinkHandlerActivity.EXTRA_EMAIL_LINK - ) + val provider = configuration.providers + .filterIsInstance() + .first() + // Handle email link sign-in if present if (emailLink != null) { LaunchedEffect(emailLink) { @@ -183,7 +194,7 @@ class MainActivity : ComponentActivity() { authUI.signInWithEmailLink( context = applicationContext, config = configuration, - provider = selectedProvider, + provider = provider, email = emailFromSession, emailLink = emailLink, ) @@ -209,8 +220,11 @@ class MainActivity : ComponentActivity() { private fun LaunchPhoneAuth( authUI: FirebaseAuthUI, configuration: AuthUIConfiguration, - selectedProvider: AuthProvider.Phone, ) { + val provider = configuration.providers + .filterIsInstance() + .first() + PhoneAuthMain( context = applicationContext, configuration = configuration, diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/FirebaseAuthScreen.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/FirebaseAuthScreen.kt index 8015dd634..b14e04a03 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/FirebaseAuthScreen.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/FirebaseAuthScreen.kt @@ -81,6 +81,31 @@ fun FirebaseAuthScreen( } } + is AuthState.RequiresEmailVerification -> { + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Authenticated User - " + + "(RequiresEmailVerification): ${authUI.getCurrentUser()?.email}", + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + coroutineScope.launch { + authUI.signOut(context) + } + } + ) { + Text("Sign Out") + } + } + } + else -> { val onSignInWithFacebook = authUI.rememberSignInWithFacebookLauncher( config = configuration, @@ -146,18 +171,18 @@ fun FirebaseAuthScreen( // Loading modal if (authState is AuthState.Loading) { AlertDialog( - onDismissRequest = { - // Dismiss by resetting auth state or going back - }, + onDismissRequest = {}, confirmButton = {}, containerColor = Color.Transparent, text = { Column( - modifier = Modifier.padding(24.dp), + modifier = Modifier.padding(24.dp) + .fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.Center, ) { CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) Text( text = (authState as? AuthState.Loading)?.message ?: "Loading...", diff --git a/composeapp/src/main/res/values/strings.xml b/composeapp/src/main/res/values/strings.xml index 2ca710b06..318d7fb0f 100644 --- a/composeapp/src/main/res/values/strings.xml +++ b/composeapp/src/main/res/values/strings.xml @@ -1,6 +1,8 @@ ComposeApp + temp-test-aa342.firebaseapp.com + 1131506989188007 fb1131506989188007 diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/testutil/EmulatorApi.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/testutil/EmulatorApi.kt index aff906c85..4b8f0497e 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/compose/testutil/EmulatorApi.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/testutil/EmulatorApi.kt @@ -4,7 +4,7 @@ import org.json.JSONArray import org.json.JSONObject import java.net.HttpURLConnection -internal class EmulatorAuthApi( +class EmulatorAuthApi( private val projectId: String, emulatorHost: String, emulatorPort: Int, diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/testutil/TestHelpers.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/testutil/TestHelpers.kt new file mode 100644 index 000000000..626d28059 --- /dev/null +++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/testutil/TestHelpers.kt @@ -0,0 +1,84 @@ +package com.firebase.ui.auth.compose.testutil + +import android.os.Looper +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.google.firebase.auth.FirebaseUser +import org.robolectric.Shadows.shadowOf + +/** + * Ensures a fresh user exists in the Firebase emulator with the given credentials. + * If a user already exists, they will be deleted first. + * The user will be signed out after creation, leaving an unverified account ready for testing. + * + * This function uses coroutines and automatically handles Robolectric's main looper. + * + * @param authUI The FirebaseAuthUI instance + * @param email The email address for the user + * @param password The password for the user + * @return The created FirebaseUser, or null if creation failed + */ +fun ensureFreshUser(authUI: FirebaseAuthUI, email: String, password: String): FirebaseUser? { + println("TEST: Ensuring fresh user for $email") + // Try to sign in - if successful, user exists and should be deleted + try { + authUI.auth.signInWithEmailAndPassword(email, password).awaitWithLooper() + .also { result -> + println("TEST: User exists (${result.user?.uid}), deleting...") + // User exists, delete them + result.user?.delete()?.awaitWithLooper() + println("TEST: User deleted") + } + } catch (_: Exception) { + // User doesn't exist - this is expected + } + + // Create fresh user + return authUI.auth.createUserWithEmailAndPassword(email, password).awaitWithLooper() + .user +} + +/** + * Verifies a user's email in the Firebase Auth Emulator by simulating the complete + * email verification flow. + * + * This function: + * 1. Sends a verification email using sendEmailVerification() + * 2. Retrieves the OOB (out-of-band) code from the emulator's OOB codes endpoint + * 3. Applies the action code to complete email verification + * + * This approach works with the Firebase Auth Emulator's documented API and simulates + * the real email verification flow that would occur in production. + * + * @param authUI The FirebaseAuthUI instance + * @param emulatorApi The EmulatorAuthApi instance for fetching OOB codes + * @param user The FirebaseUser whose email should be verified + * @throws Exception if the verification flow fails + */ +fun verifyEmailInEmulator(authUI: FirebaseAuthUI, emulatorApi: EmulatorAuthApi, user: FirebaseUser) { + println("TEST: Starting email verification for user ${user.uid}") + + // Step 1: Send verification email to generate an OOB code + user.sendEmailVerification().awaitWithLooper() + println("TEST: Sent email verification request") + + // Give the emulator time to process and store the OOB code + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(100) + + // Step 2: Retrieve the VERIFY_EMAIL OOB code for this user from the emulator + val email = requireNotNull(user.email) { "User email is required for OOB code lookup" } + val oobCode = emulatorApi.fetchVerifyEmailCode(email) + + println("TEST: Found OOB code: $oobCode") + + // Step 3: Apply the action code to verify the email + authUI.auth.applyActionCode(oobCode).awaitWithLooper() + println("TEST: Applied action code") + + // Step 4: Reload the user to refresh their email verification status + authUI.auth.currentUser?.reload()?.awaitWithLooper() + shadowOf(Looper.getMainLooper()).idle() + + println("TEST: Email verified successfully for user ${user.uid}") + println("TEST: User isEmailVerified: ${authUI.auth.currentUser?.isEmailVerified}") +} diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt index 602890412..3e975ec51 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt @@ -25,11 +25,12 @@ import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIS import com.firebase.ui.auth.compose.testutil.AUTH_STATE_WAIT_TIMEOUT_MS import com.firebase.ui.auth.compose.testutil.EmulatorAuthApi import com.firebase.ui.auth.compose.testutil.awaitWithLooper +import com.firebase.ui.auth.compose.testutil.ensureFreshUser +import com.firebase.ui.auth.compose.testutil.verifyEmailInEmulator 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.FirebaseUser import com.google.firebase.auth.actionCodeSettings import org.junit.After import org.junit.Before @@ -128,7 +129,7 @@ class EmailAuthScreenTest { val password = "test123" // Setup: Create a fresh unverified user - ensureFreshUser(email, password) + ensureFreshUser(authUI, email, password) // Sign out authUI.auth.signOut() @@ -197,12 +198,12 @@ class EmailAuthScreenTest { val password = "test123" // Setup: Create a fresh unverified user - val user = ensureFreshUser(email, password) + val user = ensureFreshUser(authUI, email, password) requireNotNull(user) { "Failed to create user" } // Verify email using Firebase Auth Emulator OOB codes flow - verifyEmailInEmulator(user = user) + verifyEmailInEmulator(authUI, emulatorApi, user) // Sign out authUI.auth.signOut() @@ -351,7 +352,7 @@ class EmailAuthScreenTest { val password = "test123" // Setup: Create a fresh user - ensureFreshUser(email, password) + ensureFreshUser(authUI, email, password) // Sign out authUI.auth.signOut() @@ -433,7 +434,7 @@ class EmailAuthScreenTest { val password = "test123" // Setup: Create a fresh user - ensureFreshUser(email, password) + ensureFreshUser(authUI, email, password) // Sign out authUI.auth.signOut() @@ -577,76 +578,4 @@ class EmailAuthScreenTest { } } } - - /** - * Ensures a fresh user exists in the Firebase emulator with the given credentials. - * If a user already exists, they will be deleted first. - * The user will be signed out after creation, leaving an unverified account ready for testing. - * - * This function uses coroutines and automatically handles Robolectric's main looper. - */ - private fun ensureFreshUser(email: String, password: String): FirebaseUser? { - println("TEST: Ensuring fresh user for $email") - // Try to sign in - if successful, user exists and should be deleted - try { - authUI.auth.signInWithEmailAndPassword(email, password).awaitWithLooper() - .also { result -> - println("TEST: User exists (${result.user?.uid}), deleting...") - // User exists, delete them - result.user?.delete()?.awaitWithLooper() - println("TEST: User deleted") - } - } catch (_: Exception) { - // User doesn't exist - this is expected - } - - // Create fresh user - return authUI.auth.createUserWithEmailAndPassword(email, password).awaitWithLooper() - .user - } - - /** - * Verifies a user's email in the Firebase Auth Emulator by simulating the complete - * email verification flow. - * - * This function: - * 1. Sends a verification email using sendEmailVerification() - * 2. Retrieves the OOB (out-of-band) code from the emulator's OOB codes endpoint - * 3. Applies the action code to complete email verification - * - * This approach works with the Firebase Auth Emulator's documented API and simulates - * the real email verification flow that would occur in production. - * - * @param user The FirebaseUser whose email should be verified - * @throws Exception if the verification flow fails - */ - private fun verifyEmailInEmulator(user: FirebaseUser) { - println("TEST: Starting email verification for user ${user.uid}") - - // Step 1: Send verification email to generate an OOB code - user.sendEmailVerification().awaitWithLooper() - println("TEST: Sent email verification request") - - // Give the emulator time to process and store the OOB code - shadowOf(Looper.getMainLooper()).idle() - Thread.sleep(100) - - // Step 2: Retrieve the VERIFY_EMAIL OOB code for this user from the emulator - val email = requireNotNull(user.email) { "User email is required for OOB code lookup" } - val oobCode = emulatorApi.fetchVerifyEmailCode(email) - - println("TEST: Found OOB code: $oobCode") - - // Step 3: Apply the action code to verify the email - authUI.auth.applyActionCode(oobCode).awaitWithLooper() - println("TEST: Applied action code") - - // Step 4: Reload the user to refresh their email verification status - authUI.auth.currentUser?.reload()?.awaitWithLooper() - shadowOf(Looper.getMainLooper()).idle() - - println("TEST: Email verified successfully for user ${user.uid}") - println("TEST: User isEmailVerified: ${authUI.auth.currentUser?.isEmailVerified}") - } - } From 32d7d99fd61e3e6643c5123df64a1f062bc4dcd3 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Sat, 18 Oct 2025 20:21:23 +0100 Subject: [PATCH 10/13] refactor: replace AuthState.MergeConflict with AccountLinkingRequiredException for account collision --- .../com/firebase/ui/auth/compose/AuthState.kt | 27 --- .../EmailAuthProvider+FirebaseAuthUI.kt | 227 +++++++----------- .../FacebookAuthProvider+FirebaseAuthUI.kt | 24 +- .../PhoneAuthProvider+FirebaseAuthUI.kt | 8 +- .../ui/screens/email/EmailAuthScreen.kt | 5 +- .../EmailAuthProviderFirebaseAuthUITest.kt | 32 ++- 6 files changed, 133 insertions(+), 190 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt index 681440aec..a6e57deed 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt @@ -209,33 +209,6 @@ abstract class AuthState private constructor() { "AuthState.RequiresProfileCompletion(user=$user, missingFields=$missingFields)" } - /** - * Pending credential for an anonymous upgrade merge conflict. - * - * Emitted when an anonymous user attempts to convert to a permanent account but - * Firebase detects that the target email already belongs to another user. The UI can - * prompt the user to resolve the conflict by signing in with the existing account and - * later linking the stored [pendingCredential]. - */ - class MergeConflict( - val pendingCredential: AuthCredential - ) : AuthState() { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is MergeConflict) return false - return pendingCredential == other.pendingCredential - } - - override fun hashCode(): Int { - var result = pendingCredential.hashCode() - result = 31 * result + pendingCredential.hashCode() - return result - } - - override fun toString(): String = - "AuthState.MergeConflict(pendingCredential=$pendingCredential)" - } - /** * Password reset link has been sent to the user's email. */ diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index c537e4909..53c216d46 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -36,22 +36,6 @@ import com.google.firebase.auth.FirebaseAuthUserCollisionException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.tasks.await -/** - * Holds credential information for account linking with email link sign-in. - * - * When a user tries to sign in with a social provider (Google, Facebook, etc.) but an - * email link account exists with that email, this data is used to link the accounts - * after email link authentication completes. - * - * @property providerType The provider ID (e.g., "google.com", "facebook.com") - * @property idToken The ID token from the provider (required for Google, optional for Facebook) - * @property accessToken The access token from the provider (required for Facebook, optional for Google) - */ -internal class CredentialForLinking( - val providerType: String, - val idToken: String?, - val accessToken: String?, -) /** * Creates an email/password account or links the credential to an anonymous user. @@ -59,8 +43,8 @@ internal class CredentialForLinking( * Mirrors the legacy email sign-up handler: validates password strength, validates custom * password rules, checks if new accounts are allowed, chooses between * `createUserWithEmailAndPassword` and `linkWithCredential`, merges the supplied display name - * into the Firebase profile, and emits [AuthState.MergeConflict] when anonymous upgrade - * encounters an existing account for the email. + * 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) @@ -118,9 +102,9 @@ internal class CredentialForLinking( * password = "MyPassword456" * ) * // Anonymous account upgraded to permanent email/password account - * } catch (e: AuthException) { - * // Check if AuthState.MergeConflict was emitted - * // This means email already exists - show merge conflict UI + * } catch (e: AuthException.AccountLinkingRequiredException) { + * // Email already exists - show account linking UI + * // User needs to sign in with existing account to link * } * ``` */ @@ -177,14 +161,20 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( updateAuthState(AuthState.Idle) return result } catch (e: FirebaseAuthUserCollisionException) { - val authException = AuthException.from(e) - if (canUpgrade && pendingCredential != null) { - // Anonymous upgrade collision: emit merge conflict state - updateAuthState(AuthState.MergeConflict(pendingCredential)) - } else { - updateAuthState(AuthState.Error(authException)) - } - throw authException + // 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, + 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", @@ -206,15 +196,15 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( * Signs in a user with email and password, optionally linking a social credential. * * This method handles both normal sign-in and anonymous upgrade flows. In anonymous upgrade - * scenarios, it validates credentials in a scratch auth instance before emitting a merge - * conflict state. + * 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 - * - Emit [AuthState.MergeConflict] after successful validation + * - Throw [AuthException.AccountLinkingRequiredException] after successful validation * 2. If normal sign-in: * - Sign in with email/password * - If credential provided: link it and merge profile @@ -277,9 +267,9 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( * email = "existing@example.com", * password = "password123" * ) - * } catch (e: AuthException) { - * // AuthState.MergeConflict emitted - * // UI shows merge conflict resolution screen + * } catch (e: AuthException.AccountLinkingRequiredException) { + * // Account linking required - UI shows account linking screen + * // User needs to sign in with existing account to link anonymous account * } * ``` */ @@ -315,8 +305,16 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( .signInWithCredential(credentialToValidate).await() .user?.linkWithCredential(credentialForLinking)?.await() .also { - // Emit merge conflict after successful validation - updateAuthState(AuthState.MergeConflict(credentialToValidate)) + // 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 @@ -324,9 +322,17 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( authExplicitlyForValidation .signInWithCredential(credentialToValidate).await() .also { - // Emit merge conflict after successful validation - // Merge failure occurs because account exists and user is anonymous - updateAuthState(AuthState.MergeConflict(credentialToValidate)) + // 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 { @@ -380,7 +386,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( * 2. If yes: Link credential to anonymous user * 3. If no: Sign in with credential * 4. Merge profile information (name, photo) into Firebase user - * 5. Handle collision exceptions by emitting [AuthState.MergeConflict] + * 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. @@ -430,10 +436,10 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( * config = authUIConfig, * credential = phoneCredential * ) - * } catch (e: FirebaseAuthUserCollisionException) { + * } catch (e: AuthException.AccountLinkingRequiredException) { * // Phone number already exists on another account - * // AuthState.MergeConflict emitted with updatedCredential - * // UI can show merge conflict resolution screen + * // Account linking required - UI can show account linking screen + * // User needs to sign in with existing account to link * } * ``` * @@ -472,32 +478,27 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( updateAuthState(AuthState.Idle) } } catch (e: FirebaseAuthUserCollisionException) { - // Special handling for collision exceptions - if (canUpgradeAnonymous(config, auth)) { - // Anonymous upgrade collision: emit merge conflict with updated credential - val updatedCredential = e.updatedCredential - val authException = AuthException.from(e) - if (updatedCredential != null) { - updateAuthState(AuthState.MergeConflict(updatedCredential)) - } else { - updateAuthState(AuthState.Error(authException)) - } - throw authException + // 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 { - // Non-anonymous collision: account already exists with different sign-in method - // Create AccountLinkingRequiredException with credential for linking - val email = e.email - val accountLinkingException = AuthException.AccountLinkingRequiredException( - message = "An account already exists with the email ${email ?: ""}. " + - "Please sign in with your existing account to link " + - "your ${provider?.name ?: "this provider"} account.", - email = email, - credential = credential, - cause = e - ) - updateAuthState(AuthState.Error(accountLinkingException)) - throw accountLinkingException + // 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?.name ?: "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", @@ -520,8 +521,7 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( * * This method initiates the email-link (passwordless) authentication flow by sending * an email containing a magic link. The link includes session information for validation - * and security. Optionally supports account linking when a user tries to sign in with - * a social provider but an email link account exists. + * and security. * * **How it works:** * 1. Generates a unique session ID for same-device validation @@ -534,10 +534,11 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( * * **Account Linking Support:** * If a user tries to sign in with a social provider (Google, Facebook) but an email link - * account already exists with that email, you can link the accounts by: - * 1. Catching the [FirebaseAuthUserCollisionException] from the social sign-in attempt - * 2. Calling this method with [credentialForLinking] containing the social provider tokens - * 3. When [signInWithEmailLink] completes, it automatically retrieves and links the saved credential + * account already exists with that email, the social provider implementation should: + * 1. Catch the [FirebaseAuthUserCollisionException] from the sign-in attempt + * 2. Call [EmailLinkPersistenceManager.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 @@ -549,9 +550,6 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( * @param config The [AuthUIConfiguration] containing authentication settings * @param provider The [AuthProvider.Email] configuration with [ActionCodeSettings] * @param email The email address to send the sign-in link to - * @param credentialForLinking Optional credential linking data. If provided, this credential - * will be automatically linked after email link sign-in completes. Pass null for basic - * email link sign-in without account linking. * * @throws AuthException.InvalidCredentialsException if email is invalid * @throws AuthException.AuthCancelledException if the operation is cancelled @@ -582,55 +580,7 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( * // User is now signed in * ``` * - * **Example 2: Complete account linking flow (Google → Email Link)** - * ```kotlin - * // Step 1: User tries to sign in with Google - * try { - * val googleAccount = GoogleSignIn.getLastSignedInAccount(context) - * val googleIdToken = googleAccount?.idToken - * val googleCredential = GoogleAuthProvider.getCredential(googleIdToken, null) - * - * firebaseAuthUI.signInAndLinkWithCredential( - * config = authUIConfig, - * credential = googleCredential - * ) - * } catch (e: FirebaseAuthUserCollisionException) { - * // Email already exists with Email Link provider - * - * // Step 2: Send email link with credential for linking - * firebaseAuthUI.sendSignInLinkToEmail( - * context = context, - * config = authUIConfig, - * provider = emailProvider, - * email = email, - * credentialForLinking = CredentialForLinking( - * providerType = "google.com", - * idToken = googleIdToken, // From GoogleSignInAccount - * accessToken = null - * ) - * ) - * - * // Step 3: Show "Check your email" UI - * } - * - * // Step 4: User clicks email link → App opens - * // (In your deep link handling Activity) - * val emailLink = intent.data.toString() - * firebaseAuthUI.signInWithEmailLink( - * context = context, - * config = authUIConfig, - * provider = emailProvider, - * email = email, - * emailLink = emailLink - * ) - * // signInWithEmailLink automatically: - * // 1. Signs in with email link - * // 2. Retrieves the saved Google credential from DataStore - * // 3. Links the Google credential to the email link account - * // 4. User is now signed in with both Email Link AND Google linked - * ``` - * - * **Example 3: Anonymous user upgrade** + * **Example 2: Anonymous user upgrade** * ```kotlin * // User is currently signed in anonymously * // Send email link to upgrade anonymous account to permanent email account @@ -652,7 +602,6 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( config: AuthUIConfiguration, provider: AuthProvider.Email, email: String, - credentialForLinking: CredentialForLinking? = null, ) { try { updateAuthState(AuthState.Loading("Sending sign in email link...")) @@ -668,16 +617,6 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( val sessionId = SessionUtils.generateRandomAlphaNumericString(AuthProvider.Email.SESSION_ID_LENGTH) - // If credential provided, save it for linking after email link sign-in - if (credentialForLinking != null) { - EmailLinkPersistenceManager.saveCredentialForLinking( - context = context, - providerType = credentialForLinking.providerType, - idToken = credentialForLinking.idToken, - accessToken = credentialForLinking.accessToken - ) - } - // Modify actionCodeSettings Url to include sessionId, anonymousUserId, force same // device flag val updatedActionCodeSettings = @@ -838,12 +777,16 @@ suspend fun FirebaseAuthUI.signInWithEmailLink( .signInWithCredential(emailLinkCredential).await() .user?.linkWithCredential(storedCredentialForLink)?.await() .also { result -> - // If safe link succeeds, emit merge conflict for UI to handle - updateAuthState( - AuthState.MergeConflict( - storedCredentialForLink - ) + // 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 } return result } else { 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 index 6864d9926..3ff865af0 100644 --- 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 @@ -14,6 +14,7 @@ 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 @@ -30,6 +31,7 @@ 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 @@ -40,6 +42,7 @@ import kotlinx.coroutines.launch * 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 * @@ -49,6 +52,7 @@ import kotlinx.coroutines.launch */ @Composable fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( + context: Context, config: AuthUIConfiguration, provider: AuthProvider.Facebook, ): () -> Unit { @@ -72,6 +76,7 @@ fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( coroutineScope.launch { try { signInWithFacebook( + context = context, config = config, provider = provider, accessToken = result.accessToken, @@ -120,9 +125,10 @@ fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( * 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 throwing - * [AuthException.AccountLinkingRequiredException] with the credential for later linking. + * 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 @@ -137,6 +143,7 @@ fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( * @see signInAndLinkWithCredential */ internal suspend fun FirebaseAuthUI.signInWithFacebook( + context: Context, config: AuthUIConfiguration, provider: AuthProvider.Facebook, accessToken: AccessToken, @@ -155,6 +162,19 @@ internal suspend fun FirebaseAuthUI.signInWithFacebook( 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.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)) 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 index 3c17d113e..af381e78c 100644 --- 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 @@ -241,7 +241,7 @@ internal suspend fun FirebaseAuthUI.submitVerificationCode( * The method automatically handles: * - Normal sign-in for new or returning users * - Linking phone credential to anonymous accounts (if enabled in config) - * - Emitting [AuthState.MergeConflict] if phone number already exists on another account + * - Throwing [AuthException.AccountLinkingRequiredException] if phone number already exists on another account * * **Example: Sign in after instant verification** * ```kotlin @@ -270,10 +270,10 @@ internal suspend fun FirebaseAuthUI.submitVerificationCode( * config = authUIConfig, * credential = phoneCredential * ) - * } catch (e: FirebaseAuthUserCollisionException) { + * } catch (e: AuthException.AccountLinkingRequiredException) { * // Phone number already exists on another account - * // AuthState.MergeConflict will be emitted - * // Show merge conflict resolution screen + * // Account linking required - show account linking screen + * // User needs to sign in with existing account to link * } * ``` * 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 index be76b6f22..5a7977361 100644 --- 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 @@ -145,7 +145,7 @@ fun EmailAuthScreen( val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) val isLoading = authState is AuthState.Loading - val oAuthCredentialForLinking = remember { credentialForLinking } + 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 @@ -206,7 +206,6 @@ fun EmailAuthScreen( config = configuration, provider = provider, email = emailTextValue.value, - credentialForLinking = null, ) } else { authUI.signInWithEmailAndPassword( @@ -214,7 +213,7 @@ fun EmailAuthScreen( config = configuration, email = emailTextValue.value, password = passwordTextValue.value, - credentialForLinking = oAuthCredentialForLinking, + credentialForLinking = authCredentialForLinking, ) } } catch (e: Exception) { diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt index 05caaacc6..76e1d66bf 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt @@ -292,6 +292,7 @@ class EmailAuthProviderFirebaseAuthUITest { 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) @@ -320,12 +321,16 @@ class EmailAuthProviderFirebaseAuthUITest { email = "test@example.com", password = "Pass@123" ) - } catch (e: AuthException) { + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.AccountLinkingRequiredException) { assertThat(e.cause).isEqualTo(collisionException) - val currentState = instance.authStateFlow().first { it is AuthState.MergeConflict } - assertThat(currentState).isInstanceOf(AuthState.MergeConflict::class.java) - val mergeConflict = currentState as AuthState.MergeConflict - assertThat(mergeConflict.pendingCredential).isNotNull() + 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) } } @@ -556,7 +561,7 @@ class EmailAuthProviderFirebaseAuthUITest { } @Test - fun `signInAndLinkWithCredential - handles collision and emits MergeConflict`() = runTest { + fun `signInAndLinkWithCredential - handles collision and throws AccountLinkingRequiredException`() = runTest { val anonymousUser = mock(FirebaseUser::class.java) `when`(anonymousUser.isAnonymous).thenReturn(true) `when`(mockFirebaseAuth.currentUser).thenReturn(anonymousUser) @@ -567,6 +572,7 @@ class EmailAuthProviderFirebaseAuthUITest { 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) @@ -592,14 +598,16 @@ class EmailAuthProviderFirebaseAuthUITest { credential = credential ) assertThat(false).isTrue() // Should not reach here - } catch (e: AuthException) { - // Expected + } 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.MergeConflict } - assertThat(currentState).isInstanceOf(AuthState.MergeConflict::class.java) - val mergeConflict = currentState as AuthState.MergeConflict - assertThat(mergeConflict.pendingCredential).isEqualTo(updatedCredential) + 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) } // ============================================================================================= From 192b6f9012d76d0e393407ee9a24cad24d886a10 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Sat, 18 Oct 2025 21:56:05 +0100 Subject: [PATCH 11/13] pass context to rememberSignInWithFacebookLauncher --- .../com/firebase/composeapp/ui/screens/FirebaseAuthScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/FirebaseAuthScreen.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/FirebaseAuthScreen.kt index b14e04a03..3d24deda8 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/FirebaseAuthScreen.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/FirebaseAuthScreen.kt @@ -108,6 +108,7 @@ fun FirebaseAuthScreen( else -> { val onSignInWithFacebook = authUI.rememberSignInWithFacebookLauncher( + context = context, config = configuration, provider = configuration.providers.filterIsInstance() .first() From b0c15b16baea4542baae43b3f0e3b916ddabf688 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Mon, 20 Oct 2025 11:32:47 +0100 Subject: [PATCH 12/13] fix: linking credential hangs AuthState --- .../ui/auth/compose/FirebaseAuthUI.kt | 5 +-- .../EmailAuthProvider+FirebaseAuthUI.kt | 35 ++++++++++--------- .../com/firebase/composeapp/MainActivity.kt | 6 +--- 3 files changed, 21 insertions(+), 25 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt index 9551759aa..6146f10f7 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt @@ -29,9 +29,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.tasks.await import java.util.concurrent.ConcurrentHashMap @@ -221,7 +218,7 @@ class FirebaseAuthUI private constructor( ) { firebaseState, internalState -> // Prefer non-idle internal states (like PasswordResetLinkSent, Error, etc.) if (internalState !is AuthState.Idle) internalState else firebaseState - } + }.distinctUntilChanged() } /** diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index 53c216d46..b85f4ef4b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -338,20 +338,25 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( } else { // Normal sign-in auth.signInWithEmailAndPassword(email, password).await() - .also { result -> + .let { result -> // If there's a credential to link, link it after sign-in if (credentialForLinking != null) { - return result.user?.linkWithCredential(credentialForLinking)?.await() - .also { linkResult -> - // Merge profile from social provider - linkResult?.user?.let { user -> - mergeProfile( - auth, - user.displayName, - user.photoUrl - ) - } - } + 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 { @@ -773,7 +778,7 @@ suspend fun FirebaseAuthUI.signInWithEmailLink( .getInstance(appExplicitlyForValidation) // Safe link: Validate that both credentials can be linked - val result = authExplicitlyForValidation + authExplicitlyForValidation .signInWithCredential(emailLinkCredential).await() .user?.linkWithCredential(storedCredentialForLink)?.await() .also { result -> @@ -788,10 +793,9 @@ suspend fun FirebaseAuthUI.signInWithEmailLink( updateAuthState(AuthState.Error(accountLinkingException)) throw accountLinkingException } - return result } else { // Non-upgrade: Sign in with email link, then link social credential - val result = auth.signInWithCredential(emailLinkCredential).await() + auth.signInWithCredential(emailLinkCredential).await() // Link the social credential .user?.linkWithCredential(storedCredentialForLink)?.await() .also { result -> @@ -804,7 +808,6 @@ suspend fun FirebaseAuthUI.signInWithEmailLink( ) } } - return result } } // Clear DataStore after success diff --git a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt index c6338ce3d..363052b80 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt @@ -66,7 +66,7 @@ class MainActivity : ComponentActivity() { AuthProvider.Email( isDisplayNameRequired = true, isEmailLinkForceSameDeviceEnabled = true, - isEmailLinkSignInEnabled = true, + isEmailLinkSignInEnabled = false, emailLinkActionCodeSettings = actionCodeSettings { // The continue URL - where to redirect after email link is clicked url = "https://temp-test-aa342.firebaseapp.com" @@ -159,10 +159,6 @@ class MainActivity : ComponentActivity() { is Route.MfaEnrollment -> NavEntry(entry) { LaunchMfaEnrollment(authUI, backStack) } - - is Route.MfaEnrollment -> NavEntry(entry) { - LaunchMfaEnrollment(authUI, backStack) - } } } ) From d68bfc2fbdb79b30ec8172b5cb9a301b8176b980 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Mon, 20 Oct 2025 16:05:10 +0100 Subject: [PATCH 13/13] facebook sign tests --- .../FacebookAuthProviderFirebaseAuthUI.kt | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt 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..1e79f9fe6 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt @@ -0,0 +1,280 @@ +/* + * 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 +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.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +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.clear(applicationContext) + EmailLinkPersistenceManager.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.retrieveSessionRecord(applicationContext) + assertThat(sessionRecord).isNotNull() + assertThat(sessionRecord?.credentialForLinking).isNotNull() + assertThat(sessionRecord?.credentialForLinking?.provider) + .isEqualTo(provider.providerId) + } finally { + EmailLinkPersistenceManager.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) + } + } +}