Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -79,6 +80,7 @@ class FirebaseAuthUI private constructor(
) {

private val _authStateFlow = MutableStateFlow<AuthState>(AuthState.Idle)
private val authStateRevision = AtomicLong(0)

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
var testCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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<VerifyPhoneNumberResult> {
return verifier.verifyPhoneNumber(
auth,
activity,
Expand All @@ -383,71 +387,78 @@ 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,
timeout: Long,
forceResendingToken: PhoneAuthProvider.ForceResendingToken?,
multiFactorSession: MultiFactorSession?,
isInstantVerificationEnabled: Boolean,
): VerifyPhoneNumberResult
): Flow<VerifyPhoneNumberResult>
}

/**
* @suppress
*/
internal class DefaultVerifier : Verifier {
override suspend fun verifyPhoneNumber(
override fun verifyPhoneNumber(
auth: FirebaseAuth,
activity: Activity?,
phoneNumber: String,
timeout: Long,
forceResendingToken: PhoneAuthProvider.ForceResendingToken?,
multiFactorSession: MultiFactorSession?,
isInstantVerificationEnabled: Boolean,
): VerifyPhoneNumberResult {
return suspendCoroutine { continuation ->
val options = PhoneAuthOptions.newBuilder(auth)
.setPhoneNumber(phoneNumber)
.requireSmsValidation(!isInstantVerificationEnabled)
.setTimeout(timeout, TimeUnit.SECONDS)
.setCallbacks(object :
PhoneAuthProvider.OnVerificationStateChangedCallbacks() {
override fun onVerificationCompleted(credential: PhoneAuthCredential) {
continuation.resume(VerifyPhoneNumberResult.AutoVerified(credential))
}
): Flow<VerifyPhoneNumberResult> = 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 { }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Comment thread
demolaf marked this conversation as resolved.
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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:**
Expand All @@ -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,
Expand Down Expand Up @@ -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 -> {
Expand Down Expand Up @@ -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 -> {
Expand Down
Loading