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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
478 changes: 307 additions & 171 deletions auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt

Large diffs are not rendered by default.

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
/*
* 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.ui.screens.email

import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.FirebaseAuthUI
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.EMAIL_ARG
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult

/**
* Registers the email flow's steps on [this] graph.
*
* Each step hosts an [EmailAuthScreen] pinned to its own [EmailAuthMode], turning mode switches
* into navigation through [navigateToEmailStep]; the address travels as [EMAIL_ARG] so it survives
* a switch. Errors are not acted on here: moving the flow on an
* [com.firebase.ui.auth.AuthState.Error] is the host's job alone.
*
* @param onCancel Invoked when the flow is *left*, not when stepping between email steps.
* @param prefillEmail The address already fixed for this flow, used to seed a step entered without
* [EMAIL_ARG]. Read during a step's composition, so it must not be state that changes while that
* step is on screen.
* @param onEmailTyped Reports the address as the user edits it, so a host-driven recovery that
* does not itself know the address can carry the live value.
*/
internal fun NavGraphBuilder.emailAuthDestinations(
navController: NavHostController,
context: Context,
configuration: AuthUIConfiguration,
authUI: FirebaseAuthUI,
content: (@Composable (EmailAuthContentState) -> Unit)?,
onCancel: () -> Unit,
prefillEmail: () -> String? = { null },
credentialForLinking: () -> AuthCredential? = { null },
emailLinkFromDifferentDevice: () -> String? = { null },
onEmailTyped: (String) -> Unit = {},
onSuccess: (AuthResult) -> Unit = {},
onError: (AuthException) -> Unit = {},
) {
AuthRoute.Email.steps.forEach { step ->
composable(
route = step.routePattern,
arguments = listOf(
navArgument(EMAIL_ARG) {
type = NavType.StringType
defaultValue = ""
}
),
) { entry ->
val routeEmail = entry.arguments?.getString(EMAIL_ARG).orEmpty()

if (!configuration.isEmailStepOffered(step)) {
// Keyed on Unit: the redirect must run at most once per back-stack entry.
LaunchedEffect(Unit) {
// Pop first; navigating alone leaves the unreachable step for back to return to.
navController.popBackStack(step.routePattern, inclusive = true)
navController.navigateToEmailStep(AuthRoute.Email.SignIn, routeEmail)
Comment thread
demolaf marked this conversation as resolved.
}
RedirectingStep()
return@composable
}

EmailAuthScreen(
context = context,
configuration = configuration,
authUI = authUI,
prefillEmail = routeEmail.ifEmpty { prefillEmail() },
credentialForLinking = credentialForLinking(),
emailLinkFromDifferentDevice = emailLinkFromDifferentDevice(),
content = content,
mode = step.mode,
onNavigateToMode = { targetMode, email ->
navController.navigateToEmailStep(AuthRoute.Email.stepFor(targetMode), email)
},
onEmailTyped = onEmailTyped,
onSuccess = onSuccess,
onError = onError,
onCancel = {
val previous = navController.previousBackStackEntry
if (previous != null && AuthRoute.Email.isStep(previous.destination.route)) {
navController.popBackStack()
} else {
onCancel()
}
},
)
}
}
}

/**
* Moves the flow to [step], carrying [email] so the typed address survives the step being left.
*
* Pops any existing entry for [step], then pushes a fresh one:
*
* * **Already on the stack — replaces.** Toggling sign-in ↔ sign-up cannot grow the stack, and a
* recovery removes the form that just failed rather than leaving it for back to return to.
* * **Not on the stack — pushes.** The origin stays reachable.
*
* Pushing rather than only popping means the address just typed wins over the stale one the old
* entry held, and saved state is not restored, so a mode switch's password clear survives. System
* back is the one case that does restore it.
*
* The push always leaves an entry for [step], so the graph's start destination can never be popped
* out from under it.
*/
internal fun NavHostController.navigateToEmailStep(step: AuthRoute.Email.Step, email: String?) {
navigate(step.withEmail(email)) {
popUpTo(step.routePattern) { inclusive = true }
}
}

/** Shown for as long as a redirect out of an unreachable step takes. */
@Composable
internal fun RedirectingStep() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}

/** Whether [step] is reachable at all in this configuration. */
internal fun AuthUIConfiguration.isEmailStepOffered(step: AuthRoute.Email.Step): Boolean =
when (step) {
AuthRoute.Email.SignUp -> isEmailSignUpOffered()
AuthRoute.Email.EmailLinkSignIn -> isEmailLinkSignInOffered()
AuthRoute.Email.SignIn, AuthRoute.Email.ResetPassword -> true
}

/**
* Whether the email flow may offer account creation. False while reauthenticating, and whenever
* the configuration or the email provider itself disables new accounts.
*/
internal fun AuthUIConfiguration.isEmailSignUpOffered(): Boolean {
if (isReauthenticationMode || !isNewEmailAccountsAllowed) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isNewAccountsAllowed == true
}

/**
* Whether the email flow may offer email-link sign-in. False while reauthenticating: a link
* reopens the app with nothing armed, so completing one there reports an interruption.
*/
internal fun AuthUIConfiguration.isEmailLinkSignInOffered(): Boolean {
if (isReauthenticationMode) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isEmailLinkSignInEnabled == true
}
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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
478 changes: 307 additions & 171 deletions auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt

