Skip to content

Commit 637e13a

Browse files
authored
fix(auth): fix "Already resumed" crash in phone auth SMS auto-verification (#2447)
1 parent f896825 commit 637e13a

8 files changed

Lines changed: 1400 additions & 153 deletions

File tree

auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import kotlinx.coroutines.flow.combine
4141
import kotlinx.coroutines.flow.distinctUntilChanged
4242
import kotlinx.coroutines.tasks.await
4343
import java.util.concurrent.ConcurrentHashMap
44+
import java.util.concurrent.atomic.AtomicLong
4445

4546
/**
4647
* The central class that coordinates all authentication operations for Firebase Auth UI Compose.
@@ -79,6 +80,7 @@ class FirebaseAuthUI private constructor(
7980
) {
8081

8182
private val _authStateFlow = MutableStateFlow<AuthState>(AuthState.Idle)
83+
private val authStateRevision = AtomicLong(0)
8284

8385
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
8486
var testCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null
@@ -363,9 +365,29 @@ class FirebaseAuthUI private constructor(
363365
*/
364366
@MainThread
365367
fun updateAuthState(state: AuthState) {
368+
authStateRevision.incrementAndGet()
366369
_authStateFlow.value = state
367370
}
368371

372+
/**
373+
* Retracts a pending [AuthState.Loading] by resetting to [AuthState.Idle], but only while
374+
* [revision] is still the most recent write. Any state emitted since is left untouched.
375+
*
376+
* The revision is what makes this precise: [AuthState.Loading] compares equal whenever the
377+
* message matches, and [MutableStateFlow] drops a write equal to the current value without
378+
* replacing the stored reference - so neither equality nor identity can tell a concurrent
379+
* operation's Loading apart from the caller's.
380+
*
381+
* @param revision The value [currentAuthStateRevision] returned right after the caller emitted
382+
* the [AuthState.Loading] it now wants to retract
383+
*/
384+
internal fun clearLoadingState(revision: Long) {
385+
if (authStateRevision.get() == revision) updateAuthState(AuthState.Idle)
386+
}
387+
388+
/** Identifies the most recent [updateAuthState] write. See [clearLoadingState]. */
389+
internal fun currentAuthStateRevision(): Long = authStateRevision.get()
390+
369391
internal fun updateAuthStateWithResult(result: AuthResult?, defaultIsNewUser: Boolean = false) {
370392
val user = result?.user
371393
if (user != null) {

auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt

Lines changed: 65 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,13 @@ import com.google.firebase.auth.PhoneAuthProvider
5555
import com.google.firebase.auth.TwitterAuthProvider
5656
import com.google.firebase.auth.UserProfileChangeRequest
5757
import com.google.firebase.auth.actionCodeSettings
58+
import kotlinx.coroutines.channels.awaitClose
59+
import kotlinx.coroutines.flow.Flow
60+
import kotlinx.coroutines.flow.callbackFlow
5861
import kotlinx.coroutines.suspendCancellableCoroutine
5962
import kotlinx.coroutines.tasks.await
6063
import java.util.concurrent.TimeUnit
6164
import kotlin.coroutines.resume
62-
import kotlin.coroutines.resumeWithException
63-
import kotlin.coroutines.suspendCoroutine
6465

6566
@AuthUIConfigurationDsl
6667
class AuthProvidersBuilder {
@@ -336,19 +337,23 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
336337
}
337338

338339
/**
339-
* Internal coroutine-based wrapper for Firebase Phone Authentication verification.
340+
* Internal wrapper that exposes Firebase Phone Authentication verification as a [Flow].
340341
*
341-
* This method wraps the callback-based Firebase Phone Auth API into a suspending function
342-
* using Kotlin coroutines. It handles the Firebase [PhoneAuthProvider.OnVerificationStateChangedCallbacks]
343-
* and converts them into a [VerifyPhoneNumberResult].
342+
* Firebase's [PhoneAuthProvider.OnVerificationStateChangedCallbacks] is a multi-shot
343+
* callback: for the same request it can report `onCodeSent` and then, once the SMS is
344+
* auto-retrieved, `onVerificationCompleted`. Each callback becomes one emission, so no
345+
* result is ever dropped.
344346
*
345347
* **Callback mapping:**
346348
* - `onVerificationCompleted` → [VerifyPhoneNumberResult.AutoVerified]
347349
* - `onCodeSent` → [VerifyPhoneNumberResult.NeedsManualVerification]
348-
* - `onVerificationFailed` → throws the exception
350+
* - `onVerificationFailed` → terminates the flow with that exception
351+
* - `onCodeAutoRetrievalTimeOut` → completes the flow normally
349352
*
350-
* This is a private helper method used by [verifyPhoneNumber]. Callers should use
351-
* [verifyPhoneNumber] instead as it handles state management and error handling.
353+
* `onCodeAutoRetrievalTimeOut` fires only when the window expires without a prior
354+
* `onVerificationCompleted`, so the flow terminates on its own only on the SMS path.
355+
* Instant verification has no terminal callback: there the flow stays open until the
356+
* collector is cancelled. Callers that only want the first result should use `first()`.
352357
*
353358
* @param auth The [FirebaseAuth] instance to use for verification
354359
* @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:
357362
* instead of primary sign-in. Pass null for standard phone authentication.
358363
* @param forceResendingToken Optional token from previous verification for resending
359364
*
360-
* @return [VerifyPhoneNumberResult] indicating auto-verified or manual verification needed
361-
* @throws FirebaseException if verification fails
365+
* @return a [Flow] of [VerifyPhoneNumberResult] emissions, one per Firebase callback
362366
*/
363-
internal suspend fun verifyPhoneNumberAwait(
367+
internal fun verifyPhoneNumberFlow(
364368
auth: FirebaseAuth,
365369
activity: Activity?,
366370
phoneNumber: String,
367371
multiFactorSession: MultiFactorSession? = null,
368372
forceResendingToken: PhoneAuthProvider.ForceResendingToken?,
369373
verifier: Verifier = DefaultVerifier(),
370-
): VerifyPhoneNumberResult {
374+
): Flow<VerifyPhoneNumberResult> {
371375
return verifier.verifyPhoneNumber(
372376
auth,
373377
activity,
@@ -383,71 +387,78 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
383387
* @suppress
384388
*/
385389
internal interface Verifier {
386-
suspend fun verifyPhoneNumber(
390+
fun verifyPhoneNumber(
387391
auth: FirebaseAuth,
388392
activity: Activity?,
389393
phoneNumber: String,
390394
timeout: Long,
391395
forceResendingToken: PhoneAuthProvider.ForceResendingToken?,
392396
multiFactorSession: MultiFactorSession?,
393397
isInstantVerificationEnabled: Boolean,
394-
): VerifyPhoneNumberResult
398+
): Flow<VerifyPhoneNumberResult>
395399
}
396400

397401
/**
398402
* @suppress
399403
*/
400404
internal class DefaultVerifier : Verifier {
401-
override suspend fun verifyPhoneNumber(
405+
override fun verifyPhoneNumber(
402406
auth: FirebaseAuth,
403407
activity: Activity?,
404408
phoneNumber: String,
405409
timeout: Long,
406410
forceResendingToken: PhoneAuthProvider.ForceResendingToken?,
407411
multiFactorSession: MultiFactorSession?,
408412
isInstantVerificationEnabled: Boolean,
409-
): VerifyPhoneNumberResult {
410-
return suspendCoroutine { continuation ->
411-
val options = PhoneAuthOptions.newBuilder(auth)
412-
.setPhoneNumber(phoneNumber)
413-
.requireSmsValidation(!isInstantVerificationEnabled)
414-
.setTimeout(timeout, TimeUnit.SECONDS)
415-
.setCallbacks(object :
416-
PhoneAuthProvider.OnVerificationStateChangedCallbacks() {
417-
override fun onVerificationCompleted(credential: PhoneAuthCredential) {
418-
continuation.resume(VerifyPhoneNumberResult.AutoVerified(credential))
419-
}
413+
): Flow<VerifyPhoneNumberResult> = callbackFlow {
414+
val options = PhoneAuthOptions.newBuilder(auth)
415+
.setPhoneNumber(phoneNumber)
416+
.requireSmsValidation(!isInstantVerificationEnabled)
417+
.setTimeout(timeout, TimeUnit.SECONDS)
418+
.setCallbacks(object :
419+
PhoneAuthProvider.OnVerificationStateChangedCallbacks() {
420+
override fun onVerificationCompleted(credential: PhoneAuthCredential) {
421+
trySend(VerifyPhoneNumberResult.AutoVerified(credential))
422+
}
420423

421-
override fun onVerificationFailed(e: FirebaseException) {
422-
continuation.resumeWithException(e)
423-
}
424+
override fun onVerificationFailed(e: FirebaseException) {
425+
close(e)
426+
}
424427

425-
override fun onCodeSent(
426-
verificationId: String,
427-
token: PhoneAuthProvider.ForceResendingToken,
428-
) {
429-
continuation.resume(
430-
VerifyPhoneNumberResult.NeedsManualVerification(
431-
verificationId,
432-
token
433-
)
428+
override fun onCodeSent(
429+
verificationId: String,
430+
token: PhoneAuthProvider.ForceResendingToken,
431+
) {
432+
trySend(
433+
VerifyPhoneNumberResult.NeedsManualVerification(
434+
verificationId,
435+
token
434436
)
435-
}
436-
})
437-
.apply {
438-
activity?.let {
439-
setActivity(it)
440-
}
441-
forceResendingToken?.let {
442-
setForceResendingToken(it)
443-
}
444-
multiFactorSession?.let {
445-
setMultiFactorSession(it)
446-
}
437+
)
447438
}
448-
.build()
449-
PhoneAuthProvider.verifyPhoneNumber(options)
450-
}
439+
440+
// Firebase's own terminal: nothing further can arrive for this request,
441+
// so complete rather than leaving the collector waiting forever.
442+
override fun onCodeAutoRetrievalTimeOut(verificationId: String) {
443+
close()
444+
}
445+
})
446+
.apply {
447+
activity?.let {
448+
setActivity(it)
449+
}
450+
forceResendingToken?.let {
451+
setForceResendingToken(it)
452+
}
453+
multiFactorSession?.let {
454+
setMultiFactorSession(it)
455+
}
456+
}
457+
.build()
458+
PhoneAuthProvider.verifyPhoneNumber(options)
459+
// Firebase exposes no way to unregister these callbacks, so there is nothing to
460+
// tear down when the collector goes away.
461+
awaitClose { }
451462
}
452463
}
453464

auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ import kotlinx.coroutines.CancellationException
3434
* - UI should show code entry screen
3535
* - User enters code → call [submitVerificationCode]
3636
*
37+
* **Lifecycle:** Firebase reports verification progress as a stream, so this call does not
38+
* return once the code is sent - on the SMS path it keeps collecting until the auto-retrieval
39+
* window expires, verification fails, or the caller is cancelled. A credential auto-retrieved
40+
* after [AuthState.PhoneNumberVerificationRequired] is therefore still emitted, as
41+
* [AuthState.SMSAutoVerified]. On the instant-verification path Firebase reports no terminal
42+
* callback at all, so only cancellation ends the call. Callers should cancel a superseded
43+
* attempt before starting a new one.
44+
*
3745
* **Resending codes:**
3846
* To resend a verification code, call this method again with:
3947
* - `forceResendingToken` = the token from [AuthState.PhoneNumberVerificationRequired]
@@ -99,8 +107,8 @@ import kotlinx.coroutines.CancellationException
99107
*
100108
* @throws AuthException.InvalidCredentialsException if the phone number is invalid
101109
* @throws AuthException.TooManyRequestsException if SMS quota is exceeded
102-
* @throws AuthException.AuthCancelledException if the operation is cancelled
103110
* @throws AuthException.NetworkException if a network error occurs
111+
* @throws kotlinx.coroutines.CancellationException if the caller's coroutine is cancelled
104112
*/
105113
internal suspend fun FirebaseAuthUI.verifyPhoneNumber(
106114
provider: AuthProvider.Phone,
@@ -111,37 +119,39 @@ internal suspend fun FirebaseAuthUI.verifyPhoneNumber(
111119
forceResendingToken: PhoneAuthProvider.ForceResendingToken? = null,
112120
verifier: AuthProvider.Phone.Verifier = AuthProvider.Phone.DefaultVerifier(),
113121
) {
122+
// -1 never matches a real revision, so a cancellation before the Loading lands clears nothing.
123+
var loadingRevision = -1L
114124
try {
115125
updateAuthState(AuthState.Loading(config.stringProvider.loadingVerifyingPhoneNumber))
116-
val result = provider.verifyPhoneNumberAwait(
126+
loadingRevision = currentAuthStateRevision()
127+
provider.verifyPhoneNumberFlow(
117128
auth = auth,
118129
activity = activity,
119130
phoneNumber = phoneNumber,
120131
multiFactorSession = multiFactorSession,
121132
forceResendingToken = forceResendingToken,
122133
verifier = verifier
123-
)
124-
when (result) {
125-
is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> {
126-
updateAuthState(AuthState.SMSAutoVerified(credential = result.credential))
127-
}
134+
).collect { result ->
135+
when (result) {
136+
is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> {
137+
updateAuthState(AuthState.SMSAutoVerified(credential = result.credential))
138+
}
128139

129-
is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> {
130-
updateAuthState(
131-
AuthState.PhoneNumberVerificationRequired(
132-
verificationId = result.verificationId,
133-
forceResendingToken = result.token,
140+
is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> {
141+
updateAuthState(
142+
AuthState.PhoneNumberVerificationRequired(
143+
verificationId = result.verificationId,
144+
forceResendingToken = result.token,
145+
)
134146
)
135-
)
147+
}
136148
}
137149
}
138150
} catch (e: CancellationException) {
139-
val cancelledException = AuthException.AuthCancelledException(
140-
message = "Verify phone number was cancelled",
141-
cause = e
142-
)
143-
updateAuthState(AuthState.Error(cancelledException))
144-
throw cancelledException
151+
// Cancellation here is the screen's own bookkeeping, not a failure: retract only the
152+
// Loading this call emitted, then rethrow so no spurious Error reaches authStateFlow.
153+
clearLoadingState(loadingRevision)
154+
throw e
145155
} catch (e: AuthException) {
146156
updateAuthState(AuthState.Error(e))
147157
throw e

auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import com.google.firebase.auth.FirebaseUser
2222
import com.google.firebase.auth.PhoneAuthCredential
2323
import com.google.firebase.auth.PhoneAuthProvider
2424
import com.google.firebase.auth.PhoneMultiFactorGenerator
25+
import kotlinx.coroutines.flow.first
2526
import kotlinx.coroutines.tasks.await
2627

2728
/**
@@ -33,7 +34,7 @@ import kotlinx.coroutines.tasks.await
3334
* - Verifying SMS codes entered by users
3435
* - Finalizing enrollment with Firebase Authentication
3536
*
36-
* This handler uses the existing [AuthProvider.Phone.verifyPhoneNumberAwait] infrastructure
37+
* This handler uses the existing [AuthProvider.Phone.verifyPhoneNumberFlow] infrastructure
3738
* for sending and verifying SMS codes, ensuring consistency with the primary phone auth flow.
3839
*
3940
* **Usage:**
@@ -59,7 +60,7 @@ import kotlinx.coroutines.tasks.await
5960
*
6061
* @since 10.0.0
6162
* @see TotpEnrollmentHandler
62-
* @see AuthProvider.Phone.verifyPhoneNumberAwait
63+
* @see AuthProvider.Phone.verifyPhoneNumberFlow
6364
*/
6465
class SmsEnrollmentHandler(
6566
private val activity: Activity,
@@ -98,13 +99,14 @@ class SmsEnrollmentHandler(
9899
}
99100

100101
val multiFactorSession = user.multiFactor.session.await()
101-
val result = phoneProvider.verifyPhoneNumberAwait(
102+
// Enrolment only needs the first result, so stop collecting after one emission.
103+
val result = phoneProvider.verifyPhoneNumberFlow(
102104
auth = auth,
103105
activity = activity,
104106
phoneNumber = phoneNumber,
105107
multiFactorSession = multiFactorSession,
106108
forceResendingToken = null
107-
)
109+
).first()
108110

109111
return when (result) {
110112
is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> {
@@ -146,13 +148,14 @@ class SmsEnrollmentHandler(
146148
}
147149

148150
val multiFactorSession = user.multiFactor.session.await()
149-
val result = phoneProvider.verifyPhoneNumberAwait(
151+
// Enrolment only needs the first result, so stop collecting after one emission.
152+
val result = phoneProvider.verifyPhoneNumberFlow(
150153
auth = auth,
151154
activity = activity,
152155
phoneNumber = session.phoneNumber,
153156
multiFactorSession = multiFactorSession,
154157
forceResendingToken = session.forceResendingToken
155-
)
158+
).first()
156159

157160
return when (result) {
158161
is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> {

0 commit comments

Comments
 (0)