diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt index 1f6b26ced..681440aec 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt @@ -20,6 +20,8 @@ import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactorResolver +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthProvider /** * Represents the authentication state in Firebase Auth UI. @@ -252,6 +254,74 @@ abstract class AuthState private constructor() { override fun toString(): String = "AuthState.EmailSignInLinkSent" } + /** + * Phone number was automatically verified via SMS instant verification. + * + * This state is emitted when Firebase Phone Authentication successfully retrieves + * and verifies the SMS code automatically without user interaction. This happens + * when Google Play services can detect the incoming SMS message. + * + * @property credential The [PhoneAuthCredential] that can be used to sign in the user + * + * @see PhoneNumberVerificationRequired for the manual verification flow + */ + class SMSAutoVerified(val credential: PhoneAuthCredential) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SMSAutoVerified) return false + return credential == other.credential + } + + override fun hashCode(): Int { + var result = credential.hashCode() + result = 31 * result + credential.hashCode() + return result + } + + override fun toString(): String = + "AuthState.SMSAutoVerified(credential=$credential)" + } + + /** + * Phone number verification requires manual code entry. + * + * This state is emitted when Firebase Phone Authentication cannot instantly verify + * the phone number and sends an SMS code that the user must manually enter. This is + * the normal flow when automatic SMS retrieval is not available or fails. + * + * **Resending codes:** + * To allow users to resend the verification code (if they didn't receive it), + * call [FirebaseAuthUI.verifyPhoneNumber] again with: + * - `isForceResendingTokenEnabled = true` + * - `forceResendingToken` from this state + * + * @property verificationId The verification ID to use when submitting the code. + * This must be passed to [FirebaseAuthUI.submitVerificationCode]. + * @property forceResendingToken Token that can be used to resend the SMS code if needed + * + */ + class PhoneNumberVerificationRequired( + val verificationId: String, + val forceResendingToken: PhoneAuthProvider.ForceResendingToken, + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PhoneNumberVerificationRequired) return false + return verificationId == other.verificationId && + forceResendingToken == other.forceResendingToken + } + + override fun hashCode(): Int { + var result = verificationId.hashCode() + result = 31 * result + forceResendingToken.hashCode() + return result + } + + override fun toString(): String = + "AuthState.PhoneNumberVerificationRequired(verificationId=$verificationId, " + + "forceResendingToken=$forceResendingToken)" + } + companion object { /** * Creates an Idle state instance. diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt index bb5f2b9b3..d0cc3be98 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt @@ -28,17 +28,26 @@ import com.firebase.ui.auth.util.Preconditions import com.firebase.ui.auth.util.data.ContinueUrlBuilder import com.firebase.ui.auth.util.data.PhoneNumberUtils import com.firebase.ui.auth.util.data.ProviderAvailability +import com.google.firebase.FirebaseException import com.google.firebase.auth.ActionCodeSettings +import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FacebookAuthProvider import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.GithubAuthProvider import com.google.firebase.auth.GoogleAuthProvider +import com.google.firebase.auth.MultiFactorSession +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthOptions import com.google.firebase.auth.PhoneAuthProvider import com.google.firebase.auth.TwitterAuthProvider import com.google.firebase.auth.UserProfileChangeRequest import com.google.firebase.auth.actionCodeSettings import kotlinx.coroutines.tasks.await +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine @AuthUIConfigurationDsl class AuthProvidersBuilder { @@ -79,7 +88,7 @@ internal enum class Provider(val id: String, val isSocialProvider: Boolean = fal abstract class OAuthProvider( override val providerId: String, open val scopes: List = emptyList(), - open val customParameters: Map = emptyMap() + open val customParameters: Map = emptyMap(), ) : AuthProvider(providerId) /** @@ -115,7 +124,7 @@ abstract class AuthProvider(open val providerId: String) { internal suspend fun mergeProfile( auth: FirebaseAuth, displayName: String?, - photoUri: Uri? + photoUri: Uri?, ) { try { val currentUser = auth.currentUser ?: return @@ -189,7 +198,7 @@ abstract class AuthProvider(open val providerId: String) { /** * A list of custom password validation rules. */ - val passwordValidationRules: List + val passwordValidationRules: List, ) : AuthProvider(providerId = Provider.EMAIL.id) { companion object { const val SESSION_ID_LENGTH = 10 @@ -202,9 +211,7 @@ abstract class AuthProvider(open val providerId: String) { val KEY_IDP_SECRET = stringPreferencesKey("com.firebase.ui.auth.data.client.idpSecret") } - internal fun validate( - isAnonymousUpgradeEnabled: Boolean = false - ) { + internal fun validate(isAnonymousUpgradeEnabled: Boolean = false) { if (isEmailLinkSignInEnabled) { val actionCodeSettings = requireNotNull(emailLinkActionCodeSettings) { "ActionCodeSettings cannot be null when using " + @@ -256,7 +263,7 @@ abstract class AuthProvider(open val providerId: String) { // For Sign In With Email Link internal fun isDifferentDevice( sessionIdFromLocal: String?, - sessionIdFromLink: String + sessionIdFromLink: String, ): Boolean { return sessionIdFromLocal == null || sessionIdFromLocal.isEmpty() || sessionIdFromLink.isEmpty() @@ -265,6 +272,24 @@ abstract class AuthProvider(open val providerId: String) { private fun continueUrl(continueUrl: String, block: ContinueUrlBuilder.() -> Unit) = ContinueUrlBuilder(continueUrl).apply(block).build() + + /** + * An interface to wrap the static `EmailAuthProvider.getCredential` method to make it testable. + * @suppress + */ + internal interface CredentialProvider { + fun getCredential(email: String, password: String): AuthCredential + } + + /** + * The default implementation of [CredentialProvider] that calls the static method. + * @suppress + */ + internal class DefaultCredentialProvider : CredentialProvider { + override fun getCredential(email: String, password: String): AuthCredential { + return EmailAuthProvider.getCredential(email, password) + } + } } /** @@ -300,12 +325,34 @@ abstract class AuthProvider(open val providerId: String) { * Enables instant verification of the phone number. Defaults to true. */ val isInstantVerificationEnabled: Boolean = true, - - /** - * Enables automatic retrieval of the SMS code. Defaults to true. - */ - val isAutoRetrievalEnabled: Boolean = true ) : AuthProvider(providerId = Provider.PHONE.id) { + /** + * Sealed class representing the result of phone number verification. + * + * Phone verification can complete in two ways: + * - [AutoVerified]: SMS was instantly retrieved and verified by the Firebase SDK + * - [NeedsManualVerification]: SMS code was sent, user must manually enter it + */ + internal sealed class VerifyPhoneNumberResult { + /** + * Instant verification succeeded via SMS auto-retrieval. + * + * @property credential The [PhoneAuthCredential] that can be used to sign in + */ + class AutoVerified(val credential: PhoneAuthCredential) : VerifyPhoneNumberResult() + + /** + * Instant verification failed, manual code entry required. + * + * @property verificationId The verification ID to use when submitting the code + * @property token Token for resending the verification code + */ + class NeedsManualVerification( + val verificationId: String, + val token: PhoneAuthProvider.ForceResendingToken, + ) : VerifyPhoneNumberResult() + } + internal fun validate() { defaultNumber?.let { check(PhoneNumberUtils.isValid(it)) { @@ -329,6 +376,130 @@ abstract class AuthProvider(open val providerId: String) { } } } + + /** + * Internal coroutine-based wrapper for Firebase Phone Authentication verification. + * + * This method wraps the callback-based Firebase Phone Auth API into a suspending function + * using Kotlin coroutines. It handles the Firebase [PhoneAuthProvider.OnVerificationStateChangedCallbacks] + * and converts them into a [VerifyPhoneNumberResult]. + * + * **Callback mapping:** + * - `onVerificationCompleted` → [VerifyPhoneNumberResult.AutoVerified] + * - `onCodeSent` → [VerifyPhoneNumberResult.NeedsManualVerification] + * - `onVerificationFailed` → throws the exception + * + * This is a private helper method used by [verifyPhoneNumber]. Callers should use + * [verifyPhoneNumber] instead as it handles state management and error handling. + * + * @param auth The [FirebaseAuth] instance to use for verification + * @param phoneNumber The phone number to verify in E.164 format + * @param multiFactorSession Optional [MultiFactorSession] for MFA enrollment. When provided, + * Firebase verifies the phone number for enrolling as a second authentication factor + * instead of primary sign-in. Pass null for standard phone authentication. + * @param forceResendingToken Optional token from previous verification for resending + * + * @return [VerifyPhoneNumberResult] indicating auto-verified or manual verification needed + * @throws FirebaseException if verification fails + */ + internal suspend fun verifyPhoneNumberAwait( + auth: FirebaseAuth, + phoneNumber: String, + multiFactorSession: MultiFactorSession? = null, + forceResendingToken: PhoneAuthProvider.ForceResendingToken?, + verifier: Verifier = DefaultVerifier(), + ): VerifyPhoneNumberResult { + return verifier.verifyPhoneNumber( + auth, + phoneNumber, + timeout, + forceResendingToken, + multiFactorSession, + isInstantVerificationEnabled + ) + } + + /** + * @suppress + */ + internal interface Verifier { + suspend fun verifyPhoneNumber( + auth: FirebaseAuth, + phoneNumber: String, + timeout: Long, + forceResendingToken: PhoneAuthProvider.ForceResendingToken?, + multiFactorSession: MultiFactorSession?, + isInstantVerificationEnabled: Boolean + ): VerifyPhoneNumberResult + } + + /** + * @suppress + */ + internal class DefaultVerifier : Verifier { + override suspend fun verifyPhoneNumber( + auth: FirebaseAuth, + phoneNumber: String, + timeout: Long, + forceResendingToken: PhoneAuthProvider.ForceResendingToken?, + multiFactorSession: MultiFactorSession?, + isInstantVerificationEnabled: Boolean + ): VerifyPhoneNumberResult { + return suspendCoroutine { continuation -> + val options = PhoneAuthOptions.newBuilder(auth) + .setPhoneNumber(phoneNumber) + .requireSmsValidation(!isInstantVerificationEnabled) + .setTimeout(timeout, TimeUnit.SECONDS) + .setCallbacks(object : PhoneAuthProvider.OnVerificationStateChangedCallbacks() { + override fun onVerificationCompleted(credential: PhoneAuthCredential) { + continuation.resume(VerifyPhoneNumberResult.AutoVerified(credential)) + } + + override fun onVerificationFailed(e: FirebaseException) { + continuation.resumeWithException(e) + } + + override fun onCodeSent( + verificationId: String, + token: PhoneAuthProvider.ForceResendingToken, + ) { + continuation.resume( + VerifyPhoneNumberResult.NeedsManualVerification( + verificationId, + token + ) + ) + } + }) + if (forceResendingToken != null) { + options.setForceResendingToken(forceResendingToken) + } + if (multiFactorSession != null) { + options.setMultiFactorSession(multiFactorSession) + } + PhoneAuthProvider.verifyPhoneNumber(options.build()) + } + } + } + + /** + * An interface to wrap the static `PhoneAuthProvider.getCredential` method to make it testable. + * @suppress + */ + internal interface CredentialProvider { + fun getCredential(verificationId: String, smsCode: String): PhoneAuthCredential + } + + /** + * The default implementation of [CredentialProvider] that calls the static method. + * @suppress + */ + internal class DefaultCredentialProvider : CredentialProvider { + override fun getCredential(verificationId: String, smsCode: String): PhoneAuthCredential { + return PhoneAuthProvider.getCredential(verificationId, smsCode) + } + } + } /** @@ -363,7 +534,7 @@ abstract class AuthProvider(open val providerId: String) { /** * A map of custom OAuth parameters. */ - override val customParameters: Map = emptyMap() + override val customParameters: Map = emptyMap(), ) : OAuthProvider( providerId = Provider.GOOGLE.id, scopes = scopes, @@ -415,7 +586,7 @@ abstract class AuthProvider(open val providerId: String) { /** * A map of custom OAuth parameters. */ - override val customParameters: Map = emptyMap() + override val customParameters: Map = emptyMap(), ) : OAuthProvider( providerId = Provider.FACEBOOK.id, scopes = scopes, @@ -452,7 +623,7 @@ abstract class AuthProvider(open val providerId: String) { /** * A map of custom OAuth parameters. */ - override val customParameters: Map + override val customParameters: Map, ) : OAuthProvider( providerId = Provider.TWITTER.id, customParameters = customParameters @@ -470,7 +641,7 @@ abstract class AuthProvider(open val providerId: String) { /** * A map of custom OAuth parameters. */ - override val customParameters: Map + override val customParameters: Map, ) : OAuthProvider( providerId = Provider.GITHUB.id, scopes = scopes, @@ -494,7 +665,7 @@ abstract class AuthProvider(open val providerId: String) { /** * A map of custom OAuth parameters. */ - override val customParameters: Map + override val customParameters: Map, ) : OAuthProvider( providerId = Provider.MICROSOFT.id, scopes = scopes, @@ -513,7 +684,7 @@ abstract class AuthProvider(open val providerId: String) { /** * A map of custom OAuth parameters. */ - override val customParameters: Map + override val customParameters: Map, ) : OAuthProvider( providerId = Provider.YAHOO.id, scopes = scopes, @@ -537,7 +708,7 @@ abstract class AuthProvider(open val providerId: String) { /** * A map of custom OAuth parameters. */ - override val customParameters: Map + override val customParameters: Map, ) : OAuthProvider( providerId = Provider.APPLE.id, scopes = scopes, @@ -595,7 +766,7 @@ abstract class AuthProvider(open val providerId: String) { /** * An optional content color for the provider button. */ - val contentColor: Color? + val contentColor: Color?, ) : OAuthProvider( providerId = providerId, scopes = scopes, @@ -611,4 +782,4 @@ abstract class AuthProvider(open val providerId: String) { } } } -} \ No newline at end of file +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index a65fcac8a..744dfd7c7 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -50,7 +50,7 @@ import kotlinx.coroutines.tasks.await internal class CredentialForLinking( val providerType: String, val idToken: String?, - val accessToken: String? + val accessToken: String?, ) /** @@ -130,11 +130,12 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( provider: AuthProvider.Email, name: String?, email: String, - password: String + password: String, + credentialProvider: AuthProvider.Email.CredentialProvider = AuthProvider.Email.DefaultCredentialProvider(), ): AuthResult? { val canUpgrade = canUpgradeAnonymous(config, auth) val pendingCredential = - if (canUpgrade) EmailAuthProvider.getCredential(email, password) else null + if (canUpgrade) credentialProvider.getCredential(email, password) else null try { // Check if new accounts are allowed (only for non-upgrade flows) @@ -454,7 +455,7 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( config: AuthUIConfiguration, credential: AuthCredential, displayName: String? = null, - photoUrl: Uri? = null + photoUrl: Uri? = null, ): AuthResult? { try { updateAuthState(AuthState.Loading("Signing in user...")) @@ -639,7 +640,7 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( config: AuthUIConfiguration, provider: AuthProvider.Email, email: String, - credentialForLinking: CredentialForLinking? = null + credentialForLinking: CredentialForLinking? = null, ) { try { updateAuthState(AuthState.Loading("Sending sign in email link...")) @@ -938,7 +939,7 @@ suspend fun FirebaseAuthUI.signInWithEmailLink( */ internal suspend fun FirebaseAuthUI.sendPasswordResetEmail( email: String, - actionCodeSettings: ActionCodeSettings? = null + actionCodeSettings: ActionCodeSettings? = null, ) { try { updateAuthState(AuthState.Loading("Sending password reset email...")) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 000000000..0eadb2189 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,312 @@ +package com.firebase.ui.auth.compose.configuration.auth_provider + +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.MultiFactorSession +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthProvider +import kotlinx.coroutines.CancellationException + +/** + * Initiates phone number verification with Firebase Phone Authentication. + * + * This method starts the phone verification flow, which can complete in two ways: + * 1. **Instant verification** (auto): Firebase SDK automatically retrieves and verifies + * the SMS code without user interaction. This happens when Google Play services can + * detect the incoming SMS automatically. + * 2. **Manual verification**: SMS code is sent to the user's device, and the user must + * manually enter the code via [submitVerificationCode]. + * + * **Flow:** + * - Call this method with the phone number + * - Firebase SDK attempts instant verification + * - If instant verification succeeds: + * - Emits [AuthState.SMSAutoVerified] with the credential + * - UI should observe this state and call [signInWithPhoneAuthCredential] + * - If instant verification fails: + * - Emits [AuthState.PhoneNumberVerificationRequired] with verification details + * - UI should show code entry screen + * - User enters code → call [submitVerificationCode] + * + * **Resending codes:** + * To resend a verification code, call this method again with: + * - `forceResendingToken` = the token from [AuthState.PhoneNumberVerificationRequired] + * + * **Example: Basic phone verification** + * ```kotlin + * // Step 1: Start verification + * firebaseAuthUI.verifyPhoneNumber( + * provider = phoneProvider, + * phoneNumber = "+1234567890", + * ) + * + * // Step 2: Observe AuthState + * authUI.authStateFlow().collect { state -> + * when (state) { + * is AuthState.SMSAutoVerified -> { + * // Instant verification succeeded! + * showToast("Phone number verified automatically") + * // Now sign in with the credential + * firebaseAuthUI.signInWithPhoneAuthCredential( + * config = authUIConfig, + * credential = state.credential + * ) + * } + * is AuthState.PhoneNumberVerificationRequired -> { + * // Show code entry screen + * showCodeEntryScreen( + * verificationId = state.verificationId, + * forceResendingToken = state.forceResendingToken + * ) + * } + * is AuthState.Error -> { + * // Handle error + * showError(state.exception.message) + * } + * } + * } + * + * // Step 3: When user enters code + * firebaseAuthUI.submitVerificationCode( + * config = authUIConfig, + * verificationId = verificationId, + * code = userEnteredCode + * ) + * ``` + * + * **Example: Resending verification code** + * ```kotlin + * // User didn't receive the code, wants to resend + * firebaseAuthUI.verifyPhoneNumber( + * provider = phoneProvider, + * phoneNumber = "+1234567890", + * forceResendingToken = savedToken // From PhoneNumberVerificationRequired state + * ) + * ``` + * + * @param provider The [AuthProvider.Phone] configuration containing timeout and other settings + * @param phoneNumber The phone number to verify in E.164 format (e.g., "+1234567890") + * @param multiFactorSession Optional [MultiFactorSession] for MFA enrollment. When provided, + * this initiates phone verification for enrolling a second factor rather than primary sign-in. + * Obtain this from `FirebaseUser.multiFactor.session` when enrolling MFA. + * @param forceResendingToken Optional token from previous verification for resending SMS + * + * @throws AuthException.InvalidCredentialsException if the phone number is invalid + * @throws AuthException.TooManyRequestsException if SMS quota is exceeded + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + */ +internal suspend fun FirebaseAuthUI.verifyPhoneNumber( + provider: AuthProvider.Phone, + phoneNumber: String, + multiFactorSession: MultiFactorSession? = null, + forceResendingToken: PhoneAuthProvider.ForceResendingToken? = null, + verifier: AuthProvider.Phone.Verifier = AuthProvider.Phone.DefaultVerifier(), +) { + try { + updateAuthState(AuthState.Loading("Verifying phone number...")) + val result = provider.verifyPhoneNumberAwait( + auth = auth, + phoneNumber = phoneNumber, + multiFactorSession = multiFactorSession, + forceResendingToken = forceResendingToken, + verifier = verifier + ) + when (result) { + is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { + updateAuthState(AuthState.SMSAutoVerified(credential = result.credential)) + } + + is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { + updateAuthState( + AuthState.PhoneNumberVerificationRequired( + verificationId = result.verificationId, + forceResendingToken = result.token, + ) + ) + } + } + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Verify phone number was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Submits a verification code entered by the user and signs them in. + * + * This method is called after [verifyPhoneNumber] emits [AuthState.PhoneNumberVerificationRequired], + * indicating that manual code entry is needed. It creates a [PhoneAuthCredential] from the + * verification ID and user-entered code, then signs in the user by calling + * [signInWithPhoneAuthCredential]. + * + * **Flow:** + * 1. User receives SMS with 6-digit code + * 2. User enters code in UI + * 3. UI calls this method with the code + * 4. Credential is created and used to sign in + * 5. Returns [AuthResult] with signed-in user + * + * This method handles both normal sign-in and anonymous account upgrade scenarios based + * on the [AuthUIConfiguration] settings. + * + * **Example: Manual code entry flow* + * ``` + * val userEnteredCode = "123456" + * try { + * val result = firebaseAuthUI.submitVerificationCode( + * config = authUIConfig, + * verificationId = savedVerificationId!!, + * code = userEnteredCode + * ) + * // User is now signed in + * } catch (e: AuthException.InvalidCredentialsException) { + * // Wrong code entered + * showError("Invalid verification code") + * } catch (e: AuthException.SessionExpiredException) { + * // Code expired + * showError("Verification code expired. Please request a new one.") + * } + * ``` + * + * @param config The [AuthUIConfiguration] containing authentication settings + * @param verificationId The verification ID from [AuthState.PhoneNumberVerificationRequired] + * @param code The 6-digit verification code entered by the user + * + * @return [AuthResult] containing the signed-in user + * + * @throws AuthException.InvalidCredentialsException if the code is incorrect or expired + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + */ +internal suspend fun FirebaseAuthUI.submitVerificationCode( + config: AuthUIConfiguration, + verificationId: String, + code: String, + credentialProvider: AuthProvider.Phone.CredentialProvider = AuthProvider.Phone.DefaultCredentialProvider(), +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Submitting verification code...")) + val credential = credentialProvider.getCredential(verificationId, code) + return signInWithPhoneAuthCredential( + config = config, + credential = credential + ) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Submit verification code was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs in a user with a phone authentication credential. + * + * This method is the final step in the phone authentication flow. It takes a + * [PhoneAuthCredential] (either from instant verification or manual code entry) and + * signs in the user. The method handles both normal sign-in and anonymous account + * upgrade scenarios by delegating to [signInAndLinkWithCredential]. + * + * **When to call this:** + * - After [verifyPhoneNumber] emits [AuthState.SMSAutoVerified] (instant verification) + * - Called internally by [submitVerificationCode] (manual verification) + * + * The method automatically handles: + * - Normal sign-in for new or returning users + * - Linking phone credential to anonymous accounts (if enabled in config) + * - Emitting [AuthState.MergeConflict] if phone number already exists on another account + * + * **Example: Sign in after instant verification** + * ```kotlin + * authUI.authStateFlow().collect { state -> + * when (state) { + * is AuthState.SMSAutoVerified -> { + * // Phone was instantly verified + * showToast("Phone verified automatically!") + * + * // Now sign in with the credential + * val result = firebaseAuthUI.signInWithPhoneAuthCredential( + * config = authUIConfig, + * credential = state.credential + * ) + * // User is now signed in + * } + * } + * } + * ``` + * + * **Example: Anonymous upgrade with collision** + * ```kotlin + * // User is currently anonymous + * try { + * firebaseAuthUI.signInWithPhoneAuthCredential( + * config = authUIConfig, + * credential = phoneCredential + * ) + * } catch (e: FirebaseAuthUserCollisionException) { + * // Phone number already exists on another account + * // AuthState.MergeConflict will be emitted + * // Show merge conflict resolution screen + * } + * ``` + * + * @param config The [AuthUIConfiguration] containing authentication settings + * @param credential The [PhoneAuthCredential] to use for signing in + * + * @return [AuthResult] containing the signed-in user, or null if anonymous upgrade collision occurred + * + * @throws AuthException.InvalidCredentialsException if the credential is invalid or expired + * @throws AuthException.EmailAlreadyInUseException if phone number is linked to another account + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + */ +internal suspend fun FirebaseAuthUI.signInWithPhoneAuthCredential( + config: AuthUIConfiguration, + credential: PhoneAuthCredential, +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Signing in with phone...")) + return signInAndLinkWithCredential( + config = config, + credential = credential, + ) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with phone was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt index 00ebc75c4..05caaacc6 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt @@ -3,9 +3,9 @@ * * 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 @@ -29,7 +29,6 @@ import com.google.firebase.FirebaseOptions import com.google.firebase.auth.ActionCodeSettings import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult -import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException import com.google.firebase.auth.FirebaseAuthInvalidUserException @@ -47,11 +46,11 @@ import org.mockito.ArgumentMatchers import org.mockito.Mock import org.mockito.Mockito.anyString import org.mockito.Mockito.mock -import org.mockito.Mockito.mockStatic import org.mockito.Mockito.never import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -75,6 +74,9 @@ class EmailAuthProviderFirebaseAuthUITest { @Mock private lateinit var mockFirebaseAuth: FirebaseAuth + @Mock + private lateinit var mockEmailAuthCredentialProvider: AuthProvider.Email.CredentialProvider + private lateinit var firebaseApp: FirebaseApp private lateinit var applicationContext: Context @@ -148,48 +150,44 @@ class EmailAuthProviderFirebaseAuthUITest { @Test fun `Link user with email and password with anonymous upgrade should succeed`() = runTest { - mockStatic(EmailAuthProvider::class.java).use { mockedProvider -> - val mockCredential = mock(AuthCredential::class.java) - mockedProvider.`when` { - EmailAuthProvider.getCredential("test@example.com", "Pass@123") - }.thenReturn(mockCredential) - val mockAnonymousUser = mock(FirebaseUser::class.java) - `when`(mockAnonymousUser.isAnonymous).thenReturn(true) - `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) - val taskCompletionSource = TaskCompletionSource() - taskCompletionSource.setResult(null) - `when`( - mockFirebaseAuth.currentUser?.linkWithCredential( - ArgumentMatchers.any(AuthCredential::class.java) - ) - ).thenReturn(taskCompletionSource.task) - val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) - val emailProvider = AuthProvider.Email( - emailLinkActionCodeSettings = null, - passwordValidationRules = emptyList() + val mockCredential = mock(AuthCredential::class.java) + `when`(mockEmailAuthCredentialProvider.getCredential("test@example.com", "Pass@123")) + .thenReturn(mockCredential) + val mockAnonymousUser = mock(FirebaseUser::class.java) + `when`(mockAnonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) + `when`( + mockFirebaseAuth.currentUser?.linkWithCredential( + ArgumentMatchers.any(AuthCredential::class.java) ) - val config = authUIConfiguration { - context = applicationContext - providers { - provider(emailProvider) - } - isAnonymousUpgradeEnabled = true + ).thenReturn(taskCompletionSource.task) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) } + isAnonymousUpgradeEnabled = true + } - instance.createOrLinkUserWithEmailAndPassword( - context = applicationContext, - config = config, - provider = emailProvider, - name = null, - email = "test@example.com", - password = "Pass@123" - ) + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123", + credentialProvider = mockEmailAuthCredentialProvider + ) - mockedProvider.verify { - EmailAuthProvider.getCredential("test@example.com", "Pass@123") - } - verify(mockAnonymousUser).linkWithCredential(mockCredential) - } + verify(mockEmailAuthCredentialProvider).getCredential("test@example.com", "Pass@123") + verify(mockAnonymousUser).linkWithCredential(mockCredential) } @Test @@ -292,10 +290,9 @@ class EmailAuthProviderFirebaseAuthUITest { `when`(mockAnonymousUser.email).thenReturn("test@example.com") `when`(mockFirebaseAuth.currentUser).thenReturn(mockAnonymousUser) - val collisionException = FirebaseAuthUserCollisionException( - "ERROR_EMAIL_ALREADY_IN_USE", - "Email already in use" - ) + val collisionException = mock(FirebaseAuthUserCollisionException::class.java) + `when`(collisionException.errorCode).thenReturn("ERROR_EMAIL_ALREADY_IN_USE") + val taskCompletionSource = TaskCompletionSource() taskCompletionSource.setException(collisionException) `when`(mockAnonymousUser.linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java))) @@ -565,16 +562,11 @@ class EmailAuthProviderFirebaseAuthUITest { `when`(mockFirebaseAuth.currentUser).thenReturn(anonymousUser) val credential = GoogleAuthProvider.getCredential("google-id-token", null) - val updatedCredential = EmailAuthProvider.getCredential("test@example.com", "Pass@123") + val updatedCredential = mock(AuthCredential::class.java) - val collisionException = FirebaseAuthUserCollisionException( - "ERROR_CREDENTIAL_ALREADY_IN_USE", - "Credential already in use" - ) - // Set updatedCredential using reflection - val field = FirebaseAuthUserCollisionException::class.java.getDeclaredField("zza") - field.isAccessible = true - field.set(collisionException, updatedCredential) + val collisionException = mock(FirebaseAuthUserCollisionException::class.java) + `when`(collisionException.errorCode).thenReturn("ERROR_CREDENTIAL_ALREADY_IN_USE") + `when`(collisionException.updatedCredential).thenReturn(updatedCredential) val taskCompletionSource = TaskCompletionSource() taskCompletionSource.setException(collisionException) @@ -723,4 +715,4 @@ class EmailAuthProviderFirebaseAuthUITest { assertThat(e.cause).isInstanceOf(CancellationException::class.java) } } -} +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt new file mode 100644 index 000000000..b01b10ab8 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt @@ -0,0 +1,400 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration.auth_provider + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Comprehensive unit tests for Phone Authentication provider methods in FirebaseAuthUI. + * + * Tests cover all phone auth methods: + * - verifyPhoneNumber (instant verification, manual verification, resend) + * - submitVerificationCode + * - signInWithPhoneAuthCredential + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class PhoneAuthProviderFirebaseAuthUITest { + + @Mock + private lateinit var mockFirebaseAuth: FirebaseAuth + + @Mock + private lateinit var mockPhoneAuthVerifier: AuthProvider.Phone.Verifier + + @Mock + private lateinit var mockPhoneAuthCredentialProvider: AuthProvider.Phone.CredentialProvider + + private lateinit var firebaseApp: FirebaseApp + private lateinit var applicationContext: Context + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + FirebaseAuthUI.clearInstanceCache() + + applicationContext = ApplicationProvider.getApplicationContext() + + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + firebaseApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + try { + firebaseApp.delete() + } catch (_: Exception) { + // Ignore if already deleted + } + } + + // ============================================================================================= + // verifyPhoneNumber Tests + // ============================================================================================= + + @Test + fun `verifyPhoneNumber - instant verification succeeds and emits SMSAutoVerified`() = runTest { + val mockCredential = mock(PhoneAuthCredential::class.java) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + + `when`( + mockPhoneAuthVerifier.verifyPhoneNumber( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + eq(true) + ) + ).thenReturn(AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified(mockCredential)) + + instance.verifyPhoneNumber( + provider = phoneProvider, + phoneNumber = "+1234567890", + verifier = mockPhoneAuthVerifier + ) + + val finalState = instance.authStateFlow().first { it is AuthState.SMSAutoVerified } + assertThat(finalState).isInstanceOf(AuthState.SMSAutoVerified::class.java) + val autoVerifiedState = finalState as AuthState.SMSAutoVerified + assertThat(autoVerifiedState.credential).isEqualTo(mockCredential) + } + + @Test + fun `verifyPhoneNumber - manual verification emits PhoneNumberVerificationRequired`() = + runTest { + val mockToken = mock(PhoneAuthProvider.ForceResendingToken::class.java) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + + `when`( + mockPhoneAuthVerifier.verifyPhoneNumber( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + eq(true) + ) + ).thenReturn( + AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification( + "test-verification-id", + mockToken + ) + ) + + instance.verifyPhoneNumber( + provider = phoneProvider, + phoneNumber = "+1234567890", + verifier = mockPhoneAuthVerifier + ) + + val finalState = + instance.authStateFlow().first { it is AuthState.PhoneNumberVerificationRequired } + assertThat(finalState).isInstanceOf(AuthState.PhoneNumberVerificationRequired::class.java) + val verificationState = finalState as AuthState.PhoneNumberVerificationRequired + assertThat(verificationState.verificationId).isEqualTo("test-verification-id") + assertThat(verificationState.forceResendingToken).isEqualTo(mockToken) + } + + @Test + fun `verifyPhoneNumber - with forceResendingToken resends code`() = runTest { + val mockToken = mock(PhoneAuthProvider.ForceResendingToken::class.java) + val newMockToken = mock(PhoneAuthProvider.ForceResendingToken::class.java) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + + `when`( + mockPhoneAuthVerifier.verifyPhoneNumber( + any(), + any(), + any(), + eq(mockToken), + anyOrNull(), + eq(true) + ) + ).thenReturn( + AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification( + "new-verification-id", + newMockToken + ) + ) + + instance.verifyPhoneNumber( + provider = phoneProvider, + phoneNumber = "+1234567890", + forceResendingToken = mockToken, + verifier = mockPhoneAuthVerifier + ) + + val finalState = + instance.authStateFlow().first { it is AuthState.PhoneNumberVerificationRequired } + assertThat(finalState).isInstanceOf(AuthState.PhoneNumberVerificationRequired::class.java) + val verificationState = finalState as AuthState.PhoneNumberVerificationRequired + assertThat(verificationState.verificationId).isEqualTo("new-verification-id") + assertThat(verificationState.forceResendingToken).isEqualTo(newMockToken) + } + + @Test + fun `verifyPhoneNumber - respects isInstantVerificationEnabled flag`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = false // Disabled + ) + + `when`( + mockPhoneAuthVerifier.verifyPhoneNumber( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + eq(false) + ) + ).thenReturn( + AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification( + "test-id", + mock() + ) + ) + + instance.verifyPhoneNumber( + provider = phoneProvider, + phoneNumber = "+1234567890", + verifier = mockPhoneAuthVerifier + ) + + verify(mockPhoneAuthVerifier).verifyPhoneNumber( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + eq(false) + ) + } + + // ============================================================================================= + // submitVerificationCode Tests + // ============================================================================================= + + @Test + fun `submitVerificationCode - creates credential and signs in successfully`() = runTest { + val mockCredential = mock(PhoneAuthCredential::class.java) + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + + `when`(mockPhoneAuthCredentialProvider.getCredential("test-verification-id", "123456")) + .thenReturn(mockCredential) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(phoneProvider) + } + } + + val result = instance.submitVerificationCode( + config = config, + verificationId = "test-verification-id", + code = "123456", + credentialProvider = mockPhoneAuthCredentialProvider + ) + + assertThat(result).isNotNull() + assertThat(result?.user).isEqualTo(mockUser) + verify(mockFirebaseAuth).signInWithCredential(mockCredential) + } + + // ============================================================================================= + // signInWithPhoneAuthCredential Tests + // ============================================================================================= + + @Test + fun `signInWithPhoneAuthCredential - successful sign in with credential`() = runTest { + val mockCredential = mock(PhoneAuthCredential::class.java) + val mockUser = mock(FirebaseUser::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.signInWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(phoneProvider) + } + } + + val result = instance.signInWithPhoneAuthCredential( + config = config, + credential = mockCredential + ) + + assertThat(result).isNotNull() + assertThat(result?.user).isEqualTo(mockUser) + verify(mockFirebaseAuth).signInWithCredential(mockCredential) + } + + @Test + fun `signInWithPhoneAuthCredential - handles anonymous upgrade`() = runTest { + val anonymousUser = mock(FirebaseUser::class.java) + `when`(anonymousUser.isAnonymous).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(anonymousUser) + + val mockCredential = mock(PhoneAuthCredential::class.java) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(anonymousUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(anonymousUser.linkWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(phoneProvider) + } + isAnonymousUpgradeEnabled = true + } + + val result = instance.signInWithPhoneAuthCredential( + config = config, + credential = mockCredential + ) + + assertThat(result).isNotNull() + verify(anonymousUser).linkWithCredential(mockCredential) + } + +} \ No newline at end of file