Large diffs are not rendered by default.

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
/*
* 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.ui.screens.email

import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.FirebaseAuthUI
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.EMAIL_ARG
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult

/**
* Registers the email flow's steps on [this] graph.
*
* Each step hosts an [EmailAuthScreen] pinned to its own [EmailAuthMode], turning mode switches
* into navigation through [navigateToEmailStep]; the address travels as [EMAIL_ARG] so it survives
* a switch. Errors are not acted on here: moving the flow on an
* [com.firebase.ui.auth.AuthState.Error] is the host's job alone.
*
* @param onCancel Invoked when the flow is *left*, not when stepping between email steps.
* @param prefillEmail The address already fixed for this flow, used to seed a step entered without
* [EMAIL_ARG]. Read during a step's composition, so it must not be state that changes while that
* step is on screen.
* @param onEmailTyped Reports the address as the user edits it, so a host-driven recovery that
* does not itself know the address can carry the live value.
*/
internal fun NavGraphBuilder.emailAuthDestinations(
navController: NavHostController,
context: Context,
configuration: AuthUIConfiguration,
authUI: FirebaseAuthUI,
content: (@Composable (EmailAuthContentState) -> Unit)?,
onCancel: () -> Unit,
prefillEmail: () -> String? = { null },
credentialForLinking: () -> AuthCredential? = { null },
emailLinkFromDifferentDevice: () -> String? = { null },
onEmailTyped: (String) -> Unit = {},
onSuccess: (AuthResult) -> Unit = {},
onError: (AuthException) -> Unit = {},
) {
AuthRoute.Email.steps.forEach { step ->
composable(
route = step.routePattern,
arguments = listOf(
navArgument(EMAIL_ARG) {
type = NavType.StringType
defaultValue = ""
}
),
) { entry ->
val routeEmail = entry.arguments?.getString(EMAIL_ARG).orEmpty()

if (!configuration.isEmailStepOffered(step)) {
// Keyed on Unit: the redirect must run at most once per back-stack entry.
LaunchedEffect(Unit) {
// Pop first; navigating alone leaves the unreachable step for back to return to.
navController.popBackStack(step.routePattern, inclusive = true)
navController.navigateToEmailStep(AuthRoute.Email.SignIn, routeEmail)
Comment thread
demolaf marked this conversation as resolved.
}
RedirectingStep()
return@composable
}

EmailAuthScreen(
context = context,
configuration = configuration,
authUI = authUI,
prefillEmail = routeEmail.ifEmpty { prefillEmail() },
credentialForLinking = credentialForLinking(),
emailLinkFromDifferentDevice = emailLinkFromDifferentDevice(),
content = content,
mode = step.mode,
onNavigateToMode = { targetMode, email ->
navController.navigateToEmailStep(AuthRoute.Email.stepFor(targetMode), email)
},
onEmailTyped = onEmailTyped,
onSuccess = onSuccess,
onError = onError,
onCancel = {
val previous = navController.previousBackStackEntry
if (previous != null && AuthRoute.Email.isStep(previous.destination.route)) {
navController.popBackStack()
} else {
onCancel()
}
},
)
}
}
}

/**
* Moves the flow to [step], carrying [email] so the typed address survives the step being left.
*
* Pops any existing entry for [step], then pushes a fresh one:
*
* * **Already on the stack — replaces.** Toggling sign-in ↔ sign-up cannot grow the stack, and a
* recovery removes the form that just failed rather than leaving it for back to return to.
* * **Not on the stack — pushes.** The origin stays reachable.
*
* Pushing rather than only popping means the address just typed wins over the stale one the old
* entry held, and saved state is not restored, so a mode switch's password clear survives. System
* back is the one case that does restore it.
*
* The push always leaves an entry for [step], so the graph's start destination can never be popped
* out from under it.
*/
internal fun NavHostController.navigateToEmailStep(step: AuthRoute.Email.Step, email: String?) {
navigate(step.withEmail(email)) {
popUpTo(step.routePattern) { inclusive = true }
}
}

/** Shown for as long as a redirect out of an unreachable step takes. */
@Composable
internal fun RedirectingStep() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}

/** Whether [step] is reachable at all in this configuration. */
internal fun AuthUIConfiguration.isEmailStepOffered(step: AuthRoute.Email.Step): Boolean =
when (step) {
AuthRoute.Email.SignUp -> isEmailSignUpOffered()
AuthRoute.Email.EmailLinkSignIn -> isEmailLinkSignInOffered()
AuthRoute.Email.SignIn, AuthRoute.Email.ResetPassword -> true
}

/**
* Whether the email flow may offer account creation. False while reauthenticating, and whenever
* the configuration or the email provider itself disables new accounts.
*/
internal fun AuthUIConfiguration.isEmailSignUpOffered(): Boolean {
if (isReauthenticationMode || !isNewEmailAccountsAllowed) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isNewAccountsAllowed == true
}

/**
* Whether the email flow may offer email-link sign-in. False while reauthenticating: a link
* reopens the app with nothing armed, so completing one there reports an interruption.
*/
internal fun AuthUIConfiguration.isEmailLinkSignInOffered(): Boolean {
if (isReauthenticationMode) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isEmailLinkSignInEnabled == true
}
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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
478 changes: 307 additions & 171 deletions auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt

