Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import com.firebase.ui.auth.AuthException
Expand DownExpand Up@@ -334,9 +335,13 @@ private fun AppAuthenticatedContent(
lifecycleOwner.lifecycleScope.launch {
isDeletingAccount = true
try {
// Reauthentication, if it is needed, happens inside this call:
// the progress indicator below covers it, and the deletion is
// retried here rather than needing anything from this caller.
uiContext.authUI.delete(context)
} catch (e: AuthException.InvalidCredentialsException) {
Log.d("HighLevelApiDemoActivity", "Reauth required before delete")
} catch (e: AuthException.AuthCancelledException) {
// Declined at the identity check; the account is untouched.
Log.d("HighLevelApiDemoActivity", "Delete cancelled", e)
} catch (e: AuthException) {
Log.e("HighLevelApiDemoActivity", "Delete failed", e)
} finally {
Expand DownExpand Up@@ -567,6 +572,15 @@ private fun ChangePasswordDialog(
Log.d("HighLevelApiDemoActivity", "Password changed successfully")
onDismiss()
}
} catch (e: CancellationException) {
// withReauth suspends across the reauthentication sheet, so this
// scope really can be cancelled mid-call. Never report that as a
// failure the user can retry.
throw e
} catch (e: AuthException.AuthCancelledException) {
// The user backed out of confirming their identity. Nothing failed,
// and the password was not changed — so say neither.
Log.d("HighLevelApiDemoActivity", "Reauthentication declined", e)
} catch (e: Exception) {
updateError = "Failed to update password. Please try again."
} finally {
Expand Down
117 changes: 117 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
/*
* 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

import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser

/** Where one auth flow's states go. */
internal fun interface AuthStateSink {
fun emit(state: AuthState)
}

/**
* One auth flow's collaborators, and where its states go. Provider code is written against this,
* not [FirebaseAuthUI], so it reaches the public state channel only through [sink].
*
* @since 10.0.0
*/
internal class AuthFlowScope(
val auth: FirebaseAuth,
val config: AuthUIConfiguration,
val credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null,
val loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null,
/**
* What this flow is currently doing, for the screens rendering it. Under a reauthentication
* request's scope this is that request's phase rather than the host's state.
*/
val state: State<AuthState>,
private val sink: AuthStateSink,
) {
fun emit(state: AuthState) = sink.emit(state)

/**
* Publishes what [result] means for this flow: a password user who still owes email
* verification is not signed in yet, however successful the credential exchange was.
*/
fun emitResult(result: AuthResult?, defaultIsNewUser: Boolean = false) {
val user = result?.user
if (user != null) {
val isNewUser = result.additionalUserInfo?.isNewUser ?: defaultIsNewUser
emit(authUserState(user, result, isNewUser))
} else {
emit(AuthState.Idle)
}
}
}

/**
* What a signed-in [user] means as an [AuthState]: the single source of truth for whether they
* still owe email verification. Callers must not re-derive it — only password users with an email
* can satisfy that screen.
*/
internal fun authUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState {
val email = user.email
return if (!user.isEmailVerified &&
email != null &&
user.providerData.any { it.providerId == "password" }
) {
AuthState.RequiresEmailVerification(user = user, email = email)
} else {
AuthState.Success(result = result, user = user, isNewUser = isNewUser)
}
}

/** The auth flow the current composition belongs to, or null outside one. */
internal val LocalAuthFlowScope = staticCompositionLocalOf<AuthFlowScope?> { null }

/**
* The ambient flow when composed inside one, otherwise a fresh flow over [authUI]'s public state —
* which is what a consumer composing `EmailAuthScreen` or `PhoneAuthScreen` on its own gets.
*/
@Composable
internal fun rememberAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
): AuthFlowScope {
val ambient = LocalAuthFlowScope.current
val hostState = remember(authUI) { authUI.authStateFlow() }
.collectAsState(AuthState.Idle)
return remember(ambient, authUI, configuration, hostState) {
ambient ?: hostAuthFlowScope(authUI, configuration, hostState)
}
}

/** An [AuthFlowScope] over [authUI]'s public flow, in both directions. */
internal fun hostAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
state: State<AuthState>,
): AuthFlowScope = AuthFlowScope(
auth = authUI.auth,
config = configuration,
credentialManagerProvider = authUI.testCredentialManagerProvider,
loginManagerProvider = authUI.testLoginManagerProvider,
state = state,
sink = { authUI.updateAuthState(it) },
)
94 changes: 32 additions & 62 deletions auth/src/main/java/com/firebase/ui/auth/AuthState.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.MultiFactorResolver
import com.google.firebase.auth.PhoneAuthCredential
import com.google.firebase.auth.PhoneAuthProvider
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID

/**
Expand DownExpand Up@@ -256,11 +257,8 @@ abstract class AuthState private constructor() {
}

/**
* A state in the lifecycle of one reauthentication request.
*
* Every state carries a stable [requestId], so Activity recreation can distinguish a
* continuation of the same sensitive operation from a new operation for the same user. The
* request itself is process-local because its retry callback cannot be serialized.
* A state in the lifecycle of one reauthentication request. Every state carries a stable
* [requestId], so recreation can tell a continuation from a new operation for the same user.
*/
sealed class Reauthentication : AuthState() {
abstract val requestId: String
Expand All@@ -273,21 +271,31 @@ abstract class AuthState private constructor() {
val requestId: String,
val user: FirebaseUser,
val reason: String?,
retryOperation: (suspend (android.content.Context) -> Unit)?,
/**
* Where the caller awaiting this request is parked, or null when nobody is — a
* standalone flow from [FirebaseAuthUI.createReauthFlow] has no operation behind it.
*/
val resolver: CompletableDeferred<Boolean>? = null,
) {
/** Null once [claimRetryOperation] consumed it, so no recreation can re-run it. */
var retryOperation: (suspend (android.content.Context) -> Unit)? = retryOperation
private set
/** Whether a caller is waiting on this request to decide a pending operation. */
val hasPendingOperation: Boolean get() = resolver != null

/** Whether the awaiting caller is still there to resume. */
val isResumable: Boolean get() = resolver?.isActive != false

/** Whether this request ever carried an operation, even after it was claimed. */
val hasRetryOperation: Boolean = retryOperation != null
/** Credentials were accepted: the caller resumes and retries. Idempotent. */
fun resolve() {
resolver?.complete(true)
}

/**
* Hands the operation out exactly once. A second claim means the first run was lost,
* which must be reported rather than retried: the operation may have committed already.
* The request ended without proof. Completed with a value, not an exception: failing a
* parented Deferred would cancel the caller's scope, so [FirebaseAuthUI.withReauth]
* throws in its own frame instead.
*/
fun claimRetryOperation(): (suspend (android.content.Context) -> Unit)? =
retryOperation.also { retryOperation = null }
fun decline() {
resolver?.complete(false)
}
}

/**
Expand All@@ -302,26 +310,27 @@ abstract class AuthState private constructor() {
class Required internal constructor(
override val request: Request,
) : Reauthentication() {
constructor(
/** A request with nobody waiting on it, as a standalone reauthentication flow has. */
internal constructor(
user: FirebaseUser,
reason: String? = null,
retryOperation: (suspend (android.content.Context) -> Unit)? = null,
) : this(
Request(
requestId = UUID.randomUUID().toString(),
user = user,
reason = reason,
retryOperation = retryOperation,
)
)

override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
val user: FirebaseUser get() = request.user
val reason: String? get() = request.reason
val retryOperation: (suspend (android.content.Context) -> Unit)?
get() = request.retryOperation

/**
* Identity is the request. Snapshot state and [FirebaseAuthUI.pendingReauth] both
* conflate equal values, so a transition that must be observed changes the phase type.
*/
override fun equals(other: Any?): Boolean =
other is Required && requestId == other.requestId

Expand All@@ -341,7 +350,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The most recent credential attempt failed, but the request remains armed. */
/** The most recent credential attempt failed, but the request remains outstanding. */
internal class AttemptFailed(
override val request: Request,
val exception: Exception,
Expand DownExpand Up@@ -395,7 +404,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** Credentials were accepted for the request's user. */
/** Credentials were accepted for the request's user. Terminal for the exchange. */
internal class Succeeded(
override val request: Request,
val success: Success,
Expand All@@ -404,43 +413,9 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The sensitive operation is being retried after credentials were accepted. */
internal class RetryingOperation(
override val request: Request,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/** The retry completed and [outcome] is ready to become the ordinary auth state. */
internal class OperationFinished(
override val request: Request,
val outcome: AuthState,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/**
* Saved UI state proved a request existed, but its process-local retry callback was lost.
*/
internal class Interrupted(
override val requestId: String,
override val userUid: String,
) : Reauthentication() {
override val request: Request? = null
}

/**
* Whether this request's reauthentication already succeeded. A sign-out must not clear such
* a phase, because the pending operation succeeding can be what signed the user out.
*/
internal val isReauthenticated: Boolean
get() = this is Succeeded || this is RetryingOperation || this is OperationFinished

/**
* A provider attempt is about to run, clearing any previously surfaced failure. Null once
* credentials were accepted, so a late attempt cannot rewind a running operation.
* credentials were accepted, so a late attempt cannot rewind a finished request.
*/
internal fun attemptStarted(): AuthState? = when (this) {
is Required,
Expand DownExpand Up@@ -484,11 +459,6 @@ abstract class AuthState private constructor() {

else -> null
}

/** The retried sensitive operation produced [outcome]. Null unless a retry is in flight. */
internal fun operationFinished(outcome: AuthState): AuthState? =
(this as? RetryingOperation)
?.let { OperationFinished(it.request, outcome) }
}

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import com.firebase.ui.auth.AuthException
Expand DownExpand Up@@ -334,9 +335,13 @@ private fun AppAuthenticatedContent(
lifecycleOwner.lifecycleScope.launch {
isDeletingAccount = true
try {
// Reauthentication, if it is needed, happens inside this call:
// the progress indicator below covers it, and the deletion is
// retried here rather than needing anything from this caller.
uiContext.authUI.delete(context)
} catch (e: AuthException.InvalidCredentialsException) {
Log.d("HighLevelApiDemoActivity", "Reauth required before delete")
} catch (e: AuthException.AuthCancelledException) {
// Declined at the identity check; the account is untouched.
Log.d("HighLevelApiDemoActivity", "Delete cancelled", e)
} catch (e: AuthException) {
Log.e("HighLevelApiDemoActivity", "Delete failed", e)
} finally {
Expand DownExpand Up@@ -567,6 +572,15 @@ private fun ChangePasswordDialog(
Log.d("HighLevelApiDemoActivity", "Password changed successfully")
onDismiss()
}
} catch (e: CancellationException) {
// withReauth suspends across the reauthentication sheet, so this
// scope really can be cancelled mid-call. Never report that as a
// failure the user can retry.
throw e
} catch (e: AuthException.AuthCancelledException) {
// The user backed out of confirming their identity. Nothing failed,
// and the password was not changed — so say neither.
Log.d("HighLevelApiDemoActivity", "Reauthentication declined", e)
} catch (e: Exception) {
updateError = "Failed to update password. Please try again."
} finally {
Expand Down
117 changes: 117 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
/*
* 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

import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser

/** Where one auth flow's states go. */
internal fun interface AuthStateSink {
fun emit(state: AuthState)
}

/**
* One auth flow's collaborators, and where its states go. Provider code is written against this,
* not [FirebaseAuthUI], so it reaches the public state channel only through [sink].
*
* @since 10.0.0
*/
internal class AuthFlowScope(
val auth: FirebaseAuth,
val config: AuthUIConfiguration,
val credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null,
val loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null,
/**
* What this flow is currently doing, for the screens rendering it. Under a reauthentication
* request's scope this is that request's phase rather than the host's state.
*/
val state: State<AuthState>,
private val sink: AuthStateSink,
) {
fun emit(state: AuthState) = sink.emit(state)

/**
* Publishes what [result] means for this flow: a password user who still owes email
* verification is not signed in yet, however successful the credential exchange was.
*/
fun emitResult(result: AuthResult?, defaultIsNewUser: Boolean = false) {
val user = result?.user
if (user != null) {
val isNewUser = result.additionalUserInfo?.isNewUser ?: defaultIsNewUser
emit(authUserState(user, result, isNewUser))
} else {
emit(AuthState.Idle)
}
}
}

/**
* What a signed-in [user] means as an [AuthState]: the single source of truth for whether they
* still owe email verification. Callers must not re-derive it — only password users with an email
* can satisfy that screen.
*/
internal fun authUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState {
val email = user.email
return if (!user.isEmailVerified &&
email != null &&
user.providerData.any { it.providerId == "password" }
) {
AuthState.RequiresEmailVerification(user = user, email = email)
} else {
AuthState.Success(result = result, user = user, isNewUser = isNewUser)
}
}

/** The auth flow the current composition belongs to, or null outside one. */
internal val LocalAuthFlowScope = staticCompositionLocalOf<AuthFlowScope?> { null }

/**
* The ambient flow when composed inside one, otherwise a fresh flow over [authUI]'s public state —
* which is what a consumer composing `EmailAuthScreen` or `PhoneAuthScreen` on its own gets.
*/
@Composable
internal fun rememberAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
): AuthFlowScope {
val ambient = LocalAuthFlowScope.current
val hostState = remember(authUI) { authUI.authStateFlow() }
.collectAsState(AuthState.Idle)
return remember(ambient, authUI, configuration, hostState) {
ambient ?: hostAuthFlowScope(authUI, configuration, hostState)
}
}

/** An [AuthFlowScope] over [authUI]'s public flow, in both directions. */
internal fun hostAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
state: State<AuthState>,
): AuthFlowScope = AuthFlowScope(
auth = authUI.auth,
config = configuration,
credentialManagerProvider = authUI.testCredentialManagerProvider,
loginManagerProvider = authUI.testLoginManagerProvider,
state = state,
sink = { authUI.updateAuthState(it) },
)
94 changes: 32 additions & 62 deletions auth/src/main/java/com/firebase/ui/auth/AuthState.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.MultiFactorResolver
import com.google.firebase.auth.PhoneAuthCredential
import com.google.firebase.auth.PhoneAuthProvider
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID

/**
Expand DownExpand Up@@ -256,11 +257,8 @@ abstract class AuthState private constructor() {
}

/**
* A state in the lifecycle of one reauthentication request.
*
* Every state carries a stable [requestId], so Activity recreation can distinguish a
* continuation of the same sensitive operation from a new operation for the same user. The
* request itself is process-local because its retry callback cannot be serialized.
* A state in the lifecycle of one reauthentication request. Every state carries a stable
* [requestId], so recreation can tell a continuation from a new operation for the same user.
*/
sealed class Reauthentication : AuthState() {
abstract val requestId: String
Expand All@@ -273,21 +271,31 @@ abstract class AuthState private constructor() {
val requestId: String,
val user: FirebaseUser,
val reason: String?,
retryOperation: (suspend (android.content.Context) -> Unit)?,
/**
* Where the caller awaiting this request is parked, or null when nobody is — a
* standalone flow from [FirebaseAuthUI.createReauthFlow] has no operation behind it.
*/
val resolver: CompletableDeferred<Boolean>? = null,
) {
/** Null once [claimRetryOperation] consumed it, so no recreation can re-run it. */
var retryOperation: (suspend (android.content.Context) -> Unit)? = retryOperation
private set
/** Whether a caller is waiting on this request to decide a pending operation. */
val hasPendingOperation: Boolean get() = resolver != null

/** Whether the awaiting caller is still there to resume. */
val isResumable: Boolean get() = resolver?.isActive != false

/** Whether this request ever carried an operation, even after it was claimed. */
val hasRetryOperation: Boolean = retryOperation != null
/** Credentials were accepted: the caller resumes and retries. Idempotent. */
fun resolve() {
resolver?.complete(true)
}

/**
* Hands the operation out exactly once. A second claim means the first run was lost,
* which must be reported rather than retried: the operation may have committed already.
* The request ended without proof. Completed with a value, not an exception: failing a
* parented Deferred would cancel the caller's scope, so [FirebaseAuthUI.withReauth]
* throws in its own frame instead.
*/
fun claimRetryOperation(): (suspend (android.content.Context) -> Unit)? =
retryOperation.also { retryOperation = null }
fun decline() {
resolver?.complete(false)
}
}

/**
Expand All@@ -302,26 +310,27 @@ abstract class AuthState private constructor() {
class Required internal constructor(
override val request: Request,
) : Reauthentication() {
constructor(
/** A request with nobody waiting on it, as a standalone reauthentication flow has. */
internal constructor(
user: FirebaseUser,
reason: String? = null,
retryOperation: (suspend (android.content.Context) -> Unit)? = null,
) : this(
Request(
requestId = UUID.randomUUID().toString(),
user = user,
reason = reason,
retryOperation = retryOperation,
)
)

override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
val user: FirebaseUser get() = request.user
val reason: String? get() = request.reason
val retryOperation: (suspend (android.content.Context) -> Unit)?
get() = request.retryOperation

/**
* Identity is the request. Snapshot state and [FirebaseAuthUI.pendingReauth] both
* conflate equal values, so a transition that must be observed changes the phase type.
*/
override fun equals(other: Any?): Boolean =
other is Required && requestId == other.requestId

Expand All@@ -341,7 +350,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The most recent credential attempt failed, but the request remains armed. */
/** The most recent credential attempt failed, but the request remains outstanding. */
internal class AttemptFailed(
override val request: Request,
val exception: Exception,
Expand DownExpand Up@@ -395,7 +404,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** Credentials were accepted for the request's user. */
/** Credentials were accepted for the request's user. Terminal for the exchange. */
internal class Succeeded(
override val request: Request,
val success: Success,
Expand All@@ -404,43 +413,9 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The sensitive operation is being retried after credentials were accepted. */
internal class RetryingOperation(
override val request: Request,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/** The retry completed and [outcome] is ready to become the ordinary auth state. */
internal class OperationFinished(
override val request: Request,
val outcome: AuthState,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/**
* Saved UI state proved a request existed, but its process-local retry callback was lost.
*/
internal class Interrupted(
override val requestId: String,
override val userUid: String,
) : Reauthentication() {
override val request: Request? = null
}

/**
* Whether this request's reauthentication already succeeded. A sign-out must not clear such
* a phase, because the pending operation succeeding can be what signed the user out.
*/
internal val isReauthenticated: Boolean
get() = this is Succeeded || this is RetryingOperation || this is OperationFinished

/**
* A provider attempt is about to run, clearing any previously surfaced failure. Null once
* credentials were accepted, so a late attempt cannot rewind a running operation.
* credentials were accepted, so a late attempt cannot rewind a finished request.
*/
internal fun attemptStarted(): AuthState? = when (this) {
is Required,
Expand DownExpand Up@@ -484,11 +459,6 @@ abstract class AuthState private constructor() {

else -> null
}

/** The retried sensitive operation produced [outcome]. Null unless a retry is in flight. */
internal fun operationFinished(outcome: AuthState): AuthState? =
(this as? RetryingOperation)
?.let { OperationFinished(it.request, outcome) }
}

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import com.firebase.ui.auth.AuthException
Expand DownExpand Up@@ -334,9 +335,13 @@ private fun AppAuthenticatedContent(
lifecycleOwner.lifecycleScope.launch {
isDeletingAccount = true
try {
// Reauthentication, if it is needed, happens inside this call:
// the progress indicator below covers it, and the deletion is
// retried here rather than needing anything from this caller.
uiContext.authUI.delete(context)
} catch (e: AuthException.InvalidCredentialsException) {
Log.d("HighLevelApiDemoActivity", "Reauth required before delete")
} catch (e: AuthException.AuthCancelledException) {
// Declined at the identity check; the account is untouched.
Log.d("HighLevelApiDemoActivity", "Delete cancelled", e)
} catch (e: AuthException) {
Log.e("HighLevelApiDemoActivity", "Delete failed", e)
} finally {
Expand DownExpand Up@@ -567,6 +572,15 @@ private fun ChangePasswordDialog(
Log.d("HighLevelApiDemoActivity", "Password changed successfully")
onDismiss()
}
} catch (e: CancellationException) {
// withReauth suspends across the reauthentication sheet, so this
// scope really can be cancelled mid-call. Never report that as a
// failure the user can retry.
throw e
} catch (e: AuthException.AuthCancelledException) {
// The user backed out of confirming their identity. Nothing failed,
// and the password was not changed — so say neither.
Log.d("HighLevelApiDemoActivity", "Reauthentication declined", e)
} catch (e: Exception) {
updateError = "Failed to update password. Please try again."
} finally {
Expand Down
117 changes: 117 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
/*
* 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

import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser

/** Where one auth flow's states go. */
internal fun interface AuthStateSink {
fun emit(state: AuthState)
}

/**
* One auth flow's collaborators, and where its states go. Provider code is written against this,
* not [FirebaseAuthUI], so it reaches the public state channel only through [sink].
*
* @since 10.0.0
*/
internal class AuthFlowScope(
val auth: FirebaseAuth,
val config: AuthUIConfiguration,
val credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null,
val loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null,
/**
* What this flow is currently doing, for the screens rendering it. Under a reauthentication
* request's scope this is that request's phase rather than the host's state.
*/
val state: State<AuthState>,
private val sink: AuthStateSink,
) {
fun emit(state: AuthState) = sink.emit(state)

/**
* Publishes what [result] means for this flow: a password user who still owes email
* verification is not signed in yet, however successful the credential exchange was.
*/
fun emitResult(result: AuthResult?, defaultIsNewUser: Boolean = false) {
val user = result?.user
if (user != null) {
val isNewUser = result.additionalUserInfo?.isNewUser ?: defaultIsNewUser
emit(authUserState(user, result, isNewUser))
} else {
emit(AuthState.Idle)
}
}
}

/**
* What a signed-in [user] means as an [AuthState]: the single source of truth for whether they
* still owe email verification. Callers must not re-derive it — only password users with an email
* can satisfy that screen.
*/
internal fun authUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState {
val email = user.email
return if (!user.isEmailVerified &&
email != null &&
user.providerData.any { it.providerId == "password" }
) {
AuthState.RequiresEmailVerification(user = user, email = email)
} else {
AuthState.Success(result = result, user = user, isNewUser = isNewUser)
}
}

/** The auth flow the current composition belongs to, or null outside one. */
internal val LocalAuthFlowScope = staticCompositionLocalOf<AuthFlowScope?> { null }

/**
* The ambient flow when composed inside one, otherwise a fresh flow over [authUI]'s public state —
* which is what a consumer composing `EmailAuthScreen` or `PhoneAuthScreen` on its own gets.
*/
@Composable
internal fun rememberAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
): AuthFlowScope {
val ambient = LocalAuthFlowScope.current
val hostState = remember(authUI) { authUI.authStateFlow() }
.collectAsState(AuthState.Idle)
return remember(ambient, authUI, configuration, hostState) {
ambient ?: hostAuthFlowScope(authUI, configuration, hostState)
}
}

/** An [AuthFlowScope] over [authUI]'s public flow, in both directions. */
internal fun hostAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
state: State<AuthState>,
): AuthFlowScope = AuthFlowScope(
auth = authUI.auth,
config = configuration,
credentialManagerProvider = authUI.testCredentialManagerProvider,
loginManagerProvider = authUI.testLoginManagerProvider,
state = state,
sink = { authUI.updateAuthState(it) },
)
94 changes: 32 additions & 62 deletions auth/src/main/java/com/firebase/ui/auth/AuthState.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.MultiFactorResolver
import com.google.firebase.auth.PhoneAuthCredential
import com.google.firebase.auth.PhoneAuthProvider
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID

/**
Expand DownExpand Up@@ -256,11 +257,8 @@ abstract class AuthState private constructor() {
}

/**
* A state in the lifecycle of one reauthentication request.
*
* Every state carries a stable [requestId], so Activity recreation can distinguish a
* continuation of the same sensitive operation from a new operation for the same user. The
* request itself is process-local because its retry callback cannot be serialized.
* A state in the lifecycle of one reauthentication request. Every state carries a stable
* [requestId], so recreation can tell a continuation from a new operation for the same user.
*/
sealed class Reauthentication : AuthState() {
abstract val requestId: String
Expand All@@ -273,21 +271,31 @@ abstract class AuthState private constructor() {
val requestId: String,
val user: FirebaseUser,
val reason: String?,
retryOperation: (suspend (android.content.Context) -> Unit)?,
/**
* Where the caller awaiting this request is parked, or null when nobody is — a
* standalone flow from [FirebaseAuthUI.createReauthFlow] has no operation behind it.
*/
val resolver: CompletableDeferred<Boolean>? = null,
) {
/** Null once [claimRetryOperation] consumed it, so no recreation can re-run it. */
var retryOperation: (suspend (android.content.Context) -> Unit)? = retryOperation
private set
/** Whether a caller is waiting on this request to decide a pending operation. */
val hasPendingOperation: Boolean get() = resolver != null

/** Whether the awaiting caller is still there to resume. */
val isResumable: Boolean get() = resolver?.isActive != false

/** Whether this request ever carried an operation, even after it was claimed. */
val hasRetryOperation: Boolean = retryOperation != null
/** Credentials were accepted: the caller resumes and retries. Idempotent. */
fun resolve() {
resolver?.complete(true)
}

/**
* Hands the operation out exactly once. A second claim means the first run was lost,
* which must be reported rather than retried: the operation may have committed already.
* The request ended without proof. Completed with a value, not an exception: failing a
* parented Deferred would cancel the caller's scope, so [FirebaseAuthUI.withReauth]
* throws in its own frame instead.
*/
fun claimRetryOperation(): (suspend (android.content.Context) -> Unit)? =
retryOperation.also { retryOperation = null }
fun decline() {
resolver?.complete(false)
}
}

/**
Expand All@@ -302,26 +310,27 @@ abstract class AuthState private constructor() {
class Required internal constructor(
override val request: Request,
) : Reauthentication() {
constructor(
/** A request with nobody waiting on it, as a standalone reauthentication flow has. */
internal constructor(
user: FirebaseUser,
reason: String? = null,
retryOperation: (suspend (android.content.Context) -> Unit)? = null,
) : this(
Request(
requestId = UUID.randomUUID().toString(),
user = user,
reason = reason,
retryOperation = retryOperation,
)
)

override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
val user: FirebaseUser get() = request.user
val reason: String? get() = request.reason
val retryOperation: (suspend (android.content.Context) -> Unit)?
get() = request.retryOperation

/**
* Identity is the request. Snapshot state and [FirebaseAuthUI.pendingReauth] both
* conflate equal values, so a transition that must be observed changes the phase type.
*/
override fun equals(other: Any?): Boolean =
other is Required && requestId == other.requestId

Expand All@@ -341,7 +350,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The most recent credential attempt failed, but the request remains armed. */
/** The most recent credential attempt failed, but the request remains outstanding. */
internal class AttemptFailed(
override val request: Request,
val exception: Exception,
Expand DownExpand Up@@ -395,7 +404,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** Credentials were accepted for the request's user. */
/** Credentials were accepted for the request's user. Terminal for the exchange. */
internal class Succeeded(
override val request: Request,
val success: Success,
Expand All@@ -404,43 +413,9 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The sensitive operation is being retried after credentials were accepted. */
internal class RetryingOperation(
override val request: Request,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/** The retry completed and [outcome] is ready to become the ordinary auth state. */
internal class OperationFinished(
override val request: Request,
val outcome: AuthState,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/**
* Saved UI state proved a request existed, but its process-local retry callback was lost.
*/
internal class Interrupted(
override val requestId: String,
override val userUid: String,
) : Reauthentication() {
override val request: Request? = null
}

/**
* Whether this request's reauthentication already succeeded. A sign-out must not clear such
* a phase, because the pending operation succeeding can be what signed the user out.
*/
internal val isReauthenticated: Boolean
get() = this is Succeeded || this is RetryingOperation || this is OperationFinished

/**
* A provider attempt is about to run, clearing any previously surfaced failure. Null once
* credentials were accepted, so a late attempt cannot rewind a running operation.
* credentials were accepted, so a late attempt cannot rewind a finished request.
*/
internal fun attemptStarted(): AuthState? = when (this) {
is Required,
Expand DownExpand Up@@ -484,11 +459,6 @@ abstract class AuthState private constructor() {

else -> null
}

/** The retried sensitive operation produced [outcome]. Null unless a retry is in flight. */
internal fun operationFinished(outcome: AuthState): AuthState? =
(this as? RetryingOperation)
?.let { OperationFinished(it.request, outcome) }
}

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import com.firebase.ui.auth.AuthException
Expand DownExpand Up@@ -334,9 +335,13 @@ private fun AppAuthenticatedContent(
lifecycleOwner.lifecycleScope.launch {
isDeletingAccount = true
try {
// Reauthentication, if it is needed, happens inside this call:
// the progress indicator below covers it, and the deletion is
// retried here rather than needing anything from this caller.
uiContext.authUI.delete(context)
} catch (e: AuthException.InvalidCredentialsException) {
Log.d("HighLevelApiDemoActivity", "Reauth required before delete")
} catch (e: AuthException.AuthCancelledException) {
// Declined at the identity check; the account is untouched.
Log.d("HighLevelApiDemoActivity", "Delete cancelled", e)
} catch (e: AuthException) {
Log.e("HighLevelApiDemoActivity", "Delete failed", e)
} finally {
Expand DownExpand Up@@ -567,6 +572,15 @@ private fun ChangePasswordDialog(
Log.d("HighLevelApiDemoActivity", "Password changed successfully")
onDismiss()
}
} catch (e: CancellationException) {
// withReauth suspends across the reauthentication sheet, so this
// scope really can be cancelled mid-call. Never report that as a
// failure the user can retry.
throw e
} catch (e: AuthException.AuthCancelledException) {
// The user backed out of confirming their identity. Nothing failed,
// and the password was not changed — so say neither.
Log.d("HighLevelApiDemoActivity", "Reauthentication declined", e)
} catch (e: Exception) {
updateError = "Failed to update password. Please try again."
} finally {
Expand Down
117 changes: 117 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
/*
* 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

import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser

/** Where one auth flow's states go. */
internal fun interface AuthStateSink {
fun emit(state: AuthState)
}

/**
* One auth flow's collaborators, and where its states go. Provider code is written against this,
* not [FirebaseAuthUI], so it reaches the public state channel only through [sink].
*
* @since 10.0.0
*/
internal class AuthFlowScope(
val auth: FirebaseAuth,
val config: AuthUIConfiguration,
val credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null,
val loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null,
/**
* What this flow is currently doing, for the screens rendering it. Under a reauthentication
* request's scope this is that request's phase rather than the host's state.
*/
val state: State<AuthState>,
private val sink: AuthStateSink,
) {
fun emit(state: AuthState) = sink.emit(state)

/**
* Publishes what [result] means for this flow: a password user who still owes email
* verification is not signed in yet, however successful the credential exchange was.
*/
fun emitResult(result: AuthResult?, defaultIsNewUser: Boolean = false) {
val user = result?.user
if (user != null) {
val isNewUser = result.additionalUserInfo?.isNewUser ?: defaultIsNewUser
emit(authUserState(user, result, isNewUser))
} else {
emit(AuthState.Idle)
}
}
}

/**
* What a signed-in [user] means as an [AuthState]: the single source of truth for whether they
* still owe email verification. Callers must not re-derive it — only password users with an email
* can satisfy that screen.
*/
internal fun authUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState {
val email = user.email
return if (!user.isEmailVerified &&
email != null &&
user.providerData.any { it.providerId == "password" }
) {
AuthState.RequiresEmailVerification(user = user, email = email)
} else {
AuthState.Success(result = result, user = user, isNewUser = isNewUser)
}
}

/** The auth flow the current composition belongs to, or null outside one. */
internal val LocalAuthFlowScope = staticCompositionLocalOf<AuthFlowScope?> { null }

/**
* The ambient flow when composed inside one, otherwise a fresh flow over [authUI]'s public state —
* which is what a consumer composing `EmailAuthScreen` or `PhoneAuthScreen` on its own gets.
*/
@Composable
internal fun rememberAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
): AuthFlowScope {
val ambient = LocalAuthFlowScope.current
val hostState = remember(authUI) { authUI.authStateFlow() }
.collectAsState(AuthState.Idle)
return remember(ambient, authUI, configuration, hostState) {
ambient ?: hostAuthFlowScope(authUI, configuration, hostState)
}
}

/** An [AuthFlowScope] over [authUI]'s public flow, in both directions. */
internal fun hostAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
state: State<AuthState>,
): AuthFlowScope = AuthFlowScope(
auth = authUI.auth,
config = configuration,
credentialManagerProvider = authUI.testCredentialManagerProvider,
loginManagerProvider = authUI.testLoginManagerProvider,
state = state,
sink = { authUI.updateAuthState(it) },
)
94 changes: 32 additions & 62 deletions auth/src/main/java/com/firebase/ui/auth/AuthState.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.MultiFactorResolver
import com.google.firebase.auth.PhoneAuthCredential
import com.google.firebase.auth.PhoneAuthProvider
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID

/**
Expand DownExpand Up@@ -256,11 +257,8 @@ abstract class AuthState private constructor() {
}

/**
* A state in the lifecycle of one reauthentication request.
*
* Every state carries a stable [requestId], so Activity recreation can distinguish a
* continuation of the same sensitive operation from a new operation for the same user. The
* request itself is process-local because its retry callback cannot be serialized.
* A state in the lifecycle of one reauthentication request. Every state carries a stable
* [requestId], so recreation can tell a continuation from a new operation for the same user.
*/
sealed class Reauthentication : AuthState() {
abstract val requestId: String
Expand All@@ -273,21 +271,31 @@ abstract class AuthState private constructor() {
val requestId: String,
val user: FirebaseUser,
val reason: String?,
retryOperation: (suspend (android.content.Context) -> Unit)?,
/**
* Where the caller awaiting this request is parked, or null when nobody is — a
* standalone flow from [FirebaseAuthUI.createReauthFlow] has no operation behind it.
*/
val resolver: CompletableDeferred<Boolean>? = null,
) {
/** Null once [claimRetryOperation] consumed it, so no recreation can re-run it. */
var retryOperation: (suspend (android.content.Context) -> Unit)? = retryOperation
private set
/** Whether a caller is waiting on this request to decide a pending operation. */
val hasPendingOperation: Boolean get() = resolver != null

/** Whether the awaiting caller is still there to resume. */
val isResumable: Boolean get() = resolver?.isActive != false

/** Whether this request ever carried an operation, even after it was claimed. */
val hasRetryOperation: Boolean = retryOperation != null
/** Credentials were accepted: the caller resumes and retries. Idempotent. */
fun resolve() {
resolver?.complete(true)
}

/**
* Hands the operation out exactly once. A second claim means the first run was lost,
* which must be reported rather than retried: the operation may have committed already.
* The request ended without proof. Completed with a value, not an exception: failing a
* parented Deferred would cancel the caller's scope, so [FirebaseAuthUI.withReauth]
* throws in its own frame instead.
*/
fun claimRetryOperation(): (suspend (android.content.Context) -> Unit)? =
retryOperation.also { retryOperation = null }
fun decline() {
resolver?.complete(false)
}
}

/**
Expand All@@ -302,26 +310,27 @@ abstract class AuthState private constructor() {
class Required internal constructor(
override val request: Request,
) : Reauthentication() {
constructor(
/** A request with nobody waiting on it, as a standalone reauthentication flow has. */
internal constructor(
user: FirebaseUser,
reason: String? = null,
retryOperation: (suspend (android.content.Context) -> Unit)? = null,
) : this(
Request(
requestId = UUID.randomUUID().toString(),
user = user,
reason = reason,
retryOperation = retryOperation,
)
)

override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
val user: FirebaseUser get() = request.user
val reason: String? get() = request.reason
val retryOperation: (suspend (android.content.Context) -> Unit)?
get() = request.retryOperation

/**
* Identity is the request. Snapshot state and [FirebaseAuthUI.pendingReauth] both
* conflate equal values, so a transition that must be observed changes the phase type.
*/
override fun equals(other: Any?): Boolean =
other is Required && requestId == other.requestId

Expand All@@ -341,7 +350,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The most recent credential attempt failed, but the request remains armed. */
/** The most recent credential attempt failed, but the request remains outstanding. */
internal class AttemptFailed(
override val request: Request,
val exception: Exception,
Expand DownExpand Up@@ -395,7 +404,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** Credentials were accepted for the request's user. */
/** Credentials were accepted for the request's user. Terminal for the exchange. */
internal class Succeeded(
override val request: Request,
val success: Success,
Expand All@@ -404,43 +413,9 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The sensitive operation is being retried after credentials were accepted. */
internal class RetryingOperation(
override val request: Request,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/** The retry completed and [outcome] is ready to become the ordinary auth state. */
internal class OperationFinished(
override val request: Request,
val outcome: AuthState,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/**
* Saved UI state proved a request existed, but its process-local retry callback was lost.
*/
internal class Interrupted(
override val requestId: String,
override val userUid: String,
) : Reauthentication() {
override val request: Request? = null
}

/**
* Whether this request's reauthentication already succeeded. A sign-out must not clear such
* a phase, because the pending operation succeeding can be what signed the user out.
*/
internal val isReauthenticated: Boolean
get() = this is Succeeded || this is RetryingOperation || this is OperationFinished

/**
* A provider attempt is about to run, clearing any previously surfaced failure. Null once
* credentials were accepted, so a late attempt cannot rewind a running operation.
* credentials were accepted, so a late attempt cannot rewind a finished request.
*/
internal fun attemptStarted(): AuthState? = when (this) {
is Required,
Expand DownExpand Up@@ -484,11 +459,6 @@ abstract class AuthState private constructor() {

else -> null
}

/** The retried sensitive operation produced [outcome]. Null unless a retry is in flight. */
internal fun operationFinished(outcome: AuthState): AuthState? =
(this as? RetryingOperation)
?.let { OperationFinished(it.request, outcome) }
}

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import com.firebase.ui.auth.AuthException
Expand DownExpand Up@@ -334,9 +335,13 @@ private fun AppAuthenticatedContent(
lifecycleOwner.lifecycleScope.launch {
isDeletingAccount = true
try {
// Reauthentication, if it is needed, happens inside this call:
// the progress indicator below covers it, and the deletion is
// retried here rather than needing anything from this caller.
uiContext.authUI.delete(context)
} catch (e: AuthException.InvalidCredentialsException) {
Log.d("HighLevelApiDemoActivity", "Reauth required before delete")
} catch (e: AuthException.AuthCancelledException) {
// Declined at the identity check; the account is untouched.
Log.d("HighLevelApiDemoActivity", "Delete cancelled", e)
} catch (e: AuthException) {
Log.e("HighLevelApiDemoActivity", "Delete failed", e)
} finally {
Expand DownExpand Up@@ -567,6 +572,15 @@ private fun ChangePasswordDialog(
Log.d("HighLevelApiDemoActivity", "Password changed successfully")
onDismiss()
}
} catch (e: CancellationException) {
// withReauth suspends across the reauthentication sheet, so this
// scope really can be cancelled mid-call. Never report that as a
// failure the user can retry.
throw e
} catch (e: AuthException.AuthCancelledException) {
// The user backed out of confirming their identity. Nothing failed,
// and the password was not changed — so say neither.
Log.d("HighLevelApiDemoActivity", "Reauthentication declined", e)
} catch (e: Exception) {
updateError = "Failed to update password. Please try again."
} finally {
Expand Down
117 changes: 117 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
/*
* 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

import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser

/** Where one auth flow's states go. */
internal fun interface AuthStateSink {
fun emit(state: AuthState)
}

/**
* One auth flow's collaborators, and where its states go. Provider code is written against this,
* not [FirebaseAuthUI], so it reaches the public state channel only through [sink].
*
* @since 10.0.0
*/
internal class AuthFlowScope(
val auth: FirebaseAuth,
val config: AuthUIConfiguration,
val credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null,
val loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null,
/**
* What this flow is currently doing, for the screens rendering it. Under a reauthentication
* request's scope this is that request's phase rather than the host's state.
*/
val state: State<AuthState>,
private val sink: AuthStateSink,
) {
fun emit(state: AuthState) = sink.emit(state)

/**
* Publishes what [result] means for this flow: a password user who still owes email
* verification is not signed in yet, however successful the credential exchange was.
*/
fun emitResult(result: AuthResult?, defaultIsNewUser: Boolean = false) {
val user = result?.user
if (user != null) {
val isNewUser = result.additionalUserInfo?.isNewUser ?: defaultIsNewUser
emit(authUserState(user, result, isNewUser))
} else {
emit(AuthState.Idle)
}
}
}

/**
* What a signed-in [user] means as an [AuthState]: the single source of truth for whether they
* still owe email verification. Callers must not re-derive it — only password users with an email
* can satisfy that screen.
*/
internal fun authUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState {
val email = user.email
return if (!user.isEmailVerified &&
email != null &&
user.providerData.any { it.providerId == "password" }
) {
AuthState.RequiresEmailVerification(user = user, email = email)
} else {
AuthState.Success(result = result, user = user, isNewUser = isNewUser)
}
}

/** The auth flow the current composition belongs to, or null outside one. */
internal val LocalAuthFlowScope = staticCompositionLocalOf<AuthFlowScope?> { null }

/**
* The ambient flow when composed inside one, otherwise a fresh flow over [authUI]'s public state —
* which is what a consumer composing `EmailAuthScreen` or `PhoneAuthScreen` on its own gets.
*/
@Composable
internal fun rememberAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
): AuthFlowScope {
val ambient = LocalAuthFlowScope.current
val hostState = remember(authUI) { authUI.authStateFlow() }
.collectAsState(AuthState.Idle)
return remember(ambient, authUI, configuration, hostState) {
ambient ?: hostAuthFlowScope(authUI, configuration, hostState)
}
}

/** An [AuthFlowScope] over [authUI]'s public flow, in both directions. */
internal fun hostAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
state: State<AuthState>,
): AuthFlowScope = AuthFlowScope(
auth = authUI.auth,
config = configuration,
credentialManagerProvider = authUI.testCredentialManagerProvider,
loginManagerProvider = authUI.testLoginManagerProvider,
state = state,
sink = { authUI.updateAuthState(it) },
)
94 changes: 32 additions & 62 deletions auth/src/main/java/com/firebase/ui/auth/AuthState.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.MultiFactorResolver
import com.google.firebase.auth.PhoneAuthCredential
import com.google.firebase.auth.PhoneAuthProvider
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID

/**
Expand DownExpand Up@@ -256,11 +257,8 @@ abstract class AuthState private constructor() {
}

/**
* A state in the lifecycle of one reauthentication request.
*
* Every state carries a stable [requestId], so Activity recreation can distinguish a
* continuation of the same sensitive operation from a new operation for the same user. The
* request itself is process-local because its retry callback cannot be serialized.
* A state in the lifecycle of one reauthentication request. Every state carries a stable
* [requestId], so recreation can tell a continuation from a new operation for the same user.
*/
sealed class Reauthentication : AuthState() {
abstract val requestId: String
Expand All@@ -273,21 +271,31 @@ abstract class AuthState private constructor() {
val requestId: String,
val user: FirebaseUser,
val reason: String?,
retryOperation: (suspend (android.content.Context) -> Unit)?,
/**
* Where the caller awaiting this request is parked, or null when nobody is — a
* standalone flow from [FirebaseAuthUI.createReauthFlow] has no operation behind it.
*/
val resolver: CompletableDeferred<Boolean>? = null,
) {
/** Null once [claimRetryOperation] consumed it, so no recreation can re-run it. */
var retryOperation: (suspend (android.content.Context) -> Unit)? = retryOperation
private set
/** Whether a caller is waiting on this request to decide a pending operation. */
val hasPendingOperation: Boolean get() = resolver != null

/** Whether the awaiting caller is still there to resume. */
val isResumable: Boolean get() = resolver?.isActive != false

/** Whether this request ever carried an operation, even after it was claimed. */
val hasRetryOperation: Boolean = retryOperation != null
/** Credentials were accepted: the caller resumes and retries. Idempotent. */
fun resolve() {
resolver?.complete(true)
}

/**
* Hands the operation out exactly once. A second claim means the first run was lost,
* which must be reported rather than retried: the operation may have committed already.
* The request ended without proof. Completed with a value, not an exception: failing a
* parented Deferred would cancel the caller's scope, so [FirebaseAuthUI.withReauth]
* throws in its own frame instead.
*/
fun claimRetryOperation(): (suspend (android.content.Context) -> Unit)? =
retryOperation.also { retryOperation = null }
fun decline() {
resolver?.complete(false)
}
}

/**
Expand All@@ -302,26 +310,27 @@ abstract class AuthState private constructor() {
class Required internal constructor(
override val request: Request,
) : Reauthentication() {
constructor(
/** A request with nobody waiting on it, as a standalone reauthentication flow has. */
internal constructor(
user: FirebaseUser,
reason: String? = null,
retryOperation: (suspend (android.content.Context) -> Unit)? = null,
) : this(
Request(
requestId = UUID.randomUUID().toString(),
user = user,
reason = reason,
retryOperation = retryOperation,
)
)

override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
val user: FirebaseUser get() = request.user
val reason: String? get() = request.reason
val retryOperation: (suspend (android.content.Context) -> Unit)?
get() = request.retryOperation

/**
* Identity is the request. Snapshot state and [FirebaseAuthUI.pendingReauth] both
* conflate equal values, so a transition that must be observed changes the phase type.
*/
override fun equals(other: Any?): Boolean =
other is Required && requestId == other.requestId

Expand All@@ -341,7 +350,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The most recent credential attempt failed, but the request remains armed. */
/** The most recent credential attempt failed, but the request remains outstanding. */
internal class AttemptFailed(
override val request: Request,
val exception: Exception,
Expand DownExpand Up@@ -395,7 +404,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** Credentials were accepted for the request's user. */
/** Credentials were accepted for the request's user. Terminal for the exchange. */
internal class Succeeded(
override val request: Request,
val success: Success,
Expand All@@ -404,43 +413,9 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The sensitive operation is being retried after credentials were accepted. */
internal class RetryingOperation(
override val request: Request,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/** The retry completed and [outcome] is ready to become the ordinary auth state. */
internal class OperationFinished(
override val request: Request,
val outcome: AuthState,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/**
* Saved UI state proved a request existed, but its process-local retry callback was lost.
*/
internal class Interrupted(
override val requestId: String,
override val userUid: String,
) : Reauthentication() {
override val request: Request? = null
}

/**
* Whether this request's reauthentication already succeeded. A sign-out must not clear such
* a phase, because the pending operation succeeding can be what signed the user out.
*/
internal val isReauthenticated: Boolean
get() = this is Succeeded || this is RetryingOperation || this is OperationFinished

/**
* A provider attempt is about to run, clearing any previously surfaced failure. Null once
* credentials were accepted, so a late attempt cannot rewind a running operation.
* credentials were accepted, so a late attempt cannot rewind a finished request.
*/
internal fun attemptStarted(): AuthState? = when (this) {
is Required,
Expand DownExpand Up@@ -484,11 +459,6 @@ abstract class AuthState private constructor() {

else -> null
}

/** The retried sensitive operation produced [outcome]. Null unless a retry is in flight. */
internal fun operationFinished(outcome: AuthState): AuthState? =
(this as? RetryingOperation)
?.let { OperationFinished(it.request, outcome) }
}

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import com.firebase.ui.auth.AuthException
Expand DownExpand Up@@ -334,9 +335,13 @@ private fun AppAuthenticatedContent(
lifecycleOwner.lifecycleScope.launch {
isDeletingAccount = true
try {
// Reauthentication, if it is needed, happens inside this call:
// the progress indicator below covers it, and the deletion is
// retried here rather than needing anything from this caller.
uiContext.authUI.delete(context)
} catch (e: AuthException.InvalidCredentialsException) {
Log.d("HighLevelApiDemoActivity", "Reauth required before delete")
} catch (e: AuthException.AuthCancelledException) {
// Declined at the identity check; the account is untouched.
Log.d("HighLevelApiDemoActivity", "Delete cancelled", e)
} catch (e: AuthException) {
Log.e("HighLevelApiDemoActivity", "Delete failed", e)
} finally {
Expand DownExpand Up@@ -567,6 +572,15 @@ private fun ChangePasswordDialog(
Log.d("HighLevelApiDemoActivity", "Password changed successfully")
onDismiss()
}
} catch (e: CancellationException) {
// withReauth suspends across the reauthentication sheet, so this
// scope really can be cancelled mid-call. Never report that as a
// failure the user can retry.
throw e
} catch (e: AuthException.AuthCancelledException) {
// The user backed out of confirming their identity. Nothing failed,
// and the password was not changed — so say neither.
Log.d("HighLevelApiDemoActivity", "Reauthentication declined", e)
} catch (e: Exception) {
updateError = "Failed to update password. Please try again."
} finally {
Expand Down
117 changes: 117 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
/*
* 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

import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser

/** Where one auth flow's states go. */
internal fun interface AuthStateSink {
fun emit(state: AuthState)
}

/**
* One auth flow's collaborators, and where its states go. Provider code is written against this,
* not [FirebaseAuthUI], so it reaches the public state channel only through [sink].
*
* @since 10.0.0
*/
internal class AuthFlowScope(
val auth: FirebaseAuth,
val config: AuthUIConfiguration,
val credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null,
val loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null,
/**
* What this flow is currently doing, for the screens rendering it. Under a reauthentication
* request's scope this is that request's phase rather than the host's state.
*/
val state: State<AuthState>,
private val sink: AuthStateSink,
) {
fun emit(state: AuthState) = sink.emit(state)

/**
* Publishes what [result] means for this flow: a password user who still owes email
* verification is not signed in yet, however successful the credential exchange was.
*/
fun emitResult(result: AuthResult?, defaultIsNewUser: Boolean = false) {
val user = result?.user
if (user != null) {
val isNewUser = result.additionalUserInfo?.isNewUser ?: defaultIsNewUser
emit(authUserState(user, result, isNewUser))
} else {
emit(AuthState.Idle)
}
}
}

/**
* What a signed-in [user] means as an [AuthState]: the single source of truth for whether they
* still owe email verification. Callers must not re-derive it — only password users with an email
* can satisfy that screen.
*/
internal fun authUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState {
val email = user.email
return if (!user.isEmailVerified &&
email != null &&
user.providerData.any { it.providerId == "password" }
) {
AuthState.RequiresEmailVerification(user = user, email = email)
} else {
AuthState.Success(result = result, user = user, isNewUser = isNewUser)
}
}

/** The auth flow the current composition belongs to, or null outside one. */
internal val LocalAuthFlowScope = staticCompositionLocalOf<AuthFlowScope?> { null }

/**
* The ambient flow when composed inside one, otherwise a fresh flow over [authUI]'s public state —
* which is what a consumer composing `EmailAuthScreen` or `PhoneAuthScreen` on its own gets.
*/
@Composable
internal fun rememberAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
): AuthFlowScope {
val ambient = LocalAuthFlowScope.current
val hostState = remember(authUI) { authUI.authStateFlow() }
.collectAsState(AuthState.Idle)
return remember(ambient, authUI, configuration, hostState) {
ambient ?: hostAuthFlowScope(authUI, configuration, hostState)
}
}

/** An [AuthFlowScope] over [authUI]'s public flow, in both directions. */
internal fun hostAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
state: State<AuthState>,
): AuthFlowScope = AuthFlowScope(
auth = authUI.auth,
config = configuration,
credentialManagerProvider = authUI.testCredentialManagerProvider,
loginManagerProvider = authUI.testLoginManagerProvider,
state = state,
sink = { authUI.updateAuthState(it) },
)
94 changes: 32 additions & 62 deletions auth/src/main/java/com/firebase/ui/auth/AuthState.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.MultiFactorResolver
import com.google.firebase.auth.PhoneAuthCredential
import com.google.firebase.auth.PhoneAuthProvider
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID

/**
Expand DownExpand Up@@ -256,11 +257,8 @@ abstract class AuthState private constructor() {
}

/**
* A state in the lifecycle of one reauthentication request.
*
* Every state carries a stable [requestId], so Activity recreation can distinguish a
* continuation of the same sensitive operation from a new operation for the same user. The
* request itself is process-local because its retry callback cannot be serialized.
* A state in the lifecycle of one reauthentication request. Every state carries a stable
* [requestId], so recreation can tell a continuation from a new operation for the same user.
*/
sealed class Reauthentication : AuthState() {
abstract val requestId: String
Expand All@@ -273,21 +271,31 @@ abstract class AuthState private constructor() {
val requestId: String,
val user: FirebaseUser,
val reason: String?,
retryOperation: (suspend (android.content.Context) -> Unit)?,
/**
* Where the caller awaiting this request is parked, or null when nobody is — a
* standalone flow from [FirebaseAuthUI.createReauthFlow] has no operation behind it.
*/
val resolver: CompletableDeferred<Boolean>? = null,
) {
/** Null once [claimRetryOperation] consumed it, so no recreation can re-run it. */
var retryOperation: (suspend (android.content.Context) -> Unit)? = retryOperation
private set
/** Whether a caller is waiting on this request to decide a pending operation. */
val hasPendingOperation: Boolean get() = resolver != null

/** Whether the awaiting caller is still there to resume. */
val isResumable: Boolean get() = resolver?.isActive != false

/** Whether this request ever carried an operation, even after it was claimed. */
val hasRetryOperation: Boolean = retryOperation != null
/** Credentials were accepted: the caller resumes and retries. Idempotent. */
fun resolve() {
resolver?.complete(true)
}

/**
* Hands the operation out exactly once. A second claim means the first run was lost,
* which must be reported rather than retried: the operation may have committed already.
* The request ended without proof. Completed with a value, not an exception: failing a
* parented Deferred would cancel the caller's scope, so [FirebaseAuthUI.withReauth]
* throws in its own frame instead.
*/
fun claimRetryOperation(): (suspend (android.content.Context) -> Unit)? =
retryOperation.also { retryOperation = null }
fun decline() {
resolver?.complete(false)
}
}

/**
Expand All@@ -302,26 +310,27 @@ abstract class AuthState private constructor() {
class Required internal constructor(
override val request: Request,
) : Reauthentication() {
constructor(
/** A request with nobody waiting on it, as a standalone reauthentication flow has. */
internal constructor(
user: FirebaseUser,
reason: String? = null,
retryOperation: (suspend (android.content.Context) -> Unit)? = null,
) : this(
Request(
requestId = UUID.randomUUID().toString(),
user = user,
reason = reason,
retryOperation = retryOperation,
)
)

override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
val user: FirebaseUser get() = request.user
val reason: String? get() = request.reason
val retryOperation: (suspend (android.content.Context) -> Unit)?
get() = request.retryOperation

/**
* Identity is the request. Snapshot state and [FirebaseAuthUI.pendingReauth] both
* conflate equal values, so a transition that must be observed changes the phase type.
*/
override fun equals(other: Any?): Boolean =
other is Required && requestId == other.requestId

Expand All@@ -341,7 +350,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The most recent credential attempt failed, but the request remains armed. */
/** The most recent credential attempt failed, but the request remains outstanding. */
internal class AttemptFailed(
override val request: Request,
val exception: Exception,
Expand DownExpand Up@@ -395,7 +404,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** Credentials were accepted for the request's user. */
/** Credentials were accepted for the request's user. Terminal for the exchange. */
internal class Succeeded(
override val request: Request,
val success: Success,
Expand All@@ -404,43 +413,9 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The sensitive operation is being retried after credentials were accepted. */
internal class RetryingOperation(
override val request: Request,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/** The retry completed and [outcome] is ready to become the ordinary auth state. */
internal class OperationFinished(
override val request: Request,
val outcome: AuthState,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/**
* Saved UI state proved a request existed, but its process-local retry callback was lost.
*/
internal class Interrupted(
override val requestId: String,
override val userUid: String,
) : Reauthentication() {
override val request: Request? = null
}

/**
* Whether this request's reauthentication already succeeded. A sign-out must not clear such
* a phase, because the pending operation succeeding can be what signed the user out.
*/
internal val isReauthenticated: Boolean
get() = this is Succeeded || this is RetryingOperation || this is OperationFinished

/**
* A provider attempt is about to run, clearing any previously surfaced failure. Null once
* credentials were accepted, so a late attempt cannot rewind a running operation.
* credentials were accepted, so a late attempt cannot rewind a finished request.
*/
internal fun attemptStarted(): AuthState? = when (this) {
is Required,
Expand DownExpand Up@@ -484,11 +459,6 @@ abstract class AuthState private constructor() {

else -> null
}

/** The retried sensitive operation produced [outcome]. Null unless a retry is in flight. */
internal fun operationFinished(outcome: AuthState): AuthState? =
(this as? RetryingOperation)
?.let { OperationFinished(it.request, outcome) }
}

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import com.firebase.ui.auth.AuthException
Expand DownExpand Up@@ -334,9 +335,13 @@ private fun AppAuthenticatedContent(
lifecycleOwner.lifecycleScope.launch {
isDeletingAccount = true
try {
// Reauthentication, if it is needed, happens inside this call:
// the progress indicator below covers it, and the deletion is
// retried here rather than needing anything from this caller.
uiContext.authUI.delete(context)
} catch (e: AuthException.InvalidCredentialsException) {
Log.d("HighLevelApiDemoActivity", "Reauth required before delete")
} catch (e: AuthException.AuthCancelledException) {
// Declined at the identity check; the account is untouched.
Log.d("HighLevelApiDemoActivity", "Delete cancelled", e)
} catch (e: AuthException) {
Log.e("HighLevelApiDemoActivity", "Delete failed", e)
} finally {
Expand DownExpand Up@@ -567,6 +572,15 @@ private fun ChangePasswordDialog(
Log.d("HighLevelApiDemoActivity", "Password changed successfully")
onDismiss()
}
} catch (e: CancellationException) {
// withReauth suspends across the reauthentication sheet, so this
// scope really can be cancelled mid-call. Never report that as a
// failure the user can retry.
throw e
} catch (e: AuthException.AuthCancelledException) {
// The user backed out of confirming their identity. Nothing failed,
// and the password was not changed — so say neither.
Log.d("HighLevelApiDemoActivity", "Reauthentication declined", e)
} catch (e: Exception) {
updateError = "Failed to update password. Please try again."
} finally {
Expand Down
117 changes: 117 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
/*
* 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

import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser

/** Where one auth flow's states go. */
internal fun interface AuthStateSink {
fun emit(state: AuthState)
}

/**
* One auth flow's collaborators, and where its states go. Provider code is written against this,
* not [FirebaseAuthUI], so it reaches the public state channel only through [sink].
*
* @since 10.0.0
*/
internal class AuthFlowScope(
val auth: FirebaseAuth,
val config: AuthUIConfiguration,
val credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null,
val loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null,
/**
* What this flow is currently doing, for the screens rendering it. Under a reauthentication
* request's scope this is that request's phase rather than the host's state.
*/
val state: State<AuthState>,
private val sink: AuthStateSink,
) {
fun emit(state: AuthState) = sink.emit(state)

/**
* Publishes what [result] means for this flow: a password user who still owes email
* verification is not signed in yet, however successful the credential exchange was.
*/
fun emitResult(result: AuthResult?, defaultIsNewUser: Boolean = false) {
val user = result?.user
if (user != null) {
val isNewUser = result.additionalUserInfo?.isNewUser ?: defaultIsNewUser
emit(authUserState(user, result, isNewUser))
} else {
emit(AuthState.Idle)
}
}
}

/**
* What a signed-in [user] means as an [AuthState]: the single source of truth for whether they
* still owe email verification. Callers must not re-derive it — only password users with an email
* can satisfy that screen.
*/
internal fun authUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState {
val email = user.email
return if (!user.isEmailVerified &&
email != null &&
user.providerData.any { it.providerId == "password" }
) {
AuthState.RequiresEmailVerification(user = user, email = email)
} else {
AuthState.Success(result = result, user = user, isNewUser = isNewUser)
}
}

/** The auth flow the current composition belongs to, or null outside one. */
internal val LocalAuthFlowScope = staticCompositionLocalOf<AuthFlowScope?> { null }

/**
* The ambient flow when composed inside one, otherwise a fresh flow over [authUI]'s public state —
* which is what a consumer composing `EmailAuthScreen` or `PhoneAuthScreen` on its own gets.
*/
@Composable
internal fun rememberAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
): AuthFlowScope {
val ambient = LocalAuthFlowScope.current
val hostState = remember(authUI) { authUI.authStateFlow() }
.collectAsState(AuthState.Idle)
return remember(ambient, authUI, configuration, hostState) {
ambient ?: hostAuthFlowScope(authUI, configuration, hostState)
}
}

/** An [AuthFlowScope] over [authUI]'s public flow, in both directions. */
internal fun hostAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
state: State<AuthState>,
): AuthFlowScope = AuthFlowScope(
auth = authUI.auth,
config = configuration,
credentialManagerProvider = authUI.testCredentialManagerProvider,
loginManagerProvider = authUI.testLoginManagerProvider,
state = state,
sink = { authUI.updateAuthState(it) },
)
94 changes: 32 additions & 62 deletions auth/src/main/java/com/firebase/ui/auth/AuthState.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.MultiFactorResolver
import com.google.firebase.auth.PhoneAuthCredential
import com.google.firebase.auth.PhoneAuthProvider
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID

/**
Expand DownExpand Up@@ -256,11 +257,8 @@ abstract class AuthState private constructor() {
}

/**
* A state in the lifecycle of one reauthentication request.
*
* Every state carries a stable [requestId], so Activity recreation can distinguish a
* continuation of the same sensitive operation from a new operation for the same user. The
* request itself is process-local because its retry callback cannot be serialized.
* A state in the lifecycle of one reauthentication request. Every state carries a stable
* [requestId], so recreation can tell a continuation from a new operation for the same user.
*/
sealed class Reauthentication : AuthState() {
abstract val requestId: String
Expand All@@ -273,21 +271,31 @@ abstract class AuthState private constructor() {
val requestId: String,
val user: FirebaseUser,
val reason: String?,
retryOperation: (suspend (android.content.Context) -> Unit)?,
/**
* Where the caller awaiting this request is parked, or null when nobody is — a
* standalone flow from [FirebaseAuthUI.createReauthFlow] has no operation behind it.
*/
val resolver: CompletableDeferred<Boolean>? = null,
) {
/** Null once [claimRetryOperation] consumed it, so no recreation can re-run it. */
var retryOperation: (suspend (android.content.Context) -> Unit)? = retryOperation
private set
/** Whether a caller is waiting on this request to decide a pending operation. */
val hasPendingOperation: Boolean get() = resolver != null

/** Whether the awaiting caller is still there to resume. */
val isResumable: Boolean get() = resolver?.isActive != false

/** Whether this request ever carried an operation, even after it was claimed. */
val hasRetryOperation: Boolean = retryOperation != null
/** Credentials were accepted: the caller resumes and retries. Idempotent. */
fun resolve() {
resolver?.complete(true)
}

/**
* Hands the operation out exactly once. A second claim means the first run was lost,
* which must be reported rather than retried: the operation may have committed already.
* The request ended without proof. Completed with a value, not an exception: failing a
* parented Deferred would cancel the caller's scope, so [FirebaseAuthUI.withReauth]
* throws in its own frame instead.
*/
fun claimRetryOperation(): (suspend (android.content.Context) -> Unit)? =
retryOperation.also { retryOperation = null }
fun decline() {
resolver?.complete(false)
}
}

/**
Expand All@@ -302,26 +310,27 @@ abstract class AuthState private constructor() {
class Required internal constructor(
override val request: Request,
) : Reauthentication() {
constructor(
/** A request with nobody waiting on it, as a standalone reauthentication flow has. */
internal constructor(
user: FirebaseUser,
reason: String? = null,
retryOperation: (suspend (android.content.Context) -> Unit)? = null,
) : this(
Request(
requestId = UUID.randomUUID().toString(),
user = user,
reason = reason,
retryOperation = retryOperation,
)
)

override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
val user: FirebaseUser get() = request.user
val reason: String? get() = request.reason
val retryOperation: (suspend (android.content.Context) -> Unit)?
get() = request.retryOperation

/**
* Identity is the request. Snapshot state and [FirebaseAuthUI.pendingReauth] both
* conflate equal values, so a transition that must be observed changes the phase type.
*/
override fun equals(other: Any?): Boolean =
other is Required && requestId == other.requestId

Expand All@@ -341,7 +350,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The most recent credential attempt failed, but the request remains armed. */
/** The most recent credential attempt failed, but the request remains outstanding. */
internal class AttemptFailed(
override val request: Request,
val exception: Exception,
Expand DownExpand Up@@ -395,7 +404,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** Credentials were accepted for the request's user. */
/** Credentials were accepted for the request's user. Terminal for the exchange. */
internal class Succeeded(
override val request: Request,
val success: Success,
Expand All@@ -404,43 +413,9 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The sensitive operation is being retried after credentials were accepted. */
internal class RetryingOperation(
override val request: Request,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/** The retry completed and [outcome] is ready to become the ordinary auth state. */
internal class OperationFinished(
override val request: Request,
val outcome: AuthState,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/**
* Saved UI state proved a request existed, but its process-local retry callback was lost.
*/
internal class Interrupted(
override val requestId: String,
override val userUid: String,
) : Reauthentication() {
override val request: Request? = null
}

/**
* Whether this request's reauthentication already succeeded. A sign-out must not clear such
* a phase, because the pending operation succeeding can be what signed the user out.
*/
internal val isReauthenticated: Boolean
get() = this is Succeeded || this is RetryingOperation || this is OperationFinished

/**
* A provider attempt is about to run, clearing any previously surfaced failure. Null once
* credentials were accepted, so a late attempt cannot rewind a running operation.
* credentials were accepted, so a late attempt cannot rewind a finished request.
*/
internal fun attemptStarted(): AuthState? = when (this) {
is Required,
Expand DownExpand Up@@ -484,11 +459,6 @@ abstract class AuthState private constructor() {

else -> null
}

/** The retried sensitive operation produced [outcome]. Null unless a retry is in flight. */
internal fun operationFinished(outcome: AuthState): AuthState? =
(this as? RetryingOperation)
?.let { OperationFinished(it.request, outcome) }
}

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import com.firebase.ui.auth.AuthException
Expand DownExpand Up@@ -334,9 +335,13 @@ private fun AppAuthenticatedContent(
lifecycleOwner.lifecycleScope.launch {
isDeletingAccount = true
try {
// Reauthentication, if it is needed, happens inside this call:
// the progress indicator below covers it, and the deletion is
// retried here rather than needing anything from this caller.
uiContext.authUI.delete(context)
} catch (e: AuthException.InvalidCredentialsException) {
Log.d("HighLevelApiDemoActivity", "Reauth required before delete")
} catch (e: AuthException.AuthCancelledException) {
// Declined at the identity check; the account is untouched.
Log.d("HighLevelApiDemoActivity", "Delete cancelled", e)
} catch (e: AuthException) {
Log.e("HighLevelApiDemoActivity", "Delete failed", e)
} finally {
Expand DownExpand Up@@ -567,6 +572,15 @@ private fun ChangePasswordDialog(
Log.d("HighLevelApiDemoActivity", "Password changed successfully")
onDismiss()
}
} catch (e: CancellationException) {
// withReauth suspends across the reauthentication sheet, so this
// scope really can be cancelled mid-call. Never report that as a
// failure the user can retry.
throw e
} catch (e: AuthException.AuthCancelledException) {
// The user backed out of confirming their identity. Nothing failed,
// and the password was not changed — so say neither.
Log.d("HighLevelApiDemoActivity", "Reauthentication declined", e)
} catch (e: Exception) {
updateError = "Failed to update password. Please try again."
} finally {
Expand Down
117 changes: 117 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
/*
* 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

import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser

/** Where one auth flow's states go. */
internal fun interface AuthStateSink {
fun emit(state: AuthState)
}

/**
* One auth flow's collaborators, and where its states go. Provider code is written against this,
* not [FirebaseAuthUI], so it reaches the public state channel only through [sink].
*
* @since 10.0.0
*/
internal class AuthFlowScope(
val auth: FirebaseAuth,
val config: AuthUIConfiguration,
val credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null,
val loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null,
/**
* What this flow is currently doing, for the screens rendering it. Under a reauthentication
* request's scope this is that request's phase rather than the host's state.
*/
val state: State<AuthState>,
private val sink: AuthStateSink,
) {
fun emit(state: AuthState) = sink.emit(state)

/**
* Publishes what [result] means for this flow: a password user who still owes email
* verification is not signed in yet, however successful the credential exchange was.
*/
fun emitResult(result: AuthResult?, defaultIsNewUser: Boolean = false) {
val user = result?.user
if (user != null) {
val isNewUser = result.additionalUserInfo?.isNewUser ?: defaultIsNewUser
emit(authUserState(user, result, isNewUser))
} else {
emit(AuthState.Idle)
}
}
}

/**
* What a signed-in [user] means as an [AuthState]: the single source of truth for whether they
* still owe email verification. Callers must not re-derive it — only password users with an email
* can satisfy that screen.
*/
internal fun authUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState {
val email = user.email
return if (!user.isEmailVerified &&
email != null &&
user.providerData.any { it.providerId == "password" }
) {
AuthState.RequiresEmailVerification(user = user, email = email)
} else {
AuthState.Success(result = result, user = user, isNewUser = isNewUser)
}
}

/** The auth flow the current composition belongs to, or null outside one. */
internal val LocalAuthFlowScope = staticCompositionLocalOf<AuthFlowScope?> { null }

/**
* The ambient flow when composed inside one, otherwise a fresh flow over [authUI]'s public state —
* which is what a consumer composing `EmailAuthScreen` or `PhoneAuthScreen` on its own gets.
*/
@Composable
internal fun rememberAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
): AuthFlowScope {
val ambient = LocalAuthFlowScope.current
val hostState = remember(authUI) { authUI.authStateFlow() }
.collectAsState(AuthState.Idle)
return remember(ambient, authUI, configuration, hostState) {
ambient ?: hostAuthFlowScope(authUI, configuration, hostState)
}
}

/** An [AuthFlowScope] over [authUI]'s public flow, in both directions. */
internal fun hostAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
state: State<AuthState>,
): AuthFlowScope = AuthFlowScope(
auth = authUI.auth,
config = configuration,
credentialManagerProvider = authUI.testCredentialManagerProvider,
loginManagerProvider = authUI.testLoginManagerProvider,
state = state,
sink = { authUI.updateAuthState(it) },
)
94 changes: 32 additions & 62 deletions auth/src/main/java/com/firebase/ui/auth/AuthState.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.MultiFactorResolver
import com.google.firebase.auth.PhoneAuthCredential
import com.google.firebase.auth.PhoneAuthProvider
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID

/**
Expand DownExpand Up@@ -256,11 +257,8 @@ abstract class AuthState private constructor() {
}

/**
* A state in the lifecycle of one reauthentication request.
*
* Every state carries a stable [requestId], so Activity recreation can distinguish a
* continuation of the same sensitive operation from a new operation for the same user. The
* request itself is process-local because its retry callback cannot be serialized.
* A state in the lifecycle of one reauthentication request. Every state carries a stable
* [requestId], so recreation can tell a continuation from a new operation for the same user.
*/
sealed class Reauthentication : AuthState() {
abstract val requestId: String
Expand All@@ -273,21 +271,31 @@ abstract class AuthState private constructor() {
val requestId: String,
val user: FirebaseUser,
val reason: String?,
retryOperation: (suspend (android.content.Context) -> Unit)?,
/**
* Where the caller awaiting this request is parked, or null when nobody is — a
* standalone flow from [FirebaseAuthUI.createReauthFlow] has no operation behind it.
*/
val resolver: CompletableDeferred<Boolean>? = null,
) {
/** Null once [claimRetryOperation] consumed it, so no recreation can re-run it. */
var retryOperation: (suspend (android.content.Context) -> Unit)? = retryOperation
private set
/** Whether a caller is waiting on this request to decide a pending operation. */
val hasPendingOperation: Boolean get() = resolver != null

/** Whether the awaiting caller is still there to resume. */
val isResumable: Boolean get() = resolver?.isActive != false

/** Whether this request ever carried an operation, even after it was claimed. */
val hasRetryOperation: Boolean = retryOperation != null
/** Credentials were accepted: the caller resumes and retries. Idempotent. */
fun resolve() {
resolver?.complete(true)
}

/**
* Hands the operation out exactly once. A second claim means the first run was lost,
* which must be reported rather than retried: the operation may have committed already.
* The request ended without proof. Completed with a value, not an exception: failing a
* parented Deferred would cancel the caller's scope, so [FirebaseAuthUI.withReauth]
* throws in its own frame instead.
*/
fun claimRetryOperation(): (suspend (android.content.Context) -> Unit)? =
retryOperation.also { retryOperation = null }
fun decline() {
resolver?.complete(false)
}
}

/**
Expand All@@ -302,26 +310,27 @@ abstract class AuthState private constructor() {
class Required internal constructor(
override val request: Request,
) : Reauthentication() {
constructor(
/** A request with nobody waiting on it, as a standalone reauthentication flow has. */
internal constructor(
user: FirebaseUser,
reason: String? = null,
retryOperation: (suspend (android.content.Context) -> Unit)? = null,
) : this(
Request(
requestId = UUID.randomUUID().toString(),
user = user,
reason = reason,
retryOperation = retryOperation,
)
)

override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
val user: FirebaseUser get() = request.user
val reason: String? get() = request.reason
val retryOperation: (suspend (android.content.Context) -> Unit)?
get() = request.retryOperation

/**
* Identity is the request. Snapshot state and [FirebaseAuthUI.pendingReauth] both
* conflate equal values, so a transition that must be observed changes the phase type.
*/
override fun equals(other: Any?): Boolean =
other is Required && requestId == other.requestId

Expand All@@ -341,7 +350,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The most recent credential attempt failed, but the request remains armed. */
/** The most recent credential attempt failed, but the request remains outstanding. */
internal class AttemptFailed(
override val request: Request,
val exception: Exception,
Expand DownExpand Up@@ -395,7 +404,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** Credentials were accepted for the request's user. */
/** Credentials were accepted for the request's user. Terminal for the exchange. */
internal class Succeeded(
override val request: Request,
val success: Success,
Expand All@@ -404,43 +413,9 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The sensitive operation is being retried after credentials were accepted. */
internal class RetryingOperation(
override val request: Request,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/** The retry completed and [outcome] is ready to become the ordinary auth state. */
internal class OperationFinished(
override val request: Request,
val outcome: AuthState,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/**
* Saved UI state proved a request existed, but its process-local retry callback was lost.
*/
internal class Interrupted(
override val requestId: String,
override val userUid: String,
) : Reauthentication() {
override val request: Request? = null
}

/**
* Whether this request's reauthentication already succeeded. A sign-out must not clear such
* a phase, because the pending operation succeeding can be what signed the user out.
*/
internal val isReauthenticated: Boolean
get() = this is Succeeded || this is RetryingOperation || this is OperationFinished

/**
* A provider attempt is about to run, clearing any previously surfaced failure. Null once
* credentials were accepted, so a late attempt cannot rewind a running operation.
* credentials were accepted, so a late attempt cannot rewind a finished request.
*/
internal fun attemptStarted(): AuthState? = when (this) {
is Required,
Expand DownExpand Up@@ -484,11 +459,6 @@ abstract class AuthState private constructor() {

else -> null
}

/** The retried sensitive operation produced [outcome]. Null unless a retry is in flight. */
internal fun operationFinished(outcome: AuthState): AuthState? =
(this as? RetryingOperation)
?.let { OperationFinished(it.request, outcome) }
}

/**
Expand Down
Loading