diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/AuthFlowControllerDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/AuthFlowControllerDemoActivity.kt index 7d82c1a18..ce24e612f 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/AuthFlowControllerDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/AuthFlowControllerDemoActivity.kt @@ -227,6 +227,7 @@ fun AuthFlowDemo( is AuthState.Success -> "Success - User: ${(authState as AuthState.Success).user.email}" is AuthState.Error -> "Error: ${(authState as AuthState.Error).exception.message}" is AuthState.Cancelled -> "Cancelled" + is AuthState.Aborted -> "Aborted" is AuthState.RequiresMfa -> "MFA Required" is AuthState.RequiresEmailVerification -> "Email Verification Required" else -> "Unknown" diff --git a/auth/README.md b/auth/README.md index 1cf4c8e00..c4864b180 100644 --- a/auth/README.md +++ b/auth/README.md @@ -179,7 +179,9 @@ class MainActivity : ComponentActivity() { Toast.makeText(this, "Error: ${exception.message}", Toast.LENGTH_SHORT).show() }, onSignInCancelled = { - finish() + // User backed out of a single provider (e.g. dismissed the Google + // Credential Manager sheet); FirebaseAuthScreen already returns to + // the method picker on its own — no action needed here. } ) } @@ -343,7 +345,12 @@ lifecycleScope.launch { Log.e(TAG, "Auth failed", state.exception) } is AuthState.Cancelled -> { - // User cancelled + // User cancelled a single sign-in attempt (e.g. dismissed the + // Credential Manager sheet, backed out of MFA); the flow stays open + } + is AuthState.Aborted -> { + // Flow was ended via controller.cancel() + finish() } else -> { // Handle other states (RequiresMfa, RequiresEmailVerification, etc.) @@ -375,6 +382,7 @@ sealed class AuthState { data class RequiresEmailVerification(val user: FirebaseUser, val email: String) : AuthState() data class RequiresProfileCompletion(val user: FirebaseUser, val missingFields: List = emptyList()) : AuthState() object Cancelled : AuthState() + object Aborted : AuthState() object PasswordResetLinkSent : AuthState() object EmailSignInLinkSent : AuthState() data class SMSAutoVerified(val credential: PhoneAuthCredential) : AuthState() @@ -671,7 +679,8 @@ fun AuthenticationScreen() { } }, onSignInCancelled = { - navigateBack() + // User backed out of a single provider; the screen already returns + // to the method picker on its own. } ) } @@ -684,7 +693,7 @@ fun AuthenticationScreen() { | `configuration` | `AuthUIConfiguration` | *Required* | Authentication configuration (providers, theme, etc.) | | `onSignInSuccess` | `(AuthResult) -> Unit` | *Required* | Callback when sign-in succeeds | | `onSignInFailure` | `(AuthException) -> Unit` | *Required* | Callback when sign-in fails | -| `onSignInCancelled` | `() -> Unit` | *Required* | Callback when user cancels authentication | +| `onSignInCancelled` | `() -> Unit` | *Required* | Callback when the user backs out of a single sign-in attempt (`AuthState.Cancelled`, e.g. dismissing the Google Credential Manager sheet); `FirebaseAuthScreen` already returns to the method picker itself, so this is informational only. Not called when the whole flow ends via `AuthFlowController.cancel()` (`AuthState.Aborted`) — that state is observable directly on `authUI.authStateFlow()`/`authFlowController.authStateFlow` for callers who need it | | `modifier` | `Modifier` | `Modifier` | Modifier for the composable | | `authUI` | `FirebaseAuthUI` | `FirebaseAuthUI.getInstance()` | Custom FirebaseAuthUI instance (for multi-app support) | | `emailLink` | `String?` | `null` | Email link for passwordless sign-in (see [Email Link Sign-In](#email-link-sign-in)) | @@ -706,7 +715,8 @@ FirebaseAuthScreen( showError(exception) }, onSignInCancelled = { - finish() + // User backed out of a single provider; the screen already returns + // to the method picker on its own. }, authenticatedContent = { state, uiContext -> // Show a welcome screen or profile completion UI @@ -777,7 +787,11 @@ class AuthActivity : ComponentActivity() { showEmailVerificationScreen(state.user) } is AuthState.Cancelled -> { - // User cancelled authentication + // User cancelled a single sign-in attempt; the flow stays open + // and returns to the method picker + } + is AuthState.Aborted -> { + // Flow was ended via controller.cancel() finish() } else -> { @@ -1729,7 +1743,8 @@ override fun onCreate(savedInstanceState: Bundle?) { // Handle error }, onSignInCancelled = { - finish() + // User backed out of a single provider; the screen already + // returns to the method picker on its own. } ) } diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthException.kt b/auth/src/main/java/com/firebase/ui/auth/AuthException.kt index 054b3e649..6d0958297 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthException.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthException.kt @@ -513,6 +513,14 @@ abstract class AuthException( cause = firebaseException ) + // FirebaseAuthWebException code for backing out of the OAuth custom tab + "ERROR_WEB_CONTEXT_CANCELED" -> AuthCancelledException( + message = stringProvider?.errorAuthCancelled.nonEmpty() + ?: firebaseException.message + ?: "Authentication was cancelled", + cause = firebaseException + ) + else -> UnknownException( message = stringProvider?.errorUnknownAuth.nonEmpty() ?: firebaseException.message diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt b/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt index 93974a174..f5c0bd6e3 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt @@ -18,6 +18,7 @@ import android.app.Activity import android.content.Context import android.content.Intent import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.MainThread import com.firebase.ui.auth.configuration.AuthUIConfiguration import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -71,7 +72,10 @@ import java.util.concurrent.atomic.AtomicBoolean * // Handle error * } * is AuthState.Cancelled -> { - * // User cancelled + * // User cancelled a single sign-in attempt; flow stays open + * } + * is AuthState.Aborted -> { + * // The whole flow was ended via authController.cancel() * } * else -> {} * } @@ -93,7 +97,7 @@ import java.util.concurrent.atomic.AtomicBoolean * **Lifecycle Management:** * - [createIntent] - Generate Intent to start the auth flow Activity * - [start] - Alternative to launch the flow (for Activity context) - * - [cancel] - Cancel the ongoing auth flow, transitions to [AuthState.Cancelled] + * - [cancel] - Cancel the ongoing auth flow, transitions to [AuthState.Aborted] * - [dispose] - Release all resources (coroutines, listeners). Call in onDestroy() * * @property authUI The [FirebaseAuthUI] instance managing authentication @@ -120,7 +124,9 @@ class AuthFlowController internal constructor( * - [AuthState.Loading] - Authentication in progress * - [AuthState.Success] - User signed in successfully * - [AuthState.Error] - Authentication error occurred - * - [AuthState.Cancelled] - User cancelled the flow + * - [AuthState.Cancelled] - Operation-level cancellation; the user cancelled a single + * sign-in attempt and the flow stays open + * - [AuthState.Aborted] - The whole flow was ended via [cancel] * - [AuthState.RequiresMfa] - Multi-factor authentication required * - [AuthState.RequiresEmailVerification] - Email verification required */ @@ -195,10 +201,14 @@ class AuthFlowController internal constructor( /** * Cancels the ongoing authentication flow. * - * This method transitions the auth state to [AuthState.Cancelled] and + * This method transitions the auth state to [AuthState.Aborted] and * signals the auth flow to terminate. The auth flow Activity will finish * and return [Activity.RESULT_CANCELED]. * + * Unlike [AuthState.Cancelled] (an operation-level cancellation that leaves the flow + * open, e.g. dismissing the Google Credential Manager sheet), calling this method ends + * the entire flow. + * * **Example:** * ```kotlin * // User clicked a "Cancel" button @@ -209,9 +219,10 @@ class AuthFlowController internal constructor( * * @throws IllegalStateException if the controller has been disposed */ + @MainThread fun cancel() { checkNotDisposed() - authUI.updateAuthState(AuthState.Cancelled) + authUI.updateAuthState(AuthState.Aborted) } /** diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt index 697480213..410107cdd 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt @@ -34,10 +34,19 @@ import com.google.firebase.auth.PhoneAuthProvider */ abstract class AuthState private constructor() { + /** + * Whether this is a one-off notification: something a screen shows once (a dialog, a "link + * sent" message) and must reset back to [Idle] immediately after consuming, so it doesn't + * leak to a screen/Activity created later. `abstract` so every new state must explicitly + * decide this rather than silently defaulting one way. + */ + abstract val isNotification: Boolean + /** * Initial state before any authentication operation has been started. */ class Idle internal constructor() : AuthState() { + override val isNotification: Boolean = false override fun equals(other: Any?): Boolean = other is Idle override fun hashCode(): Int = javaClass.hashCode() override fun toString(): String = "AuthState.Idle" @@ -49,6 +58,7 @@ abstract class AuthState private constructor() { * @property message Optional message describing what is being loaded */ class Loading(val message: String? = null) : AuthState() { + override val isNotification: Boolean = false override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Loading) return false @@ -72,6 +82,7 @@ abstract class AuthState private constructor() { val user: FirebaseUser, val isNewUser: Boolean = false ) : AuthState() { + override val isNotification: Boolean = false override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Success) return false @@ -101,6 +112,7 @@ abstract class AuthState private constructor() { val exception: Exception, val isRecoverable: Boolean = true ) : AuthState() { + override val isNotification: Boolean = true override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Error) return false @@ -120,13 +132,37 @@ abstract class AuthState private constructor() { /** * Authentication was cancelled by the user. + * + * This is an operation-level cancellation: the user backed out of a single sign-in + * attempt (e.g. dismissed the Google Credential Manager sheet, backed out of an MFA + * challenge). The flow stays open and the screen returns to the method picker. + * + * @see Aborted for the state that ends the whole flow instead */ class Cancelled internal constructor() : AuthState() { + override val isNotification: Boolean = true override fun equals(other: Any?): Boolean = other is Cancelled override fun hashCode(): Int = javaClass.hashCode() override fun toString(): String = "AuthState.Cancelled" } + /** + * The entire authentication flow was aborted. + * + * This state is emitted only by [AuthFlowController.cancel]. Unlike [Cancelled], which is + * a normal in-flow outcome that leaves the flow open, [Aborted] ends the whole flow — + * for example, [FirebaseAuthActivity] finishes with `RESULT_CANCELED` when it observes + * this state. + * + * @see Cancelled for the operation-level state that leaves the flow open + */ + class Aborted internal constructor() : AuthState() { + override val isNotification: Boolean = true + override fun equals(other: Any?): Boolean = other is Aborted + override fun hashCode(): Int = javaClass.hashCode() + override fun toString(): String = "AuthState.Aborted" + } + /** * Multi-factor authentication is required to complete sign-in. * @@ -137,6 +173,7 @@ abstract class AuthState private constructor() { val resolver: MultiFactorResolver, val hint: String? = null ) : AuthState() { + override val isNotification: Boolean = false override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is RequiresMfa) return false @@ -164,6 +201,7 @@ abstract class AuthState private constructor() { val user: FirebaseUser, val email: String ) : AuthState() { + override val isNotification: Boolean = false override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is RequiresEmailVerification) return false @@ -191,6 +229,7 @@ abstract class AuthState private constructor() { val user: FirebaseUser, val missingFields: List = emptyList() ) : AuthState() { + override val isNotification: Boolean = false override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is RequiresProfileCompletion) return false @@ -221,6 +260,7 @@ abstract class AuthState private constructor() { // Not included in equals/hashCode — lambdas have no meaningful equality. val retryOperation: (suspend (android.content.Context) -> Unit)? = null, ) : AuthState() { + override val isNotification: Boolean = false override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is ReauthenticationRequired) return false @@ -241,6 +281,7 @@ abstract class AuthState private constructor() { * Password reset link has been sent to the user's email. */ class PasswordResetLinkSent : AuthState() { + override val isNotification: Boolean = true override fun equals(other: Any?): Boolean = other is PasswordResetLinkSent override fun hashCode(): Int = javaClass.hashCode() override fun toString(): String = "AuthState.PasswordResetLinkSent" @@ -250,6 +291,7 @@ abstract class AuthState private constructor() { * Email sign in link has been sent to the user's email. */ class EmailSignInLinkSent : AuthState() { + override val isNotification: Boolean = true override fun equals(other: Any?): Boolean = other is EmailSignInLinkSent override fun hashCode(): Int = javaClass.hashCode() override fun toString(): String = "AuthState.EmailSignInLinkSent" @@ -267,6 +309,7 @@ abstract class AuthState private constructor() { * @see PhoneNumberVerificationRequired for the manual verification flow */ class SMSAutoVerified(val credential: PhoneAuthCredential) : AuthState() { + override val isNotification: Boolean = true override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is SMSAutoVerified) return false @@ -305,6 +348,7 @@ abstract class AuthState private constructor() { val verificationId: String, val forceResendingToken: PhoneAuthProvider.ForceResendingToken, ) : AuthState() { + override val isNotification: Boolean = false override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is PhoneNumberVerificationRequired) return false @@ -337,5 +381,12 @@ abstract class AuthState private constructor() { */ @JvmStatic val Cancelled: Cancelled = Cancelled() + + /** + * Creates an Aborted state instance. + * @return A new [Aborted] state + */ + @JvmStatic + val Aborted: Aborted = Aborted() } } diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt index b936d1cb2..1c502d22c 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt @@ -36,7 +36,11 @@ import java.util.concurrent.ConcurrentHashMap * * This activity displays the [FirebaseAuthScreen] composable and manages * the authentication flow lifecycle. It automatically finishes when the user - * signs in successfully or cancels the flow. + * signs in successfully ([AuthState.Success]) or the flow is aborted + * ([AuthState.Aborted], e.g. via [AuthFlowController.cancel]). Operation-level + * cancellations ([AuthState.Cancelled], e.g. dismissing the Google Credential Manager + * sheet) do not finish the activity — the flow stays open and the screen returns to + * the method picker. * * **Do not launch this Activity directly.** * Use [AuthFlowController] to start the auth flow: @@ -116,9 +120,9 @@ class FirebaseAuthActivity : ComponentActivity() { setResult(RESULT_OK, resultIntent) finish() } - is AuthState.Cancelled -> { - // User cancelled the flow + is AuthState.Aborted -> { setResult(RESULT_CANCELED) + authUI.updateAuthState(AuthState.Idle) finish() } is AuthState.Error -> { @@ -149,9 +153,7 @@ class FirebaseAuthActivity : ComponentActivity() { onSignInFailure = { exception -> // State flow will handle error }, - onSignInCancelled = { - authUI.updateAuthState(AuthState.Cancelled) - } + onSignInCancelled = {} ) } } diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt index ef85813fa..972b1786b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -16,6 +16,7 @@ package com.firebase.ui.auth import android.content.Context import android.content.Intent +import androidx.annotation.MainThread import androidx.annotation.RestrictTo import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider @@ -40,6 +41,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.tasks.await import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong /** * The central class that coordinates all authentication operations for Firebase Auth UI Compose. @@ -78,6 +80,7 @@ class FirebaseAuthUI private constructor( ) { private val _authStateFlow = MutableStateFlow(AuthState.Idle) + private val authStateRevision = AtomicLong(0) @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) var testCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null @@ -360,10 +363,31 @@ class FirebaseAuthUI private constructor( * * @param state The new [AuthState] to emit */ + @MainThread fun updateAuthState(state: AuthState) { + authStateRevision.incrementAndGet() _authStateFlow.value = state } + /** + * Retracts a pending [AuthState.Loading] by resetting to [AuthState.Idle], but only while + * [revision] is still the most recent write. Any state emitted since is left untouched. + * + * The revision is what makes this precise: [AuthState.Loading] compares equal whenever the + * message matches, and [MutableStateFlow] drops a write equal to the current value without + * replacing the stored reference - so neither equality nor identity can tell a concurrent + * operation's Loading apart from the caller's. + * + * @param revision The value [currentAuthStateRevision] returned right after the caller emitted + * the [AuthState.Loading] it now wants to retract + */ + internal fun clearLoadingState(revision: Long) { + if (authStateRevision.get() == revision) updateAuthState(AuthState.Idle) + } + + /** Identifies the most recent [updateAuthState] write. See [clearLoadingState]. */ + internal fun currentAuthStateRevision(): Long = authStateRevision.get() + internal fun updateAuthStateWithResult(result: AuthResult?, defaultIsNewUser: Boolean = false) { val user = result?.user if (user != null) { diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt index 65ea606dd..1027b9cab 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt @@ -1,7 +1,6 @@ package com.firebase.ui.auth.configuration.auth_provider import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState @@ -14,27 +13,32 @@ import kotlinx.coroutines.tasks.await /** * Creates a remembered launcher function for anonymous sign-in. * + * @param config Authentication UI configuration + * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure * @return A launcher function that starts the anonymous sign-in flow when invoked * * @see signInAnonymously * @see createOrLinkUserWithEmailAndPassword for upgrading anonymous accounts */ @Composable -internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(config: AuthUIConfiguration): () -> Unit { +internal fun FirebaseAuthUI.rememberAnonymousSignInHandler( + config: AuthUIConfiguration, + onSignInFailure: (AuthException) -> Unit = {}, +): () -> Unit { val context = androidx.compose.ui.platform.LocalContext.current val coroutineScope = rememberCoroutineScope() - return remember(this) { - { - coroutineScope.launch { - try { - signInAnonymously(config) - } catch (e: AuthException) { - // Already an AuthException, don't re-wrap it - updateAuthState(AuthState.Error(e)) - } catch (e: Exception) { - val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) - } + return { + coroutineScope.launch { + try { + signInAnonymously(config) + } catch (e: AuthException) { + // Already an AuthException, don't re-wrap it + updateAuthState(AuthState.Error(e)) + if (e !is AuthException.AuthCancelledException) onSignInFailure(e) + } catch (e: Exception) { + val authException = AuthException.from(e, context) + updateAuthState(AuthState.Error(authException)) + if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt index 59bff5d73..53ca93660 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt @@ -55,12 +55,13 @@ import com.google.firebase.auth.PhoneAuthProvider import com.google.firebase.auth.TwitterAuthProvider import com.google.firebase.auth.UserProfileChangeRequest import com.google.firebase.auth.actionCodeSettings +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.tasks.await import java.util.concurrent.TimeUnit import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine @AuthUIConfigurationDsl class AuthProvidersBuilder { @@ -336,19 +337,23 @@ abstract class AuthProvider(open val providerId: String, open val providerName: } /** - * Internal coroutine-based wrapper for Firebase Phone Authentication verification. + * Internal wrapper that exposes Firebase Phone Authentication verification as a [Flow]. * - * This method wraps the callback-based Firebase Phone Auth API into a suspending function - * using Kotlin coroutines. It handles the Firebase [PhoneAuthProvider.OnVerificationStateChangedCallbacks] - * and converts them into a [VerifyPhoneNumberResult]. + * Firebase's [PhoneAuthProvider.OnVerificationStateChangedCallbacks] is a multi-shot + * callback: for the same request it can report `onCodeSent` and then, once the SMS is + * auto-retrieved, `onVerificationCompleted`. Each callback becomes one emission, so no + * result is ever dropped. * * **Callback mapping:** * - `onVerificationCompleted` → [VerifyPhoneNumberResult.AutoVerified] * - `onCodeSent` → [VerifyPhoneNumberResult.NeedsManualVerification] - * - `onVerificationFailed` → throws the exception + * - `onVerificationFailed` → terminates the flow with that exception + * - `onCodeAutoRetrievalTimeOut` → completes the flow normally * - * This is a private helper method used by [verifyPhoneNumber]. Callers should use - * [verifyPhoneNumber] instead as it handles state management and error handling. + * `onCodeAutoRetrievalTimeOut` fires only when the window expires without a prior + * `onVerificationCompleted`, so the flow terminates on its own only on the SMS path. + * Instant verification has no terminal callback: there the flow stays open until the + * collector is cancelled. Callers that only want the first result should use `first()`. * * @param auth The [FirebaseAuth] instance to use for verification * @param phoneNumber The phone number to verify in E.164 format @@ -357,17 +362,16 @@ abstract class AuthProvider(open val providerId: String, open val providerName: * instead of primary sign-in. Pass null for standard phone authentication. * @param forceResendingToken Optional token from previous verification for resending * - * @return [VerifyPhoneNumberResult] indicating auto-verified or manual verification needed - * @throws FirebaseException if verification fails + * @return a [Flow] of [VerifyPhoneNumberResult] emissions, one per Firebase callback */ - internal suspend fun verifyPhoneNumberAwait( + internal fun verifyPhoneNumberFlow( auth: FirebaseAuth, activity: Activity?, phoneNumber: String, multiFactorSession: MultiFactorSession? = null, forceResendingToken: PhoneAuthProvider.ForceResendingToken?, verifier: Verifier = DefaultVerifier(), - ): VerifyPhoneNumberResult { + ): Flow { return verifier.verifyPhoneNumber( auth, activity, @@ -383,7 +387,7 @@ abstract class AuthProvider(open val providerId: String, open val providerName: * @suppress */ internal interface Verifier { - suspend fun verifyPhoneNumber( + fun verifyPhoneNumber( auth: FirebaseAuth, activity: Activity?, phoneNumber: String, @@ -391,14 +395,14 @@ abstract class AuthProvider(open val providerId: String, open val providerName: forceResendingToken: PhoneAuthProvider.ForceResendingToken?, multiFactorSession: MultiFactorSession?, isInstantVerificationEnabled: Boolean, - ): VerifyPhoneNumberResult + ): Flow } /** * @suppress */ internal class DefaultVerifier : Verifier { - override suspend fun verifyPhoneNumber( + override fun verifyPhoneNumber( auth: FirebaseAuth, activity: Activity?, phoneNumber: String, @@ -406,48 +410,55 @@ abstract class AuthProvider(open val providerId: String, open val providerName: forceResendingToken: PhoneAuthProvider.ForceResendingToken?, multiFactorSession: MultiFactorSession?, isInstantVerificationEnabled: Boolean, - ): VerifyPhoneNumberResult { - return suspendCoroutine { continuation -> - val options = PhoneAuthOptions.newBuilder(auth) - .setPhoneNumber(phoneNumber) - .requireSmsValidation(!isInstantVerificationEnabled) - .setTimeout(timeout, TimeUnit.SECONDS) - .setCallbacks(object : - PhoneAuthProvider.OnVerificationStateChangedCallbacks() { - override fun onVerificationCompleted(credential: PhoneAuthCredential) { - continuation.resume(VerifyPhoneNumberResult.AutoVerified(credential)) - } + ): Flow = callbackFlow { + val options = PhoneAuthOptions.newBuilder(auth) + .setPhoneNumber(phoneNumber) + .requireSmsValidation(!isInstantVerificationEnabled) + .setTimeout(timeout, TimeUnit.SECONDS) + .setCallbacks(object : + PhoneAuthProvider.OnVerificationStateChangedCallbacks() { + override fun onVerificationCompleted(credential: PhoneAuthCredential) { + trySend(VerifyPhoneNumberResult.AutoVerified(credential)) + } - override fun onVerificationFailed(e: FirebaseException) { - continuation.resumeWithException(e) - } + override fun onVerificationFailed(e: FirebaseException) { + close(e) + } - override fun onCodeSent( - verificationId: String, - token: PhoneAuthProvider.ForceResendingToken, - ) { - continuation.resume( - VerifyPhoneNumberResult.NeedsManualVerification( - verificationId, - token - ) + override fun onCodeSent( + verificationId: String, + token: PhoneAuthProvider.ForceResendingToken, + ) { + trySend( + VerifyPhoneNumberResult.NeedsManualVerification( + verificationId, + token ) - } - }) - .apply { - activity?.let { - setActivity(it) - } - forceResendingToken?.let { - setForceResendingToken(it) - } - multiFactorSession?.let { - setMultiFactorSession(it) - } + ) } - .build() - PhoneAuthProvider.verifyPhoneNumber(options) - } + + // Firebase's own terminal: nothing further can arrive for this request, + // so complete rather than leaving the collector waiting forever. + override fun onCodeAutoRetrievalTimeOut(verificationId: String) { + close() + } + }) + .apply { + activity?.let { + setActivity(it) + } + forceResendingToken?.let { + setForceResendingToken(it) + } + multiFactorSession?.let { + setMultiFactorSession(it) + } + } + .build() + PhoneAuthProvider.verifyPhoneNumber(options) + // Firebase exposes no way to unregister these callbacks, so there is nothing to + // tear down when the collector goes away. + awaitClose { } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt index d6a9622e7..3cd51c1d3 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt @@ -19,8 +19,10 @@ import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import com.facebook.AccessToken import com.facebook.CallbackManager import com.facebook.FacebookCallback @@ -47,6 +49,7 @@ import kotlinx.coroutines.launch * @param config The [AuthUIConfiguration] containing authentication settings * @param provider The [AuthProvider.Facebook] configuration with scopes and credential provider * @param loginManagerProvider Provides logout operations to clear stale Facebook sessions + * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure * * @return A launcher function that starts the Facebook sign-in flow when invoked * @@ -58,10 +61,15 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( config: AuthUIConfiguration, provider: AuthProvider.Facebook, loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(), + onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { val coroutineScope = rememberCoroutineScope() val callbackManager = remember { CallbackManager.Factory.create() } val loginManager = LoginManager.getInstance() + val currentContext by rememberUpdatedState(context) + val currentConfig by rememberUpdatedState(config) + val currentProvider by rememberUpdatedState(provider) + val currentOnSignInFailure by rememberUpdatedState(onSignInFailure) val launcher = rememberLauncherForActivityResult( loginManager.createLogInActivityResultContract( @@ -71,7 +79,7 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( onResult = {}, ) - DisposableEffect(config) { + DisposableEffect(Unit) { loginManager.registerCallback( callbackManager, object : FacebookCallback { @@ -79,17 +87,19 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( coroutineScope.launch { try { signInWithFacebook( - context = context, - config = config, - provider = provider, + context = currentContext, + config = currentConfig, + provider = currentProvider, accessToken = result.accessToken, ) } catch (e: AuthException) { // Already an AuthException, don't re-wrap it updateAuthState(AuthState.Error(e)) + if (e !is AuthException.AuthCancelledException) currentOnSignInFailure(e) } catch (e: Exception) { - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, currentContext) updateAuthState(AuthState.Error(authException)) + if (authException !is AuthException.AuthCancelledException) currentOnSignInFailure(authException) } } } @@ -100,12 +110,13 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( override fun onError(error: FacebookException) { Log.e("FacebookAuthProvider", "Error during Facebook sign in", error) - val authException = AuthException.from(error, context) + val authException = AuthException.from(error, currentContext) updateAuthState( AuthState.Error( authException ) ) + if (authException !is AuthException.AuthCancelledException) currentOnSignInFailure(authException) } }) diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt index 89837df3e..09736f190 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt @@ -3,9 +3,9 @@ package com.firebase.ui.auth.configuration.auth_provider import android.content.Context import android.util.Log import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.credentials.CredentialManager +import androidx.credentials.exceptions.GetCredentialCancellationException import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.NoCredentialException import com.firebase.ui.auth.AuthException @@ -23,8 +23,8 @@ import kotlinx.coroutines.launch * Creates a remembered callback for Google Sign-In that can be invoked from UI components. * * This Composable function returns a lambda that, when invoked, initiates the Google Sign-In - * flow using [signInWithGoogle]. The callback is stable across recompositions and automatically - * handles coroutine scoping and error state management. + * flow using [signInWithGoogle]. The callback is rebuilt on every recomposition so it always + * captures the latest parameters, and handles coroutine scoping and error state management. * * **Usage:** * ```kotlin @@ -47,6 +47,7 @@ import kotlinx.coroutines.launch * @param context Android context for Credential Manager * @param config Authentication UI configuration * @param provider Google provider configuration with server client ID and optional scopes + * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure * @return A callback function that initiates Google Sign-In when invoked * * @see signInWithGoogle @@ -57,19 +58,20 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler( context: Context, config: AuthUIConfiguration, provider: AuthProvider.Google, + onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { val coroutineScope = rememberCoroutineScope() - return remember(this, config) { - { - coroutineScope.launch { - try { - signInWithGoogle(context, config, provider) - } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) - } catch (e: Exception) { - val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) - } + return { + coroutineScope.launch { + try { + signInWithGoogle(context, config, provider) + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + if (e !is AuthException.AuthCancelledException) onSignInFailure(e) + } catch (e: Exception) { + val authException = AuthException.from(e, context) + updateAuthState(AuthState.Error(authException)) + if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } } } @@ -94,7 +96,9 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler( * **Error Handling:** * - [GoogleIdTokenParsingException]: Library version mismatch * - [NoCredentialException]: No Google accounts on device - * - [GetCredentialException]: User cancellation, configuration errors, or no credentials + * - [GetCredentialCancellationException]: User dismissed the Credential Manager sheet - + * updates [AuthState.Cancelled] and does not throw + * - [GetCredentialException]: Configuration errors or no credentials * - Configuration errors trigger detailed developer guidance logs * * @param context Android context for Credential Manager @@ -214,6 +218,13 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( // Re-throw to let UI handle the account linking flow updateAuthState(AuthState.Error(e)) throw e + } catch (e: GetCredentialCancellationException) { + // User dismissed the Credential Manager sheet - this is a normal user action, + // not an error, so it goes to AuthState.Cancelled instead of AuthState.Error. + // Swallow (don't rethrow) so rememberGoogleSignInHandler's catch block doesn't + // overwrite this state with AuthState.Error. + updateAuthState(AuthState.Cancelled) + } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in with google was cancelled", diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt index add6bb235..e85c4fea4 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt @@ -3,7 +3,6 @@ package com.firebase.ui.auth.configuration.auth_provider import android.app.Activity import android.content.Context import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState @@ -21,8 +20,9 @@ import kotlinx.coroutines.tasks.await /** * Creates a Composable handler for OAuth provider sign-in. * - * This function creates a remember-scoped sign-in handler that can be invoked - * from button clicks or other UI events. It automatically handles: + * This function creates a sign-in handler, rebuilt on every recomposition so it always + * captures the latest parameters, that can be invoked from button clicks or other UI events. + * It automatically handles: * - Activity retrieval from LocalActivity * - Coroutine scope management * - Error handling and state updates @@ -41,6 +41,7 @@ import kotlinx.coroutines.tasks.await * * @param config Authentication UI configuration * @param provider OAuth provider configuration + * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure * * @return Lambda that triggers OAuth sign-in when invoked * @@ -54,6 +55,7 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler( activity: Activity?, config: AuthUIConfiguration, provider: AuthProvider.OAuth, + onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { val coroutineScope = rememberCoroutineScope() activity ?: throw IllegalStateException( @@ -61,22 +63,22 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler( "Ensure FirebaseAuthScreen is used within an Activity." ) - return remember(this, provider.providerId, config) { - { - coroutineScope.launch { - try { - signInWithProvider( - context = context, - config = config, - activity = activity, - provider = provider - ) - } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) - } catch (e: Exception) { - val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) - } + return { + coroutineScope.launch { + try { + signInWithProvider( + context = context, + config = config, + activity = activity, + provider = provider + ) + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + if (e !is AuthException.AuthCancelledException) onSignInFailure(e) + } catch (e: Exception) { + val authException = AuthException.from(e, context) + updateAuthState(AuthState.Error(authException)) + if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt index 24487dd58..1ee3dc72f 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt @@ -34,6 +34,14 @@ import kotlinx.coroutines.CancellationException * - UI should show code entry screen * - User enters code → call [submitVerificationCode] * + * **Lifecycle:** Firebase reports verification progress as a stream, so this call does not + * return once the code is sent - on the SMS path it keeps collecting until the auto-retrieval + * window expires, verification fails, or the caller is cancelled. A credential auto-retrieved + * after [AuthState.PhoneNumberVerificationRequired] is therefore still emitted, as + * [AuthState.SMSAutoVerified]. On the instant-verification path Firebase reports no terminal + * callback at all, so only cancellation ends the call. Callers should cancel a superseded + * attempt before starting a new one. + * * **Resending codes:** * To resend a verification code, call this method again with: * - `forceResendingToken` = the token from [AuthState.PhoneNumberVerificationRequired] @@ -99,8 +107,8 @@ import kotlinx.coroutines.CancellationException * * @throws AuthException.InvalidCredentialsException if the phone number is invalid * @throws AuthException.TooManyRequestsException if SMS quota is exceeded - * @throws AuthException.AuthCancelledException if the operation is cancelled * @throws AuthException.NetworkException if a network error occurs + * @throws kotlinx.coroutines.CancellationException if the caller's coroutine is cancelled */ internal suspend fun FirebaseAuthUI.verifyPhoneNumber( provider: AuthProvider.Phone, @@ -111,37 +119,39 @@ internal suspend fun FirebaseAuthUI.verifyPhoneNumber( forceResendingToken: PhoneAuthProvider.ForceResendingToken? = null, verifier: AuthProvider.Phone.Verifier = AuthProvider.Phone.DefaultVerifier(), ) { + // -1 never matches a real revision, so a cancellation before the Loading lands clears nothing. + var loadingRevision = -1L try { updateAuthState(AuthState.Loading(config.stringProvider.loadingVerifyingPhoneNumber)) - val result = provider.verifyPhoneNumberAwait( + loadingRevision = currentAuthStateRevision() + provider.verifyPhoneNumberFlow( auth = auth, activity = activity, phoneNumber = phoneNumber, multiFactorSession = multiFactorSession, forceResendingToken = forceResendingToken, verifier = verifier - ) - when (result) { - is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { - updateAuthState(AuthState.SMSAutoVerified(credential = result.credential)) - } + ).collect { result -> + when (result) { + is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { + updateAuthState(AuthState.SMSAutoVerified(credential = result.credential)) + } - is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { - updateAuthState( - AuthState.PhoneNumberVerificationRequired( - verificationId = result.verificationId, - forceResendingToken = result.token, + is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { + updateAuthState( + AuthState.PhoneNumberVerificationRequired( + verificationId = result.verificationId, + forceResendingToken = result.token, + ) ) - ) + } } } } catch (e: CancellationException) { - val cancelledException = AuthException.AuthCancelledException( - message = "Verify phone number was cancelled", - cause = e - ) - updateAuthState(AuthState.Error(cancelledException)) - throw cancelledException + // Cancellation here is the screen's own bookkeeping, not a failure: retract only the + // Loading this call emitted, then rethrow so no spurious Error reaches authStateFlow. + clearLoadingState(loadingRevision) + throw e } catch (e: AuthException) { updateAuthState(AuthState.Error(e)) throw e diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt index bc2ca3b43..9af38935b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt @@ -582,9 +582,6 @@ interface AuthUIStringProvider { /** ToS and Privacy Policy combined message with placeholders for links */ fun tosAndPrivacyPolicy(termsOfServiceLabel: String, privacyPolicyLabel: String): String - /** Tooltip message shown when new account sign-up is disabled */ - val newAccountsDisabledTooltip: String - /** Tooltip message shown when MFA is disabled */ val mfaDisabledTooltip: String diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt index cda581acb..aec4a83cc 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -523,9 +523,6 @@ class DefaultAuthUIStringProvider( override fun tosAndPrivacyPolicy(termsOfServiceLabel: String, privacyPolicyLabel: String): String = localizedContext.getString(R.string.fui_tos_and_pp, termsOfServiceLabel, privacyPolicyLabel) - override val newAccountsDisabledTooltip: String - get() = localizedContext.getString(R.string.fui_new_accounts_disabled_tooltip) - override val mfaDisabledTooltip: String get() = localizedContext.getString(R.string.fui_mfa_disabled_tooltip) diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt index 8aec7c9c3..4f7034364 100644 --- a/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt +++ b/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt @@ -22,6 +22,7 @@ import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.PhoneAuthCredential import com.google.firebase.auth.PhoneAuthProvider import com.google.firebase.auth.PhoneMultiFactorGenerator +import kotlinx.coroutines.flow.first import kotlinx.coroutines.tasks.await /** @@ -33,7 +34,7 @@ import kotlinx.coroutines.tasks.await * - Verifying SMS codes entered by users * - Finalizing enrollment with Firebase Authentication * - * This handler uses the existing [AuthProvider.Phone.verifyPhoneNumberAwait] infrastructure + * This handler uses the existing [AuthProvider.Phone.verifyPhoneNumberFlow] infrastructure * for sending and verifying SMS codes, ensuring consistency with the primary phone auth flow. * * **Usage:** @@ -59,7 +60,7 @@ import kotlinx.coroutines.tasks.await * * @since 10.0.0 * @see TotpEnrollmentHandler - * @see AuthProvider.Phone.verifyPhoneNumberAwait + * @see AuthProvider.Phone.verifyPhoneNumberFlow */ class SmsEnrollmentHandler( private val activity: Activity, @@ -98,13 +99,14 @@ class SmsEnrollmentHandler( } val multiFactorSession = user.multiFactor.session.await() - val result = phoneProvider.verifyPhoneNumberAwait( + // Enrolment only needs the first result, so stop collecting after one emission. + val result = phoneProvider.verifyPhoneNumberFlow( auth = auth, activity = activity, phoneNumber = phoneNumber, multiFactorSession = multiFactorSession, forceResendingToken = null - ) + ).first() return when (result) { is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { @@ -146,13 +148,14 @@ class SmsEnrollmentHandler( } val multiFactorSession = user.multiFactor.session.await() - val result = phoneProvider.verifyPhoneNumberAwait( + // Enrolment only needs the first result, so stop collecting after one emission. + val result = phoneProvider.verifyPhoneNumberFlow( auth = auth, activity = activity, phoneNumber = session.phoneNumber, multiFactorSession = multiFactorSession, forceResendingToken = session.forceResendingToken - ) + ).first() return when (result) { is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt index d0d707bda..a3e216ebe 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt @@ -130,7 +130,7 @@ fun ErrorRecoveryDialog( * @param stringProvider The [AuthUIStringProvider] for localized strings * @return The localized recovery message */ -private fun getRecoveryMessage( +internal fun getRecoveryMessage( error: AuthException, stringProvider: AuthUIStringProvider ): String { @@ -202,12 +202,12 @@ private fun getRecoveryMessage( * @param stringProvider The [AuthUIStringProvider] for localized strings * @return The localized action text */ -private fun getRecoveryActionText( +internal fun getRecoveryActionText( error: AuthException, stringProvider: AuthUIStringProvider ): String { return when (error) { - is AuthException.AuthCancelledException -> error.message ?: stringProvider.continueText + is AuthException.AuthCancelledException -> stringProvider.continueText is AuthException.EmailAlreadyInUseException -> stringProvider.signInDefault // Use existing "Sign in" text is AuthException.AccountLinkingRequiredException -> stringProvider.signInDefault // User needs to sign in to link accounts is AuthException.DifferentSignInMethodRequiredException -> @@ -236,7 +236,7 @@ private fun getRecoveryActionText( * @param error The [AuthException] to check * @return `true` if the error is recoverable, `false` otherwise */ -private fun isRecoverable(error: AuthException): Boolean { +internal fun isRecoverable(error: AuthException): Boolean { return when (error) { is AuthException.NetworkException -> true is AuthException.InvalidCredentialsException -> true diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt index 4cd0aadd8..e5d22c1a8 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt @@ -40,18 +40,18 @@ val LocalTopLevelDialogController = compositionLocalOf * dialogController?.showErrorDialog( @@ -68,7 +68,7 @@ val LocalTopLevelDialogController = compositionLocalOf AuthState ) { private var dialogState by mutableStateOf(null) private val shownErrorStates = mutableSetOf() @@ -78,18 +78,22 @@ class TopLevelDialogController( * Automatically prevents duplicate dialogs for the same AuthState.Error instance. * * @param exception The auth exception to display + * @param errorState The specific [AuthState.Error] instance this call is reacting to, used + * for de-duplication. Pass this explicitly when the caller might not be the only observer of + * the same error: by the time this runs, another observer may have already reset the live + * auth state to `Idle`, so falling back to [currentAuthState] alone would miss the dedup. * @param onRetry Callback when user clicks retry button * @param onRecover Callback when user clicks recover button (e.g., navigate to different screen) * @param onDismiss Callback when dialog is dismissed */ fun showErrorDialog( exception: AuthException, + errorState: AuthState.Error? = null, onRetry: (AuthException) -> Unit = {}, onRecover: ((AuthException) -> Unit)? = null, onDismiss: () -> Unit = {} ) { - // Get current error state - val currentErrorState = authState as? AuthState.Error + val currentErrorState = errorState ?: (currentAuthState() as? AuthState.Error) // If this exact error state has already been shown, skip if (currentErrorState != null && currentErrorState in shownErrorStates) { @@ -162,13 +166,21 @@ class TopLevelDialogController( /** * Creates and remembers a [TopLevelDialogController]. + * + * [authState] is a lambda rather than a snapshot value so the controller can read the + * live auth state on every [TopLevelDialogController.showErrorDialog] call without being + * recreated (and losing its de-duplication history) whenever the auth state changes. + * + * Keyed on [stringProvider] rather than left unkeyed: callers must pass a `remember`ed + * [stringProvider] (stable across recompositions), otherwise the controller — and its + * de-duplication history — would be recreated on every recomposition. */ @Composable fun rememberTopLevelDialogController( stringProvider: AuthUIStringProvider, - authState: AuthState + authState: () -> AuthState ): TopLevelDialogController { - return remember(stringProvider, authState) { + return remember(stringProvider) { TopLevelDialogController(stringProvider, authState) } } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt index ab79e8954..58137835c 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt @@ -292,7 +292,7 @@ private fun SingleDigitField( lineHeight = 24.sp, ), keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.NumberPassword + keyboardType = KeyboardType.Number ), decorationBox = { innerTextField -> Box( diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt index e51c71c52..26c2feaed 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt @@ -88,6 +88,9 @@ class MethodPickerTermsConfiguration( * @param lastSignInPreference The last sign-in preference to show a "Continue as..." button. * @param termsConfiguration Optional configuration for a custom ToS/Privacy Policy footer. * When provided, replaces the default "By continuing..." text. See [MethodPickerTermsConfiguration]. + * @param onContinueAsSelected A callback when the "Continue as..." button is selected, with the + * provider and saved identifier (email address). Falls back to [onProviderSelected] + * if not provided. * * @since 10.0.0 */ @@ -102,7 +105,10 @@ fun AuthMethodPicker( lastSignInPreference: SignInPreferenceManager.SignInPreference? = null, customLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null, termsConfiguration: MethodPickerTermsConfiguration? = null, + onContinueAsSelected: ((AuthProvider, String?) -> Unit)? = null, ) { + val continueAsHandler: (AuthProvider, String?) -> Unit = + onContinueAsSelected ?: { provider, _ -> onProviderSelected(provider) } val context = LocalContext.current val inPreview = LocalInspectionMode.current val stringProvider = LocalAuthUIStringProvider.current @@ -150,7 +156,7 @@ fun AuthMethodPicker( provider = lastProvider, identifier = preference.identifier, enabled = providerButtonsEnabled, - onClick = { onProviderSelected(lastProvider) } + onClick = { continueAsHandler(lastProvider, preference.identifier) } ) Spacer(modifier = Modifier.height(24.dp)) @@ -218,7 +224,7 @@ fun AuthMethodPicker( * A prominent "Continue as..." button that shows the last-used provider and identifier. * * @param provider The authentication provider - * @param identifier The user identifier (email, phone number, etc.) + * @param identifier The user identifier (email address) * @param onClick Callback when the button is clicked */ @Composable diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index 2d40e98e7..14e0965f7 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt @@ -62,6 +62,7 @@ import androidx.navigation.compose.rememberNavController import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.BuildConfig +import com.firebase.ui.auth.FirebaseAuthActivity import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.MfaConfiguration @@ -144,11 +145,11 @@ fun FirebaseAuthScreen( val activity = LocalActivity.current val context = LocalContext.current val coroutineScope = rememberCoroutineScope() - val stringProvider = DefaultAuthUIStringProvider(context) + val stringProvider = remember(context) { DefaultAuthUIStringProvider(context) } val navController = rememberNavController() val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle) - val dialogController = rememberTopLevelDialogController(stringProvider, authState) + val dialogController = rememberTopLevelDialogController(stringProvider) { authState } val lastSuccessfulUserId = remember { mutableStateOf(null) } val pendingLinkingCredential = remember { mutableStateOf(null) } val pendingResolver = remember { mutableStateOf(null) } @@ -156,8 +157,12 @@ fun FirebaseAuthScreen( val pendingReauthState = remember { mutableStateOf(null) } val pendingReauthOperation = remember { mutableStateOf<(suspend (android.content.Context) -> Unit)?>(null) } val emailLinkFromDifferentDevice = remember { mutableStateOf(null) } + val prefillEmail = remember { mutableStateOf(null) } val lastSignInPreference = remember { mutableStateOf(null) } + // Last-processed AuthState, so the Idle branch below can tell a genuine reset apart from + // Idle-as-a-side-effect of consuming a notification (see AuthState.isNotification). + val previousAuthState = remember { mutableStateOf(AuthState.Idle) } val startRoute = remember(configuration.providers, configuration.isProviderChoiceAlwaysShown) { getStartRoute(configuration) } @@ -185,6 +190,7 @@ fun FirebaseAuthScreen( ) ) }, + onSignInFailure = onSignInFailure, ) val continueWithProvider: (String) -> Unit = { providerId -> configuration.providers.find { it.providerId == providerId }?.let { onProviderSelected(it) } @@ -231,7 +237,14 @@ fun FirebaseAuthScreen( privacyPolicyUrl = configuration.privacyPolicyUrl, lastSignInPreference = lastSignInPreference.value, termsConfiguration = customMethodPickerTermsConfiguration, - onProviderSelected = onProviderSelected, + onProviderSelected = { provider -> + prefillEmail.value = null + onProviderSelected(provider) + }, + onContinueAsSelected = { provider, identifier -> + prefillEmail.value = if (provider is AuthProvider.Email) identifier else null + onProviderSelected(provider) + }, ) } } @@ -242,6 +255,7 @@ fun FirebaseAuthScreen( context = context, configuration = configuration, authUI = authUI, + prefillEmail = prefillEmail.value, credentialForLinking = pendingLinkingCredential.value, emailLinkFromDifferentDevice = emailLinkFromDifferentDevice.value, onContinueWithProvider = continueWithProvider, @@ -447,30 +461,36 @@ fun FirebaseAuthScreen( // Synchronise auth state changes with navigation stack. LaunchedEffect(authState) { val state = authState + val previous = previousAuthState.value + previousAuthState.value = state val currentRoute = navController.currentBackStackEntry?.destination?.route when (state) { is AuthState.Success -> { pendingResolver.value = null pendingLinkingCredential.value = null - // If reauth just completed, execute the pending retry and skip normal success handling - pendingReauthOperation.value?.let { retry -> - pendingReauthOperation.value = null - pendingReauthConfig.value = null - pendingReauthState.value = null - // Lock the state to Loading before launching the retry so no - // intermediate Success emission can navigate to AuthRoute.Success. - authUI.updateAuthState(AuthState.Loading()) - coroutineScope.launch { - try { - retry(context) - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Exception) { - authUI.updateAuthState(AuthState.Error(e)) + // If reauth just completed, execute the pending retry and skip normal success handling. + // Guarded on !previous.isNotification: a wrong-password Error masks back into + // Success while signed in, and that must not be mistaken for a completed reauth. + if (!previous.isNotification) { + pendingReauthOperation.value?.let { retry -> + pendingReauthOperation.value = null + pendingReauthConfig.value = null + pendingReauthState.value = null + // Lock the state to Loading before launching the retry so no + // intermediate Success emission can navigate to AuthRoute.Success. + authUI.updateAuthState(AuthState.Loading()) + coroutineScope.launch { + try { + retry(context) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + authUI.updateAuthState(AuthState.Error(e)) + } } + return@LaunchedEffect } - return@LaunchedEffect } state.result?.let { result -> @@ -553,22 +573,39 @@ fun FirebaseAuthScreen( launchSingleTop = true } } - // Keep external cancellation reporting centralized here so child screens - // can handle local navigation without triggering duplicate callbacks. onSignInCancelled() + authUI.updateAuthState(AuthState.Idle) + } + + is AuthState.Aborted -> { + // Hosted by FirebaseAuthActivity: its own authStateFlow collector + // independently finishes the activity and resets state on Aborted. + if (activity !is FirebaseAuthActivity) { + pendingReauthOperation.value = null + pendingReauthConfig.value = null + pendingReauthState.value = null + pendingResolver.value = null + pendingLinkingCredential.value = null + lastSuccessfulUserId.value = null + authUI.updateAuthState(AuthState.Idle) + } } is AuthState.Idle -> { - pendingReauthOperation.value = null - pendingReauthConfig.value = null - pendingReauthState.value = null - pendingResolver.value = null - pendingLinkingCredential.value = null - lastSuccessfulUserId.value = null - if (currentRoute != startRoute.route) { - navController.navigate(startRoute.route) { - popUpTo(navController.graph.findStartDestination().id) { inclusive = true } - launchSingleTop = true + // A notification resets to Idle purely to avoid leaking to a freshly + // created screen — that's not a request to leave the current one. + if (!previous.isNotification) { + pendingReauthOperation.value = null + pendingReauthConfig.value = null + pendingReauthState.value = null + pendingResolver.value = null + pendingLinkingCredential.value = null + lastSuccessfulUserId.value = null + if (currentRoute != startRoute.route) { + navController.navigate(startRoute.route) { + popUpTo(navController.graph.findStartDestination().id) { inclusive = true } + launchSingleTop = true + } } } } @@ -588,6 +625,7 @@ fun FirebaseAuthScreen( dialogController.showErrorDialog( exception = exception, + errorState = errorState, onRetry = { _ -> // Child screens handle their own retry logic }, @@ -646,6 +684,8 @@ fun FirebaseAuthScreen( // Dialog dismissed } ) + // Consumed immediately so this doesn't leak to a freshly created screen. + authUI.updateAuthState(AuthState.Idle) } } @@ -994,6 +1034,7 @@ private fun FirebaseAuthUI.rememberOnProviderSelected( config: AuthUIConfiguration, onNavigate: (AuthRoute) -> Unit, onUnknownProvider: ((AuthProvider) -> Unit)? = null, + onSignInFailure: (AuthException) -> Unit = {}, ): (AuthProvider) -> Unit { val anonymousProvider = config.providers.filterIsInstance().firstOrNull() val googleProvider = config.providers.filterIsInstance().firstOrNull() @@ -1005,16 +1046,18 @@ private fun FirebaseAuthUI.rememberOnProviderSelected( val twitterProvider = config.providers.filterIsInstance().firstOrNull() val genericOAuthProviders = config.providers.filterIsInstance() - val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler(config) } - val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, config, it) } - val onSignInWithFacebook = facebookProvider?.let { rememberSignInWithFacebookLauncher(context, config, it) } - val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } - val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } - val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } - val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } - val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } + val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler(config, onSignInFailure) } + val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, config, it, onSignInFailure) } + val onSignInWithFacebook = facebookProvider?.let { + rememberSignInWithFacebookLauncher(context, config, it, onSignInFailure = onSignInFailure) + } + val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } + val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } + val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } + val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } + val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } val genericOAuthHandlers = genericOAuthProviders.associateWith { - rememberOAuthSignInHandler(context, activity, config, it) + rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } return { provider -> diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt index 667fc364b..adf17afc5 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.FirebaseAuthUI @@ -135,6 +136,7 @@ fun EmailAuthScreen( onSuccess: (AuthResult) -> Unit, onError: (AuthException) -> Unit, onCancel: () -> Unit, + prefillEmail: String? = null, content: @Composable ((EmailAuthContentState) -> Unit)? = null, ) { val provider = configuration.providers.filterIsInstance().first() @@ -150,7 +152,7 @@ fun EmailAuthScreen( } val mode = rememberSaveable { mutableStateOf(initialMode) } val displayNameValue = rememberSaveable { mutableStateOf("") } - val emailTextValue = rememberSaveable { mutableStateOf("") } + val emailTextValue = rememberSaveable { mutableStateOf(prefillEmail ?: "") } val passwordTextValue = rememberSaveable { mutableStateOf("") } val confirmPasswordTextValue = rememberSaveable { mutableStateOf("") } @@ -167,8 +169,11 @@ fun EmailAuthScreen( val authCredentialForLinking = remember { credentialForLinking } val errorMessage = if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null - val resetLinkSent = authState is AuthState.PasswordResetLinkSent - val emailSignInLinkSent = authState is AuthState.EmailSignInLinkSent + + // Latched locally since these get consumed (reset to Idle) below — deriving directly from + // authState would close ResetPasswordUI/SignInEmailLinkUI's dialogs as soon as it resets. + var resetLinkSentLocal by rememberSaveable { mutableStateOf(false) } + var emailSignInLinkSentLocal by rememberSaveable { mutableStateOf(false) } // Track if credentials were retrieved from Credential Manager val retrievedCredential = remember { mutableStateOf?>(null) } @@ -187,6 +192,7 @@ fun EmailAuthScreen( onError(exception) dialogController?.showErrorDialog( exception = exception, + errorState = state, onRetry = { ex -> when (ex) { is AuthException.UserNotFoundException -> { @@ -229,10 +235,24 @@ fun EmailAuthScreen( // Dialog dismissed } ) + // Consumed immediately so this doesn't leak to a freshly created screen. + authUI.updateAuthState(AuthState.Idle) } is AuthState.Cancelled -> { onCancel() + // Consumed so this doesn't leak to a freshly created screen. + authUI.updateAuthState(AuthState.Idle) + } + + is AuthState.PasswordResetLinkSent -> { + resetLinkSentLocal = true + authUI.updateAuthState(AuthState.Idle) + } + + is AuthState.EmailSignInLinkSent -> { + emailSignInLinkSentLocal = true + authUI.updateAuthState(AuthState.Idle) } else -> Unit @@ -247,8 +267,8 @@ fun EmailAuthScreen( confirmPassword = confirmPasswordTextValue.value, isLoading = isLoading, error = errorMessage, - resetLinkSent = resetLinkSent, - emailSignInLinkSent = emailSignInLinkSent, + resetLinkSent = resetLinkSentLocal, + emailSignInLinkSent = emailSignInLinkSentLocal, onEmailChange = { email -> emailTextValue.value = email }, @@ -286,6 +306,7 @@ fun EmailAuthScreen( } }, onSignInEmailLinkClick = { + emailSignInLinkSentLocal = false coroutineScope.launch { try { if (emailLinkFromDifferentDevice != null) { @@ -327,6 +348,7 @@ fun EmailAuthScreen( } }, onSendResetLinkClick = { + resetLinkSentLocal = false coroutineScope.launch { try { authUI.sendPasswordResetEmail( @@ -346,14 +368,17 @@ fun EmailAuthScreen( onGoToSignIn = { textValues.forEach { it.value = "" } mode.value = EmailAuthMode.SignIn + emailSignInLinkSentLocal = false }, onGoToResetPassword = { textValues.forEach { it.value = "" } mode.value = EmailAuthMode.ResetPassword + resetLinkSentLocal = false }, onGoToEmailLinkSignIn = { textValues.forEach { it.value = "" } mode.value = EmailAuthMode.EmailLinkSignIn + emailSignInLinkSentLocal = false }, ) diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt index 4f290c699..eb8b50159 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt @@ -35,15 +35,10 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TooltipAnchorPosition -import androidx.compose.material3.TooltipBox -import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TopAppBar -import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -226,29 +221,17 @@ fun SignInUI( modifier = Modifier .align(Alignment.End), ) { - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider( - TooltipAnchorPosition.Above - ), - tooltip = { - PlainTooltip { - Text(stringProvider.newAccountsDisabledTooltip) - } - }, - state = rememberTooltipState( - initialIsVisible = !provider.isNewAccountsAllowed - ) - ) { + if (provider.isNewAccountsAllowed) { Button( onClick = { onGoToSignUp() }, - enabled = provider.isNewAccountsAllowed && !isLoading, + enabled = !isLoading, ) { Text(stringProvider.signupPageTitle.uppercase()) } + Spacer(modifier = Modifier.width(16.dp)) } - Spacer(modifier = Modifier.width(16.dp)) Button( onClick = { onSignInClick() diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt index e317c639d..2406da779 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt @@ -41,6 +41,7 @@ import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController import com.firebase.ui.auth.util.CountryUtils import com.google.firebase.auth.AuthResult import com.google.firebase.auth.PhoneAuthProvider +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -161,6 +162,22 @@ fun PhoneAuthScreen( val pendingVerificationPhoneNumber = remember { mutableStateOf(null) } val verificationStartTime = remember { mutableStateOf(null) } + // Verification is a long-lived collection: it stays open until Firebase's auto-retrieval + // timeout, so a superseded attempt must be cancelled or it keeps writing auth state. + val verificationJob = remember { mutableStateOf(null) } + // Not rememberSaveable: the coroutine that clears this dies with the composition, so a value + // restored after rotation would latch forever and permanently disable auto sign-in. + val isSubmittingCode = remember { mutableStateOf(false) } + + // Logged, not silent: which attempt was torn down and why is the first thing needed from a + // field report of a stuck or duplicated phone sign-in. + val cancelVerification: (String) -> Unit = { reason -> + verificationJob.value?.let { job -> + Log.d("PhoneAuthScreen", "Cancelling verification attempt ($reason)") + job.cancel() + } + } + val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle) val isLoading = authState is AuthState.Loading val errorMessage = @@ -178,6 +195,9 @@ fun PhoneAuthScreen( Log.d("PhoneAuthScreen", "Current state: $authState") when (val state = authState) { is AuthState.Success -> { + // Sign-in is done, so nothing is left to verify. Hosts that keep this screen + // composed would otherwise let a late emission start a redundant sign-in. + cancelVerification("sign-in complete") state.result?.let { result -> onSuccess(result) } @@ -196,42 +216,70 @@ fun PhoneAuthScreen( pendingVerificationPhoneNumber.value = null verificationStartTime.value = null - coroutineScope.launch { - try { - authUI.signInWithPhoneAuthCredential( - context = context, - config = configuration, - credential = state.credential - ) - } catch (e: Exception) { - // Error will be handled by authState flow + // A manually submitted code is already signing in: auto-verifying now would run a + // second concurrent sign-in with the same phone number. + if (isSubmittingCode.value) { + Log.d("PhoneAuthScreen", "Suppressed auto sign-in: manual submit in flight") + // Restoring the submit's Loading both consumes the credential (so it can't + // leak to a freshly composed screen) and keeps Verify/Resend disabled. + authUI.updateAuthState( + AuthState.Loading(configuration.stringProvider.loadingSigningInWithPhone) + ) + } else { + // Consumed before the async sign-in call so it can't be clobbered by that + // call's own state. + authUI.updateAuthState(AuthState.Idle) + coroutineScope.launch { + try { + authUI.signInWithPhoneAuthCredential( + context = context, + config = configuration, + credential = state.credential + ) + } catch (e: Exception) { + // Error will be handled by authState flow + } } } } is AuthState.Error -> { val exception = AuthException.from(state.exception, stringProvider) - onError(exception) - - // Show dialog for phone-specific errors using top-level controller - dialogController?.showErrorDialog( - exception = exception, - onRetry = { ex -> - when (ex) { - is AuthException.InvalidCredentialsException -> { - // User can retry with corrected code or phone number + // A cooldown rejection is about the duplicate tap, not the attempt in flight. + // Every other error ends the attempt, so stop holding Firebase's callbacks. + if (exception !is AuthException.PhoneVerificationCooldownException) { + cancelVerification("verification failed") + } + // Sign-in and code submission report cancellation as an Error, but this screen + // cancels them as routine bookkeeping, so that is not a host-facing failure. + if (exception !is AuthException.AuthCancelledException) { + onError(exception) + + // Show dialog for phone-specific errors using top-level controller + dialogController?.showErrorDialog( + exception = exception, + errorState = state, + onRetry = { ex -> + when (ex) { + is AuthException.InvalidCredentialsException -> { + // User can retry with corrected code or phone number + } + else -> Unit } - else -> Unit + }, + onDismiss = { + // Dialog dismissed } - }, - onDismiss = { - // Dialog dismissed - } - ) + ) + } + // Consumed immediately so this doesn't leak to a freshly created screen. + authUI.updateAuthState(AuthState.Idle) } is AuthState.Cancelled -> { onCancel() + // Consumed so this doesn't leak to a freshly created screen. + authUI.updateAuthState(AuthState.Idle) } else -> Unit @@ -251,43 +299,52 @@ fun PhoneAuthScreen( selectedCountry.value = country }, onSendCodeClick = { - coroutineScope.launch { - try { - val currentTime = System.currentTimeMillis() - val timeoutMs = provider.timeout * 1000 - val timeSinceLastVerification = verificationStartTime.value?.let { - currentTime - it - } ?: Long.MAX_VALUE - - // Check if the same phone number is being verified again within the cooldown period - val storedNumber = pendingVerificationPhoneNumber.value - val isSameNumber = storedNumber != null && fullPhoneNumber == storedNumber - - // Check cooldown: same number and still within timeout period - if (isSameNumber && timeSinceLastVerification < timeoutMs) { - // Calculate remaining cooldown time in seconds - val remainingCooldownSeconds = ((timeoutMs - timeSinceLastVerification) / 1000).coerceAtLeast(1) - val cooldownException = AuthException.PhoneVerificationCooldownException( - message = "Please wait ${remainingCooldownSeconds} second${if (remainingCooldownSeconds != 1L) "s" else ""} before verifying the same phone number again. The cooldown period is ${provider.timeout} seconds.", + val currentTime = System.currentTimeMillis() + val timeoutMs = provider.timeout * 1000 + val timeSinceLastVerification = verificationStartTime.value?.let { + currentTime - it + } ?: Long.MAX_VALUE + + // Check if the same phone number is being verified again within the cooldown period + val storedNumber = pendingVerificationPhoneNumber.value + val isSameNumber = storedNumber != null && fullPhoneNumber == storedNumber + + // Check cooldown: same number and still within timeout period + if (isSameNumber && timeSinceLastVerification < timeoutMs) { + // Calculate remaining cooldown time in seconds + val remainingCooldownSeconds = + ((timeoutMs - timeSinceLastVerification) / 1000).coerceAtLeast(1) + val plural = if (remainingCooldownSeconds != 1L) "s" else "" + // Rejected before anything is cancelled: a duplicate tap must not tear down the + // healthy in-flight verification it was rejected in favour of. + authUI.updateAuthState( + AuthState.Error( + AuthException.PhoneVerificationCooldownException( + message = "Please wait $remainingCooldownSeconds second$plural " + + "before verifying the same phone number again. The cooldown " + + "period is ${provider.timeout} seconds.", cooldownSeconds = remainingCooldownSeconds ) - // Update auth state to show the error - authUI.updateAuthState(AuthState.Error(cooldownException)) - throw cooldownException - } - - // Track the phone number and start time for cooldown checking - pendingVerificationPhoneNumber.value = fullPhoneNumber - verificationStartTime.value = currentTime - - authUI.verifyPhoneNumber( - provider = provider, - activity = activity, - phoneNumber = fullPhoneNumber, - config = configuration, ) - } catch (e: Exception) { - // Error will be handled by authState flow + ) + } else { + cancelVerification("new verification requested") + + // Track the phone number and start time for cooldown checking + pendingVerificationPhoneNumber.value = fullPhoneNumber + verificationStartTime.value = currentTime + + verificationJob.value = coroutineScope.launch { + try { + authUI.verifyPhoneNumber( + provider = provider, + activity = activity, + phoneNumber = fullPhoneNumber, + config = configuration, + ) + } catch (e: Exception) { + // Error will be handled by authState flow + } } } }, @@ -296,6 +353,9 @@ fun PhoneAuthScreen( verificationCodeValue.value = code }, onVerifyCodeClick = { + // Latched before the launch so an auto-verification arriving in between can't slip + // past the guard. + isSubmittingCode.value = true coroutineScope.launch { try { verificationId.value?.let { id -> @@ -308,14 +368,21 @@ fun PhoneAuthScreen( } } catch (e: Exception) { // Error will be handled by authState flow + } finally { + // Cleared in finally, not catch: submitVerificationCode also returns null + // without throwing (MFA and reauth paths). + isSubmittingCode.value = false } } }, fullPhoneNumber = fullPhoneNumber, onResendCodeClick = { if (resendTimerSeconds.intValue == 0) { - coroutineScope.launch { + cancelVerification("code resent") + verificationJob.value = coroutineScope.launch { try { + // The timer is restarted by the PhoneNumberVerificationRequired branch + // above: this call only returns once the verification window closes. authUI.verifyPhoneNumber( activity = activity, provider = provider, @@ -323,7 +390,6 @@ fun PhoneAuthScreen( config = configuration, forceResendingToken = forceResendingToken.value, ) - resendTimerSeconds.intValue = provider.timeout.toInt() // Restart timer } catch (e: Exception) { // Error will be handled by authState flow } @@ -332,6 +398,9 @@ fun PhoneAuthScreen( }, resendTimer = resendTimerSeconds.intValue, onChangeNumberClick = { + cancelVerification("changing phone number") + verificationJob.value = null + isSubmittingCode.value = false step.value = PhoneAuthStep.EnterPhoneNumber verificationCodeValue.value = "" verificationId.value = null diff --git a/auth/src/main/res/values-ar/strings.xml b/auth/src/main/res/values-ar/strings.xml index e6d6f9b96..f888d1d64 100755 --- a/auth/src/main/res/values-ar/strings.xml +++ b/auth/src/main/res/values-ar/strings.xml @@ -178,6 +178,5 @@ أرسلنا بريدًا للتحقق إلى %1$s - This button is currently disabled because new accounts are not allowed المصادقة متعددة العوامل معطلة حاليًا 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 0513671be..2f0530751 100755 --- a/auth/src/main/res/values-b+es+419/strings.xml +++ b/auth/src/main/res/values-b+es+419/strings.xml @@ -196,6 +196,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed Multi-factor authentication is currently disabled diff --git a/auth/src/main/res/values-bg/strings.xml b/auth/src/main/res/values-bg/strings.xml index 08432527b..344e45c44 100755 --- a/auth/src/main/res/values-bg/strings.xml +++ b/auth/src/main/res/values-bg/strings.xml @@ -178,6 +178,5 @@ Изпратихме имейл за потвърждение до %1$s - This button is currently disabled because new accounts are not allowed Многофакторната автентификация в момента е деактивирана diff --git a/auth/src/main/res/values-bn/strings.xml b/auth/src/main/res/values-bn/strings.xml index 18010598a..e0fd8e00b 100755 --- a/auth/src/main/res/values-bn/strings.xml +++ b/auth/src/main/res/values-bn/strings.xml @@ -179,6 +179,5 @@ আমরা %1$s-এ একটি যাচাইকরণ ইমেল পাঠিয়েছি - This button is currently disabled because new accounts are not allowed মাল্টি-ফ্যাক্টর প্রমাণীকরণ বর্তমানে নিষ্ক্রিয় diff --git a/auth/src/main/res/values-ca/strings.xml b/auth/src/main/res/values-ca/strings.xml index a4cb13b80..2fb311bfb 100755 --- a/auth/src/main/res/values-ca/strings.xml +++ b/auth/src/main/res/values-ca/strings.xml @@ -179,6 +179,5 @@ Hem enviat un correu de verificació a %1$s - This button is currently disabled because new accounts are not allowed L\'autenticació multifactor està desactivada actualment diff --git a/auth/src/main/res/values-cs/strings.xml b/auth/src/main/res/values-cs/strings.xml index 527ddce0f..1d1dab36e 100755 --- a/auth/src/main/res/values-cs/strings.xml +++ b/auth/src/main/res/values-cs/strings.xml @@ -178,6 +178,5 @@ Odeslali jsme ověřovací e-mail na %1$s - This button is currently disabled because new accounts are not allowed Vícefaktorové ověřování je aktuálně zakázáno diff --git a/auth/src/main/res/values-da/strings.xml b/auth/src/main/res/values-da/strings.xml index 0fb15c8cf..8096a7d84 100755 --- a/auth/src/main/res/values-da/strings.xml +++ b/auth/src/main/res/values-da/strings.xml @@ -178,6 +178,5 @@ Vi har sendt en bekræftelsesemail til %1$s - This button is currently disabled because new accounts are not allowed Multifaktorgodkendelse er i øjeblikket deaktiveret diff --git a/auth/src/main/res/values-de-rAT/strings.xml b/auth/src/main/res/values-de-rAT/strings.xml index 10ebb575b..21dbe9cb6 100755 --- a/auth/src/main/res/values-de-rAT/strings.xml +++ b/auth/src/main/res/values-de-rAT/strings.xml @@ -196,6 +196,5 @@ Erneut authentifizieren - This button is currently disabled because new accounts are not allowed Die Multi-Faktor-Authentifizierung ist derzeit deaktiviert diff --git a/auth/src/main/res/values-de-rCH/strings.xml b/auth/src/main/res/values-de-rCH/strings.xml index f45895074..f18d40f5b 100755 --- a/auth/src/main/res/values-de-rCH/strings.xml +++ b/auth/src/main/res/values-de-rCH/strings.xml @@ -197,6 +197,5 @@ Erneut authentifizieren - This button is currently disabled because new accounts are not allowed Die Multi-Faktor-Authentifizierung ist derzeit deaktiviert diff --git a/auth/src/main/res/values-de/strings.xml b/auth/src/main/res/values-de/strings.xml index 01232ea04..da405144b 100755 --- a/auth/src/main/res/values-de/strings.xml +++ b/auth/src/main/res/values-de/strings.xml @@ -196,6 +196,5 @@ Erneut authentifizieren - This button is currently disabled because new accounts are not allowed Die Multi-Faktor-Authentifizierung ist derzeit deaktiviert diff --git a/auth/src/main/res/values-el/strings.xml b/auth/src/main/res/values-el/strings.xml index 434a11268..19f5daef9 100755 --- a/auth/src/main/res/values-el/strings.xml +++ b/auth/src/main/res/values-el/strings.xml @@ -179,6 +179,5 @@ Στείλαμε email επαλήθευσης στο %1$s - This button is currently disabled because new accounts are not allowed Ο έλεγχος ταυτότητας πολλαπλών παραγόντων είναι απενεργοποιημένος προς το παρόν diff --git a/auth/src/main/res/values-en-rAU/strings.xml b/auth/src/main/res/values-en-rAU/strings.xml index 4f5b7581e..fe9f1a0f4 100755 --- a/auth/src/main/res/values-en-rAU/strings.xml +++ b/auth/src/main/res/values-en-rAU/strings.xml @@ -178,6 +178,5 @@ We sent a verification email to %1$s - This button is currently disabled because new accounts are not allowed Multi-factor authentication is currently disabled diff --git a/auth/src/main/res/values-en-rCA/strings.xml b/auth/src/main/res/values-en-rCA/strings.xml index 214a3d91f..2c53e3eb5 100755 --- a/auth/src/main/res/values-en-rCA/strings.xml +++ b/auth/src/main/res/values-en-rCA/strings.xml @@ -178,6 +178,5 @@ We sent a verification email to %1$s - This button is currently disabled because new accounts are not allowed Multi-factor authentication is currently disabled diff --git a/auth/src/main/res/values-en-rGB/strings.xml b/auth/src/main/res/values-en-rGB/strings.xml index c30f71c1d..cb667e4c5 100755 --- a/auth/src/main/res/values-en-rGB/strings.xml +++ b/auth/src/main/res/values-en-rGB/strings.xml @@ -178,6 +178,5 @@ We sent a verification email to %1$s - This button is currently disabled because new accounts are not allowed Multi-factor authentication is currently disabled diff --git a/auth/src/main/res/values-en-rIE/strings.xml b/auth/src/main/res/values-en-rIE/strings.xml index 1a3b4860e..f73f72711 100755 --- a/auth/src/main/res/values-en-rIE/strings.xml +++ b/auth/src/main/res/values-en-rIE/strings.xml @@ -171,6 +171,5 @@ We sent a verification email to %1$s - This button is currently disabled because new accounts are not allowed Multi-factor authentication is currently disabled diff --git a/auth/src/main/res/values-en-rIN/strings.xml b/auth/src/main/res/values-en-rIN/strings.xml index 1a3b4860e..f73f72711 100755 --- a/auth/src/main/res/values-en-rIN/strings.xml +++ b/auth/src/main/res/values-en-rIN/strings.xml @@ -171,6 +171,5 @@ We sent a verification email to %1$s - This button is currently disabled because new accounts are not allowed Multi-factor authentication is currently disabled diff --git a/auth/src/main/res/values-en-rSG/strings.xml b/auth/src/main/res/values-en-rSG/strings.xml index 1a3b4860e..f73f72711 100755 --- a/auth/src/main/res/values-en-rSG/strings.xml +++ b/auth/src/main/res/values-en-rSG/strings.xml @@ -171,6 +171,5 @@ We sent a verification email to %1$s - This button is currently disabled because new accounts are not allowed Multi-factor authentication is currently disabled diff --git a/auth/src/main/res/values-en-rZA/strings.xml b/auth/src/main/res/values-en-rZA/strings.xml index 1a3b4860e..f73f72711 100755 --- a/auth/src/main/res/values-en-rZA/strings.xml +++ b/auth/src/main/res/values-en-rZA/strings.xml @@ -171,6 +171,5 @@ We sent a verification email to %1$s - This button is currently disabled because new accounts are not allowed Multi-factor authentication is currently disabled diff --git a/auth/src/main/res/values-es-rAR/strings.xml b/auth/src/main/res/values-es-rAR/strings.xml index 8bf944c45..f20ebae00 100755 --- a/auth/src/main/res/values-es-rAR/strings.xml +++ b/auth/src/main/res/values-es-rAR/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rBO/strings.xml b/auth/src/main/res/values-es-rBO/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rBO/strings.xml +++ b/auth/src/main/res/values-es-rBO/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rCL/strings.xml b/auth/src/main/res/values-es-rCL/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rCL/strings.xml +++ b/auth/src/main/res/values-es-rCL/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rCO/strings.xml b/auth/src/main/res/values-es-rCO/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rCO/strings.xml +++ b/auth/src/main/res/values-es-rCO/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rCR/strings.xml b/auth/src/main/res/values-es-rCR/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rCR/strings.xml +++ b/auth/src/main/res/values-es-rCR/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rDO/strings.xml b/auth/src/main/res/values-es-rDO/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rDO/strings.xml +++ b/auth/src/main/res/values-es-rDO/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rEC/strings.xml b/auth/src/main/res/values-es-rEC/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rEC/strings.xml +++ b/auth/src/main/res/values-es-rEC/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rGT/strings.xml b/auth/src/main/res/values-es-rGT/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rGT/strings.xml +++ b/auth/src/main/res/values-es-rGT/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rHN/strings.xml b/auth/src/main/res/values-es-rHN/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rHN/strings.xml +++ b/auth/src/main/res/values-es-rHN/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rMX/strings.xml b/auth/src/main/res/values-es-rMX/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rMX/strings.xml +++ b/auth/src/main/res/values-es-rMX/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rNI/strings.xml b/auth/src/main/res/values-es-rNI/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rNI/strings.xml +++ b/auth/src/main/res/values-es-rNI/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rPA/strings.xml b/auth/src/main/res/values-es-rPA/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rPA/strings.xml +++ b/auth/src/main/res/values-es-rPA/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rPE/strings.xml b/auth/src/main/res/values-es-rPE/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rPE/strings.xml +++ b/auth/src/main/res/values-es-rPE/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rPR/strings.xml b/auth/src/main/res/values-es-rPR/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rPR/strings.xml +++ b/auth/src/main/res/values-es-rPR/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rPY/strings.xml b/auth/src/main/res/values-es-rPY/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rPY/strings.xml +++ b/auth/src/main/res/values-es-rPY/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rSV/strings.xml b/auth/src/main/res/values-es-rSV/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rSV/strings.xml +++ b/auth/src/main/res/values-es-rSV/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rUS/strings.xml b/auth/src/main/res/values-es-rUS/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rUS/strings.xml +++ b/auth/src/main/res/values-es-rUS/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rUY/strings.xml b/auth/src/main/res/values-es-rUY/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rUY/strings.xml +++ b/auth/src/main/res/values-es-rUY/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es-rVE/strings.xml b/auth/src/main/res/values-es-rVE/strings.xml index 95195a514..87af2ee76 100755 --- a/auth/src/main/res/values-es-rVE/strings.xml +++ b/auth/src/main/res/values-es-rVE/strings.xml @@ -189,6 +189,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-es/strings.xml b/auth/src/main/res/values-es/strings.xml index ba0a84efe..f937887cc 100755 --- a/auth/src/main/res/values-es/strings.xml +++ b/auth/src/main/res/values-es/strings.xml @@ -196,6 +196,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed La autenticación multifactor está actualmente desactivada diff --git a/auth/src/main/res/values-fa/strings.xml b/auth/src/main/res/values-fa/strings.xml index 16f3338fc..5d21a20e3 100755 --- a/auth/src/main/res/values-fa/strings.xml +++ b/auth/src/main/res/values-fa/strings.xml @@ -179,6 +179,5 @@ ایمیل تأیید به %1$s ارسال کردیم - This button is currently disabled because new accounts are not allowed احراز هویت چند مرحله‌ای در حال حاضر غیرفعال است diff --git a/auth/src/main/res/values-fi/strings.xml b/auth/src/main/res/values-fi/strings.xml index 9cfb2ce03..c12c36c37 100755 --- a/auth/src/main/res/values-fi/strings.xml +++ b/auth/src/main/res/values-fi/strings.xml @@ -178,6 +178,5 @@ Lähetimme vahvistussähköpostin osoitteeseen %1$s - This button is currently disabled because new accounts are not allowed Monivaiheinen todennus on tällä hetkellä poistettu käytöstä diff --git a/auth/src/main/res/values-fil/strings.xml b/auth/src/main/res/values-fil/strings.xml index 1db48118c..8e28c1bd9 100755 --- a/auth/src/main/res/values-fil/strings.xml +++ b/auth/src/main/res/values-fil/strings.xml @@ -178,6 +178,5 @@ Nagpadala kami ng verification email sa %1$s - This button is currently disabled because new accounts are not allowed Kasalukuyang naka-disable ang multi-factor authentication diff --git a/auth/src/main/res/values-fr-rCH/strings.xml b/auth/src/main/res/values-fr-rCH/strings.xml index 173450749..178fb4e2b 100755 --- a/auth/src/main/res/values-fr-rCH/strings.xml +++ b/auth/src/main/res/values-fr-rCH/strings.xml @@ -190,6 +190,5 @@ Se réauthentifier - This button is currently disabled because new accounts are not allowed L\'authentification multifacteur est actuellement désactivée diff --git a/auth/src/main/res/values-fr/strings.xml b/auth/src/main/res/values-fr/strings.xml index d92ef24e1..22f509157 100755 --- a/auth/src/main/res/values-fr/strings.xml +++ b/auth/src/main/res/values-fr/strings.xml @@ -196,6 +196,5 @@ Se réauthentifier - This button is currently disabled because new accounts are not allowed L\'authentification multifacteur est actuellement désactivée diff --git a/auth/src/main/res/values-gsw/strings.xml b/auth/src/main/res/values-gsw/strings.xml index 0b97606b8..6accbd34c 100755 --- a/auth/src/main/res/values-gsw/strings.xml +++ b/auth/src/main/res/values-gsw/strings.xml @@ -178,6 +178,5 @@ Mir hend e Verifizierigs-E-Mail an %1$s gschickt - This button is currently disabled because new accounts are not allowed D\'Multi-Faktor-Authentifizierig isch zurziit deaktiviert diff --git a/auth/src/main/res/values-gu/strings.xml b/auth/src/main/res/values-gu/strings.xml index d83ee4813..f80face1b 100755 --- a/auth/src/main/res/values-gu/strings.xml +++ b/auth/src/main/res/values-gu/strings.xml @@ -179,6 +179,5 @@ અમે %1$s પર ચકાસણી ઇમેઇલ મોકલ્યો - This button is currently disabled because new accounts are not allowed મલ્ટિ-ફેક્ટર પ્રમાણીકરણ હાલમાં અક્ષમ છે diff --git a/auth/src/main/res/values-hi/strings.xml b/auth/src/main/res/values-hi/strings.xml index 53339be6e..361aaf69a 100755 --- a/auth/src/main/res/values-hi/strings.xml +++ b/auth/src/main/res/values-hi/strings.xml @@ -179,6 +179,5 @@ हमने %1$s पर एक सत्यापन ईमेल भेजा - This button is currently disabled because new accounts are not allowed मल्टी-फैक्टर प्रमाणीकरण वर्तमान में अक्षम है diff --git a/auth/src/main/res/values-hr/strings.xml b/auth/src/main/res/values-hr/strings.xml index e2516dfe0..d3db464d1 100755 --- a/auth/src/main/res/values-hr/strings.xml +++ b/auth/src/main/res/values-hr/strings.xml @@ -178,6 +178,5 @@ Poslali smo e-poštu za provjeru na %1$s - This button is currently disabled because new accounts are not allowed Višefaktorska autentifikacija trenutno je onemogućena diff --git a/auth/src/main/res/values-hu/strings.xml b/auth/src/main/res/values-hu/strings.xml index e10f495b4..7e0f61ceb 100755 --- a/auth/src/main/res/values-hu/strings.xml +++ b/auth/src/main/res/values-hu/strings.xml @@ -178,6 +178,5 @@ Ellenőrző e-mailt küldtünk a következő címre: %1$s - This button is currently disabled because new accounts are not allowed A többfaktoros hitelesítés jelenleg le van tiltva diff --git a/auth/src/main/res/values-in/strings.xml b/auth/src/main/res/values-in/strings.xml index cc6b13419..de450ae52 100755 --- a/auth/src/main/res/values-in/strings.xml +++ b/auth/src/main/res/values-in/strings.xml @@ -179,6 +179,5 @@ Kami telah mengirim email verifikasi ke %1$s - This button is currently disabled because new accounts are not allowed Autentikasi multifaktor saat ini dinonaktifkan diff --git a/auth/src/main/res/values-it/strings.xml b/auth/src/main/res/values-it/strings.xml index a0efdc6d3..5f39e4d28 100755 --- a/auth/src/main/res/values-it/strings.xml +++ b/auth/src/main/res/values-it/strings.xml @@ -178,6 +178,5 @@ Abbiamo inviato un\'email di verifica a %1$s - This button is currently disabled because new accounts are not allowed L\'autenticazione a più fattori è attualmente disabilitata diff --git a/auth/src/main/res/values-iw/strings.xml b/auth/src/main/res/values-iw/strings.xml index 10f0db9c1..5d2864c7f 100755 --- a/auth/src/main/res/values-iw/strings.xml +++ b/auth/src/main/res/values-iw/strings.xml @@ -179,6 +179,5 @@ שלחנו אימייל אימות אל %1$s - This button is currently disabled because new accounts are not allowed אימות רב-גורמי מושבת כעת diff --git a/auth/src/main/res/values-ja/strings.xml b/auth/src/main/res/values-ja/strings.xml index 620c2cb11..9faa6db47 100755 --- a/auth/src/main/res/values-ja/strings.xml +++ b/auth/src/main/res/values-ja/strings.xml @@ -178,6 +178,5 @@ %1$s に確認メールを送信しました - This button is currently disabled because new accounts are not allowed 多要素認証は現在無効になっています diff --git a/auth/src/main/res/values-kn/strings.xml b/auth/src/main/res/values-kn/strings.xml index 12f649a31..fd3730c06 100755 --- a/auth/src/main/res/values-kn/strings.xml +++ b/auth/src/main/res/values-kn/strings.xml @@ -179,6 +179,5 @@ ನಾವು %1$s ಗೆ ಪರಿಶೀಲನೆ ಇಮೇಲ್ ಕಳುಹಿಸಿದ್ದೇವೆ - This button is currently disabled because new accounts are not allowed ಮಲ್ಟಿ-ಫ್ಯಾಕ್ಟರ್ ದೃಢೀಕರಣವು ಪ್ರಸ್ತುತ ನಿಷ್ಕ್ರಿಯಗೊಂಡಿದೆ diff --git a/auth/src/main/res/values-ko/strings.xml b/auth/src/main/res/values-ko/strings.xml index d829ffe62..574d81fe7 100755 --- a/auth/src/main/res/values-ko/strings.xml +++ b/auth/src/main/res/values-ko/strings.xml @@ -177,6 +177,5 @@ %1$s(으)로 확인 이메일을 보냈습니다 - This button is currently disabled because new accounts are not allowed 다단계 인증이 현재 비활성화되어 있습니다 diff --git a/auth/src/main/res/values-ln/strings.xml b/auth/src/main/res/values-ln/strings.xml index 6304e1f10..832335a0d 100755 --- a/auth/src/main/res/values-ln/strings.xml +++ b/auth/src/main/res/values-ln/strings.xml @@ -179,6 +179,5 @@ Totindi e-mail ya vérification na %1$s - This button is currently disabled because new accounts are not allowed Bondimisami ya makambo mingi ezali sikoyo te diff --git a/auth/src/main/res/values-lt/strings.xml b/auth/src/main/res/values-lt/strings.xml index b08c4dbb1..1c5270c7b 100755 --- a/auth/src/main/res/values-lt/strings.xml +++ b/auth/src/main/res/values-lt/strings.xml @@ -179,6 +179,5 @@ Išsiuntėme patvirtinimo el. laišką adresu %1$s - This button is currently disabled because new accounts are not allowed Daugiafaktoris tapatybės nustatymas šiuo metu išjungtas diff --git a/auth/src/main/res/values-lv/strings.xml b/auth/src/main/res/values-lv/strings.xml index 21aa5fc1d..6e2fa6097 100755 --- a/auth/src/main/res/values-lv/strings.xml +++ b/auth/src/main/res/values-lv/strings.xml @@ -179,6 +179,5 @@ Nosūtījām verifikācijas e-pastu uz %1$s - This button is currently disabled because new accounts are not allowed Daudzfaktoru autentifikācija pašlaik ir atspējota diff --git a/auth/src/main/res/values-mo/strings.xml b/auth/src/main/res/values-mo/strings.xml index 717eb65ca..4d9f9fef4 100755 --- a/auth/src/main/res/values-mo/strings.xml +++ b/auth/src/main/res/values-mo/strings.xml @@ -179,6 +179,5 @@ Am trimis un e-mail de verificare la %1$s - This button is currently disabled because new accounts are not allowed Autentificarea cu mai mulți factori este dezactivată în prezent diff --git a/auth/src/main/res/values-mr/strings.xml b/auth/src/main/res/values-mr/strings.xml index c95fef12a..d4075f456 100755 --- a/auth/src/main/res/values-mr/strings.xml +++ b/auth/src/main/res/values-mr/strings.xml @@ -179,6 +179,5 @@ आम्ही %1$s वर सत्यापन ईमेल पाठवला - This button is currently disabled because new accounts are not allowed मल्टी-फॅक्टर ऑथेंटिकेशन सध्या अक्षम आहे diff --git a/auth/src/main/res/values-ms/strings.xml b/auth/src/main/res/values-ms/strings.xml index e35e22476..55876519c 100755 --- a/auth/src/main/res/values-ms/strings.xml +++ b/auth/src/main/res/values-ms/strings.xml @@ -179,6 +179,5 @@ Kami menghantar e-mel pengesahan ke %1$s - This button is currently disabled because new accounts are not allowed Pengesahan berbilang faktor dilumpuhkan buat masa ini diff --git a/auth/src/main/res/values-nb/strings.xml b/auth/src/main/res/values-nb/strings.xml index 5018c1952..4ea4f79c1 100755 --- a/auth/src/main/res/values-nb/strings.xml +++ b/auth/src/main/res/values-nb/strings.xml @@ -178,6 +178,5 @@ Vi sendte en bekreftelsese-post til %1$s - This button is currently disabled because new accounts are not allowed Flerfaktorautentisering er for øyeblikket deaktivert diff --git a/auth/src/main/res/values-nl/strings.xml b/auth/src/main/res/values-nl/strings.xml index 328497cf5..c71fc63c3 100755 --- a/auth/src/main/res/values-nl/strings.xml +++ b/auth/src/main/res/values-nl/strings.xml @@ -178,6 +178,5 @@ We hebben een verificatie-e-mail verzonden naar %1$s - This button is currently disabled because new accounts are not allowed Multi-factorauthenticatie is momenteel uitgeschakeld diff --git a/auth/src/main/res/values-no/strings.xml b/auth/src/main/res/values-no/strings.xml index 7833bccd9..3e3fe493a 100755 --- a/auth/src/main/res/values-no/strings.xml +++ b/auth/src/main/res/values-no/strings.xml @@ -179,6 +179,5 @@ Vi sendte en bekreftelsese-post til %1$s - This button is currently disabled because new accounts are not allowed Flerfaktorautentisering er for øyeblikket deaktivert diff --git a/auth/src/main/res/values-pl/strings.xml b/auth/src/main/res/values-pl/strings.xml index 9c4bcadbf..8bcb36d6a 100755 --- a/auth/src/main/res/values-pl/strings.xml +++ b/auth/src/main/res/values-pl/strings.xml @@ -178,6 +178,5 @@ Wysłaliśmy e-mail weryfikacyjny na adres %1$s - This button is currently disabled because new accounts are not allowed Uwierzytelnianie wieloskładnikowe jest obecnie wyłączone diff --git a/auth/src/main/res/values-pt-rBR/strings.xml b/auth/src/main/res/values-pt-rBR/strings.xml index f576f884c..38a96c93f 100755 --- a/auth/src/main/res/values-pt-rBR/strings.xml +++ b/auth/src/main/res/values-pt-rBR/strings.xml @@ -197,6 +197,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed A autenticação multifator está atualmente desativada diff --git a/auth/src/main/res/values-pt-rPT/strings.xml b/auth/src/main/res/values-pt-rPT/strings.xml index bc6c216d4..36f1ed714 100755 --- a/auth/src/main/res/values-pt-rPT/strings.xml +++ b/auth/src/main/res/values-pt-rPT/strings.xml @@ -197,6 +197,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed A autenticação multifator está atualmente desativada diff --git a/auth/src/main/res/values-pt/strings.xml b/auth/src/main/res/values-pt/strings.xml index fbd9e7b37..0541291cc 100755 --- a/auth/src/main/res/values-pt/strings.xml +++ b/auth/src/main/res/values-pt/strings.xml @@ -196,6 +196,5 @@ Reautenticar - This button is currently disabled because new accounts are not allowed A autenticação multifator está atualmente desativada diff --git a/auth/src/main/res/values-ro/strings.xml b/auth/src/main/res/values-ro/strings.xml index 1b5b8cafb..bdd7bece4 100755 --- a/auth/src/main/res/values-ro/strings.xml +++ b/auth/src/main/res/values-ro/strings.xml @@ -178,6 +178,5 @@ Am trimis un e-mail de verificare la %1$s - This button is currently disabled because new accounts are not allowed Autentificarea cu mai mulți factori este dezactivată în prezent diff --git a/auth/src/main/res/values-ru/strings.xml b/auth/src/main/res/values-ru/strings.xml index e2d654da9..c68934e03 100755 --- a/auth/src/main/res/values-ru/strings.xml +++ b/auth/src/main/res/values-ru/strings.xml @@ -178,6 +178,5 @@ Мы отправили письмо подтверждения на %1$s - This button is currently disabled because new accounts are not allowed Многофакторная аутентификация в настоящее время отключена diff --git a/auth/src/main/res/values-sk/strings.xml b/auth/src/main/res/values-sk/strings.xml index 49c93e6e1..52f1b1845 100755 --- a/auth/src/main/res/values-sk/strings.xml +++ b/auth/src/main/res/values-sk/strings.xml @@ -178,6 +178,5 @@ Poslali sme overovací e-mail na adresu %1$s - This button is currently disabled because new accounts are not allowed Viacfaktorové overovanie je momentálne zakázané diff --git a/auth/src/main/res/values-sl/strings.xml b/auth/src/main/res/values-sl/strings.xml index be839d41b..fc81a8e4a 100755 --- a/auth/src/main/res/values-sl/strings.xml +++ b/auth/src/main/res/values-sl/strings.xml @@ -179,6 +179,5 @@ Poslali smo e-sporočilo za preverjanje na %1$s - This button is currently disabled because new accounts are not allowed Večfaktorska avtentikacija je trenutno onemogočena diff --git a/auth/src/main/res/values-sr/strings.xml b/auth/src/main/res/values-sr/strings.xml index be4fd78b2..24ecfc208 100755 --- a/auth/src/main/res/values-sr/strings.xml +++ b/auth/src/main/res/values-sr/strings.xml @@ -179,6 +179,5 @@ Послали смо имејл за верификацију на %1$s - This button is currently disabled because new accounts are not allowed Вишефакторска аутентификација је тренутно онемогућена diff --git a/auth/src/main/res/values-sv/strings.xml b/auth/src/main/res/values-sv/strings.xml index 616d43dd2..b32888d05 100755 --- a/auth/src/main/res/values-sv/strings.xml +++ b/auth/src/main/res/values-sv/strings.xml @@ -178,6 +178,5 @@ Vi har skickat ett verifieringsmail till %1$s - This button is currently disabled because new accounts are not allowed Multifaktorautentisering är för närvarande inaktiverad diff --git a/auth/src/main/res/values-ta/strings.xml b/auth/src/main/res/values-ta/strings.xml index 378ced01f..c81b3054c 100755 --- a/auth/src/main/res/values-ta/strings.xml +++ b/auth/src/main/res/values-ta/strings.xml @@ -179,6 +179,5 @@ %1$s க்கு சரிபார்ப்பு மின்னஞ்சலை அனுப்பியுள்ளோம் - This button is currently disabled because new accounts are not allowed பல-காரணி அங்கீகாரம் தற்போது முடக்கப்பட்டுள்ளது diff --git a/auth/src/main/res/values-th/strings.xml b/auth/src/main/res/values-th/strings.xml index 859b5fc36..ec9196f9f 100755 --- a/auth/src/main/res/values-th/strings.xml +++ b/auth/src/main/res/values-th/strings.xml @@ -179,6 +179,5 @@ เราส่งอีเมลยืนยันไปที่ %1$s แล้ว - This button is currently disabled because new accounts are not allowed การรับรองความถูกต้องแบบหลายปัจจัยถูกปิดใช้งานในขณะนี้ diff --git a/auth/src/main/res/values-tl/strings.xml b/auth/src/main/res/values-tl/strings.xml index e5f5b11e5..ccd438d35 100755 --- a/auth/src/main/res/values-tl/strings.xml +++ b/auth/src/main/res/values-tl/strings.xml @@ -178,6 +178,5 @@ Nagpadala kami ng verification email sa %1$s - This button is currently disabled because new accounts are not allowed Kasalukuyang naka-disable ang multi-factor authentication diff --git a/auth/src/main/res/values-tr/strings.xml b/auth/src/main/res/values-tr/strings.xml index 7885d361e..bbefc6917 100755 --- a/auth/src/main/res/values-tr/strings.xml +++ b/auth/src/main/res/values-tr/strings.xml @@ -179,6 +179,5 @@ %1$s adresine bir doğrulama e-postası gönderdik - This button is currently disabled because new accounts are not allowed Çok faktörlü kimlik doğrulama şu anda devre dışı diff --git a/auth/src/main/res/values-uk/strings.xml b/auth/src/main/res/values-uk/strings.xml index 5c99137d3..1fad8a98d 100755 --- a/auth/src/main/res/values-uk/strings.xml +++ b/auth/src/main/res/values-uk/strings.xml @@ -179,6 +179,5 @@ Ми надіслали лист підтвердження на %1$s - This button is currently disabled because new accounts are not allowed Багатофакторна автентифікація наразі вимкнена diff --git a/auth/src/main/res/values-ur/strings.xml b/auth/src/main/res/values-ur/strings.xml index f73956b29..1394f2c1f 100755 --- a/auth/src/main/res/values-ur/strings.xml +++ b/auth/src/main/res/values-ur/strings.xml @@ -179,6 +179,5 @@ ہم نے %1$s کو تصدیقی ای میل بھیجی - This button is currently disabled because new accounts are not allowed ملٹی فیکٹر تصدیق فی الحال غیر فعال ہے diff --git a/auth/src/main/res/values-vi/strings.xml b/auth/src/main/res/values-vi/strings.xml index e77e08e6d..53266567b 100755 --- a/auth/src/main/res/values-vi/strings.xml +++ b/auth/src/main/res/values-vi/strings.xml @@ -179,6 +179,5 @@ Chúng tôi đã gửi email xác minh đến %1$s - This button is currently disabled because new accounts are not allowed Xác thực đa yếu tố hiện đang bị vô hiệu hóa diff --git a/auth/src/main/res/values-zh-rCN/strings.xml b/auth/src/main/res/values-zh-rCN/strings.xml index a4b5e61ff..61721caa5 100755 --- a/auth/src/main/res/values-zh-rCN/strings.xml +++ b/auth/src/main/res/values-zh-rCN/strings.xml @@ -179,6 +179,5 @@ 我们已向 %1$s 发送了验证邮件 - This button is currently disabled because new accounts are not allowed 多重身份验证当前已禁用 diff --git a/auth/src/main/res/values-zh-rHK/strings.xml b/auth/src/main/res/values-zh-rHK/strings.xml index f002ae7b6..3cd99ad3b 100755 --- a/auth/src/main/res/values-zh-rHK/strings.xml +++ b/auth/src/main/res/values-zh-rHK/strings.xml @@ -179,6 +179,5 @@ 我們已向 %1$s 發送了驗證電郵 - This button is currently disabled because new accounts are not allowed 多重身份验证当前已禁用 diff --git a/auth/src/main/res/values-zh-rTW/strings.xml b/auth/src/main/res/values-zh-rTW/strings.xml index 094052f93..fd247fb40 100755 --- a/auth/src/main/res/values-zh-rTW/strings.xml +++ b/auth/src/main/res/values-zh-rTW/strings.xml @@ -179,6 +179,5 @@ 我們已傳送驗證郵件至 %1$s - This button is currently disabled because new accounts are not allowed 多重身份验证当前已禁用 diff --git a/auth/src/main/res/values-zh/strings.xml b/auth/src/main/res/values-zh/strings.xml index 55f8cb92b..a1f514f8f 100755 --- a/auth/src/main/res/values-zh/strings.xml +++ b/auth/src/main/res/values-zh/strings.xml @@ -178,6 +178,5 @@ 我们已向 %1$s 发送了验证邮件 - This button is currently disabled because new accounts are not allowed 多重身份验证当前已禁用 diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index ad2e50279..6217412de 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -284,6 +284,5 @@ An error occurred during enrollment. Please try again. - This button is currently disabled because new accounts are not allowed Multi-factor authentication is currently disabled diff --git a/auth/src/test/java/com/firebase/ui/auth/AuthFlowControllerTest.kt b/auth/src/test/java/com/firebase/ui/auth/AuthFlowControllerTest.kt index 061422d83..6824f05e2 100644 --- a/auth/src/test/java/com/firebase/ui/auth/AuthFlowControllerTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/AuthFlowControllerTest.kt @@ -201,7 +201,7 @@ class AuthFlowControllerTest { // ============================================================================================= @Test - fun `cancel() updates state to Cancelled`() = runTest { + fun `cancel() updates state to Aborted`() = runTest { val controller = authUI.createAuthFlow(configuration) // Cancel the flow @@ -213,8 +213,7 @@ class AuthFlowControllerTest { // Collect first state after cancel val state = controller.authStateFlow.first() - // Should be Cancelled state - assertThat(state).isInstanceOf(AuthState.Cancelled::class.java) + assertThat(state).isInstanceOf(AuthState.Aborted::class.java) } @Test @@ -438,9 +437,8 @@ class AuthFlowControllerTest { // Advance test scheduler to process all pending coroutines testScheduler.advanceUntilIdle() - // Verify cancelled state val state = controller.authStateFlow.first() - assertThat(state).isInstanceOf(AuthState.Cancelled::class.java) + assertThat(state).isInstanceOf(AuthState.Aborted::class.java) // Dispose controller.dispose() diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt index 5b2ee24b0..b8c05b415 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthActivityTest.kt @@ -313,18 +313,17 @@ class FirebaseAuthActivityTest { } // ============================================================================================= - // Auth State Cancelled Tests + // Auth State Aborted Tests // ============================================================================================= @Test - fun `activity finishes with RESULT_CANCELED on Cancelled state`() = runTest { + fun `activity finishes with RESULT_CANCELED on Aborted state`() = runTest { val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) val activity = controller.create().start().resume().get() - // Update to Cancelled state - authUI.updateAuthState(AuthState.Cancelled) + authUI.updateAuthState(AuthState.Aborted) shadowOf(Looper.getMainLooper()).idle() @@ -336,6 +335,44 @@ class FirebaseAuthActivityTest { assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_CANCELED) } + @Test + fun `Aborted state resets to Idle so a later flow on the same authUI does not immediately finish`() = runTest { + val firstIntent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val firstController = Robolectric.buildActivity(FirebaseAuthActivity::class.java, firstIntent) + val firstActivity = firstController.create().start().resume().get() + + authUI.updateAuthState(AuthState.Aborted) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(firstActivity.isFinishing).isTrue() + + val secondIntent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val secondController = Robolectric.buildActivity(FirebaseAuthActivity::class.java, secondIntent) + val secondActivity = secondController.create().start().resume().get() + + shadowOf(Looper.getMainLooper()).idle() + + assertThat(secondActivity.isFinishing).isFalse() + } + + // ============================================================================================= + // Auth State Cancelled Tests + // ============================================================================================= + + @Test + fun `activity does not finish on Cancelled state`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + authUI.updateAuthState(AuthState.Cancelled) + + shadowOf(Looper.getMainLooper()).idle() + + assertThat(activity.isFinishing).isFalse() + } + // ============================================================================================= // Auth State Error Tests // ============================================================================================= @@ -559,6 +596,27 @@ class FirebaseAuthActivityTest { assertThat(activity.isFinishing).isFalse() } + @Test + fun `activity does not finish when MFA challenge is cancelled`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + authUI.updateAuthState(AuthState.RequiresMfa( + resolver = mockMultiFactorResolver, + hint = "Enter verification code" + )) + + shadowOf(Looper.getMainLooper()).idle() + + authUI.updateAuthState(AuthState.Cancelled) + + shadowOf(Looper.getMainLooper()).idle() + + assertThat(activity.isFinishing).isFalse() + } + @Test fun `activity continues showing UI on RequiresEmailVerification state`() = runTest { val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt index 8a4715c97..78e7f0dd3 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt @@ -318,6 +318,53 @@ class FirebaseAuthUIAuthStateTest { assertThat(states[2]).isEqualTo(AuthState.Cancelled) // After second update } + // ============================================================================================= + // Stale one-off AuthState regression tests + // ============================================================================================= + + @Test + fun `Error does not leak to a fresh collector after being consumed`() = runBlocking { + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + authUI.updateAuthState(AuthState.Error(Exception("boom"))) + authUI.updateAuthState(AuthState.Idle) + + // A brand-new collector (simulating a freshly created Activity) must see Idle. + assertThat(authUI.authStateFlow().first()).isEqualTo(AuthState.Idle) + } + + @Test + fun `Cancelled does not leak to a fresh collector after being consumed`() = runBlocking { + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + authUI.updateAuthState(AuthState.Cancelled) + authUI.updateAuthState(AuthState.Idle) + + assertThat(authUI.authStateFlow().first()).isEqualTo(AuthState.Idle) + } + + @Test + fun `SMSAutoVerified does not leak to a fresh collector after being consumed`() = runBlocking { + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + val credential = mock(com.google.firebase.auth.PhoneAuthCredential::class.java) + + authUI.updateAuthState(AuthState.SMSAutoVerified(credential)) + authUI.updateAuthState(AuthState.Idle) + + assertThat(authUI.authStateFlow().first()).isEqualTo(AuthState.Idle) + } + + @Test + fun `Error left uncleared still leaks to a fresh collector (pins down the bug being fixed)`() = + runBlocking { + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + + // No consuming reset here — documents the pre-fix leaking behavior. + authUI.updateAuthState(AuthState.Error(Exception("boom"))) + + assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.Error::class.java) + } + // ============================================================================================= // AuthState Class Tests // ============================================================================================= diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt index 6e71c4170..18f396f9b 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt @@ -15,6 +15,7 @@ package com.firebase.ui.auth.configuration.auth_provider import android.content.Context +import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState @@ -38,6 +39,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers @@ -54,6 +56,9 @@ import org.robolectric.annotation.Config @Config(manifest = Config.NONE) class AnonymousAuthProviderFirebaseAuthUITest { + @get:Rule + val composeTestRule = createComposeRule() + @Mock private lateinit var mockFirebaseAuth: FirebaseAuth @@ -214,6 +219,35 @@ class AnonymousAuthProviderFirebaseAuthUITest { assertThat(errorState.exception).isInstanceOf(AuthException.UnknownException::class.java) } + // ============================================================================================= + // rememberAnonymousSignInHandler - onSignInFailure reporting + // ============================================================================================= + + @Test + fun `rememberAnonymousSignInHandler reports failure via onSignInFailure immediately, at the source`() { + val networkException = FirebaseNetworkException("Network error") + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(networkException) + `when`(mockFirebaseAuth.signInAnonymously()).thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val reportedFailures = mutableListOf() + var launcher: (() -> Unit)? = null + + composeTestRule.setContent { + launcher = instance.rememberAnonymousSignInHandler( + config = config, + onSignInFailure = { reportedFailures.add(it) }, + ) + } + + composeTestRule.runOnIdle { launcher?.invoke() } + composeTestRule.waitForIdle() + + assertThat(reportedFailures).hasSize(1) + assertThat(reportedFailures.single()).isInstanceOf(AuthException.NetworkException::class.java) + } + // ============================================================================================= // Anonymous Account Upgrade Tests // ============================================================================================= diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt index 81f94b161..ce65580d5 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt @@ -15,8 +15,10 @@ package com.firebase.ui.auth.configuration.auth_provider import android.content.Context +import androidx.compose.ui.test.junit4.createComposeRule import androidx.core.net.toUri import androidx.credentials.CredentialManager +import androidx.credentials.exceptions.GetCredentialCancellationException import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState @@ -37,6 +39,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest 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 @@ -47,7 +50,9 @@ import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -67,6 +72,9 @@ import org.robolectric.annotation.Config @Config(manifest = Config.NONE) class GoogleAuthProviderFirebaseAuthUITest { + @get:Rule + val composeTestRule = createComposeRule() + @Mock private lateinit var mockFirebaseAuth: FirebaseAuth @@ -539,6 +547,46 @@ class GoogleAuthProviderFirebaseAuthUITest { assertThat(errorState.exception).isInstanceOf(AuthException.AuthCancelledException::class.java) } + @Test + fun `Sign in with Google when Credential Manager sheet is dismissed should update state to Cancelled without throwing`() = runTest { + // GetCredentialCancellationException is a checked exception, so it must be stubbed via + // doAnswer rather than thenThrow (which validates against the method's declared throws). + doAnswer { throw GetCredentialCancellationException("User cancelled the selector") } + .whenever(mockCredentialManagerProvider) + .getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(googleProvider) + } + } + + // Should not throw - user cancellation is not an error + instance.signInWithGoogle( + context = applicationContext, + config = config, + provider = googleProvider, + authorizationProvider = mockAuthorizationProvider, + credentialManagerProvider = mockCredentialManagerProvider + ) + + // Verify state is Cancelled, not Error + val finalState = instance.authStateFlow().first() + assertThat(finalState).isEqualTo(AuthState.Cancelled) + } + // ============================================================================================= // signInWithGoogle - Anonymous Upgrade // ============================================================================================= @@ -920,4 +968,105 @@ class GoogleAuthProviderFirebaseAuthUITest { val finalState = instance.authStateFlow().first { it !is AuthState.Loading } assertThat(finalState).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false)) } + + // ============================================================================================= + // rememberGoogleSignInHandler - onSignInFailure reporting + // ============================================================================================= + + @Test + fun `rememberGoogleSignInHandler reports failure via onSignInFailure immediately, at the source`() { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(googleProvider) + } + } + + // A picker-level failure that used to never reach onSignInFailure at all: + // the outer fallback throws AuthException.UnknownException when no Google accounts are found. + instance.testCredentialManagerProvider = object : AuthProvider.Google.CredentialManagerProvider { + override suspend fun getGoogleCredential( + context: Context, + credentialManager: CredentialManager, + serverClientId: String, + filterByAuthorizedAccounts: Boolean, + autoSelectEnabled: Boolean + ): AuthProvider.Google.GoogleSignInResult { + throw AuthException.UnknownException( + "No Google accounts available.\n\nPlease add a Google account to your device and try again." + ) + } + + override suspend fun clearCredentialState(context: Context, credentialManager: CredentialManager) = Unit + } + + val reportedFailures = mutableListOf() + var launcher: (() -> Unit)? = null + + composeTestRule.setContent { + launcher = instance.rememberGoogleSignInHandler( + context = applicationContext, + config = config, + provider = googleProvider, + onSignInFailure = { reportedFailures.add(it) }, + ) + } + + composeTestRule.runOnIdle { launcher?.invoke() } + composeTestRule.waitForIdle() + + assertThat(reportedFailures).hasSize(1) + assertThat(reportedFailures.single()).isInstanceOf(AuthException.UnknownException::class.java) + } + + @Test + fun `rememberGoogleSignInHandler does not report onSignInFailure for user cancellation`() { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(googleProvider) + } + } + + instance.testCredentialManagerProvider = object : AuthProvider.Google.CredentialManagerProvider { + override suspend fun getGoogleCredential( + context: Context, + credentialManager: CredentialManager, + serverClientId: String, + filterByAuthorizedAccounts: Boolean, + autoSelectEnabled: Boolean + ): AuthProvider.Google.GoogleSignInResult { + throw CancellationException("User cancelled") + } + + override suspend fun clearCredentialState(context: Context, credentialManager: CredentialManager) = Unit + } + + val reportedFailures = mutableListOf() + var launcher: (() -> Unit)? = null + + composeTestRule.setContent { + launcher = instance.rememberGoogleSignInHandler( + context = applicationContext, + config = config, + provider = googleProvider, + onSignInFailure = { reportedFailures.add(it) }, + ) + } + + composeTestRule.runOnIdle { launcher?.invoke() } + composeTestRule.waitForIdle() + + assertThat(reportedFailures).isEmpty() + } } diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt index 672e0c11d..893f34540 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt @@ -16,6 +16,7 @@ package com.firebase.ui.auth.configuration.auth_provider import android.app.Activity import android.content.Context +import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState @@ -30,6 +31,7 @@ import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuthUserCollisionException +import com.google.firebase.auth.FirebaseAuthWebException import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.OAuthCredential import com.google.firebase.auth.OAuthProvider @@ -38,6 +40,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest 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 @@ -60,6 +63,9 @@ import org.robolectric.annotation.Config @Config(sdk = [34], manifest = Config.NONE) class OAuthProviderFirebaseAuthUITest { + @get:Rule + val composeTestRule = createComposeRule() + @Mock private lateinit var mockFirebaseAuth: FirebaseAuth @@ -290,4 +296,43 @@ class OAuthProviderFirebaseAuthUITest { val errorState = finalState as AuthState.Error assertThat(errorState.exception).isInstanceOf(AuthException.AuthCancelledException::class.java) } + + @Test + fun `rememberOAuthSignInHandler does not report onSignInFailure when the web context is cancelled`() { + `when`(mockFirebaseAuth.pendingAuthResult).thenReturn(null) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException( + FirebaseAuthWebException("ERROR_WEB_CONTEXT_CANCELED", "The web operation was canceled") + ) + `when`(mockFirebaseAuth.startActivityForSignInWithProvider(any(), any())) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val microsoftProvider = AuthProvider.Microsoft(tenant = null, customParameters = emptyMap()) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(microsoftProvider) + } + } + + val reportedFailures = mutableListOf() + var launcher: (() -> Unit)? = null + + composeTestRule.setContent { + launcher = instance.rememberOAuthSignInHandler( + context = applicationContext, + activity = mockActivity, + config = config, + provider = microsoftProvider, + onSignInFailure = { reportedFailures.add(it) }, + ) + } + + composeTestRule.runOnIdle { launcher?.invoke() } + composeTestRule.waitForIdle() + + assertThat(reportedFailures).isEmpty() + } } diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthDefaultVerifierTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthDefaultVerifierTest.kt new file mode 100644 index 000000000..fbbb1cb9c --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthDefaultVerifierTest.kt @@ -0,0 +1,347 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.auth_provider + +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Phone.VerifyPhoneNumberResult +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseException +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthOptions +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.PhoneAuthProvider.OnVerificationStateChangedCallbacks +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.MockedStatic +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class PhoneAuthDefaultVerifierTest { + + private val verifier = AuthProvider.Phone.DefaultVerifier() + private val mockAuth = mock(FirebaseAuth::class.java) + + /** + * Records everything one collection of the verifier's flow observed: every emission in order, + * plus the exception that terminated it (if any). + */ + private class Verification { + val emissions = mutableListOf() + var terminal: Throwable? = null + lateinit var callbacks: OnVerificationStateChangedCallbacks + lateinit var job: Job + } + + /** + * Looks up the callbacks stashed inside [PhoneAuthOptions]. There's no public accessor - + * only an obfuscated zero-arg method whose return type is + * [PhoneAuthProvider.OnVerificationStateChangedCallbacks]. We locate it reflectively and + * assert exactly one such method exists, so this test breaks loudly (rather than silently) + * if a future SDK bump changes the obfuscated shape. + */ + private fun extractCallbacks(options: PhoneAuthOptions): OnVerificationStateChangedCallbacks { + val candidates = PhoneAuthOptions::class.java.declaredMethods.filter { + it.parameterCount == 0 && + it.returnType == OnVerificationStateChangedCallbacks::class.java + } + check(candidates.size == 1) { + "Expected exactly one zero-arg accessor returning " + + "OnVerificationStateChangedCallbacks on PhoneAuthOptions, found " + + "${candidates.size}: $candidates" + } + val method = candidates.single() + method.isAccessible = true + return method.invoke(options) as OnVerificationStateChangedCallbacks + } + + // UNDISPATCHED so the callbackFlow builder runs (and registers with Firebase) before we return. + private fun TestScope.startVerification( + mockedStatic: MockedStatic + ): Verification { + val verification = Verification() + verification.job = backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + try { + verifier.verifyPhoneNumber( + auth = mockAuth, + activity = null, + phoneNumber = "+15555550123", + timeout = 60L, + forceResendingToken = null, + multiFactorSession = null, + isInstantVerificationEnabled = true + ).collect { verification.emissions += it } + } catch (e: FirebaseException) { + verification.terminal = e + } + } + + val captor = ArgumentCaptor.forClass(PhoneAuthOptions::class.java) + mockedStatic.verify { PhoneAuthProvider.verifyPhoneNumber(captor.capture()) } + verification.callbacks = extractCallbacks(captor.value) + + return verification + } + + private fun autoVerified(result: VerifyPhoneNumberResult): PhoneAuthCredential { + assertThat(result).isInstanceOf(VerifyPhoneNumberResult.AutoVerified::class.java) + return (result as VerifyPhoneNumberResult.AutoVerified).credential + } + + private fun manual( + result: VerifyPhoneNumberResult + ): VerifyPhoneNumberResult.NeedsManualVerification { + assertThat(result) + .isInstanceOf(VerifyPhoneNumberResult.NeedsManualVerification::class.java) + return result as VerifyPhoneNumberResult.NeedsManualVerification + } + + // ============================================================================================= + // Single callbacks - one callback in, one emission out. + // ============================================================================================= + + @Test + fun `onVerificationCompleted emits AutoVerified`() = + runTest(UnconfinedTestDispatcher()) { + mockStatic(PhoneAuthProvider::class.java).use { mockedStatic -> + val verification = startVerification(mockedStatic) + val credential = mock(PhoneAuthCredential::class.java) + + verification.callbacks.onVerificationCompleted(credential) + runCurrent() + + assertThat(verification.emissions).hasSize(1) + assertThat(autoVerified(verification.emissions[0])).isEqualTo(credential) + assertThat(verification.terminal).isNull() + } + } + + @Test + fun `onCodeSent emits NeedsManualVerification`() = + runTest(UnconfinedTestDispatcher()) { + mockStatic(PhoneAuthProvider::class.java).use { mockedStatic -> + val verification = startVerification(mockedStatic) + val token = mock(PhoneAuthProvider.ForceResendingToken::class.java) + + verification.callbacks.onCodeSent("verification-id", token) + runCurrent() + + assertThat(verification.emissions).hasSize(1) + val emitted = manual(verification.emissions[0]) + assertThat(emitted.verificationId).isEqualTo("verification-id") + assertThat(emitted.token).isEqualTo(token) + assertThat(verification.terminal).isNull() + } + } + + @Test + fun `onVerificationFailed terminates the flow with the exception`() = + runTest(UnconfinedTestDispatcher()) { + mockStatic(PhoneAuthProvider::class.java).use { mockedStatic -> + val verification = startVerification(mockedStatic) + + verification.callbacks.onVerificationFailed(FirebaseException("boom")) + runCurrent() + + assertThat(verification.emissions).isEmpty() + // kotlinx.coroutines copies exceptions crossing a channel, so compare type and + // message instead of reference identity. + assertThat(verification.terminal).isInstanceOf(FirebaseException::class.java) + assertThat(verification.terminal?.message).isEqualTo("boom") + assertThat(verification.job.isActive).isFalse() + } + } + + // ============================================================================================= + // Multiple callbacks - every callback becomes an emission; none is dropped. + // ============================================================================================= + + @Test + fun `onCodeSent then onVerificationCompleted both emit - regression for issue 2446`() = + runTest(UnconfinedTestDispatcher()) { + mockStatic(PhoneAuthProvider::class.java).use { mockedStatic -> + val verification = startVerification(mockedStatic) + val token = mock(PhoneAuthProvider.ForceResendingToken::class.java) + val credential = mock(PhoneAuthCredential::class.java) + + // Firebase's SMS auto-retrieval order. Under the old single-shot continuation this + // threw "IllegalStateException: Already resumed" (or silently dropped the + // credential); a flow simply emits twice. + val thrownAtCallbackSite = runCatching { + verification.callbacks.onCodeSent("verification-id", token) + verification.callbacks.onVerificationCompleted(credential) + }.exceptionOrNull() + runCurrent() + + assertThat(thrownAtCallbackSite).isNull() + assertThat(verification.emissions).hasSize(2) + val first = manual(verification.emissions[0]) + assertThat(first.verificationId).isEqualTo("verification-id") + assertThat(first.token).isEqualTo(token) + assertThat(autoVerified(verification.emissions[1])).isEqualTo(credential) + assertThat(verification.terminal).isNull() + } + } + + @Test + fun `onVerificationCompleted then onCodeSent both emit`() = + runTest(UnconfinedTestDispatcher()) { + mockStatic(PhoneAuthProvider::class.java).use { mockedStatic -> + val verification = startVerification(mockedStatic) + val credential = mock(PhoneAuthCredential::class.java) + val token = mock(PhoneAuthProvider.ForceResendingToken::class.java) + + verification.callbacks.onVerificationCompleted(credential) + verification.callbacks.onCodeSent("later-verification-id", token) + runCurrent() + + assertThat(verification.emissions).hasSize(2) + assertThat(autoVerified(verification.emissions[0])).isEqualTo(credential) + val second = manual(verification.emissions[1]) + assertThat(second.verificationId).isEqualTo("later-verification-id") + assertThat(second.token).isEqualTo(token) + assertThat(verification.terminal).isNull() + } + } + + @Test + fun `onCodeSent then onVerificationFailed emits then terminates`() = + runTest(UnconfinedTestDispatcher()) { + mockStatic(PhoneAuthProvider::class.java).use { mockedStatic -> + val verification = startVerification(mockedStatic) + val token = mock(PhoneAuthProvider.ForceResendingToken::class.java) + + verification.callbacks.onCodeSent("verification-id", token) + verification.callbacks.onVerificationFailed(FirebaseException("later failure")) + runCurrent() + + assertThat(verification.emissions).hasSize(1) + assertThat(manual(verification.emissions[0]).verificationId) + .isEqualTo("verification-id") + assertThat(verification.terminal).isInstanceOf(FirebaseException::class.java) + assertThat(verification.terminal?.message).isEqualTo("later failure") + } + } + + @Test + fun `onCodeSent then onCodeAutoRetrievalTimeOut emits then completes`() = + runTest(UnconfinedTestDispatcher()) { + mockStatic(PhoneAuthProvider::class.java).use { mockedStatic -> + val verification = startVerification(mockedStatic) + val token = mock(PhoneAuthProvider.ForceResendingToken::class.java) + + verification.callbacks.onCodeSent("verification-id", token) + verification.callbacks.onCodeAutoRetrievalTimeOut("verification-id") + runCurrent() + + assertThat(verification.emissions).hasSize(1) + assertThat(manual(verification.emissions[0]).verificationId) + .isEqualTo("verification-id") + // Firebase's own terminal, so the collector completes rather than hanging. + assertThat(verification.terminal).isNull() + assertThat(verification.job.isCompleted).isTrue() + assertThat(verification.job.isCancelled).isFalse() + } + } + + @Test + fun `duplicate onVerificationCompleted emits both credentials`() = + runTest(UnconfinedTestDispatcher()) { + mockStatic(PhoneAuthProvider::class.java).use { mockedStatic -> + val verification = startVerification(mockedStatic) + val firstCredential = mock(PhoneAuthCredential::class.java) + val secondCredential = mock(PhoneAuthCredential::class.java) + + verification.callbacks.onVerificationCompleted(firstCredential) + verification.callbacks.onVerificationCompleted(secondCredential) + runCurrent() + + assertThat(verification.emissions).hasSize(2) + assertThat(autoVerified(verification.emissions[0])).isEqualTo(firstCredential) + assertThat(autoVerified(verification.emissions[1])).isEqualTo(secondCredential) + assertThat(verification.terminal).isNull() + } + } + + @Test + fun `duplicate onCodeSent emits both verification ids`() = + runTest(UnconfinedTestDispatcher()) { + mockStatic(PhoneAuthProvider::class.java).use { mockedStatic -> + val verification = startVerification(mockedStatic) + val firstToken = mock(PhoneAuthProvider.ForceResendingToken::class.java) + val secondToken = mock(PhoneAuthProvider.ForceResendingToken::class.java) + + verification.callbacks.onCodeSent("first-verification-id", firstToken) + verification.callbacks.onCodeSent("second-verification-id", secondToken) + runCurrent() + + assertThat(verification.emissions).hasSize(2) + val first = manual(verification.emissions[0]) + assertThat(first.verificationId).isEqualTo("first-verification-id") + assertThat(first.token).isEqualTo(firstToken) + val second = manual(verification.emissions[1]) + assertThat(second.verificationId).isEqualTo("second-verification-id") + assertThat(second.token).isEqualTo(secondToken) + assertThat(verification.terminal).isNull() + } + } + + // ============================================================================================= + // Cancellation - callbacks arriving after the collector went away must be inert. `trySend` on + // a closed channel returns a failed result instead of throwing, which is what replaces the + // old first-callback-wins latch. + // ============================================================================================= + + @Test + fun `callbacks after the collector is cancelled do not throw at the Firebase call site`() = + runTest(UnconfinedTestDispatcher()) { + mockStatic(PhoneAuthProvider::class.java).use { mockedStatic -> + val verification = startVerification(mockedStatic) + + verification.job.cancel() + runCurrent() + assertThat(verification.job.isCancelled).isTrue() + + val thrownAtCallbackSite = runCatching { + verification.callbacks.onCodeSent( + "late-verification-id", + mock(PhoneAuthProvider.ForceResendingToken::class.java) + ) + verification.callbacks.onVerificationCompleted( + mock(PhoneAuthCredential::class.java) + ) + verification.callbacks.onVerificationFailed(FirebaseException("late failure")) + }.exceptionOrNull() + runCurrent() + + assertThat(thrownAtCallbackSite).isNull() + assertThat(verification.emissions).isEmpty() + assertThat(verification.terminal).isNull() + } + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt index 3efe90c5b..18b7a7c22 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt @@ -14,8 +14,10 @@ package com.firebase.ui.auth.configuration.auth_provider +import android.app.Activity import android.content.Context import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.AuthUIConfiguration @@ -27,9 +29,19 @@ import com.google.firebase.FirebaseOptions import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorSession import com.google.firebase.auth.PhoneAuthCredential import com.google.firebase.auth.PhoneAuthProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before @@ -139,7 +151,9 @@ class PhoneAuthProviderFirebaseAuthUITest { multiFactorSession = anyOrNull(), isInstantVerificationEnabled = eq(true) ) - ).thenReturn(AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified(mockCredential)) + ).thenReturn( + flowOf(AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified(mockCredential)) + ) instance.verifyPhoneNumber( provider = phoneProvider, @@ -179,9 +193,11 @@ class PhoneAuthProviderFirebaseAuthUITest { isInstantVerificationEnabled = eq(true) ) ).thenReturn( - AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification( - "test-verification-id", - mockToken + flowOf( + AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification( + "test-verification-id", + mockToken + ) ) ) @@ -201,6 +217,56 @@ class PhoneAuthProviderFirebaseAuthUITest { assertThat(verificationState.forceResendingToken).isEqualTo(mockToken) } + @Test + fun `verifyPhoneNumber - late auto-verification after code sent emits SMSAutoVerified`() = + runTest { + val mockToken = mock(PhoneAuthProvider.ForceResendingToken::class.java) + val mockCredential = mock(PhoneAuthCredential::class.java) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + + // Firebase's SMS auto-retrieval order: the code is sent first, then the credential + // arrives on its own. The late credential must not be dropped. + `when`( + mockPhoneAuthVerifier.verifyPhoneNumber( + auth = any(), + activity = anyOrNull(), + phoneNumber = any(), + timeout = eq(60L), + forceResendingToken = anyOrNull(), + multiFactorSession = anyOrNull(), + isInstantVerificationEnabled = eq(true) + ) + ).thenReturn( + flowOf( + AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification( + "test-verification-id", + mockToken + ), + AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified(mockCredential) + ) + ) + + instance.verifyPhoneNumber( + provider = phoneProvider, + activity = null, + phoneNumber = "+1234567890", + config = phoneConfig, + verifier = mockPhoneAuthVerifier + ) + + val finalState = instance.authStateFlow().first() + assertThat(finalState).isInstanceOf(AuthState.SMSAutoVerified::class.java) + assertThat((finalState as AuthState.SMSAutoVerified).credential) + .isEqualTo(mockCredential) + } + @Test fun `verifyPhoneNumber - with forceResendingToken resends code`() = runTest { val mockToken = mock(PhoneAuthProvider.ForceResendingToken::class.java) @@ -225,9 +291,11 @@ class PhoneAuthProviderFirebaseAuthUITest { isInstantVerificationEnabled = eq(true) ) ).thenReturn( - AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification( - "new-verification-id", - newMockToken + flowOf( + AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification( + "new-verification-id", + newMockToken + ) ) ) @@ -270,9 +338,11 @@ class PhoneAuthProviderFirebaseAuthUITest { isInstantVerificationEnabled = eq(false) ) ).thenReturn( - AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification( - "test-id", - mock() + flowOf( + AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification( + "test-id", + mock() + ) ) ) @@ -295,6 +365,172 @@ class PhoneAuthProviderFirebaseAuthUITest { ) } + @Test + fun `verifyPhoneNumber - cancellation propagates CancellationException and emits no Error`() = + runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + val cancellingVerifier = object : AuthProvider.Phone.Verifier { + override fun verifyPhoneNumber( + auth: FirebaseAuth, + activity: Activity?, + phoneNumber: String, + timeout: Long, + forceResendingToken: PhoneAuthProvider.ForceResendingToken?, + multiFactorSession: MultiFactorSession?, + isInstantVerificationEnabled: Boolean, + ): Flow = flow { + throw CancellationException("Verification cancelled") + } + } + + var thrown: Throwable? = null + try { + instance.verifyPhoneNumber( + provider = phoneProvider, + activity = null, + phoneNumber = "+1234567890", + config = phoneConfig, + verifier = cancellingVerifier + ) + } catch (t: Throwable) { + thrown = t + } + + // The screen cancels this collection as routine bookkeeping, so the cancellation must + // travel back untranslated and leave nothing behind in authStateFlow. + assertThat(thrown).isInstanceOf(CancellationException::class.java) + assertThat(thrown).isNotInstanceOf(AuthException::class.java) + assertThat(instance.authStateFlow().first()) + .isNotInstanceOf(AuthState.Error::class.java) + } + + @Test + fun `verifyPhoneNumber - cancellation clears the pending Loading state`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val deferred = startNeverResolvingVerifyPhoneNumber(instance) + + deferred.cancel() + try { + deferred.await() + } catch (_: CancellationException) { + // Expected + } + + val state = instance.authStateFlow().first() + assertThat(state).isNotInstanceOf(AuthState.Loading::class.java) + assertThat(state).isInstanceOf(AuthState.Idle::class.java) + } + + @Test + fun `verifyPhoneNumber - cancellation does not clobber a newer unrelated state`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val deferred = startNeverResolvingVerifyPhoneNumber(instance) + + // A newer, unrelated state lands while the verification is still in flight. + instance.updateAuthState(AuthState.PasswordResetLinkSent()) + + deferred.cancel() + try { + deferred.await() + } catch (_: CancellationException) { + // Expected + } + + val state = instance.authStateFlow().first() + assertThat(state).isInstanceOf(AuthState.PasswordResetLinkSent::class.java) + } + + @Test + fun `verifyPhoneNumber - cancellation does not clear a newer Loading from a resend`() = + runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val first = startNeverResolvingVerifyPhoneNumber(instance) + + // A resend starts while the first call is still in flight and emits its own Loading, + // which carries the same message and so compares equal to the first one. + val resend = startNeverResolvingVerifyPhoneNumber(instance) + + first.cancel() + try { + first.await() + } catch (_: CancellationException) { + // Expected + } + + val state = instance.authStateFlow().first() + assertThat(state).isInstanceOf(AuthState.Loading::class.java) + + resend.cancel() + } + + @Test + fun `clearLoadingState - equal but distinct Loading instances do not clear each other`() = + runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val first = AuthState.Loading("verifying") + val second = AuthState.Loading("verifying") + assertThat(first).isEqualTo(second) + + instance.updateAuthState(first) + val firstRevision = instance.currentAuthStateRevision() + instance.updateAuthState(second) + instance.clearLoadingState(firstRevision) + + assertThat(instance.authStateFlow().first()).isInstanceOf(AuthState.Loading::class.java) + } + + @Test + fun `clearLoadingState - clears when its Loading is still the latest state`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + instance.updateAuthState(AuthState.Loading("verifying")) + + instance.clearLoadingState(instance.currentAuthStateRevision()) + + assertThat(instance.authStateFlow().first()).isInstanceOf(AuthState.Idle::class.java) + } + + // Starts verifyPhoneNumber against a flow that never emits, UNDISPATCHED so the call reaches + // its suspension point (past the Loading emission) before the caller can cancel it. + private fun CoroutineScope.startNeverResolvingVerifyPhoneNumber( + instance: FirebaseAuthUI, + ): Deferred { + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + val neverResolvingVerifier = object : AuthProvider.Phone.Verifier { + override fun verifyPhoneNumber( + auth: FirebaseAuth, + activity: Activity?, + phoneNumber: String, + timeout: Long, + forceResendingToken: PhoneAuthProvider.ForceResendingToken?, + multiFactorSession: MultiFactorSession?, + isInstantVerificationEnabled: Boolean, + ): Flow = flow { awaitCancellation() } + } + + return async(start = CoroutineStart.UNDISPATCHED) { + instance.verifyPhoneNumber( + provider = phoneProvider, + activity = null, + phoneNumber = "+1234567890", + config = phoneConfig, + verifier = neverResolvingVerifier + ) + } + } + // ============================================================================================= // submitVerificationCode Tests // ============================================================================================= diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialogLogicTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialogLogicTest.kt index 05d86eecd..c095131a1 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialogLogicTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialogLogicTest.kt @@ -185,9 +185,10 @@ class ErrorRecoveryDialogLogicTest { } @Test - fun `getRecoveryActionText returns continue for AuthCancelledException`() { - // Arrange - val error = AuthException.AuthCancelledException("Auth cancelled") + fun `getRecoveryActionText returns continue for AuthCancelledException, ignoring the raw exception message`() { + // Arrange - message mirrors the raw GetCredentialCancellationException message from GH #2422; + // the action label must never surface this raw string to the user + val error = AuthException.AuthCancelledException("User cancelled the selector") // Act val actionText = getRecoveryActionText(error, mockStringProvider) @@ -209,15 +210,15 @@ class ErrorRecoveryDialogLogicTest { } @Test - fun `getRecoveryActionText returns continue for AccountLinkingRequiredException`() { - // Arrange + fun `getRecoveryActionText returns sign in for AccountLinkingRequiredException`() { + // Arrange - user needs to sign in with the existing method to link accounts val error = AuthException.AccountLinkingRequiredException("Account linking required") // Act val actionText = getRecoveryActionText(error, mockStringProvider) // Assert - Truth.assertThat(actionText).isEqualTo("Continue") + Truth.assertThat(actionText).isEqualTo("Sign in") } @Test @@ -308,75 +309,4 @@ class ErrorRecoveryDialogLogicTest { // Act & Assert Truth.assertThat(isRecoverable(error)).isTrue() } - - // Helper functions to test the private functions - we need to make them internal for testing - private fun getRecoveryMessage(error: AuthException, stringProvider: AuthUIStringProvider): String { - return when (error) { - is AuthException.NetworkException -> stringProvider.networkErrorRecoveryMessage - is AuthException.InvalidCredentialsException -> { - // Use the actual error message from Firebase if available, otherwise fallback to generic message - error.message?.takeIf { it.isNotBlank() && it != "Invalid credentials provided" } - ?: stringProvider.invalidCredentialsRecoveryMessage - } - is AuthException.UserNotFoundException -> stringProvider.userNotFoundRecoveryMessage - is AuthException.WeakPasswordException -> { - val baseMessage = stringProvider.weakPasswordRecoveryMessage - error.reason?.let { reason -> - "$baseMessage\n\nReason: $reason" - } ?: baseMessage - } - is AuthException.EmailAlreadyInUseException -> { - val baseMessage = stringProvider.emailAlreadyInUseRecoveryMessage - error.email?.let { email -> - "$baseMessage ($email)" - } ?: baseMessage - } - is AuthException.TooManyRequestsException -> stringProvider.tooManyRequestsRecoveryMessage - is AuthException.MfaRequiredException -> stringProvider.mfaRequiredRecoveryMessage - is AuthException.AccountLinkingRequiredException -> stringProvider.accountLinkingRequiredRecoveryMessage - is AuthException.DifferentSignInMethodRequiredException -> - error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage - is AuthException.AuthCancelledException -> stringProvider.authCancelledRecoveryMessage - is AuthException.UnknownException -> stringProvider.unknownErrorRecoveryMessage - else -> stringProvider.unknownErrorRecoveryMessage - } - } - - private fun getRecoveryActionText(error: AuthException, stringProvider: AuthUIStringProvider): String { - return when (error) { - is AuthException.AuthCancelledException -> stringProvider.continueText - is AuthException.EmailAlreadyInUseException -> stringProvider.signInDefault - is AuthException.AccountLinkingRequiredException -> stringProvider.continueText - is AuthException.DifferentSignInMethodRequiredException -> when (error.suggestedSignInMethod) { - GoogleAuthProvider.PROVIDER_ID -> stringProvider.continueWithGoogle - EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD -> stringProvider.signInWithEmailLink - else -> stringProvider.continueText - } - is AuthException.MfaRequiredException -> stringProvider.continueText - is AuthException.NetworkException, - is AuthException.InvalidCredentialsException, - is AuthException.UserNotFoundException, - is AuthException.WeakPasswordException, - is AuthException.TooManyRequestsException, - is AuthException.UnknownException -> stringProvider.retryAction - else -> stringProvider.retryAction - } - } - - private fun isRecoverable(error: AuthException): Boolean { - return when (error) { - is AuthException.NetworkException -> true - is AuthException.InvalidCredentialsException -> true - is AuthException.UserNotFoundException -> true - is AuthException.WeakPasswordException -> true - is AuthException.EmailAlreadyInUseException -> true - is AuthException.TooManyRequestsException -> false - is AuthException.MfaRequiredException -> true - is AuthException.AccountLinkingRequiredException -> true - is AuthException.DifferentSignInMethodRequiredException -> true - is AuthException.AuthCancelledException -> true - is AuthException.UnknownException -> true - else -> true - } - } } diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/components/TopLevelDialogControllerTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/components/TopLevelDialogControllerTest.kt new file mode 100644 index 000000000..60fa0ba40 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/components/TopLevelDialogControllerTest.kt @@ -0,0 +1,153 @@ +package com.firebase.ui.auth.ui.components + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [TopLevelDialogController] and [rememberTopLevelDialogController]. + * + * These cover the fix for a bug where keying `remember` on the live `authState` value recreated + * the controller (and wiped its `shownErrorStates` de-duplication set) on every state change — + * which, combined with screens resetting `AuthState` back to `Idle` immediately after consuming + * an `Error`, would tear down and discard the just-shown dialog on the very next recomposition. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class TopLevelDialogControllerTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var stringProvider: DefaultAuthUIStringProvider + + @Test + fun `controller survives an authState change instead of being recreated`() { + stringProvider = DefaultAuthUIStringProvider(ApplicationProvider.getApplicationContext()) + var state: AuthState = AuthState.Idle + lateinit var controller: TopLevelDialogController + + composeTestRule.setContent { + controller = rememberTopLevelDialogController(stringProvider) { state } + controller.CurrentDialog() + } + + val error = AuthState.Error(Exception("boom")) + composeTestRule.runOnIdle { + state = error + controller.showErrorDialog( + exception = AuthException.from(error.exception, stringProvider) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists() + + // Mirrors the fixed screens resetting authState right after showing the dialog. + composeTestRule.runOnIdle { + state = AuthState.Idle + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists() + } + + @Test + fun `showErrorDialog does not re-show the same error state twice`() { + stringProvider = DefaultAuthUIStringProvider(ApplicationProvider.getApplicationContext()) + var state: AuthState = AuthState.Idle + lateinit var controller: TopLevelDialogController + + composeTestRule.setContent { + controller = rememberTopLevelDialogController(stringProvider) { state } + controller.CurrentDialog() + } + + val error = AuthState.Error(Exception("boom")) + val exception = AuthException.from(error.exception, stringProvider) + + composeTestRule.runOnIdle { + state = error + controller.showErrorDialog(exception = exception) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists() + + composeTestRule.runOnIdle { + controller.dismissDialog() + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertDoesNotExist() + + // Same Error instance again — must be a no-op, the de-dup set persists across calls. + composeTestRule.runOnIdle { + controller.showErrorDialog(exception = exception) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertDoesNotExist() + } + + @Test + fun `second observer of the same error does not overwrite the first observer's dialog`() { + stringProvider = DefaultAuthUIStringProvider(ApplicationProvider.getApplicationContext()) + var state: AuthState = AuthState.Idle + lateinit var controller: TopLevelDialogController + + composeTestRule.setContent { + controller = rememberTopLevelDialogController(stringProvider) { state } + controller.CurrentDialog() + } + + val error = AuthState.Error(Exception("boom")) + val exception = AuthException.from(error.exception, stringProvider) + + var firstOnRetryCalled = false + var secondOnRetryCalled = false + + // Observer #1 (e.g. FirebaseAuthScreen's top-level effect): sees the Error, shows the + // dialog passing the specific errorState, then immediately resets the live state to + // Idle -- mirroring the real screens' consume-then-reset pattern. + composeTestRule.runOnIdle { + state = error + controller.showErrorDialog( + exception = exception, + errorState = error, + onRetry = { firstOnRetryCalled = true } + ) + state = AuthState.Idle + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists() + + // Observer #2 (e.g. EmailAuthScreen's own effect on the same authState emission): its + // LaunchedEffect(authState) still holds the same `error` instance locally, so it passes + // it as errorState even though the controller's live currentAuthState() now reads Idle. + composeTestRule.runOnIdle { + controller.showErrorDialog( + exception = exception, + errorState = error, + onRetry = { secondOnRetryCalled = true } + ) + } + composeTestRule.waitForIdle() + + // Dedup must key off the passed-in errorState, not the (possibly already-reset) live + // state, so observer #2's call is a no-op and observer #1's dialog/callback survives. + composeTestRule.onNodeWithText(stringProvider.retryAction).performClick() + assert(firstOnRetryCalled && !secondOnRetryCalled) { + "Expected observer #1's dialog/callback to survive untouched, but observer #2's " + + "call overwrote it (firstOnRetryCalled=$firstOnRetryCalled, " + + "secondOnRetryCalled=$secondOnRetryCalled)" + } + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt index c4029b198..870e71132 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt @@ -24,6 +24,7 @@ import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.util.SignInPreferenceManager import com.google.common.truth.Truth import org.junit.Before import org.junit.Rule @@ -468,4 +469,118 @@ class AuthMethodPickerTest { .onNodeWithText(context.getString(R.string.fui_sign_in_anonymously)) .assertIsDisplayed() } + + // ============================================================================================= + // Continue As Tests + // ============================================================================================= + + @Test + fun `AuthMethodPicker shows ContinueAsButton when lastSignInPreference matches a provider`() { + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val preference = SignInPreferenceManager.SignInPreference( + providerId = emailProvider.providerId, + identifier = "user@example.com", + timestamp = 0L + ) + + setContentWithStringProvider { + AuthMethodPicker( + providers = listOf(emailProvider), + onProviderSelected = { selectedProvider = it }, + lastSignInPreference = preference + ) + } + + composeTestRule + .onNodeWithTag("ContinueAsButton") + .assertIsDisplayed() + } + + @Test + fun `AuthMethodPicker hides ContinueAsButton when lastSignInPreference has no matching provider`() { + val preference = SignInPreferenceManager.SignInPreference( + providerId = "some.unlisted.provider", + identifier = "user@example.com", + timestamp = 0L + ) + + setContentWithStringProvider { + AuthMethodPicker( + providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ), + onProviderSelected = { selectedProvider = it }, + lastSignInPreference = preference + ) + } + + composeTestRule + .onNodeWithTag("ContinueAsButton") + .assertDoesNotExist() + } + + @Test + fun `AuthMethodPicker calls onContinueAsSelected with provider and identifier when ContinueAsButton is clicked`() { + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val preference = SignInPreferenceManager.SignInPreference( + providerId = emailProvider.providerId, + identifier = "user@example.com", + timestamp = 0L + ) + var continueAsProvider: AuthProvider? = null + var continueAsIdentifier: String? = null + + setContentWithStringProvider { + AuthMethodPicker( + providers = listOf(emailProvider), + onProviderSelected = { selectedProvider = it }, + lastSignInPreference = preference, + onContinueAsSelected = { provider, identifier -> + continueAsProvider = provider + continueAsIdentifier = identifier + } + ) + } + + composeTestRule + .onNodeWithTag("ContinueAsButton") + .performClick() + + Truth.assertThat(continueAsProvider).isEqualTo(emailProvider) + Truth.assertThat(continueAsIdentifier).isEqualTo("user@example.com") + Truth.assertThat(selectedProvider).isNull() + } + + @Test + fun `AuthMethodPicker falls back to onProviderSelected when onContinueAsSelected is not provided`() { + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val preference = SignInPreferenceManager.SignInPreference( + providerId = emailProvider.providerId, + identifier = "user@example.com", + timestamp = 0L + ) + + setContentWithStringProvider { + AuthMethodPicker( + providers = listOf(emailProvider), + onProviderSelected = { selectedProvider = it }, + lastSignInPreference = preference + ) + } + + composeTestRule + .onNodeWithTag("ContinueAsButton") + .performClick() + + Truth.assertThat(selectedProvider).isEqualTo(emailProvider) + } } \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenCancellationTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenCancellationTest.kt index e5f1e9e7c..22f615018 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenCancellationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenCancellationTest.kt @@ -69,7 +69,7 @@ class FirebaseAuthScreenCancellationTest { } @Test - fun `single email provider cancellation invokes callback once`() { + fun `single email provider cancellation invokes callback exactly once`() { val configuration = authUIConfiguration { context = ApplicationProvider.getApplicationContext() providers { @@ -102,7 +102,7 @@ class FirebaseAuthScreenCancellationTest { } @Test - fun `single phone provider cancellation invokes callback once`() { + fun `single phone provider cancellation invokes callback exactly once`() { val configuration = authUIConfiguration { context = ApplicationProvider.getApplicationContext() providers { @@ -134,4 +134,71 @@ class FirebaseAuthScreenCancellationTest { assertThat(cancelCount).isEqualTo(1) } + + @Test + fun `single email provider abort does not invoke callback`() { + val configuration = authUIConfiguration { + context = ApplicationProvider.getApplicationContext() + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + } + var cancelCount = 0 + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = { cancelCount++ } + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.Aborted) + } + composeTestRule.waitForIdle() + + assertThat(cancelCount).isEqualTo(0) + } + + @Test + fun `single phone provider abort does not invoke callback`() { + val configuration = authUIConfiguration { + context = ApplicationProvider.getApplicationContext() + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + } + } + var cancelCount = 0 + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = { cancelCount++ } + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.Aborted) + } + composeTestRule.waitForIdle() + + assertThat(cancelCount).isEqualTo(0) + } } diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenErrorNavigationTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenErrorNavigationTest.kt new file mode 100644 index 000000000..ab3c05270 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenErrorNavigationTest.kt @@ -0,0 +1,152 @@ +/* + * 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.ui.screens + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import org.junit.After +import org.junit.Before +import org.junit.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 + +/** + * Covers a regression surfaced by code review on the stale-AuthState-reset fix: with multiple + * providers configured, an Error occurring on a provider screen (e.g. Email) must not bounce the + * user back to the method picker once the error dialog is consumed. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class FirebaseAuthScreenErrorNavigationTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Mock + private lateinit var mockFirebaseAuth: FirebaseAuth + + private lateinit var authUI: FirebaseAuthUI + private lateinit var stringProvider: DefaultAuthUIStringProvider + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + FirebaseAuthUI.clearInstanceCache() + + val context = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(context).forEach { app -> app.delete() } + + val defaultApp = FirebaseApp.initializeApp( + context, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + )!! + + `when`(mockFirebaseAuth.app).thenReturn(defaultApp) + + authUI = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + stringProvider = DefaultAuthUIStringProvider(context) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + + val context = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(context).forEach { app -> app.delete() } + } + + @Test + fun `error on email screen with multiple providers does not navigate back to method picker`() { + val configuration = authUIConfiguration { + context = ApplicationProvider.getApplicationContext() + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + } + } + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + ) + } + + // Navigate from the method picker into the Email screen. + composeTestRule.onNodeWithText(stringProvider.signInWithEmail) + .assertIsDisplayed() + .performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.signInDefault) + .assertIsDisplayed() + + // Trigger a plain error while on the Email screen. + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.Error(Exception("boom"))) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.errorDialogTitle) + .assertIsDisplayed() + + // Dismiss the dialog. + composeTestRule.onNodeWithText(stringProvider.dismissAction) + .performClick() + composeTestRule.waitForIdle() + + // We must still be on the Email screen, not bounced back to the method picker. + composeTestRule.onNodeWithText(stringProvider.signInDefault) + .assertIsDisplayed() + composeTestRule.onNodeWithText(stringProvider.signInWithEmail) + .assertIsNotDisplayed() + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt new file mode 100644 index 000000000..3bd40b643 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens + +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.UserInfo +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.mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class FirebaseAuthScreenReauthIdleResetTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Mock + private lateinit var mockFirebaseAuth: FirebaseAuth + + private lateinit var authUI: FirebaseAuthUI + private lateinit var stringProvider: DefaultAuthUIStringProvider + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + FirebaseAuthUI.clearInstanceCache() + + val context = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(context).forEach { app -> app.delete() } + + val defaultApp = FirebaseApp.initializeApp( + context, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + )!! + + `when`(mockFirebaseAuth.app).thenReturn(defaultApp) + + authUI = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + stringProvider = DefaultAuthUIStringProvider(context) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + + val context = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(context).forEach { app -> app.delete() } + } + + @Test + fun `wrong password error during reauth does not dismiss the reauth sheet`() { + val mockProviderInfo = mock(UserInfo::class.java) + `when`(mockProviderInfo.providerId).thenReturn("password") + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.providerData).thenReturn(listOf(mockProviderInfo)) + + val configuration = authUIConfiguration { + context = ApplicationProvider.getApplicationContext() + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + } + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { _, _ -> + Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker")) + } + ) + } + + // Enter the reauth flow. + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.ReauthenticationRequired(mockUser)) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_marker").assertIsDisplayed() + + // Wrong password entered inside the reauth flow surfaces an Error on the same authUI. + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.Error(Exception("wrong password"))) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertIsDisplayed() + + // Dismiss the error dialog, which self-consumes the Error back to Idle. + composeTestRule.onNodeWithText(stringProvider.dismissAction).performClick() + composeTestRule.waitForIdle() + + // The reauth sheet must survive the notification-consume Idle. + composeTestRule.onNodeWithTag("reauth_marker").assertIsDisplayed() + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt new file mode 100644 index 000000000..6a3775287 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt @@ -0,0 +1,173 @@ +/* + * 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.ui.screens.email + +import android.content.Context +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [SignInUI], covering the sign-up button's visibility and email pre-fill. + * + * @suppress Internal test class + */ +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class SignInUITest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var applicationContext: Context + private lateinit var stringProvider: AuthUIStringProvider + + @Before + fun setUp() { + applicationContext = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(applicationContext) + } + + private fun setSignInUIContent(isNewAccountsAllowed: Boolean) { + val provider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + isNewAccountsAllowed = isNewAccountsAllowed, + passwordValidationRules = emptyList() + ) + val configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + } + + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + SignInUI( + configuration = configuration, + isLoading = false, + emailSignInLinkSent = false, + email = "", + password = "", + onEmailChange = { }, + onPasswordChange = { }, + onSignInClick = { }, + onRetrievedCredential = { }, + onGoToEmailLinkSignIn = { }, + onGoToSignUp = { }, + onGoToResetPassword = { }, + ) + } + } + } + + @Test + fun `sign up button is hidden when new accounts are not allowed`() { + setSignInUIContent(isNewAccountsAllowed = false) + + composeTestRule.onNode(hasText(stringProvider.signupPageTitle.uppercase()) and hasClickAction()) + .assertDoesNotExist() + } + + @Test + fun `sign up button is enabled when new accounts are allowed`() { + setSignInUIContent(isNewAccountsAllowed = true) + + composeTestRule.onNode(hasText(stringProvider.signupPageTitle.uppercase()) and hasClickAction()) + .assertIsEnabled() + } + + @Test + fun `email field is pre-filled when initial email value is provided`() { + val prefillEmail = "user@example.com" + val provider = AuthProvider.Email( + isDisplayNameRequired = false, + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + } + + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + SignInUI( + configuration = configuration, + isLoading = false, + emailSignInLinkSent = false, + email = prefillEmail, + password = "", + onEmailChange = { }, + onPasswordChange = { }, + onRetrievedCredential = { }, + onSignInClick = { }, + onGoToSignUp = { }, + onGoToResetPassword = { }, + onGoToEmailLinkSignIn = { }, + ) + } + } + + composeTestRule.onNodeWithText(prefillEmail).assertExists() + } + + @Test + fun `email field is empty when no initial email value is provided`() { + val provider = AuthProvider.Email( + isDisplayNameRequired = false, + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + } + + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + SignInUI( + configuration = configuration, + isLoading = false, + emailSignInLinkSent = false, + email = "", + password = "", + onEmailChange = { }, + onPasswordChange = { }, + onRetrievedCredential = { }, + onSignInClick = { }, + onGoToSignUp = { }, + onGoToResetPassword = { }, + onGoToEmailLinkSignIn = { }, + ) + } + } + + composeTestRule.onNodeWithText("user@example.com").assertDoesNotExist() + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt index ac642adb7..99012b4e8 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt @@ -112,4 +112,103 @@ class SignUpUITest { composeTestRule.onNode(hasText(stringProvider.signupPageTitle.uppercase()) and hasClickAction()) .assertIsEnabled() } + + @Test + fun `eager form validation does not surface an error on the untouched email field`() { + val provider = AuthProvider.Email( + isDisplayNameRequired = false, + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + } + + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + + SignUpUI( + configuration = configuration, + isLoading = false, + displayName = "", + email = email, + password = password, + confirmPassword = confirmPassword, + onDisplayNameChange = { }, + onEmailChange = { email = it }, + onPasswordChange = { password = it }, + onConfirmPasswordChange = { confirmPassword = it }, + onGoToSignIn = { }, + onSignUpClick = { } + ) + } + } + + composeTestRule.waitForIdle() + + // isFormValid validates every field on each recomposition, so emailValidator.hasError + // flips to true while email is still empty. That stays invisible because FieldValidator + // backs its state with a plain var, not a Compose MutableState, so nothing recomposes. + // Guards a regression: making isFormValid a plain Boolean moves validation ahead of the + // text fields in the same composition pass, and the error does then show up. + composeTestRule.onNodeWithText(stringProvider.missingEmailAddress).assertDoesNotExist() + + // Touch ONLY the password field; email is never focused or edited. + composeTestRule.onNodeWithText(stringProvider.passwordHint) + .performTextInput("Password123") + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(stringProvider.missingEmailAddress).assertDoesNotExist() + } + + @Test + fun `typing an invalid email into the email field shows its error`() { + val provider = AuthProvider.Email( + isDisplayNameRequired = false, + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + } + + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + + SignUpUI( + configuration = configuration, + isLoading = false, + displayName = "", + email = email, + password = password, + confirmPassword = confirmPassword, + onDisplayNameChange = { }, + onEmailChange = { email = it }, + onPasswordChange = { password = it }, + onConfirmPasswordChange = { confirmPassword = it }, + onGoToSignIn = { }, + onSignUpClick = { } + ) + } + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.invalidEmailAddress).assertDoesNotExist() + + composeTestRule.onNodeWithText(stringProvider.emailHint) + .performTextInput("notanemail") + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(stringProvider.invalidEmailAddress).assertExists() + } } diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt new file mode 100644 index 000000000..1eb925f8f --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt @@ -0,0 +1,557 @@ +/* + * 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.ui.screens.phone + +import android.content.Context +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController +import com.firebase.ui.auth.ui.components.TopLevelDialogController +import com.firebase.ui.auth.ui.components.rememberTopLevelDialogController +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.android.gms.tasks.Tasks +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthMultiFactorException +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorResolver +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthOptions +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.PhoneAuthProvider.OnVerificationStateChangedCallbacks +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.MockedStatic +import org.mockito.Mockito.atLeastOnce +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.kotlin.any +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class PhoneAuthScreenVerificationLifecycleTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var context: Context + private lateinit var app: FirebaseApp + private lateinit var mockAuth: FirebaseAuth + private lateinit var authUI: FirebaseAuthUI + private lateinit var configuration: AuthUIConfiguration + private var capturedState: PhoneAuthContentState? = null + private val reportedErrors = mutableListOf() + + @Before + fun setUp() { + FirebaseAuthUI.clearInstanceCache() + context = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(context).forEach { it.delete() } + app = FirebaseApp.initializeApp( + context, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + + mockAuth = mock(FirebaseAuth::class.java) + `when`(mockAuth.app).thenReturn(app) + authUI = FirebaseAuthUI.create(app, mockAuth) + + // timeout = 0 keeps the resend countdown at zero, so resend is available immediately and + // no 1-second ticking effect is left pending between assertions. + configuration = phoneConfiguration(timeout = 0L) + } + + private fun phoneConfiguration(timeout: Long): AuthUIConfiguration = authUIConfiguration { + context = this@PhoneAuthScreenVerificationLifecycleTest.context + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = timeout + ) + ) + } + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(context).forEach { it.delete() } + } + + /** + * Looks up the callbacks stashed inside [PhoneAuthOptions]. There's no public accessor - only + * an obfuscated zero-arg method returning + * [PhoneAuthProvider.OnVerificationStateChangedCallbacks], so we locate it reflectively and + * assert exactly one such method exists. + */ + private fun extractCallbacks(options: PhoneAuthOptions): OnVerificationStateChangedCallbacks { + val candidates = PhoneAuthOptions::class.java.declaredMethods.filter { + it.parameterCount == 0 && + it.returnType == OnVerificationStateChangedCallbacks::class.java + } + check(candidates.size == 1) { + "Expected exactly one zero-arg accessor returning " + + "OnVerificationStateChangedCallbacks on PhoneAuthOptions, found " + + "${candidates.size}: $candidates" + } + return candidates.single().also { it.isAccessible = true } + .invoke(options) as OnVerificationStateChangedCallbacks + } + + /** The callbacks Firebase was handed by the most recent verification attempt. */ + private fun latestCallbacks( + statics: MockedStatic + ): OnVerificationStateChangedCallbacks { + val captor = ArgumentCaptor.forClass(PhoneAuthOptions::class.java) + statics.verify({ PhoneAuthProvider.verifyPhoneNumber(captor.capture()) }, atLeastOnce()) + return extractCallbacks(captor.allValues.last()) + } + + private fun stubGetCredential( + statics: MockedStatic, + credential: PhoneAuthCredential, + ) { + statics.`when` { + PhoneAuthProvider.getCredential(any(), any()) + }.thenReturn(credential) + } + + /** + * @param withDialogs installs a real [TopLevelDialogController] and renders its dialog, so + * tests can assert on what the screen actually puts in front of the user. + */ + private fun setScreenContent(withDialogs: Boolean = false) { + composeTestRule.setContent { + val controller = rememberTopLevelDialogController(configuration.stringProvider) { + AuthState.Idle + } + CompositionLocalProvider( + LocalAuthUIStringProvider provides configuration.stringProvider, + LocalTopLevelDialogController provides controller.takeIf { withDialogs }, + ) { + PhoneAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + onSuccess = {}, + onError = { reportedErrors += it }, + onCancel = {}, + ) { state -> capturedState = state } + } + if (withDialogs) controller.CurrentDialog() + } + composeTestRule.waitForIdle() + } + + /** + * Cancelling a verification lands its terminal emission a dispatch or two later, so pump the + * clock until nothing is left in flight. + */ + private fun settle() { + repeat(3) { composeTestRule.waitForIdle() } + } + + private fun onUi(block: (PhoneAuthContentState) -> Unit) { + composeTestRule.runOnUiThread { block(capturedState!!) } + composeTestRule.waitForIdle() + } + + private fun sendCode( + statics: MockedStatic + ): OnVerificationStateChangedCallbacks { + onUi { it.onPhoneNumberChange("5555550123") } + onUi { it.onSendCodeClick() } + return latestCallbacks(statics) + } + + private fun codeSent(callbacks: OnVerificationStateChangedCallbacks, verificationId: String) { + composeTestRule.runOnUiThread { + callbacks.onCodeSent( + verificationId, + mock(PhoneAuthProvider.ForceResendingToken::class.java) + ) + } + composeTestRule.waitForIdle() + } + + private fun submitCode(code: String) { + onUi { it.onVerificationCodeChange(code) } + onUi { it.onVerifyCodeClick() } + } + + private fun autoVerified( + callbacks: OnVerificationStateChangedCallbacks, + credential: PhoneAuthCredential, + ) { + composeTestRule.runOnUiThread { callbacks.onVerificationCompleted(credential) } + composeTestRule.waitForIdle() + } + + /** No email on the user, so the success state isn't diverted to RequiresEmailVerification. */ + private fun signedInResult(): AuthResult { + val result = mock(AuthResult::class.java) + `when`(result.user).thenReturn(mock(FirebaseUser::class.java)) + return result + } + + private fun multiFactorException(): FirebaseAuthMultiFactorException { + val resolver = mock(MultiFactorResolver::class.java) + `when`(resolver.hints).thenReturn(emptyList()) + val exception = mock(FirebaseAuthMultiFactorException::class.java) + `when`(exception.resolver).thenReturn(resolver) + return exception + } + + @Test + fun `late auto-verification does not start a second sign-in during manual submit`() { + val credential = mock(PhoneAuthCredential::class.java) + // Never completed: the manually submitted code stays in flight. + `when`(mockAuth.signInWithCredential(any())) + .thenReturn(TaskCompletionSource().task) + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + stubGetCredential(statics, credential) + setScreenContent() + val callbacks = sendCode(statics) + codeSent(callbacks, "verification-id-1") + submitCode("123456") + + autoVerified(callbacks, credential) + + verify(mockAuth, times(1)).signInWithCredential(any()) + } + } + + @Test + fun `suppressed auto-verification leaves the manual submit's loading state intact`() { + val credential = mock(PhoneAuthCredential::class.java) + // Never completed: the manually submitted code stays in flight. + `when`(mockAuth.signInWithCredential(any())) + .thenReturn(TaskCompletionSource().task) + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + stubGetCredential(statics, credential) + setScreenContent() + val callbacks = sendCode(statics) + codeSent(callbacks, "verification-id-1") + submitCode("123456") + assertThat(capturedState!!.isLoading).isTrue() + + autoVerified(callbacks, credential) + + // isLoading gates Verify and Resend. Dropping it mid-sign-in re-enables both, so a + // second tap would start a duplicate sign-in with the same credential. + // Loading also means the credential was consumed: SMSAutoVerified is no longer the + // current state, so it cannot leak to a freshly composed screen. + assertThat(capturedState!!.isLoading).isTrue() + verify(mockAuth, times(1)).signInWithCredential(any()) + } + } + + @Test + fun `a cooldown-rejected send leaves the in-flight verification alive`() { + configuration = phoneConfiguration(timeout = 60L) + val credential = mock(PhoneAuthCredential::class.java) + `when`(mockAuth.signInWithCredential(any())) + .thenReturn(TaskCompletionSource().task) + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + stubGetCredential(statics, credential) + setScreenContent() + val first = sendCode(statics) + codeSent(first, "verification-id-1") + + // Same number inside the cooldown window, so this attempt is rejected. + onUi { it.onSendCodeClick() } + assertThat(reportedErrors.single()) + .isInstanceOf(AuthException.PhoneVerificationCooldownException::class.java) + + // The rejected duplicate must not have torn down the healthy live attempt. + autoVerified(first, credential) + verify(mockAuth, times(1)).signInWithCredential(any()) + } + } + + @Test + fun `verification-required restarts the resend countdown`() { + configuration = phoneConfiguration(timeout = 60L) + val credential = mock(PhoneAuthCredential::class.java) + `when`(mockAuth.signInWithCredential(any())) + .thenReturn(TaskCompletionSource().task) + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + stubGetCredential(statics, credential) + setScreenContent() + onUi { it.onPhoneNumberChange("5555550123") } + onUi { it.onSendCodeClick() } + codeSent(latestCallbacks(statics), "verification-id-1") + assertThat(capturedState!!.resendTimer).isEqualTo(60) + + onUi { it.onChangeNumberClick() } + assertThat(capturedState!!.resendTimer).isEqualTo(0) + + // A different number so the cooldown check accepts the second attempt. + onUi { it.onPhoneNumberChange("5555550124") } + onUi { it.onSendCodeClick() } + codeSent(latestCallbacks(statics), "verification-id-2") + + // Only the PhoneNumberVerificationRequired branch restarts this countdown. + assertThat(capturedState!!.resendTimer).isEqualTo(60) + } + } + + @Test + fun `auto-verification signs in when no manual submit is in flight`() { + val credential = mock(PhoneAuthCredential::class.java) + `when`(mockAuth.signInWithCredential(any())) + .thenReturn(TaskCompletionSource().task) + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + stubGetCredential(statics, credential) + setScreenContent() + val callbacks = sendCode(statics) + codeSent(callbacks, "verification-id-1") + + autoVerified(callbacks, credential) + + verify(mockAuth, times(1)).signInWithCredential(any()) + } + } + + @Test + fun `guard is released when submitting a code returns null without throwing`() { + val credential = mock(PhoneAuthCredential::class.java) + // RequiresMfa: submitVerificationCode returns null and never throws. + val mfaTask = Tasks.forException(multiFactorException()) + `when`(mockAuth.signInWithCredential(any())).thenReturn(mfaTask) + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + stubGetCredential(statics, credential) + setScreenContent() + val callbacks = sendCode(statics) + codeSent(callbacks, "verification-id-1") + submitCode("123456") + verify(mockAuth, times(1)).signInWithCredential(any()) + + autoVerified(callbacks, credential) + + // A catch-only reset would leave the guard latched here and block this second sign-in. + verify(mockAuth, times(2)).signInWithCredential(any()) + } + } + + @Test + fun `resend re-fires the verification-required state with the new verification id`() { + val credential = mock(PhoneAuthCredential::class.java) + `when`(mockAuth.signInWithCredential(any())) + .thenReturn(TaskCompletionSource().task) + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + stubGetCredential(statics, credential) + setScreenContent() + val first = sendCode(statics) + codeSent(first, "verification-id-1") + + onUi { it.onResendCodeClick() } + // The resend's Loading state reaches the screen, so the + // PhoneNumberVerificationRequired that follows is never conflated with the previous + // one - which is what restarts the resend countdown. + assertThat(capturedState!!.isLoading).isTrue() + + codeSent(latestCallbacks(statics), "verification-id-2") + submitCode("123456") + + // Only the PhoneNumberVerificationRequired branch writes the verification id. + statics.verify { PhoneAuthProvider.getCredential("verification-id-2", "123456") } + } + } + + @Test + fun `resend cancels the superseded verification attempt`() { + val credential = mock(PhoneAuthCredential::class.java) + `when`(mockAuth.signInWithCredential(any())) + .thenReturn(TaskCompletionSource().task) + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + stubGetCredential(statics, credential) + setScreenContent() + val first = sendCode(statics) + codeSent(first, "verification-id-1") + + onUi { it.onResendCodeClick() } + val second = latestCallbacks(statics) + assertThat(second).isNotSameInstanceAs(first) + + // The superseded attempt auto-verifies late; its emissions must be dropped. + autoVerified(first, credential) + verify(mockAuth, never()).signInWithCredential(any()) + + // The live attempt still drives the screen. + codeSent(second, "verification-id-2") + autoVerified(second, credential) + verify(mockAuth, times(1)).signInWithCredential(any()) + } + } + + @Test + fun `a successful sign-in reports no error to the host`() { + val credential = mock(PhoneAuthCredential::class.java) + // Stubbed outside the `when` chain: mocking inside it trips Mockito's unfinished stubbing. + val signedIn = Tasks.forResult(signedInResult()) + `when`(mockAuth.signInWithCredential(any())).thenReturn(signedIn) + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + stubGetCredential(statics, credential) + setScreenContent(withDialogs = true) + val callbacks = sendCode(statics) + codeSent(callbacks, "verification-id-1") + submitCode("123456") + settle() + + // Signing in tears down the still-open verification. That teardown is bookkeeping, so + // it must not reach the host as a failure or pop a recovery dialog over the success. + assertThat(reportedErrors).isEmpty() + composeTestRule + .onNodeWithText(configuration.stringProvider.authCancelledRecoveryMessage) + .assertDoesNotExist() + } + } + + @Test + fun `a successful sign-in publishes no Error into authStateFlow`() { + val credential = mock(PhoneAuthCredential::class.java) + val signedIn = Tasks.forResult(signedInResult()) + `when`(mockAuth.signInWithCredential(any())).thenReturn(signedIn) + + val observed = mutableListOf() + val collector = CoroutineScope(Dispatchers.Main.immediate).launch { + authUI.authStateFlow().collect { observed += it } + } + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + stubGetCredential(statics, credential) + setScreenContent(withDialogs = true) + val callbacks = sendCode(statics) + codeSent(callbacks, "verification-id-1") + submitCode("123456") + settle() + } + collector.cancel() + + // authStateFlow is shared with the host, whose own error handling does not filter + // cancellations - so tearing the verification down on success must publish no Error at all. + assertThat(observed.filterIsInstance()).isNotEmpty() + assertThat(observed.filterIsInstance()).isEmpty() + } + + @Test + fun `change-number reports no error to the host`() { + val credential = mock(PhoneAuthCredential::class.java) + `when`(mockAuth.signInWithCredential(any())) + .thenReturn(TaskCompletionSource().task) + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + stubGetCredential(statics, credential) + setScreenContent(withDialogs = true) + codeSent(sendCode(statics), "verification-id-1") + + onUi { it.onChangeNumberClick() } + settle() + + assertThat(reportedErrors).isEmpty() + composeTestRule + .onNodeWithText(configuration.stringProvider.authCancelledRecoveryMessage) + .assertDoesNotExist() + } + } + + @Test + fun `a failed sign-in reports exactly one error`() { + val credential = mock(PhoneAuthCredential::class.java) + `when`(mockAuth.signInWithCredential(any())) + .thenReturn(Tasks.forException(Exception("sign-in blew up"))) + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + stubGetCredential(statics, credential) + setScreenContent() + val callbacks = sendCode(statics) + codeSent(callbacks, "verification-id-1") + + autoVerified(callbacks, credential) + settle() + + // The failure also tears down the verification, which must not append a second, + // spurious cancellation error behind the real one. + assertThat(reportedErrors.map { it.message }).containsExactly("sign-in blew up") + } + } + + @Test + fun `a cooldown-rejected send still reports its cooldown error`() { + configuration = phoneConfiguration(timeout = 60L) + val credential = mock(PhoneAuthCredential::class.java) + `when`(mockAuth.signInWithCredential(any())) + .thenReturn(TaskCompletionSource().task) + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + stubGetCredential(statics, credential) + setScreenContent(withDialogs = true) + codeSent(sendCode(statics), "verification-id-1") + + // Same number inside the cooldown window, so this attempt is rejected. + onUi { it.onSendCodeClick() } + settle() + + // Not reporting cancellations must not also swallow the "wait N seconds" message. + assertThat(reportedErrors.single()) + .isInstanceOf(AuthException.PhoneVerificationCooldownException::class.java) + composeTestRule + .onNodeWithText(configuration.stringProvider.errorDialogTitle) + .assertExists() + } + } +} diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/AnonymousAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/AnonymousAuthScreenTest.kt index 743a26db4..6ef77c0b5 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/AnonymousAuthScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/AnonymousAuthScreenTest.kt @@ -310,9 +310,13 @@ class AnonymousAuthScreenTest { } var currentAuthState: AuthState = AuthState.Idle + var capturedFailure: AuthException? = null composeTestRule.setContent { - TestAuthScreen(configuration = configuration) + TestAuthScreen( + configuration = configuration, + onSignInFailure = { capturedFailure = it }, + ) val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) currentAuthState = authState } @@ -382,12 +386,18 @@ class AnonymousAuthScreenTest { composeTestRule.waitForIdle() shadowOf(Looper.getMainLooper()).idle() - // Step 5: Wait for error state (AccountLinkingRequiredException) + // Step 5: Wait for onSignInFailure to fire with AccountLinkingRequiredException. + // + // This is captured via the onSignInFailure callback rather than polling authStateFlow(): + // the screen resets AuthState back to Idle immediately after consuming the Error (so a + // second, independent authStateFlow() collector — like polling currentAuthState here — + // can miss the transient value entirely per StateFlow's conflation contract), whereas + // onSignInFailure is a direct, synchronous call from the same effect, so it can't race. println("TEST: Waiting for AccountLinkingRequiredException...") composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { shadowOf(Looper.getMainLooper()).idle() - println("TEST: Auth state: $currentAuthState") - currentAuthState is AuthState.Error + println("TEST: Captured failure: $capturedFailure") + capturedFailure != null } // Step 6: Verify ErrorRecoveryDialog is displayed @@ -396,24 +406,22 @@ class AnonymousAuthScreenTest { .assertIsDisplayed() // Verify exception - assertThat(currentAuthState).isInstanceOf(AuthState.Error::class.java) - val errorState = currentAuthState as AuthState.Error - assertThat(errorState.exception).isInstanceOf(AuthException.AccountLinkingRequiredException::class.java) + assertThat(capturedFailure).isInstanceOf(AuthException.AccountLinkingRequiredException::class.java) - val linkingException = errorState.exception as AuthException.AccountLinkingRequiredException + val linkingException = capturedFailure as AuthException.AccountLinkingRequiredException assertThat(linkingException.email).isEqualTo(email) } @Composable - private fun TestAuthScreen(configuration: AuthUIConfiguration) { - composeTestRule.waitForIdle() - shadowOf(Looper.getMainLooper()).idle() - + private fun TestAuthScreen( + configuration: AuthUIConfiguration, + onSignInFailure: (AuthException) -> Unit = {}, + ) { FirebaseAuthScreen( configuration = configuration, authUI = authUI, onSignInSuccess = { result -> }, - onSignInFailure = { exception: AuthException -> }, + onSignInFailure = onSignInFailure, onSignInCancelled = {}, authenticatedContent = { state, uiContext -> when (state) { diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt index d438eb45b..ff61ae95f 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo @@ -28,6 +29,7 @@ import androidx.credentials.CreatePasswordRequest import androidx.credentials.CredentialManager import androidx.credentials.GetCredentialRequest import androidx.credentials.GetCredentialResponse +import androidx.credentials.exceptions.NoCredentialException import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.AuthState @@ -138,6 +140,11 @@ class EmailAuthScreenTest { fun tearDown() { closeable.close() + // Sign out first: the FirebaseAuth SDK instance backing authUI isn't recreated until the + // next test's setUp() deletes/reinitializes the FirebaseApp, so a session left signed in + // here would otherwise still be live when the next test's composition starts. + authUI.auth.signOut() + // Clean up after each test to prevent test pollution FirebaseAuthUI.clearInstanceCache() @@ -492,21 +499,18 @@ class EmailAuthScreenTest { println("TEST: Pumping looper after click...") shadowOf(Looper.getMainLooper()).idle() - // Wait for auth state to transition to PasswordResetLinkSent - println("TEST: Waiting for auth state change... Current state: $currentAuthState") + // Wait for the dialog rather than polling currentAuthState, which the screen resets to + // Idle right after consuming PasswordResetLinkSent (see EmailSignInLinkSent test above). + println("TEST: Waiting for password reset link sent dialog...") composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { shadowOf(Looper.getMainLooper()).idle() - println("TEST: Auth state during wait: $currentAuthState") - currentAuthState is AuthState.PasswordResetLinkSent + composeAndroidTestRule.onAllNodesWithText(stringProvider.recoverPasswordLinkSentDialogTitle) + .fetchSemanticsNodes().isNotEmpty() } // Ensure final recomposition is complete before assertions shadowOf(Looper.getMainLooper()).idle() - // Verify the auth state and user properties - println("TEST: Verifying final auth state: $currentAuthState") - assertThat(currentAuthState) - .isInstanceOf(AuthState.PasswordResetLinkSent::class.java) assertThat(authUI.auth.currentUser).isNull() composeAndroidTestRule.onNodeWithText(stringProvider.recoverPasswordLinkSentDialogTitle) .assertIsDisplayed() @@ -585,22 +589,24 @@ class EmailAuthScreenTest { shadowOf(Looper.getMainLooper()).idle() composeAndroidTestRule.waitForIdle() - // Wait for auth state to transition to EmailSignInLinkSent - println("TEST: Waiting for auth state change... Current state: $currentAuthState") + // Wait for the "email link sent" dialog to appear, rather than polling currentAuthState: + // the screen resets AuthState back to Idle immediately after consuming + // EmailSignInLinkSent (so a second, independent authStateFlow() collector — like + // currentAuthState here — can miss the transient value entirely per StateFlow's + // conflation contract), whereas the dialog's visibility is latched in local Compose + // state that isn't reset the same way, so it's a reliable, non-racy signal. + println("TEST: Waiting for email link sent dialog...") composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { shadowOf(Looper.getMainLooper()).idle() - println("TEST: Auth state during wait: $currentAuthState") - currentAuthState is AuthState.EmailSignInLinkSent + composeAndroidTestRule.onAllNodesWithText(stringProvider.emailSignInLinkSentDialogTitle) + .fetchSemanticsNodes().isNotEmpty() } // Ensure final recomposition is complete before assertions shadowOf(Looper.getMainLooper()).idle() composeAndroidTestRule.waitForIdle() - // Verify the auth state and user properties - println("TEST: Verifying auth state: $currentAuthState") - assertThat(currentAuthState) - .isInstanceOf(AuthState.EmailSignInLinkSent::class.java) + // Verify the dialog and user properties assertThat(authUI.auth.currentUser).isNull() composeAndroidTestRule.onNodeWithText(stringProvider.emailSignInLinkSentDialogTitle) .assertIsDisplayed() @@ -842,9 +848,15 @@ class EmailAuthScreenTest { whenever(mockCredentialManager.createCredential(any(), any())) .thenReturn(mock()) - // Mock successful credential retrieval + // No credential exists yet for this account (sign-up hasn't happened), so the very first + // mount's auto-retrieval attempt must find nothing rather than "succeed" with a credential + // for an account that doesn't exist — otherwise it triggers a real sign-in failure (and, + // now that dialogs correctly persist, a real error dialog) before step 1 even runs. + // thenAnswer (not thenThrow) since getCredential's suspend-compiled signature doesn't + // declare GetCredentialException, which Mockito otherwise rejects as an invalid checked + // exception for the method. whenever(mockCredentialManager.getCredential(any(), any())) - .thenReturn(mockCredentialResponse) + .thenAnswer { throw NoCredentialException() } val configuration = authUIConfiguration { context = applicationContext @@ -906,6 +918,11 @@ class EmailAuthScreenTest { verify(mockCredentialManager, times(1)).createCredential(any(), any()) println("TEST: Sign-up complete, credentials saved (createCredential called once)") + // Now that the account actually exists, retrieval can start "succeeding" — matching the + // real scenario this test verifies (auto-sign-in via a previously-saved credential). + whenever(mockCredentialManager.getCredential(any(), any())) + .thenReturn(mockCredentialResponse) + // STEP 2: Sign out println("TEST: Signing out...") authUI.auth.signOut() diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt index 2939527ac..a3fb863b7 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt @@ -308,4 +308,126 @@ class ReauthFlowTest { assertThat(currentAuthState).isInstanceOf(AuthState.Idle::class.java) } + + @Test + fun `wrong password during reauth does not fire the pending retry operation`() { + val email = "reauth-wrong-pw-${System.currentTimeMillis()}@example.com" + val password = "test123" + val wrongPassword = "wrong-password" + + val user = ensureFreshUser(authUI, email, password) + requireNotNull(user) { "Failed to create user" } + + try { + verifyEmailInEmulator(authUI, emulatorApi, user) + } catch (e: Exception) { + Assume.assumeTrue( + "Skipping: Firebase Auth Emulator OOB codes not available. Error: ${e.message}", + false + ) + } + + authUI.auth.signOut() + shadowOf(Looper.getMainLooper()).idle() + + var currentAuthState: AuthState = AuthState.Idle + var retryOperationCalled = false + + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + isCredentialManagerEnabled = false + } + + composeAndroidTestRule.setContent { + CompositionLocalProvider( + LocalAuthUIStringProvider provides DefaultAuthUIStringProvider(applicationContext) + ) { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + ) { state, _ -> + if (state is AuthState.Success) Text("AUTHENTICATED") else Text("NOT AUTHENTICATED") + } + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + currentAuthState = authState + } + } + + shadowOf(Looper.getMainLooper()).idle() + + // Step 1: complete initial sign-in via the main screen form (correct password). + composeAndroidTestRule.onNodeWithText(stringProvider.emailHint) + .performScrollTo() + .performTextInput(email) + composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint) + .performScrollTo() + .performTextInput(password) + composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault.uppercase()) + .performScrollTo() + .performClick() + + shadowOf(Looper.getMainLooper()).idle() + + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + currentAuthState is AuthState.Success + } + composeAndroidTestRule.onNodeWithText("AUTHENTICATED").assertIsDisplayed() + + val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" } + + // Step 2: emit ReauthenticationRequired with a retryOperation. + authUI.updateAuthState( + AuthState.ReauthenticationRequired( + user = signedInUser, + reason = "Please verify your identity to continue", + retryOperation = { retryOperationCalled = true }, + ) + ) + + shadowOf(Looper.getMainLooper()).idle() + + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.onAllNodesWithText(stringProvider.emailHint) + .fetchSemanticsNodes().isNotEmpty() + } + + // Step 3: enter the WRONG password in the reauth sheet. + composeAndroidTestRule.onNodeWithText(stringProvider.emailHint) + .performScrollTo() + .performTextInput(email) + composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint) + .performScrollTo() + .performTextInput(wrongPassword) + composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault.uppercase()) + .performScrollTo() + .performClick() + + shadowOf(Looper.getMainLooper()).idle() + + // The error dialog surfaces the failed reauth attempt. + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.onAllNodesWithText(stringProvider.errorDialogTitle) + .fetchSemanticsNodes().isNotEmpty() + } + + // Dismiss the error dialog, which self-consumes Error -> Idle on the shared authUI. + composeAndroidTestRule.onNodeWithText(stringProvider.dismissAction).performClick() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(retryOperationCalled).isFalse() + } }