Large diffs are not rendered by default.

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
/*
* 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.ui.screens.email

import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.FirebaseAuthUI
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.EMAIL_ARG
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult

/**
* Registers the email flow's steps on [this] graph.
*
* Each step hosts an [EmailAuthScreen] pinned to its own [EmailAuthMode], turning mode switches
* into navigation through [navigateToEmailStep]; the address travels as [EMAIL_ARG] so it survives
* a switch. Errors are not acted on here: moving the flow on an
* [com.firebase.ui.auth.AuthState.Error] is the host's job alone.
*
* @param onCancel Invoked when the flow is *left*, not when stepping between email steps.
* @param prefillEmail The address already fixed for this flow, used to seed a step entered without
* [EMAIL_ARG]. Read during a step's composition, so it must not be state that changes while that
* step is on screen.
* @param onEmailTyped Reports the address as the user edits it, so a host-driven recovery that
* does not itself know the address can carry the live value.
*/
internal fun NavGraphBuilder.emailAuthDestinations(
navController: NavHostController,
context: Context,
configuration: AuthUIConfiguration,
authUI: FirebaseAuthUI,
content: (@Composable (EmailAuthContentState) -> Unit)?,
onCancel: () -> Unit,
prefillEmail: () -> String? = { null },
credentialForLinking: () -> AuthCredential? = { null },
emailLinkFromDifferentDevice: () -> String? = { null },
onEmailTyped: (String) -> Unit = {},
onSuccess: (AuthResult) -> Unit = {},
onError: (AuthException) -> Unit = {},
) {
AuthRoute.Email.steps.forEach { step ->
composable(
route = step.routePattern,
arguments = listOf(
navArgument(EMAIL_ARG) {
type = NavType.StringType
defaultValue = ""
}
),
) { entry ->
val routeEmail = entry.arguments?.getString(EMAIL_ARG).orEmpty()

if (!configuration.isEmailStepOffered(step)) {
// Keyed on Unit: the redirect must run at most once per back-stack entry.
LaunchedEffect(Unit) {
// Pop first; navigating alone leaves the unreachable step for back to return to.
navController.popBackStack(step.routePattern, inclusive = true)
navController.navigateToEmailStep(AuthRoute.Email.SignIn, routeEmail)
Comment thread
demolaf marked this conversation as resolved.
}
RedirectingStep()
return@composable
}

EmailAuthScreen(
context = context,
configuration = configuration,
authUI = authUI,
prefillEmail = routeEmail.ifEmpty { prefillEmail() },
credentialForLinking = credentialForLinking(),
emailLinkFromDifferentDevice = emailLinkFromDifferentDevice(),
content = content,
mode = step.mode,
onNavigateToMode = { targetMode, email ->
navController.navigateToEmailStep(AuthRoute.Email.stepFor(targetMode), email)
},
onEmailTyped = onEmailTyped,
onSuccess = onSuccess,
onError = onError,
onCancel = {
val previous = navController.previousBackStackEntry
if (previous != null && AuthRoute.Email.isStep(previous.destination.route)) {
navController.popBackStack()
} else {
onCancel()
}
},
)
}
}
}

/**
* Moves the flow to [step], carrying [email] so the typed address survives the step being left.
*
* Pops any existing entry for [step], then pushes a fresh one:
*
* * **Already on the stack — replaces.** Toggling sign-in ↔ sign-up cannot grow the stack, and a
* recovery removes the form that just failed rather than leaving it for back to return to.
* * **Not on the stack — pushes.** The origin stays reachable.
*
* Pushing rather than only popping means the address just typed wins over the stale one the old
* entry held, and saved state is not restored, so a mode switch's password clear survives. System
* back is the one case that does restore it.
*
* The push always leaves an entry for [step], so the graph's start destination can never be popped
* out from under it.
*/
internal fun NavHostController.navigateToEmailStep(step: AuthRoute.Email.Step, email: String?) {
navigate(step.withEmail(email)) {
popUpTo(step.routePattern) { inclusive = true }
}
}

/** Shown for as long as a redirect out of an unreachable step takes. */
@Composable
internal fun RedirectingStep() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}

/** Whether [step] is reachable at all in this configuration. */
internal fun AuthUIConfiguration.isEmailStepOffered(step: AuthRoute.Email.Step): Boolean =
when (step) {
AuthRoute.Email.SignUp -> isEmailSignUpOffered()
AuthRoute.Email.EmailLinkSignIn -> isEmailLinkSignInOffered()
AuthRoute.Email.SignIn, AuthRoute.Email.ResetPassword -> true
}

/**
* Whether the email flow may offer account creation. False while reauthenticating, and whenever
* the configuration or the email provider itself disables new accounts.
*/
internal fun AuthUIConfiguration.isEmailSignUpOffered(): Boolean {
if (isReauthenticationMode || !isNewEmailAccountsAllowed) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isNewAccountsAllowed == true
}

/**
* Whether the email flow may offer email-link sign-in. False while reauthenticating: a link
* reopens the app with nothing armed, so completing one there reports an interruption.
*/
internal fun AuthUIConfiguration.isEmailLinkSignInOffered(): Boolean {
if (isReauthenticationMode) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isEmailLinkSignInEnabled == true
}
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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
478 changes: 307 additions & 171 deletions auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt

