Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ fun AuthFlowDemo(
is AuthState.Success -> "Success - User: ${(authState as AuthState.Success).user.email}"
is AuthState.Error -> "Error: ${(authState as AuthState.Error).exception.message}"
is AuthState.Cancelled -> "Cancelled"
is AuthState.Aborted -> "Aborted"
is AuthState.RequiresMfa -> "MFA Required"
is AuthState.RequiresEmailVerification -> "Email Verification Required"
else -> "Unknown"
Expand Down
29 changes: 22 additions & 7 deletions auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,9 @@ class MainActivity : ComponentActivity() {
Toast.makeText(this, "Error: ${exception.message}", Toast.LENGTH_SHORT).show()
},
onSignInCancelled = {
finish()
// User backed out of a single provider (e.g. dismissed the Google
// Credential Manager sheet); FirebaseAuthScreen already returns to
// the method picker on its own — no action needed here.
}
)
}
Expand Down Expand Up @@ -343,7 +345,12 @@ lifecycleScope.launch {
Log.e(TAG, "Auth failed", state.exception)
}
is AuthState.Cancelled -> {
// User cancelled
// User cancelled a single sign-in attempt (e.g. dismissed the
// Credential Manager sheet, backed out of MFA); the flow stays open
}
is AuthState.Aborted -> {
// Flow was ended via controller.cancel()
finish()
}
else -> {
// Handle other states (RequiresMfa, RequiresEmailVerification, etc.)
Expand Down Expand Up @@ -375,6 +382,7 @@ sealed class AuthState {
data class RequiresEmailVerification(val user: FirebaseUser, val email: String) : AuthState()
data class RequiresProfileCompletion(val user: FirebaseUser, val missingFields: List<String> = emptyList()) : AuthState()
object Cancelled : AuthState()
object Aborted : AuthState()
object PasswordResetLinkSent : AuthState()
object EmailSignInLinkSent : AuthState()
data class SMSAutoVerified(val credential: PhoneAuthCredential) : AuthState()
Expand Down Expand Up @@ -671,7 +679,8 @@ fun AuthenticationScreen() {
}
},
onSignInCancelled = {
navigateBack()
// User backed out of a single provider; the screen already returns
// to the method picker on its own.
}
)
}
Expand All @@ -684,7 +693,7 @@ fun AuthenticationScreen() {
| `configuration` | `AuthUIConfiguration` | *Required* | Authentication configuration (providers, theme, etc.) |
| `onSignInSuccess` | `(AuthResult) -> Unit` | *Required* | Callback when sign-in succeeds |
| `onSignInFailure` | `(AuthException) -> Unit` | *Required* | Callback when sign-in fails |
| `onSignInCancelled` | `() -> Unit` | *Required* | Callback when user cancels authentication |
| `onSignInCancelled` | `() -> Unit` | *Required* | Callback when the user backs out of a single sign-in attempt (`AuthState.Cancelled`, e.g. dismissing the Google Credential Manager sheet); `FirebaseAuthScreen` already returns to the method picker itself, so this is informational only. Not called when the whole flow ends via `AuthFlowController.cancel()` (`AuthState.Aborted`) — that state is observable directly on `authUI.authStateFlow()`/`authFlowController.authStateFlow` for callers who need it |
| `modifier` | `Modifier` | `Modifier` | Modifier for the composable |
| `authUI` | `FirebaseAuthUI` | `FirebaseAuthUI.getInstance()` | Custom FirebaseAuthUI instance (for multi-app support) |
| `emailLink` | `String?` | `null` | Email link for passwordless sign-in (see [Email Link Sign-In](#email-link-sign-in)) |
Expand All @@ -706,7 +715,8 @@ FirebaseAuthScreen(
showError(exception)
},
onSignInCancelled = {
finish()
// User backed out of a single provider; the screen already returns
// to the method picker on its own.
},
authenticatedContent = { state, uiContext ->
// Show a welcome screen or profile completion UI
Expand Down Expand Up @@ -777,7 +787,11 @@ class AuthActivity : ComponentActivity() {
showEmailVerificationScreen(state.user)
}
is AuthState.Cancelled -> {
// User cancelled authentication
// User cancelled a single sign-in attempt; the flow stays open
// and returns to the method picker
}
is AuthState.Aborted -> {
// Flow was ended via controller.cancel()
finish()
}
else -> {
Expand Down Expand Up @@ -1729,7 +1743,8 @@ override fun onCreate(savedInstanceState: Bundle?) {
// Handle error
},
onSignInCancelled = {
finish()
// User backed out of a single provider; the screen already
// returns to the method picker on its own.
}
)
}
Expand Down
8 changes: 8 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/AuthException.kt
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,14 @@ abstract class AuthException(
cause = firebaseException
)

// FirebaseAuthWebException code for backing out of the OAuth custom tab
"ERROR_WEB_CONTEXT_CANCELED" -> AuthCancelledException(
message = stringProvider?.errorAuthCancelled.nonEmpty()
?: firebaseException.message
?: "Authentication was cancelled",
cause = firebaseException
)

else -> UnknownException(
message = stringProvider?.errorUnknownAuth.nonEmpty()
?: firebaseException.message
Expand Down
21 changes: 16 additions & 5 deletions auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.MainThread
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -71,7 +72,10 @@ import java.util.concurrent.atomic.AtomicBoolean
* // Handle error
* }
* is AuthState.Cancelled -> {
* // User cancelled
* // User cancelled a single sign-in attempt; flow stays open
* }
* is AuthState.Aborted -> {
* // The whole flow was ended via authController.cancel()
* }
* else -> {}
* }
Expand All @@ -93,7 +97,7 @@ import java.util.concurrent.atomic.AtomicBoolean
* **Lifecycle Management:**
* - [createIntent] - Generate Intent to start the auth flow Activity
* - [start] - Alternative to launch the flow (for Activity context)
* - [cancel] - Cancel the ongoing auth flow, transitions to [AuthState.Cancelled]
* - [cancel] - Cancel the ongoing auth flow, transitions to [AuthState.Aborted]
* - [dispose] - Release all resources (coroutines, listeners). Call in onDestroy()
*
* @property authUI The [FirebaseAuthUI] instance managing authentication
Expand All @@ -120,7 +124,9 @@ class AuthFlowController internal constructor(
* - [AuthState.Loading] - Authentication in progress
* - [AuthState.Success] - User signed in successfully
* - [AuthState.Error] - Authentication error occurred
* - [AuthState.Cancelled] - User cancelled the flow
* - [AuthState.Cancelled] - Operation-level cancellation; the user cancelled a single
* sign-in attempt and the flow stays open
* - [AuthState.Aborted] - The whole flow was ended via [cancel]
* - [AuthState.RequiresMfa] - Multi-factor authentication required
* - [AuthState.RequiresEmailVerification] - Email verification required
*/
Expand Down Expand Up @@ -195,10 +201,14 @@ class AuthFlowController internal constructor(
/**
* Cancels the ongoing authentication flow.
*
* This method transitions the auth state to [AuthState.Cancelled] and
* This method transitions the auth state to [AuthState.Aborted] and
* signals the auth flow to terminate. The auth flow Activity will finish
* and return [Activity.RESULT_CANCELED].
*
* Unlike [AuthState.Cancelled] (an operation-level cancellation that leaves the flow
* open, e.g. dismissing the Google Credential Manager sheet), calling this method ends
* the entire flow.
*
* **Example:**
* ```kotlin
* // User clicked a "Cancel" button
Expand All @@ -209,9 +219,10 @@ class AuthFlowController internal constructor(
*
* @throws IllegalStateException if the controller has been disposed
*/
@MainThread
fun cancel() {
checkNotDisposed()
authUI.updateAuthState(AuthState.Cancelled)
authUI.updateAuthState(AuthState.Aborted)
}

/**
Expand Down
51 changes: 51 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/AuthState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,19 @@ import com.google.firebase.auth.PhoneAuthProvider
*/
abstract class AuthState private constructor() {

/**
* Whether this is a one-off notification: something a screen shows once (a dialog, a "link
* sent" message) and must reset back to [Idle] immediately after consuming, so it doesn't
* leak to a screen/Activity created later. `abstract` so every new state must explicitly
* decide this rather than silently defaulting one way.
*/
abstract val isNotification: Boolean

/**
* Initial state before any authentication operation has been started.
*/
class Idle internal constructor() : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean = other is Idle
override fun hashCode(): Int = javaClass.hashCode()
override fun toString(): String = "AuthState.Idle"
Expand All @@ -49,6 +58,7 @@ abstract class AuthState private constructor() {
* @property message Optional message describing what is being loaded
*/
class Loading(val message: String? = null) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Loading) return false
Expand All @@ -72,6 +82,7 @@ abstract class AuthState private constructor() {
val user: FirebaseUser,
val isNewUser: Boolean = false
) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Success) return false
Expand Down Expand Up @@ -101,6 +112,7 @@ abstract class AuthState private constructor() {
val exception: Exception,
val isRecoverable: Boolean = true
) : AuthState() {
override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Error) return false
Expand All @@ -120,13 +132,37 @@ abstract class AuthState private constructor() {

/**
* Authentication was cancelled by the user.
*
* This is an operation-level cancellation: the user backed out of a single sign-in
* attempt (e.g. dismissed the Google Credential Manager sheet, backed out of an MFA
* challenge). The flow stays open and the screen returns to the method picker.
*
* @see Aborted for the state that ends the whole flow instead
*/
class Cancelled internal constructor() : AuthState() {
override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean = other is Cancelled
override fun hashCode(): Int = javaClass.hashCode()
override fun toString(): String = "AuthState.Cancelled"
}

/**
* The entire authentication flow was aborted.
*
* This state is emitted only by [AuthFlowController.cancel]. Unlike [Cancelled], which is
* a normal in-flow outcome that leaves the flow open, [Aborted] ends the whole flow —
* for example, [FirebaseAuthActivity] finishes with `RESULT_CANCELED` when it observes
* this state.
*
* @see Cancelled for the operation-level state that leaves the flow open
*/
class Aborted internal constructor() : AuthState() {
override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean = other is Aborted
override fun hashCode(): Int = javaClass.hashCode()
override fun toString(): String = "AuthState.Aborted"
}

/**
* Multi-factor authentication is required to complete sign-in.
*
Expand All @@ -137,6 +173,7 @@ abstract class AuthState private constructor() {
val resolver: MultiFactorResolver,
val hint: String? = null
) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RequiresMfa) return false
Expand Down Expand Up @@ -164,6 +201,7 @@ abstract class AuthState private constructor() {
val user: FirebaseUser,
val email: String
) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RequiresEmailVerification) return false
Expand Down Expand Up @@ -191,6 +229,7 @@ abstract class AuthState private constructor() {
val user: FirebaseUser,
val missingFields: List<String> = emptyList()
) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RequiresProfileCompletion) return false
Expand Down Expand Up @@ -221,6 +260,7 @@ abstract class AuthState private constructor() {
// Not included in equals/hashCode — lambdas have no meaningful equality.
val retryOperation: (suspend (android.content.Context) -> Unit)? = null,
) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ReauthenticationRequired) return false
Expand All @@ -241,6 +281,7 @@ abstract class AuthState private constructor() {
* Password reset link has been sent to the user's email.
*/
class PasswordResetLinkSent : AuthState() {
override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean = other is PasswordResetLinkSent
override fun hashCode(): Int = javaClass.hashCode()
override fun toString(): String = "AuthState.PasswordResetLinkSent"
Expand All @@ -250,6 +291,7 @@ abstract class AuthState private constructor() {
* Email sign in link has been sent to the user's email.
*/
class EmailSignInLinkSent : AuthState() {
override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean = other is EmailSignInLinkSent
override fun hashCode(): Int = javaClass.hashCode()
override fun toString(): String = "AuthState.EmailSignInLinkSent"
Expand All @@ -267,6 +309,7 @@ abstract class AuthState private constructor() {
* @see PhoneNumberVerificationRequired for the manual verification flow
*/
class SMSAutoVerified(val credential: PhoneAuthCredential) : AuthState() {
override val isNotification: Boolean = true
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SMSAutoVerified) return false
Expand Down Expand Up @@ -305,6 +348,7 @@ abstract class AuthState private constructor() {
val verificationId: String,
val forceResendingToken: PhoneAuthProvider.ForceResendingToken,
) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is PhoneNumberVerificationRequired) return false
Expand Down Expand Up @@ -337,5 +381,12 @@ abstract class AuthState private constructor() {
*/
@JvmStatic
val Cancelled: Cancelled = Cancelled()

/**
* Creates an Aborted state instance.
* @return A new [Aborted] state
*/
@JvmStatic
val Aborted: Aborted = Aborted()
}
}
14 changes: 8 additions & 6 deletions auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ import java.util.concurrent.ConcurrentHashMap
*
* This activity displays the [FirebaseAuthScreen] composable and manages
* the authentication flow lifecycle. It automatically finishes when the user
* signs in successfully or cancels the flow.
* signs in successfully ([AuthState.Success]) or the flow is aborted
* ([AuthState.Aborted], e.g. via [AuthFlowController.cancel]). Operation-level
* cancellations ([AuthState.Cancelled], e.g. dismissing the Google Credential Manager
* sheet) do not finish the activity — the flow stays open and the screen returns to
* the method picker.
*
* **Do not launch this Activity directly.**
* Use [AuthFlowController] to start the auth flow:
Expand Down Expand Up @@ -116,9 +120,9 @@ class FirebaseAuthActivity : ComponentActivity() {
setResult(RESULT_OK, resultIntent)
finish()
}
is AuthState.Cancelled -> {
// User cancelled the flow
is AuthState.Aborted -> {
setResult(RESULT_CANCELED)
authUI.updateAuthState(AuthState.Idle)
finish()
}
is AuthState.Error -> {
Expand Down Expand Up @@ -149,9 +153,7 @@ class FirebaseAuthActivity : ComponentActivity() {
onSignInFailure = { exception ->
// State flow will handle error
},
onSignInCancelled = {
authUI.updateAuthState(AuthState.Cancelled)
}
onSignInCancelled = {}
)
}
}
Expand Down
Loading
Loading