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 432d25c82..972b1786b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -41,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. @@ -79,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 @@ -363,9 +365,29 @@ class FirebaseAuthUI private constructor( */ @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/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/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/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/screens/phone/PhoneAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt index fb3411c81..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,62 @@ fun PhoneAuthScreen( pendingVerificationPhoneNumber.value = null verificationStartTime.value = null - // 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 + // 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, - errorState = state, - 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) } @@ -259,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 + } } } }, @@ -304,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 -> @@ -316,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, @@ -331,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 } @@ -340,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/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/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() + } + } +}