Large diffs are not rendered by default.

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
/*
* 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.ui.screens.email

import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.FirebaseAuthUI
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.EMAIL_ARG
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult

/**
* Registers the email flow's steps on [this] graph.
*
* Each step hosts an [EmailAuthScreen] pinned to its own [EmailAuthMode], turning mode switches
* into navigation through [navigateToEmailStep]; the address travels as [EMAIL_ARG] so it survives
* a switch. Errors are not acted on here: moving the flow on an
* [com.firebase.ui.auth.AuthState.Error] is the host's job alone.
*
* @param onCancel Invoked when the flow is *left*, not when stepping between email steps.
* @param prefillEmail The address already fixed for this flow, used to seed a step entered without
* [EMAIL_ARG]. Read during a step's composition, so it must not be state that changes while that
* step is on screen.
* @param onEmailTyped Reports the address as the user edits it, so a host-driven recovery that
* does not itself know the address can carry the live value.
*/
internal fun NavGraphBuilder.emailAuthDestinations(
navController: NavHostController,
context: Context,
configuration: AuthUIConfiguration,
authUI: FirebaseAuthUI,
content: (@Composable (EmailAuthContentState) -> Unit)?,
onCancel: () -> Unit,
prefillEmail: () -> String? = { null },
credentialForLinking: () -> AuthCredential? = { null },
emailLinkFromDifferentDevice: () -> String? = { null },
onEmailTyped: (String) -> Unit = {},
onSuccess: (AuthResult) -> Unit = {},
onError: (AuthException) -> Unit = {},
) {
AuthRoute.Email.steps.forEach { step ->
composable(
route = step.routePattern,
arguments = listOf(
navArgument(EMAIL_ARG) {
type = NavType.StringType
defaultValue = ""
}
),
) { entry ->
val routeEmail = entry.arguments?.getString(EMAIL_ARG).orEmpty()

if (!configuration.isEmailStepOffered(step)) {
// Keyed on Unit: the redirect must run at most once per back-stack entry.
LaunchedEffect(Unit) {
// Pop first; navigating alone leaves the unreachable step for back to return to.
navController.popBackStack(step.routePattern, inclusive = true)
navController.navigateToEmailStep(AuthRoute.Email.SignIn, routeEmail)
Comment thread
demolaf marked this conversation as resolved.
}
RedirectingStep()
return@composable
}

EmailAuthScreen(
context = context,
configuration = configuration,
authUI = authUI,
prefillEmail = routeEmail.ifEmpty { prefillEmail() },
credentialForLinking = credentialForLinking(),
emailLinkFromDifferentDevice = emailLinkFromDifferentDevice(),
content = content,
mode = step.mode,
onNavigateToMode = { targetMode, email ->
navController.navigateToEmailStep(AuthRoute.Email.stepFor(targetMode), email)
},
onEmailTyped = onEmailTyped,
onSuccess = onSuccess,
onError = onError,
onCancel = {
val previous = navController.previousBackStackEntry
if (previous != null && AuthRoute.Email.isStep(previous.destination.route)) {
navController.popBackStack()
} else {
onCancel()
}
},
)
}
}
}

/**
* Moves the flow to [step], carrying [email] so the typed address survives the step being left.
*
* Pops any existing entry for [step], then pushes a fresh one:
*
* * **Already on the stack — replaces.** Toggling sign-in ↔ sign-up cannot grow the stack, and a
* recovery removes the form that just failed rather than leaving it for back to return to.
* * **Not on the stack — pushes.** The origin stays reachable.
*
* Pushing rather than only popping means the address just typed wins over the stale one the old
* entry held, and saved state is not restored, so a mode switch's password clear survives. System
* back is the one case that does restore it.
*
* The push always leaves an entry for [step], so the graph's start destination can never be popped
* out from under it.
*/
internal fun NavHostController.navigateToEmailStep(step: AuthRoute.Email.Step, email: String?) {
navigate(step.withEmail(email)) {
popUpTo(step.routePattern) { inclusive = true }
}
}

/** Shown for as long as a redirect out of an unreachable step takes. */
@Composable
internal fun RedirectingStep() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}

/** Whether [step] is reachable at all in this configuration. */
internal fun AuthUIConfiguration.isEmailStepOffered(step: AuthRoute.Email.Step): Boolean =
when (step) {
AuthRoute.Email.SignUp -> isEmailSignUpOffered()
AuthRoute.Email.EmailLinkSignIn -> isEmailLinkSignInOffered()
AuthRoute.Email.SignIn, AuthRoute.Email.ResetPassword -> true
}

/**
* Whether the email flow may offer account creation. False while reauthenticating, and whenever
* the configuration or the email provider itself disables new accounts.
*/
internal fun AuthUIConfiguration.isEmailSignUpOffered(): Boolean {
if (isReauthenticationMode || !isNewEmailAccountsAllowed) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isNewAccountsAllowed == true
}

/**
* Whether the email flow may offer email-link sign-in. False while reauthenticating: a link
* reopens the app with nothing armed, so completing one there reports an interruption.
*/
internal fun AuthUIConfiguration.isEmailLinkSignInOffered(): Boolean {
if (isReauthenticationMode) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isEmailLinkSignInEnabled == true
}
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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
478 changes: 307 additions & 171 deletions auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt

Large diffs are not rendered by default.

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
/*
* 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.ui.screens.email

import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.FirebaseAuthUI
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.EMAIL_ARG
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult

/**
* Registers the email flow's steps on [this] graph.
*
* Each step hosts an [EmailAuthScreen] pinned to its own [EmailAuthMode], turning mode switches
* into navigation through [navigateToEmailStep]; the address travels as [EMAIL_ARG] so it survives
* a switch. Errors are not acted on here: moving the flow on an
* [com.firebase.ui.auth.AuthState.Error] is the host's job alone.
*
* @param onCancel Invoked when the flow is *left*, not when stepping between email steps.
* @param prefillEmail The address already fixed for this flow, used to seed a step entered without
* [EMAIL_ARG]. Read during a step's composition, so it must not be state that changes while that
* step is on screen.
* @param onEmailTyped Reports the address as the user edits it, so a host-driven recovery that
* does not itself know the address can carry the live value.
*/
internal fun NavGraphBuilder.emailAuthDestinations(
navController: NavHostController,
context: Context,
configuration: AuthUIConfiguration,
authUI: FirebaseAuthUI,
content: (@Composable (EmailAuthContentState) -> Unit)?,
onCancel: () -> Unit,
prefillEmail: () -> String? = { null },
credentialForLinking: () -> AuthCredential? = { null },
emailLinkFromDifferentDevice: () -> String? = { null },
onEmailTyped: (String) -> Unit = {},
onSuccess: (AuthResult) -> Unit = {},
onError: (AuthException) -> Unit = {},
) {
AuthRoute.Email.steps.forEach { step ->
composable(
route = step.routePattern,
arguments = listOf(
navArgument(EMAIL_ARG) {
type = NavType.StringType
defaultValue = ""
}
),
) { entry ->
val routeEmail = entry.arguments?.getString(EMAIL_ARG).orEmpty()

if (!configuration.isEmailStepOffered(step)) {
// Keyed on Unit: the redirect must run at most once per back-stack entry.
LaunchedEffect(Unit) {
// Pop first; navigating alone leaves the unreachable step for back to return to.
navController.popBackStack(step.routePattern, inclusive = true)
navController.navigateToEmailStep(AuthRoute.Email.SignIn, routeEmail)
Comment thread
demolaf marked this conversation as resolved.
}
RedirectingStep()
return@composable
}

EmailAuthScreen(
context = context,
configuration = configuration,
authUI = authUI,
prefillEmail = routeEmail.ifEmpty { prefillEmail() },
credentialForLinking = credentialForLinking(),
emailLinkFromDifferentDevice = emailLinkFromDifferentDevice(),
content = content,
mode = step.mode,
onNavigateToMode = { targetMode, email ->
navController.navigateToEmailStep(AuthRoute.Email.stepFor(targetMode), email)
},
onEmailTyped = onEmailTyped,
onSuccess = onSuccess,
onError = onError,
onCancel = {
val previous = navController.previousBackStackEntry
if (previous != null && AuthRoute.Email.isStep(previous.destination.route)) {
navController.popBackStack()
} else {
onCancel()
}
},
)
}
}
}

/**
* Moves the flow to [step], carrying [email] so the typed address survives the step being left.
*
* Pops any existing entry for [step], then pushes a fresh one:
*
* * **Already on the stack — replaces.** Toggling sign-in ↔ sign-up cannot grow the stack, and a
* recovery removes the form that just failed rather than leaving it for back to return to.
* * **Not on the stack — pushes.** The origin stays reachable.
*
* Pushing rather than only popping means the address just typed wins over the stale one the old
* entry held, and saved state is not restored, so a mode switch's password clear survives. System
* back is the one case that does restore it.
*
* The push always leaves an entry for [step], so the graph's start destination can never be popped
* out from under it.
*/
internal fun NavHostController.navigateToEmailStep(step: AuthRoute.Email.Step, email: String?) {
navigate(step.withEmail(email)) {
popUpTo(step.routePattern) { inclusive = true }
}
}

/** Shown for as long as a redirect out of an unreachable step takes. */
@Composable
internal fun RedirectingStep() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}

/** Whether [step] is reachable at all in this configuration. */
internal fun AuthUIConfiguration.isEmailStepOffered(step: AuthRoute.Email.Step): Boolean =
when (step) {
AuthRoute.Email.SignUp -> isEmailSignUpOffered()
AuthRoute.Email.EmailLinkSignIn -> isEmailLinkSignInOffered()
AuthRoute.Email.SignIn, AuthRoute.Email.ResetPassword -> true
}

/**
* Whether the email flow may offer account creation. False while reauthenticating, and whenever
* the configuration or the email provider itself disables new accounts.
*/
internal fun AuthUIConfiguration.isEmailSignUpOffered(): Boolean {
if (isReauthenticationMode || !isNewEmailAccountsAllowed) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isNewAccountsAllowed == true
}

/**
* Whether the email flow may offer email-link sign-in. False while reauthenticating: a link
* reopens the app with nothing armed, so completing one there reports an interruption.
*/
internal fun AuthUIConfiguration.isEmailLinkSignInOffered(): Boolean {
if (isReauthenticationMode) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isEmailLinkSignInEnabled == true
}
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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
478 changes: 307 additions & 171 deletions auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt

Large diffs are not rendered by default.

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
/*
* 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.ui.screens.email

import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.FirebaseAuthUI
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.EMAIL_ARG
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult

/**
* Registers the email flow's steps on [this] graph.
*
* Each step hosts an [EmailAuthScreen] pinned to its own [EmailAuthMode], turning mode switches
* into navigation through [navigateToEmailStep]; the address travels as [EMAIL_ARG] so it survives
* a switch. Errors are not acted on here: moving the flow on an
* [com.firebase.ui.auth.AuthState.Error] is the host's job alone.
*
* @param onCancel Invoked when the flow is *left*, not when stepping between email steps.
* @param prefillEmail The address already fixed for this flow, used to seed a step entered without
* [EMAIL_ARG]. Read during a step's composition, so it must not be state that changes while that
* step is on screen.
* @param onEmailTyped Reports the address as the user edits it, so a host-driven recovery that
* does not itself know the address can carry the live value.
*/
internal fun NavGraphBuilder.emailAuthDestinations(
navController: NavHostController,
context: Context,
configuration: AuthUIConfiguration,
authUI: FirebaseAuthUI,
content: (@Composable (EmailAuthContentState) -> Unit)?,
onCancel: () -> Unit,
prefillEmail: () -> String? = { null },
credentialForLinking: () -> AuthCredential? = { null },
emailLinkFromDifferentDevice: () -> String? = { null },
onEmailTyped: (String) -> Unit = {},
onSuccess: (AuthResult) -> Unit = {},
onError: (AuthException) -> Unit = {},
) {
AuthRoute.Email.steps.forEach { step ->
composable(
route = step.routePattern,
arguments = listOf(
navArgument(EMAIL_ARG) {
type = NavType.StringType
defaultValue = ""
}
),
) { entry ->
val routeEmail = entry.arguments?.getString(EMAIL_ARG).orEmpty()

if (!configuration.isEmailStepOffered(step)) {
// Keyed on Unit: the redirect must run at most once per back-stack entry.
LaunchedEffect(Unit) {
// Pop first; navigating alone leaves the unreachable step for back to return to.
navController.popBackStack(step.routePattern, inclusive = true)
navController.navigateToEmailStep(AuthRoute.Email.SignIn, routeEmail)
Comment thread
demolaf marked this conversation as resolved.
}
RedirectingStep()
return@composable
}

EmailAuthScreen(
context = context,
configuration = configuration,
authUI = authUI,
prefillEmail = routeEmail.ifEmpty { prefillEmail() },
credentialForLinking = credentialForLinking(),
emailLinkFromDifferentDevice = emailLinkFromDifferentDevice(),
content = content,
mode = step.mode,
onNavigateToMode = { targetMode, email ->
navController.navigateToEmailStep(AuthRoute.Email.stepFor(targetMode), email)
},
onEmailTyped = onEmailTyped,
onSuccess = onSuccess,
onError = onError,
onCancel = {
val previous = navController.previousBackStackEntry
if (previous != null && AuthRoute.Email.isStep(previous.destination.route)) {
navController.popBackStack()
} else {
onCancel()
}
},
)
}
}
}

/**
* Moves the flow to [step], carrying [email] so the typed address survives the step being left.
*
* Pops any existing entry for [step], then pushes a fresh one:
*
* * **Already on the stack — replaces.** Toggling sign-in ↔ sign-up cannot grow the stack, and a
* recovery removes the form that just failed rather than leaving it for back to return to.
* * **Not on the stack — pushes.** The origin stays reachable.
*
* Pushing rather than only popping means the address just typed wins over the stale one the old
* entry held, and saved state is not restored, so a mode switch's password clear survives. System
* back is the one case that does restore it.
*
* The push always leaves an entry for [step], so the graph's start destination can never be popped
* out from under it.
*/
internal fun NavHostController.navigateToEmailStep(step: AuthRoute.Email.Step, email: String?) {
navigate(step.withEmail(email)) {
popUpTo(step.routePattern) { inclusive = true }
}
}

/** Shown for as long as a redirect out of an unreachable step takes. */
@Composable
internal fun RedirectingStep() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}

/** Whether [step] is reachable at all in this configuration. */
internal fun AuthUIConfiguration.isEmailStepOffered(step: AuthRoute.Email.Step): Boolean =
when (step) {
AuthRoute.Email.SignUp -> isEmailSignUpOffered()
AuthRoute.Email.EmailLinkSignIn -> isEmailLinkSignInOffered()
AuthRoute.Email.SignIn, AuthRoute.Email.ResetPassword -> true
}

/**
* Whether the email flow may offer account creation. False while reauthenticating, and whenever
* the configuration or the email provider itself disables new accounts.
*/
internal fun AuthUIConfiguration.isEmailSignUpOffered(): Boolean {
if (isReauthenticationMode || !isNewEmailAccountsAllowed) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isNewAccountsAllowed == true
}

/**
* Whether the email flow may offer email-link sign-in. False while reauthenticating: a link
* reopens the app with nothing armed, so completing one there reports an interruption.
*/
internal fun AuthUIConfiguration.isEmailLinkSignInOffered(): Boolean {
if (isReauthenticationMode) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isEmailLinkSignInEnabled == true
}
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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
478 changes: 307 additions & 171 deletions auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt

Large diffs are not rendered by default.

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
/*
* 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.ui.screens.email

import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.FirebaseAuthUI
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.EMAIL_ARG
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult

/**
* Registers the email flow's steps on [this] graph.
*
* Each step hosts an [EmailAuthScreen] pinned to its own [EmailAuthMode], turning mode switches
* into navigation through [navigateToEmailStep]; the address travels as [EMAIL_ARG] so it survives
* a switch. Errors are not acted on here: moving the flow on an
* [com.firebase.ui.auth.AuthState.Error] is the host's job alone.
*
* @param onCancel Invoked when the flow is *left*, not when stepping between email steps.
* @param prefillEmail The address already fixed for this flow, used to seed a step entered without
* [EMAIL_ARG]. Read during a step's composition, so it must not be state that changes while that
* step is on screen.
* @param onEmailTyped Reports the address as the user edits it, so a host-driven recovery that
* does not itself know the address can carry the live value.
*/
internal fun NavGraphBuilder.emailAuthDestinations(
navController: NavHostController,
context: Context,
configuration: AuthUIConfiguration,
authUI: FirebaseAuthUI,
content: (@Composable (EmailAuthContentState) -> Unit)?,
onCancel: () -> Unit,
prefillEmail: () -> String? = { null },
credentialForLinking: () -> AuthCredential? = { null },
emailLinkFromDifferentDevice: () -> String? = { null },
onEmailTyped: (String) -> Unit = {},
onSuccess: (AuthResult) -> Unit = {},
onError: (AuthException) -> Unit = {},
) {
AuthRoute.Email.steps.forEach { step ->
composable(
route = step.routePattern,
arguments = listOf(
navArgument(EMAIL_ARG) {
type = NavType.StringType
defaultValue = ""
}
),
) { entry ->
val routeEmail = entry.arguments?.getString(EMAIL_ARG).orEmpty()

if (!configuration.isEmailStepOffered(step)) {
// Keyed on Unit: the redirect must run at most once per back-stack entry.
LaunchedEffect(Unit) {
// Pop first; navigating alone leaves the unreachable step for back to return to.
navController.popBackStack(step.routePattern, inclusive = true)
navController.navigateToEmailStep(AuthRoute.Email.SignIn, routeEmail)
Comment thread
demolaf marked this conversation as resolved.
}
RedirectingStep()
return@composable
}

EmailAuthScreen(
context = context,
configuration = configuration,
authUI = authUI,
prefillEmail = routeEmail.ifEmpty { prefillEmail() },
credentialForLinking = credentialForLinking(),
emailLinkFromDifferentDevice = emailLinkFromDifferentDevice(),
content = content,
mode = step.mode,
onNavigateToMode = { targetMode, email ->
navController.navigateToEmailStep(AuthRoute.Email.stepFor(targetMode), email)
},
onEmailTyped = onEmailTyped,
onSuccess = onSuccess,
onError = onError,
onCancel = {
val previous = navController.previousBackStackEntry
if (previous != null && AuthRoute.Email.isStep(previous.destination.route)) {
navController.popBackStack()
} else {
onCancel()
}
},
)
}
}
}

/**
* Moves the flow to [step], carrying [email] so the typed address survives the step being left.
*
* Pops any existing entry for [step], then pushes a fresh one:
*
* * **Already on the stack — replaces.** Toggling sign-in ↔ sign-up cannot grow the stack, and a
* recovery removes the form that just failed rather than leaving it for back to return to.
* * **Not on the stack — pushes.** The origin stays reachable.
*
* Pushing rather than only popping means the address just typed wins over the stale one the old
* entry held, and saved state is not restored, so a mode switch's password clear survives. System
* back is the one case that does restore it.
*
* The push always leaves an entry for [step], so the graph's start destination can never be popped
* out from under it.
*/
internal fun NavHostController.navigateToEmailStep(step: AuthRoute.Email.Step, email: String?) {
navigate(step.withEmail(email)) {
popUpTo(step.routePattern) { inclusive = true }
}
}

/** Shown for as long as a redirect out of an unreachable step takes. */
@Composable
internal fun RedirectingStep() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}

/** Whether [step] is reachable at all in this configuration. */
internal fun AuthUIConfiguration.isEmailStepOffered(step: AuthRoute.Email.Step): Boolean =
when (step) {
AuthRoute.Email.SignUp -> isEmailSignUpOffered()
AuthRoute.Email.EmailLinkSignIn -> isEmailLinkSignInOffered()
AuthRoute.Email.SignIn, AuthRoute.Email.ResetPassword -> true
}

/**
* Whether the email flow may offer account creation. False while reauthenticating, and whenever
* the configuration or the email provider itself disables new accounts.
*/
internal fun AuthUIConfiguration.isEmailSignUpOffered(): Boolean {
if (isReauthenticationMode || !isNewEmailAccountsAllowed) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isNewAccountsAllowed == true
}

/**
* Whether the email flow may offer email-link sign-in. False while reauthenticating: a link
* reopens the app with nothing armed, so completing one there reports an interruption.
*/
internal fun AuthUIConfiguration.isEmailLinkSignInOffered(): Boolean {
if (isReauthenticationMode) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isEmailLinkSignInEnabled == true
}
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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
478 changes: 307 additions & 171 deletions auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt

Large diffs are not rendered by default.

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
/*
* 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.ui.screens.email

import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.FirebaseAuthUI
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.EMAIL_ARG
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult

/**
* Registers the email flow's steps on [this] graph.
*
* Each step hosts an [EmailAuthScreen] pinned to its own [EmailAuthMode], turning mode switches
* into navigation through [navigateToEmailStep]; the address travels as [EMAIL_ARG] so it survives
* a switch. Errors are not acted on here: moving the flow on an
* [com.firebase.ui.auth.AuthState.Error] is the host's job alone.
*
* @param onCancel Invoked when the flow is *left*, not when stepping between email steps.
* @param prefillEmail The address already fixed for this flow, used to seed a step entered without
* [EMAIL_ARG]. Read during a step's composition, so it must not be state that changes while that
* step is on screen.
* @param onEmailTyped Reports the address as the user edits it, so a host-driven recovery that
* does not itself know the address can carry the live value.
*/
internal fun NavGraphBuilder.emailAuthDestinations(
navController: NavHostController,
context: Context,
configuration: AuthUIConfiguration,
authUI: FirebaseAuthUI,
content: (@Composable (EmailAuthContentState) -> Unit)?,
onCancel: () -> Unit,
prefillEmail: () -> String? = { null },
credentialForLinking: () -> AuthCredential? = { null },
emailLinkFromDifferentDevice: () -> String? = { null },
onEmailTyped: (String) -> Unit = {},
onSuccess: (AuthResult) -> Unit = {},
onError: (AuthException) -> Unit = {},
) {
AuthRoute.Email.steps.forEach { step ->
composable(
route = step.routePattern,
arguments = listOf(
navArgument(EMAIL_ARG) {
type = NavType.StringType
defaultValue = ""
}
),
) { entry ->
val routeEmail = entry.arguments?.getString(EMAIL_ARG).orEmpty()

if (!configuration.isEmailStepOffered(step)) {
// Keyed on Unit: the redirect must run at most once per back-stack entry.
LaunchedEffect(Unit) {
// Pop first; navigating alone leaves the unreachable step for back to return to.
navController.popBackStack(step.routePattern, inclusive = true)
navController.navigateToEmailStep(AuthRoute.Email.SignIn, routeEmail)
Comment thread
demolaf marked this conversation as resolved.
}
RedirectingStep()
return@composable
}

EmailAuthScreen(
context = context,
configuration = configuration,
authUI = authUI,
prefillEmail = routeEmail.ifEmpty { prefillEmail() },
credentialForLinking = credentialForLinking(),
emailLinkFromDifferentDevice = emailLinkFromDifferentDevice(),
content = content,
mode = step.mode,
onNavigateToMode = { targetMode, email ->
navController.navigateToEmailStep(AuthRoute.Email.stepFor(targetMode), email)
},
onEmailTyped = onEmailTyped,
onSuccess = onSuccess,
onError = onError,
onCancel = {
val previous = navController.previousBackStackEntry
if (previous != null && AuthRoute.Email.isStep(previous.destination.route)) {
navController.popBackStack()
} else {
onCancel()
}
},
)
}
}
}

/**
* Moves the flow to [step], carrying [email] so the typed address survives the step being left.
*
* Pops any existing entry for [step], then pushes a fresh one:
*
* * **Already on the stack — replaces.** Toggling sign-in ↔ sign-up cannot grow the stack, and a
* recovery removes the form that just failed rather than leaving it for back to return to.
* * **Not on the stack — pushes.** The origin stays reachable.
*
* Pushing rather than only popping means the address just typed wins over the stale one the old
* entry held, and saved state is not restored, so a mode switch's password clear survives. System
* back is the one case that does restore it.
*
* The push always leaves an entry for [step], so the graph's start destination can never be popped
* out from under it.
*/
internal fun NavHostController.navigateToEmailStep(step: AuthRoute.Email.Step, email: String?) {
navigate(step.withEmail(email)) {
popUpTo(step.routePattern) { inclusive = true }
}
}

/** Shown for as long as a redirect out of an unreachable step takes. */
@Composable
internal fun RedirectingStep() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}

/** Whether [step] is reachable at all in this configuration. */
internal fun AuthUIConfiguration.isEmailStepOffered(step: AuthRoute.Email.Step): Boolean =
when (step) {
AuthRoute.Email.SignUp -> isEmailSignUpOffered()
AuthRoute.Email.EmailLinkSignIn -> isEmailLinkSignInOffered()
AuthRoute.Email.SignIn, AuthRoute.Email.ResetPassword -> true
}

/**
* Whether the email flow may offer account creation. False while reauthenticating, and whenever
* the configuration or the email provider itself disables new accounts.
*/
internal fun AuthUIConfiguration.isEmailSignUpOffered(): Boolean {
if (isReauthenticationMode || !isNewEmailAccountsAllowed) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isNewAccountsAllowed == true
}

/**
* Whether the email flow may offer email-link sign-in. False while reauthenticating: a link
* reopens the app with nothing armed, so completing one there reports an interruption.
*/
internal fun AuthUIConfiguration.isEmailLinkSignInOffered(): Boolean {
if (isReauthenticationMode) return false
return providers.filterIsInstance<AuthProvider.Email>()
.firstOrNull()
?.isEmailLinkSignInEnabled == true
}
Loading