configuredProviders = new HashSet<>();
- for (IdpConfig idpConfig : idpConfigs) {
- if (configuredProviders.contains(idpConfig.getProviderId())) {
- throw new IllegalArgumentException("Each provider can only be set once. "
- + idpConfig.getProviderId()
- + " was set twice.");
- }
- configuredProviders.add(idpConfig.getProviderId());
- mProviders.add(idpConfig);
- }
- return this;
- }
-
- /**
- * Specifies the set of supported authentication providers. At least one provider
- * must be specified, and the set of providers must be a subset of
- * {@link #SUPPORTED_PROVIDERS}. There may only be one instance of each provider.
- *
- * If no providers are explicitly specified by calling this method, then
- * {@link #EMAIL_PROVIDER email} is the default supported provider.
- *
- * @see #EMAIL_PROVIDER
- * @see #FACEBOOK_PROVIDER
- * @see #GOOGLE_PROVIDER
- */
- @Deprecated
- public SignInIntentBuilder setProviders(@NonNull String... providers) {
- mProviders.clear(); // clear the default email provider
- for (String provider : providers) {
- if (isIdpAlreadyConfigured(provider)) {
- throw new IllegalArgumentException("Provider already configured: " + provider);
- }
- mProviders.add(new IdpConfig.Builder(provider).build());
- }
- return this;
- }
-
- /**
- * Enables or disables the use of Smart Lock for Passwords in the sign in flow.
- *
- *
SmartLock is enabled by default
- */
- public SignInIntentBuilder setIsSmartLockEnabled(boolean enabled) {
- mIsSmartLockEnabled = enabled;
- return this;
- }
-
- private boolean isIdpAlreadyConfigured(@NonNull String providerId) {
- for (IdpConfig config : mProviders) {
- if (config.getProviderId().equals(providerId)) {
- return true;
- }
- }
- return false;
- }
-
- public Intent build() {
- return KickoffActivity.createIntent(mApp.getApplicationContext(), getFlowParams());
- }
-
- @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
- public FlowParameters getFlowParams() {
- return new FlowParameters(mApp.getName(),
- new ArrayList<>(mProviders),
- mTheme,
- mLogo,
- mTosUrl,
- mIsSmartLockEnabled);
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt
new file mode 100644
index 0000000000..b936d1cb2a
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt
@@ -0,0 +1,235 @@
+/*
+ * 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 android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.annotation.RestrictTo
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.lifecycle.lifecycleScope
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen
+import com.firebase.ui.auth.util.EmailLinkConstants
+import kotlinx.coroutines.launch
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Activity that hosts the Firebase authentication flow UI.
+ *
+ * This activity displays the [FirebaseAuthScreen] composable and manages
+ * the authentication flow lifecycle. It automatically finishes when the user
+ * signs in successfully or cancels the flow.
+ *
+ * **Do not launch this Activity directly.**
+ * Use [AuthFlowController] to start the auth flow:
+ *
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ * val configuration = authUIConfiguration {
+ * providers = listOf(AuthProvider.Email(), AuthProvider.Google(...))
+ * }
+ * val controller = authUI.createAuthFlow(configuration)
+ * val intent = controller.createIntent(context)
+ * launcher.launch(intent)
+ * ```
+ *
+ * **Result Codes:**
+ * - [Activity.RESULT_OK] - User signed in successfully
+ * - [Activity.RESULT_CANCELED] - User cancelled or error occurred
+ *
+ * **Result Data:**
+ * - [EXTRA_USER_ID] - User ID string (when RESULT_OK)
+ * - [EXTRA_IS_NEW_USER] - Boolean indicating if user is new (when RESULT_OK)
+ * - [EXTRA_ERROR] - [AuthException] when an error occurs
+ *
+ * **Note:** To get the full user object after successful sign-in, use:
+ * ```kotlin
+ * FirebaseAuth.getInstance().currentUser
+ * ```
+ *
+ * @see AuthFlowController
+ * @see FirebaseAuthScreen
+ * @since 10.0.0
+ */
+class FirebaseAuthActivity : ComponentActivity() {
+
+ private lateinit var authUI: FirebaseAuthUI
+ private lateinit var configuration: AuthUIConfiguration
+ private var launchKey: String? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ window.decorView.filterTouchesWhenObscured = true
+ enableEdgeToEdge()
+
+ // Extract configuration and auth instance from cache using UUID key
+ launchKey = intent.getStringExtra(EXTRA_CONFIGURATION_KEY)
+ configuration = if (launchKey != null) {
+ configurationCache[launchKey]
+ } else {
+ null
+ } ?: run {
+ // Missing configuration, finish with error
+ setResult(RESULT_CANCELED)
+ finish()
+ return
+ }
+
+ authUI = launchKey?.let { authUICache[it] } ?: run {
+ // Missing auth instance, finish with error
+ setResult(RESULT_CANCELED)
+ finish()
+ return
+ }
+
+ // Extract email link if present
+ val emailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK)
+
+ // Observe auth state to automatically finish when done
+ lifecycleScope.launch {
+ authUI.authStateFlow().collect { state ->
+ when (state) {
+ is AuthState.Success -> {
+ // User signed in successfully
+ val resultIntent = Intent().apply {
+ putExtra(EXTRA_USER_ID, state.user.uid)
+ putExtra(EXTRA_IS_NEW_USER, state.isNewUser)
+ }
+ setResult(RESULT_OK, resultIntent)
+ finish()
+ }
+ is AuthState.Cancelled -> {
+ // User cancelled the flow
+ setResult(RESULT_CANCELED)
+ finish()
+ }
+ is AuthState.Error -> {
+ // Error occurred, finish with error info
+ val resultIntent = Intent().apply {
+ putExtra(EXTRA_ERROR, state.exception)
+ }
+ setResult(RESULT_CANCELED, resultIntent)
+ // Don't finish on error, let user see error and retry
+ }
+ else -> {
+ // Other states, keep showing UI
+ }
+ }
+ }
+ }
+
+ // Set up Compose UI
+ setContent {
+ AuthUITheme {
+ FirebaseAuthScreen(
+ authUI = authUI,
+ configuration = configuration,
+ emailLink = emailLink,
+ onSignInSuccess = { authResult ->
+ // State flow will handle finishing
+ },
+ onSignInFailure = { exception ->
+ // State flow will handle error
+ },
+ onSignInCancelled = {
+ authUI.updateAuthState(AuthState.Cancelled)
+ }
+ )
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ if (isFinishing) {
+ launchKey?.let { key ->
+ configurationCache.remove(key)
+ authUICache.remove(key)
+ }
+ } else {
+ // Preserve cached launch state so the recreated activity can recover it.
+ authUI.updateAuthState(AuthState.Idle)
+ }
+
+ super.onDestroy()
+ }
+
+ companion object {
+ private const val EXTRA_CONFIGURATION_KEY = "com.firebase.ui.auth.CONFIGURATION_KEY"
+
+ /**
+ * Intent extra key for user ID on successful sign-in.
+ * Use [com.google.firebase.auth.FirebaseAuth.getInstance().currentUser] to get the full user object.
+ */
+ const val EXTRA_USER_ID = "com.firebase.ui.auth.USER_ID"
+
+ /**
+ * Intent extra key for isNewUser flag on successful sign-in.
+ */
+ const val EXTRA_IS_NEW_USER = "com.firebase.ui.auth.IS_NEW_USER"
+
+ /**
+ * Intent extra key for [AuthException] on error.
+ */
+ const val EXTRA_ERROR = "com.firebase.ui.auth.ERROR"
+
+ /**
+ * Cache for configurations passed through Intents.
+ * Uses UUID keys to avoid serialization issues with Context references.
+ */
+ private val configurationCache = ConcurrentHashMap()
+
+ /**
+ * Creates an Intent to launch the Firebase authentication flow.
+ *
+ * @param context Android [Context]
+ * @param configuration [AuthUIConfiguration] defining the auth flow
+ * @return Configured [Intent] to start [FirebaseAuthActivity]
+ */
+ internal fun createIntent(
+ context: Context,
+ configuration: AuthUIConfiguration,
+ authUI: FirebaseAuthUI = FirebaseAuthUI.getInstance()
+ ): Intent {
+ val configKey = UUID.randomUUID().toString()
+ configurationCache[configKey] = configuration
+ authUICache[configKey] = authUI
+
+ return Intent(context, FirebaseAuthActivity::class.java).apply {
+ putExtra(EXTRA_CONFIGURATION_KEY, configKey)
+ }
+ }
+
+ /**
+ * Clears cached launch state. This method is intended for testing purposes only.
+ *
+ * @suppress This is an internal API and should not be used in production code.
+ * @RestrictTo RestrictTo.Scope.TESTS
+ */
+ @JvmStatic
+ @RestrictTo(RestrictTo.Scope.TESTS)
+ fun clearLaunchStateCache() {
+ configurationCache.clear()
+ authUICache.clear()
+ }
+
+ private val authUICache = ConcurrentHashMap()
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
new file mode 100644
index 0000000000..ef85813faf
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
@@ -0,0 +1,723 @@
+/*
+ * 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 android.content.Context
+import android.content.Intent
+import androidx.annotation.RestrictTo
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.auth_provider.filterToLinkedProviders
+import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException
+import com.firebase.ui.auth.configuration.auth_provider.signOutFromFacebook
+import com.firebase.ui.auth.configuration.auth_provider.signOutFromGoogle
+import com.google.firebase.Firebase
+import com.google.firebase.FirebaseApp
+import com.google.firebase.auth.AuthResult
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.FirebaseAuth.AuthStateListener
+import com.google.firebase.auth.FirebaseAuth.IdTokenListener
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.auth
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.tasks.await
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * The central class that coordinates all authentication operations for Firebase Auth UI Compose.
+ * This class manages UI state and provides methods for signing in, signing up, and managing
+ * user accounts.
+ *
+ * Usage
+ *
+ * **Default app instance:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ * ```
+ *
+ * **Custom app instance:**
+ * ```kotlin
+ * val customApp = Firebase.app("secondary")
+ * val authUI = FirebaseAuthUI.getInstance(customApp)
+ * ```
+ *
+ * **Multi-tenancy with custom auth:**
+ * ```kotlin
+ * val customAuth = Firebase.auth(customApp).apply {
+ * tenantId = "my-tenant-id"
+ * }
+ * val authUI = FirebaseAuthUI.create(customApp, customAuth)
+ * ```
+ *
+ * @property app The [FirebaseApp] instance used for authentication
+ * @property auth The [FirebaseAuth] instance used for authentication operations
+ *
+ * @since 10.0.0
+ */
+class FirebaseAuthUI private constructor(
+ val app: FirebaseApp,
+ val auth: FirebaseAuth,
+) {
+
+ private val _authStateFlow = MutableStateFlow(AuthState.Idle)
+
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ var testCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null
+
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ var testLoginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null
+
+ /**
+ * Checks whether a user is currently signed in.
+ *
+ * This method directly mirrors the state of [FirebaseAuth] and returns true if there is
+ * a currently signed-in user, false otherwise.
+ *
+ * **Example:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ * if (authUI.isSignedIn()) {
+ * // User is signed in
+ * navigateToHome()
+ * } else {
+ * // User is not signed in
+ * navigateToLogin()
+ * }
+ * ```
+ *
+ * @return `true` if a user is signed in, `false` otherwise
+ */
+ fun isSignedIn(): Boolean = auth.currentUser != null
+
+ /**
+ * Returns the currently signed-in user, or null if no user is signed in.
+ *
+ * This method returns the same value as [FirebaseAuth.currentUser] and provides
+ * direct access to the current user object.
+ *
+ * **Example:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ * val user = authUI.getCurrentUser()
+ * user?.let {
+ * println("User email: ${it.email}")
+ * println("User ID: ${it.uid}")
+ * }
+ * ```
+ *
+ * @return The currently signed-in [FirebaseUser], or `null` if no user is signed in
+ */
+ fun getCurrentUser(): FirebaseUser? = auth.currentUser
+
+ /**
+ * Returns true if this instance can handle the provided [Intent].
+ *
+ * This mirrors the classic `AuthUI.canHandleIntent` API but uses the [FirebaseAuth] instance
+ * backing this [FirebaseAuthUI], ensuring custom app/auth configurations are respected.
+ */
+ fun canHandleIntent(intent: Intent?): Boolean {
+ val link = intent?.data ?: return false
+ return auth.isSignInWithEmailLink(link.toString())
+ }
+
+ /**
+ * Creates a new authentication flow controller with the specified configuration.
+ *
+ * This method returns an [AuthFlowController] that manages the authentication flow
+ * lifecycle. The controller provides methods to start the flow, monitor its state,
+ * and clean up resources when done.
+ *
+ * **Example with ActivityResultLauncher:**
+ * ```kotlin
+ * class MyActivity : ComponentActivity() {
+ * private lateinit var authController: AuthFlowController
+ *
+ * private val authLauncher = registerForActivityResult(
+ * ActivityResultContracts.StartActivityForResult()
+ * ) { result ->
+ * if (result.resultCode == Activity.RESULT_OK) {
+ * val userId = result.data?.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID)
+ * val isNewUser = result.data?.getBooleanExtra(
+ * FirebaseAuthActivity.EXTRA_IS_NEW_USER,
+ * false
+ * ) ?: false
+ * // Get the full user object
+ * val user = FirebaseAuth.getInstance().currentUser
+ * }
+ * }
+ *
+ * override fun onCreate(savedInstanceState: Bundle?) {
+ * super.onCreate(savedInstanceState)
+ *
+ * val authUI = FirebaseAuthUI.getInstance()
+ * val configuration = authUIConfiguration {
+ * providers = listOf(
+ * AuthProvider.Email(),
+ * AuthProvider.Google(...)
+ * )
+ * }
+ *
+ * authController = authUI.createAuthFlow(configuration)
+ *
+ * // Observe auth state
+ * lifecycleScope.launch {
+ * authController.authStateFlow.collect { state ->
+ * when (state) {
+ * is AuthState.Success -> {
+ * // User signed in successfully
+ * }
+ * is AuthState.Error -> {
+ * // Handle error
+ * }
+ * else -> {}
+ * }
+ * }
+ * }
+ *
+ * // Start auth flow
+ * val intent = authController.createIntent(this)
+ * authLauncher.launch(intent)
+ * }
+ *
+ * override fun onDestroy() {
+ * super.onDestroy()
+ * authController.dispose()
+ * }
+ * }
+ * ```
+ *
+ * @param configuration The [AuthUIConfiguration] defining the auth flow behavior
+ * @return A new [AuthFlowController] instance
+ * @see AuthFlowController
+ * @since 10.0.0
+ */
+ fun createAuthFlow(configuration: AuthUIConfiguration): AuthFlowController {
+ return AuthFlowController(this, configuration)
+ }
+
+ /**
+ * Creates a reauthentication flow scoped to the current user's linked providers.
+ *
+ * This method builds a sign-in flow where:
+ * - Only providers already linked to the current [FirebaseUser] are offered
+ * - Account creation is disabled
+ * - The credential path calls [FirebaseUser.reauthenticateWithCredential] instead of
+ * [FirebaseAuth.signInWithCredential]
+ *
+ * Use this before sensitive operations (delete account, change email, etc.) that require
+ * a recent sign-in.
+ *
+ * @param configuration Base [AuthUIConfiguration] whose provider list is filtered to
+ * the user's linked providers. All other settings are preserved.
+ * @param reason Optional human-readable string shown to the user explaining why
+ * reauthentication is needed (e.g. "To delete your account we need to verify it's you").
+ * @return An [AuthFlowController] configured for reauthentication
+ * @throws AuthException.UserNotFoundException if no user is currently signed in
+ * @throws IllegalStateException if none of the configured providers are linked to the
+ * current user
+ * @since 10.0.0
+ */
+ fun createReauthFlow(configuration: AuthUIConfiguration): AuthFlowController {
+ val currentUser = auth.currentUser
+ ?: throw AuthException.UserNotFoundException(
+ message = "No user is currently signed in"
+ )
+ val linked = configuration.providers.filterToLinkedProviders(currentUser)
+ check(linked.isNotEmpty()) {
+ "No configured providers are linked to the current user"
+ }
+ val reauthConfig = configuration.copy(
+ providers = linked,
+ isNewEmailAccountsAllowed = false,
+ isReauthenticationMode = true,
+ )
+ return AuthFlowController(this, reauthConfig)
+ }
+
+ /**
+ * Returns a [Flow] that emits [AuthState] changes.
+ *
+ * This flow observes changes to the authentication state and emits appropriate
+ * [AuthState] objects. The flow will emit:
+ * - [AuthState.Idle] when there's no active authentication operation
+ * - [AuthState.Loading] during authentication operations
+ * - [AuthState.Success] when a user successfully signs in
+ * - [AuthState.Error] when an authentication error occurs
+ * - [AuthState.Cancelled] when authentication is cancelled
+ * - [AuthState.RequiresMfa] when multi-factor authentication is needed
+ * - [AuthState.RequiresEmailVerification] when email verification is needed
+ *
+ * The flow automatically emits [AuthState.Success] or [AuthState.Idle] based on
+ * the current authentication state when collection starts.
+ *
+ * **Example:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ *
+ * lifecycleScope.launch {
+ * authUI.authStateFlow().collect { state ->
+ * when (state) {
+ * is AuthState.Success -> {
+ * // User is signed in
+ * updateUI(state.user)
+ * }
+ * is AuthState.Error -> {
+ * // Handle error
+ * showError(state.exception.message)
+ * }
+ * is AuthState.Loading -> {
+ * // Show loading indicator
+ * showProgressBar()
+ * }
+ * // ... handle other states
+ * }
+ * }
+ * }
+ * ```
+ *
+ * @return A [Flow] of [AuthState] that emits authentication state changes
+ */
+ fun authStateFlow(): Flow {
+ // Create a flow from FirebaseAuth state listener
+ val firebaseAuthFlow = callbackFlow {
+ fun buildState(currentUser: FirebaseUser?): AuthState {
+ return if (currentUser != null) {
+ handleAuthUserState(currentUser, result = null, isNewUser = false)
+ } else {
+ AuthState.Idle
+ }
+ }
+
+ // Set initial state based on current auth state
+ val initialState = buildState(auth.currentUser)
+
+ trySend(initialState)
+
+ // Create auth state listener
+ val authStateListener = AuthStateListener { firebaseAuth ->
+ // When user signs out, clear stale user-presence internal states so the combine
+ // doesn't return Success/RequiresEmailVerification after the user is gone.
+ if (firebaseAuth.currentUser == null) {
+ val current = _authStateFlow.value
+ if (current is AuthState.Success ||
+ current is AuthState.RequiresEmailVerification ||
+ current is AuthState.RequiresProfileCompletion
+ ) {
+ _authStateFlow.value = AuthState.Idle
+ }
+ }
+ trySend(buildState(firebaseAuth.currentUser))
+ }
+
+ // AuthStateListener does not reliably fire for account linking, but IdTokenListener does.
+ val idTokenListener = IdTokenListener { firebaseAuth: FirebaseAuth ->
+ trySend(buildState(firebaseAuth.currentUser))
+ }
+
+ // Add listener
+ auth.addAuthStateListener(authStateListener)
+ auth.addIdTokenListener(idTokenListener)
+
+ // Remove listener when flow collection is cancelled
+ awaitClose {
+ auth.removeAuthStateListener(authStateListener)
+ auth.removeIdTokenListener(idTokenListener)
+ }
+ }
+
+ // Also observe internal state changes
+ return combine(
+ firebaseAuthFlow,
+ _authStateFlow
+ ) { firebaseState, internalState ->
+ // Prefer non-idle internal states (like PasswordResetLinkSent, Error, etc.)
+ if (internalState !is AuthState.Idle) internalState else firebaseState
+ }.distinctUntilChanged()
+ }
+
+ /**
+ * Updates the internal authentication state.
+ * This method can be used to manually trigger state updates when the Firebase Auth state
+ * listener doesn't automatically detect changes (e.g., after reloading user properties).
+ *
+ * @param state The new [AuthState] to emit
+ */
+ fun updateAuthState(state: AuthState) {
+ _authStateFlow.value = state
+ }
+
+ internal fun updateAuthStateWithResult(result: AuthResult?, defaultIsNewUser: Boolean = false) {
+ val user = result?.user
+ if (user != null) {
+ updateAuthState(
+ handleAuthUserState(
+ user = user,
+ result = result,
+ isNewUser = result.additionalUserInfo?.isNewUser ?: defaultIsNewUser
+ )
+ )
+ } else {
+ updateAuthState(AuthState.Idle)
+ }
+ }
+
+ private fun handleAuthUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState {
+ return if (!user.isEmailVerified &&
+ user.email != null &&
+ user.providerData.any { it.providerId == "password" }
+ ) {
+ AuthState.RequiresEmailVerification(user = user, email = user.email!!)
+ } else {
+ AuthState.Success(result = result, user = user, isNewUser = isNewUser)
+ }
+ }
+
+ /**
+ * Signs out the current user and clears authentication state.
+ *
+ * This method signs out the user from Firebase Auth and updates the auth state flow
+ * to reflect the change. The operation is performed asynchronously and will emit
+ * appropriate states during the process.
+ *
+ * **Example:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ *
+ * try {
+ * authUI.signOut(context)
+ * // User is now signed out
+ * } catch (e: AuthException) {
+ * // Handle sign-out error
+ * when (e) {
+ * is AuthException.AuthCancelledException -> {
+ * // User cancelled sign-out
+ * }
+ * else -> {
+ * // Other error occurred
+ * }
+ * }
+ * }
+ * ```
+ *
+ * @param context The Android [Context] for any required UI operations
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ * @throws AuthException.UnknownException for other errors
+ * @since 10.0.0
+ */
+ suspend fun signOut(context: Context) {
+ try {
+ // Update state to loading
+ updateAuthState(AuthState.Loading(context.getString(R.string.fui_loading_signing_out)))
+
+ // Sign out from Firebase Auth
+ auth.signOut()
+ .also {
+ signOutFromGoogle(context)
+ signOutFromFacebook()
+ }
+
+ // Update state to idle (user signed out)
+ updateAuthState(AuthState.Idle)
+
+ } catch (e: CancellationException) {
+ // Handle coroutine cancellation
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign-out was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ // Already mapped AuthException, just update state and re-throw
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ // Map to appropriate AuthException
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+ }
+
+ /**
+ * Deletes the current user account and clears authentication state.
+ *
+ * This method deletes the current user's account from Firebase Auth. If the user
+ * hasn't signed in recently, it will throw an exception requiring reauthentication.
+ * The operation is performed asynchronously and will emit appropriate states during
+ * the process.
+ *
+ * **Example:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ *
+ * try {
+ * authUI.delete(context)
+ * // User account is now deleted
+ * } catch (e: AuthException.InvalidCredentialsException) {
+ * // Recent login required - show reauthentication UI
+ * handleReauthentication()
+ * } catch (e: AuthException) {
+ * // Handle other errors
+ * }
+ * ```
+ *
+ * @param context The Android [Context] for any required UI operations
+ * @throws AuthException.InvalidCredentialsException if reauthentication is required
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ * @throws AuthException.UnknownException for other errors
+ * @since 10.0.0
+ */
+ /**
+ * Executes a sensitive operation, automatically handling reauthentication if required.
+ *
+ * If the [operation] throws [FirebaseAuthRecentLoginRequiredException], this method emits
+ * [AuthState.ReauthenticationRequired] with the operation attached as [AuthState.ReauthenticationRequired.retryOperation].
+ * [FirebaseAuthScreen] observes this state and presents a reauthentication sheet; on success
+ * the operation is retried automatically without any further action from the caller.
+ *
+ * All other exceptions propagate normally.
+ *
+ * **Example:**
+ * ```kotlin
+ * lifecycleScope.launch {
+ * authUI.withReauth(context, reason = "Verify your identity to change email") {
+ * user.updateEmail(newEmail).await()
+ * }
+ * }
+ * ```
+ *
+ * @param context Android [Context]
+ * @param reason Optional message shown to the user explaining why reauthentication is needed
+ * @param operation The sensitive operation to attempt
+ * @since 10.0.0
+ */
+ suspend fun withReauth(
+ context: Context,
+ reason: String? = null,
+ operation: suspend () -> Unit,
+ ) {
+ try {
+ operation()
+ } catch (e: FirebaseAuthRecentLoginRequiredException) {
+ val user = auth.currentUser
+ ?: throw AuthException.UserNotFoundException(message = "No user is currently signed in")
+ updateAuthState(
+ AuthState.ReauthenticationRequired(
+ user = user,
+ reason = reason,
+ retryOperation = {
+ try {
+ operation()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ updateAuthState(AuthState.Error(e))
+ return@ReauthenticationRequired
+ }
+ val currentUser = auth.currentUser
+ if (currentUser != null) {
+ updateAuthState(AuthState.Success(result = null, user = currentUser))
+ } else {
+ updateAuthState(AuthState.Idle)
+ }
+ },
+ )
+ )
+ }
+ }
+
+ suspend fun delete(context: Context) {
+ try {
+ val currentUser = auth.currentUser
+ ?: throw AuthException.UserNotFoundException(
+ message = "No user is currently signed in"
+ )
+
+ // Update state to loading
+ updateAuthState(AuthState.Loading(context.getString(R.string.fui_loading_deleting_account)))
+
+ // Delete the user account
+ currentUser.delete().await()
+
+ // Update state to idle (user deleted and signed out)
+ updateAuthState(AuthState.Idle)
+
+ } catch (e: FirebaseAuthRecentLoginRequiredException) {
+ auth.currentUser?.let {
+ updateAuthState(
+ AuthState.ReauthenticationRequired(
+ user = it,
+ retryOperation = { ctx -> delete(ctx) },
+ )
+ )
+ }
+ throw AuthException.InvalidCredentialsException(
+ message = e.message ?: "Recent login required for this operation",
+ cause = e
+ )
+ } catch (e: CancellationException) {
+ // Handle coroutine cancellation
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Account deletion was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ // Already mapped AuthException, just update state and re-throw
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ // Map to appropriate AuthException
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+ }
+
+ companion object {
+ /** Cache for singleton instances per FirebaseApp. Thread-safe via ConcurrentHashMap. */
+ private val instanceCache = ConcurrentHashMap()
+
+ /** Special key for the default app instance to distinguish from named instances. */
+ private const val DEFAULT_APP_KEY = "__FIREBASE_UI_DEFAULT__"
+
+ /**
+ * Returns a cached singleton instance for the default Firebase app.
+ *
+ * This method ensures that the same instance is returned for the default app across the
+ * entire application lifecycle. The instance is lazily created on first access and cached
+ * for subsequent calls.
+ *
+ * **Example:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ * val user = authUI.auth.currentUser
+ * ```
+ *
+ * @return The cached [FirebaseAuthUI] instance for the default app
+ * @throws IllegalStateException if Firebase has not been initialized. Call
+ * `FirebaseApp.initializeApp(Context)` before using this method.
+ */
+ @JvmStatic
+ fun getInstance(): FirebaseAuthUI {
+ val defaultApp = try {
+ FirebaseApp.getInstance()
+ } catch (e: IllegalStateException) {
+ throw IllegalStateException(
+ "Default FirebaseApp is not initialized. " +
+ "Make sure to call FirebaseApp.initializeApp(Context) first.",
+ e
+ )
+ }
+
+ return instanceCache.getOrPut(DEFAULT_APP_KEY) {
+ FirebaseAuthUI(defaultApp, Firebase.auth)
+ }
+ }
+
+ /**
+ * Returns a cached instance for a specific Firebase app.
+ *
+ * Each [FirebaseApp] gets its own distinct instance that is cached for subsequent calls
+ * with the same app. This allows for multiple Firebase projects to be used within the
+ * same application.
+ *
+ * **Example:**
+ * ```kotlin
+ * val secondaryApp = Firebase.app("secondary")
+ * val authUI = FirebaseAuthUI.getInstance(secondaryApp)
+ * ```
+ *
+ * @param app The [FirebaseApp] instance to use
+ * @return The cached [FirebaseAuthUI] instance for the specified app
+ */
+ @JvmStatic
+ fun getInstance(app: FirebaseApp): FirebaseAuthUI {
+ val cacheKey = app.name
+ return instanceCache.getOrPut(cacheKey) {
+ FirebaseAuthUI(app, Firebase.auth(app))
+ }
+ }
+
+ /**
+ * Creates a new instance with custom configuration, useful for multi-tenancy.
+ *
+ * This method always returns a new instance and does **not** use caching, allowing for
+ * custom [FirebaseAuth] configurations such as tenant IDs or custom authentication states.
+ * Use this when you need fine-grained control over the authentication instance.
+ *
+ * **Example - Multi-tenancy:**
+ * ```kotlin
+ * val app = Firebase.app("tenant-app")
+ * val auth = Firebase.auth(app).apply {
+ * tenantId = "customer-tenant-123"
+ * }
+ * val authUI = FirebaseAuthUI.create(app, auth)
+ * ```
+ *
+ * @param app The [FirebaseApp] instance to use
+ * @param auth The [FirebaseAuth] instance with custom configuration
+ * @return A new [FirebaseAuthUI] instance with the provided dependencies
+ *
+ * **NOTE:** because this method always returns a new instance, calling it directly inside
+ * a `@Composable` function body (instead of hoisting the result with `remember` or storing
+ * it outside composition) gives an unstable reference every recomposition. Code that keys
+ * `remember(authUI) { authUI.authStateFlow() }` on the result won't see a stable key and
+ * will silently lose the intended optimization of subscribing to the flow only once.
+ */
+ @JvmStatic
+ fun create(app: FirebaseApp, auth: FirebaseAuth): FirebaseAuthUI {
+ return FirebaseAuthUI(app, auth)
+ }
+
+ /**
+ * Clears all cached instances. This method is intended for testing purposes only.
+ *
+ * @suppress This is an internal API and should not be used in production code.
+ * @RestrictTo RestrictTo.Scope.TESTS
+ */
+ @JvmStatic
+ @RestrictTo(RestrictTo.Scope.TESTS)
+ fun clearInstanceCache() {
+ instanceCache.clear()
+ }
+
+ /**
+ * Returns the current number of cached instances. This method is intended for testing
+ * purposes only.
+ *
+ * @return The number of cached [FirebaseAuthUI] instances
+ * @suppress This is an internal API and should not be used in production code.
+ * @RestrictTo RestrictTo.Scope.TESTS
+ */
+ @JvmStatic
+ @RestrictTo(RestrictTo.Scope.TESTS)
+ internal fun getCacheSize(): Int {
+ return instanceCache.size
+ }
+
+ const val UNCONFIGURED_CONFIG_VALUE: String = "CHANGE-ME"
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseUIComposeRegistrar.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseUIComposeRegistrar.kt
new file mode 100644
index 0000000000..c985a29af7
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseUIComposeRegistrar.kt
@@ -0,0 +1,37 @@
+// Copyright 2025 Google LLC
+//
+// 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 android.util.Log
+import androidx.annotation.Keep
+import com.google.firebase.components.Component
+import com.google.firebase.components.ComponentRegistrar
+import com.google.firebase.platforminfo.LibraryVersionComponent
+
+/**
+ * Registers the FirebaseUI-Android Compose library with Firebase Analytics.
+ * This enables Firebase to track which versions of FirebaseUI are being used.
+ */
+@Keep
+class FirebaseUIComposeRegistrar : ComponentRegistrar {
+ override fun getComponents(): List> {
+ Log.d("FirebaseUIRegistrar", "FirebaseUI Compose Registrar initialized: " +
+ "LIBRARY_NAME: ${BuildConfig.LIBRARY_NAME}, " +
+ "VERSION_NAME: ${BuildConfig.VERSION_NAME}")
+ return listOf(
+ LibraryVersionComponent.create(BuildConfig.LIBRARY_NAME, BuildConfig.VERSION_NAME)
+ )
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/IdpResponse.java b/auth/src/main/java/com/firebase/ui/auth/IdpResponse.java
deleted file mode 100644
index fa58625631..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/IdpResponse.java
+++ /dev/null
@@ -1,125 +0,0 @@
-/*
- * Copyright 2016 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 android.content.Intent;
-import android.os.Parcel;
-import android.os.Parcelable;
-import android.support.annotation.Nullable;
-
-import com.firebase.ui.auth.ui.ExtraConstants;
-
-/**
- * A container that encapsulates the result of authenticating with an Identity Provider.
- */
-public class IdpResponse implements Parcelable {
-
- private final String mProviderId;
- @Nullable private final String mEmail;
- private final String mToken;
- private final String mSecret;
-
- public IdpResponse(String providerId, @Nullable String email) {
- this(providerId, email, null, null);
- }
-
- public IdpResponse(
- String providerId, @Nullable String email, @Nullable String token) {
- this(providerId, email, token, null);
- }
-
- public IdpResponse(
- String providerId,
- @Nullable String email,
- @Nullable String token,
- @Nullable String secret) {
- mProviderId = providerId;
- mEmail = email;
- mToken = token;
- mSecret = secret;
- }
-
- public static final Creator CREATOR = new Creator() {
- @Override
- public IdpResponse createFromParcel(Parcel in) {
- return new IdpResponse(
- in.readString(),
- in.readString(),
- in.readString(),
- in.readString()
- );
- }
-
- @Override
- public IdpResponse[] newArray(int size) {
- return new IdpResponse[size];
- }
- };
-
- /**
- * Get the type of provider. e.g. {@link AuthUI#GOOGLE_PROVIDER}
- */
- public String getProviderType() {
- return mProviderId;
- }
-
- /**
- * Get the token received as a result of logging in with the specified IDP
- */
- @Nullable
- public String getIdpToken() {
- return mToken;
- }
-
- /**
- * Twitter only. Return the token secret received as a result of logging in with Twitter.
- */
- @Nullable
- public String getIdpSecret() {
- return mSecret;
- }
-
- /**
- * Get the email used to sign in.
- */
- @Nullable
- public String getEmail() {
- return mEmail;
- }
-
- @Override
- public int describeContents() {
- return 0;
- }
-
- @Override
- public void writeToParcel(Parcel dest, int flags) {
- dest.writeString(mProviderId);
- dest.writeString(mEmail);
- dest.writeString(mToken);
- dest.writeString(mSecret);
- }
-
- /**
- * Extract the {@link IdpResponse} from the flow's result intent.
- *
- * @param resultIntent The intent which {@code onActivityResult} was called with.
- * @return The IdpResponse containing the token(s) from signing in with the Idp
- */
- @Nullable
- public static IdpResponse fromResultIntent(Intent resultIntent) {
- return resultIntent.getParcelableExtra(ExtraConstants.EXTRA_IDP_RESPONSE);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/KickoffActivity.java b/auth/src/main/java/com/firebase/ui/auth/KickoffActivity.java
deleted file mode 100644
index d09fd514b3..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/KickoffActivity.java
+++ /dev/null
@@ -1,41 +0,0 @@
-package com.firebase.ui.auth;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-
-import com.firebase.ui.auth.ui.ActivityHelper;
-import com.firebase.ui.auth.ui.AppCompatBase;
-import com.firebase.ui.auth.ui.ExtraConstants;
-import com.firebase.ui.auth.ui.FlowParameters;
-import com.firebase.ui.auth.util.signincontainer.SignInDelegate;
-
-public class KickoffActivity extends AppCompatBase {
- @Override
- protected void onCreate(Bundle savedInstance) {
- super.onCreate(savedInstance);
- if (savedInstance == null) {
- SignInDelegate.delegate(this, mActivityHelper.getFlowParams());
- }
- }
-
- @Override
- public void onSaveInstanceState(Bundle outState) {
- // It doesn't matter what we put here, we just don't want outState to be empty
- outState.putBoolean(ExtraConstants.HAS_EXISTING_INSTANCE, true);
- super.onSaveInstanceState(outState);
- }
-
- @Override
- protected void onActivityResult(int requestCode, int resultCode, Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- SignInDelegate delegate = SignInDelegate.getInstance(this);
- if (delegate != null) {
- delegate.onActivityResult(requestCode, resultCode, data);
- }
- }
-
- public static Intent createIntent(Context context, FlowParameters flowParams) {
- return ActivityHelper.createBaseIntent(context, KickoffActivity.class, flowParams);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt
new file mode 100644
index 0000000000..7eb92114e6
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt
@@ -0,0 +1,261 @@
+/*
+ * 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.configuration
+
+import android.content.Context
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvidersBuilder
+import com.firebase.ui.auth.configuration.auth_provider.Provider
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUIAsset
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.google.firebase.auth.ActionCodeSettings
+import java.util.Locale
+
+fun authUIConfiguration(block: AuthUIConfigurationBuilder.() -> Unit) =
+ AuthUIConfigurationBuilder().apply(block).build()
+
+@DslMarker
+annotation class AuthUIConfigurationDsl
+
+@AuthUIConfigurationDsl
+class AuthUIConfigurationBuilder {
+ var context: Context? = null
+ private val providers = mutableListOf()
+ var theme: AuthUITheme? = null
+ var locale: Locale? = null
+ var stringProvider: AuthUIStringProvider? = null
+ var isCredentialManagerEnabled: Boolean = true
+ var isMfaEnabled: Boolean = true
+ var isAnonymousUpgradeEnabled: Boolean = false
+ var isCredentialLinkingEnabled: Boolean = false
+ var tosUrl: String? = null
+ var privacyPolicyUrl: String? = null
+ var logo: AuthUIAsset? = null
+ var passwordResetActionCodeSettings: ActionCodeSettings? = null
+ var isNewEmailAccountsAllowed: Boolean = true
+ var isDisplayNameRequired: Boolean = true
+ var isProviderChoiceAlwaysShown: Boolean = false
+ var legacyFetchSignInWithEmail: Boolean = false
+ var transitions: AuthUITransitions? = null
+ internal var isReauthenticationMode: Boolean = false
+
+ fun providers(block: AuthProvidersBuilder.() -> Unit) =
+ providers.addAll(AuthProvidersBuilder().apply(block).build())
+
+ internal fun build(): AuthUIConfiguration {
+ val context = requireNotNull(context) {
+ "Application context is required"
+ }
+
+ require(providers.isNotEmpty()) {
+ "At least one provider must be configured"
+ }
+
+ // No unsupported providers (allow predefined providers and custom OIDC/SAML providers)
+ val supportedProviderIds = Provider.entries.map { it.id }.toSet()
+ val customPrefixes = listOf("oidc.", "saml.")
+ val unknownProviders = providers.filter { provider ->
+ provider.providerId !in supportedProviderIds &&
+ customPrefixes.none { provider.providerId.startsWith(it) }
+ }
+ require(unknownProviders.isEmpty()) {
+ "Unknown providers: ${unknownProviders.joinToString { it.providerId }}"
+ }
+
+ // Cannot have only anonymous provider
+ AuthProvider.Anonymous.validate(providers)
+
+ // Check for duplicate providers
+ val providerIds = providers.map { it.providerId }
+ val duplicates = providerIds.groupingBy { it }.eachCount().filter { it.value > 1 }
+
+ require(duplicates.isEmpty()) {
+ val message = duplicates.keys.joinToString(", ")
+ throw IllegalArgumentException(
+ "Each provider can only be set once. Duplicates: $message"
+ )
+ }
+
+ // Provider specific validations
+ providers.forEach { provider ->
+ when (provider) {
+ is AuthProvider.Email -> provider.validate(isAnonymousUpgradeEnabled)
+ is AuthProvider.Phone -> provider.validate()
+ is AuthProvider.Google -> provider.validate(context)
+ is AuthProvider.Facebook -> provider.validate(context)
+ is AuthProvider.GenericOAuth -> provider.validate()
+ else -> null
+ }
+ }
+
+ return AuthUIConfiguration(
+ context = context,
+ providers = providers.toList(),
+ theme = theme,
+ locale = locale,
+ stringProvider = stringProvider ?: DefaultAuthUIStringProvider(context, locale),
+ isCredentialManagerEnabled = isCredentialManagerEnabled,
+ isMfaEnabled = isMfaEnabled,
+ isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled,
+ isCredentialLinkingEnabled = isCredentialLinkingEnabled,
+ tosUrl = tosUrl,
+ privacyPolicyUrl = privacyPolicyUrl,
+ logo = logo,
+ passwordResetActionCodeSettings = passwordResetActionCodeSettings,
+ isNewEmailAccountsAllowed = isNewEmailAccountsAllowed,
+ isDisplayNameRequired = isDisplayNameRequired,
+ isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown,
+ legacyFetchSignInWithEmail = legacyFetchSignInWithEmail,
+ transitions = transitions,
+ isReauthenticationMode = isReauthenticationMode,
+ )
+ }
+}
+
+/**
+ * Configuration object for the authentication flow.
+ */
+class AuthUIConfiguration(
+ /**
+ * Application context
+ */
+ val context: Context,
+
+ /**
+ * The list of enabled authentication providers.
+ */
+ val providers: List = emptyList(),
+
+ /**
+ * The theming configuration for the UI. If null, inherits from the outer AuthUITheme wrapper
+ * or defaults to [AuthUITheme.Default] if no wrapper is present.
+ */
+ val theme: AuthUITheme? = null,
+
+ /**
+ * The locale for internationalization.
+ */
+ val locale: Locale? = null,
+
+ /**
+ * A custom provider for localized strings.
+ */
+ val stringProvider: AuthUIStringProvider = DefaultAuthUIStringProvider(context, locale),
+
+ /**
+ * Enables integration with Android's Credential Manager API. Defaults to true.
+ */
+ val isCredentialManagerEnabled: Boolean = true,
+
+ /**
+ * Enables Multi-Factor Authentication support. Defaults to true.
+ */
+ val isMfaEnabled: Boolean = true,
+
+ /**
+ * Allows upgrading an anonymous user to a new credential.
+ */
+ val isAnonymousUpgradeEnabled: Boolean = false,
+
+ /**
+ * Allows linking a new credential to an already authenticated (non-anonymous) user.
+ * When enabled, signing in via FirebaseUI while a user is already signed in will link
+ * the new credential to the existing account instead of creating a new one.
+ */
+ val isCredentialLinkingEnabled: Boolean = false,
+
+ /**
+ * The URL for the terms of service.
+ */
+ val tosUrl: String? = null,
+
+ /**
+ * The URL for the privacy policy.
+ */
+ val privacyPolicyUrl: String? = null,
+
+ /**
+ * The logo to display on the authentication screens.
+ */
+ val logo: AuthUIAsset? = null,
+
+ /**
+ * Configuration for sending email reset link.
+ */
+ val passwordResetActionCodeSettings: ActionCodeSettings? = null,
+
+ /**
+ * Allows new email accounts to be created. Defaults to true.
+ */
+ val isNewEmailAccountsAllowed: Boolean = true,
+
+ /**
+ * Requires the user to provide a display name on sign-up. Defaults to true.
+ */
+ val isDisplayNameRequired: Boolean = true,
+
+ /**
+ * Always shows the provider selection screen, even if only one is enabled.
+ */
+ val isProviderChoiceAlwaysShown: Boolean = false,
+
+ /**
+ * Enables legacy provider recovery via `fetchSignInMethodsForEmail`.
+ *
+ * This should only be enabled when email enumeration protection is disabled for the
+ * Firebase project and the application explicitly wants to use the legacy API to
+ * recover from email/password attempts made with the wrong provider.
+ */
+ val legacyFetchSignInWithEmail: Boolean = false,
+
+ /**
+ * Custom screen transition animations.
+ * If null, uses default fade in/out transitions.
+ */
+ val transitions: AuthUITransitions? = null,
+
+ /**
+ * When true, the flow operates as a reauthentication flow: account creation is disabled and
+ * only providers already linked to the current user are shown. Set by [FirebaseAuthUI.createReauthFlow].
+ */
+ internal val isReauthenticationMode: Boolean = false,
+) {
+ internal fun copy(
+ providers: List = this.providers,
+ isNewEmailAccountsAllowed: Boolean = this.isNewEmailAccountsAllowed,
+ isReauthenticationMode: Boolean = this.isReauthenticationMode,
+ ): AuthUIConfiguration = AuthUIConfiguration(
+ context = this.context,
+ providers = providers,
+ theme = this.theme,
+ locale = this.locale,
+ stringProvider = this.stringProvider,
+ isCredentialManagerEnabled = this.isCredentialManagerEnabled,
+ isMfaEnabled = this.isMfaEnabled,
+ isAnonymousUpgradeEnabled = this.isAnonymousUpgradeEnabled,
+ tosUrl = this.tosUrl,
+ privacyPolicyUrl = this.privacyPolicyUrl,
+ logo = this.logo,
+ passwordResetActionCodeSettings = this.passwordResetActionCodeSettings,
+ isNewEmailAccountsAllowed = isNewEmailAccountsAllowed,
+ isDisplayNameRequired = this.isDisplayNameRequired,
+ isProviderChoiceAlwaysShown = this.isProviderChoiceAlwaysShown,
+ transitions = this.transitions,
+ isReauthenticationMode = isReauthenticationMode,
+ )
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUITransitions.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUITransitions.kt
new file mode 100644
index 0000000000..b37dc34e19
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUITransitions.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.configuration
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.navigation.NavBackStackEntry
+
+/**
+ * Container for screen transition animations used in Firebase Auth UI.
+ *
+ * @property enterTransition Transition when entering a new screen
+ * @property exitTransition Transition when exiting current screen
+ * @property popEnterTransition Transition when returning to previous screen (back navigation)
+ * @property popExitTransition Transition when exiting during back navigation
+ */
+data class AuthUITransitions(
+ val enterTransition: (AnimatedContentTransitionScope.() -> EnterTransition)? = null,
+ val exitTransition: (AnimatedContentTransitionScope.() -> ExitTransition)? = null,
+ val popEnterTransition: (AnimatedContentTransitionScope.() -> EnterTransition)? = null,
+ val popExitTransition: (AnimatedContentTransitionScope.() -> ExitTransition)? = null,
+)
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/MfaConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/MfaConfiguration.kt
new file mode 100644
index 0000000000..ed748bfe05
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/MfaConfiguration.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.configuration
+
+/**
+ * Configuration class for Multi-Factor Authentication (MFA) enrollment and verification behavior.
+ *
+ * This class controls which MFA factors are available to users, whether enrollment is mandatory,
+ * and whether recovery codes are generated.
+ *
+ * @property allowedFactors List of MFA factors that users are permitted to enroll in.
+ * Defaults to [MfaFactor.Sms, MfaFactor.Totp].
+ * @property requireEnrollment Whether MFA enrollment is mandatory for all users.
+ * When true, users must enroll in at least one MFA factor.
+ * Defaults to false.
+ * @property enableRecoveryCodes Whether to generate and provide recovery codes to users
+ * after successful MFA enrollment. These codes can be used
+ * as a backup authentication method. Defaults to true.
+ */
+class MfaConfiguration(
+ val allowedFactors: List = listOf(MfaFactor.Sms, MfaFactor.Totp),
+ val requireEnrollment: Boolean = false,
+ val enableRecoveryCodes: Boolean = true
+) {
+ init {
+ require(allowedFactors.isNotEmpty()) {
+ "At least one MFA factor must be allowed"
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/package-info.java b/auth/src/main/java/com/firebase/ui/auth/configuration/MfaFactor.kt
similarity index 50%
rename from auth/src/main/java/com/firebase/ui/auth/ui/email/package-info.java
rename to auth/src/main/java/com/firebase/ui/auth/configuration/MfaFactor.kt
index 86e03fcecf..472740560d 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/package-info.java
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/MfaFactor.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2016 Google Inc. All Rights Reserved.
+ * 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
@@ -12,7 +12,22 @@
* limitations under the License.
*/
+package com.firebase.ui.auth.configuration
+
/**
- * Activities related to the email and password based authentication.
+ * Represents the different Multi-Factor Authentication (MFA) factors that can be used
+ * for enrollment and verification.
*/
-package com.firebase.ui.auth.ui.email;
\ No newline at end of file
+enum class MfaFactor {
+ /**
+ * SMS-based authentication factor.
+ * Users receive a verification code via text message to their registered phone number.
+ */
+ Sms,
+
+ /**
+ * Time-based One-Time Password (TOTP) authentication factor.
+ * Users generate verification codes using an authenticator app.
+ */
+ Totp
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/PasswordRule.kt
new file mode 100644
index 0000000000..fef83a4ecc
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/PasswordRule.kt
@@ -0,0 +1,123 @@
+/*
+ * 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.configuration
+
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+/**
+ * An abstract class representing a set of validation rules that can be applied to a password field,
+ * typically within the [com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Email] configuration.
+ */
+abstract class PasswordRule {
+ /**
+ * Requires the password to have at least a certain number of characters.
+ */
+ class MinimumLength(val value: Int) : PasswordRule() {
+ override fun isValid(password: String): Boolean {
+ return password.length >= this@MinimumLength.value
+ }
+
+ override fun getErrorMessage(stringProvider: AuthUIStringProvider): String {
+ return stringProvider.passwordTooShort(value)
+ }
+ }
+
+ /**
+ * Requires the password to contain at least one uppercase letter (A-Z).
+ */
+ object RequireUppercase : PasswordRule() {
+ override fun isValid(password: String): Boolean {
+ return password.any { it.isUpperCase() }
+ }
+
+ override fun getErrorMessage(stringProvider: AuthUIStringProvider): String {
+ return stringProvider.passwordMissingUppercase
+ }
+ }
+
+ /**
+ * Requires the password to contain at least one lowercase letter (a-z).
+ */
+ object RequireLowercase : PasswordRule() {
+ override fun isValid(password: String): Boolean {
+ return password.any { it.isLowerCase() }
+ }
+
+ override fun getErrorMessage(stringProvider: AuthUIStringProvider): String {
+ return stringProvider.passwordMissingLowercase
+ }
+ }
+
+ /**
+ * Requires the password to contain at least one numeric digit (0-9).
+ */
+ object RequireDigit : PasswordRule() {
+ override fun isValid(password: String): Boolean {
+ return password.any { it.isDigit() }
+ }
+
+ override fun getErrorMessage(stringProvider: AuthUIStringProvider): String {
+ return stringProvider.passwordMissingDigit
+ }
+ }
+
+ /**
+ * Requires the password to contain at least one special character (e.g., !@#$%^&*).
+ */
+ object RequireSpecialCharacter : PasswordRule() {
+ private val specialCharacters = "!@#$%^&*()_+-=[]{}|;:,.<>?".toSet()
+
+ override fun isValid(password: String): Boolean {
+ return password.any { it in specialCharacters }
+ }
+
+ override fun getErrorMessage(stringProvider: AuthUIStringProvider): String {
+ return stringProvider.passwordMissingSpecialCharacter
+ }
+ }
+
+ /**
+ * Defines a custom validation rule using a regular expression and provides a specific error
+ * message on failure.
+ */
+ class Custom(
+ val regex: Regex,
+ val errorMessage: String
+ ) : PasswordRule() {
+ override fun isValid(password: String): Boolean {
+ return regex.matches(password)
+ }
+
+ override fun getErrorMessage(stringProvider: AuthUIStringProvider): String {
+ return errorMessage
+ }
+ }
+
+ /**
+ * Validates whether the given password meets this rule's requirements.
+ *
+ * @param password The password to validate
+ * @return true if the password meets this rule's requirements, false otherwise
+ */
+ abstract fun isValid(password: String): Boolean
+
+ /**
+ * Returns the appropriate error message for this rule when validation fails.
+ *
+ * @param stringProvider The string provider for localized error messages
+ * @return The localized error message for this rule
+ */
+ abstract fun getErrorMessage(stringProvider: AuthUIStringProvider): String
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt
new file mode 100644
index 0000000000..65ea606dd6
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt
@@ -0,0 +1,131 @@
+package com.firebase.ui.auth.configuration.auth_provider
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+/**
+ * Creates a remembered launcher function for anonymous sign-in.
+ *
+ * @return A launcher function that starts the anonymous sign-in flow when invoked
+ *
+ * @see signInAnonymously
+ * @see createOrLinkUserWithEmailAndPassword for upgrading anonymous accounts
+ */
+@Composable
+internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(config: AuthUIConfiguration): () -> Unit {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+ return remember(this) {
+ {
+ coroutineScope.launch {
+ try {
+ signInAnonymously(config)
+ } catch (e: AuthException) {
+ // Already an AuthException, don't re-wrap it
+ updateAuthState(AuthState.Error(e))
+ } catch (e: Exception) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Signs in a user anonymously with Firebase Authentication.
+ *
+ * This method creates a temporary anonymous user account that can be used for testing
+ * or as a starting point for users who want to try the app before creating a permanent
+ * account. Anonymous users can later be upgraded to permanent accounts by linking
+ * credentials (email/password, social providers, phone, etc.).
+ *
+ * **Flow:**
+ * 1. Updates auth state to loading with "Signing in anonymously..." message
+ * 2. Calls Firebase Auth's `signInAnonymously()` method
+ * 3. Updates auth state to idle on success
+ * 4. Handles cancellation and converts exceptions to [AuthException] types
+ *
+ * **Anonymous Account Benefits:**
+ * - No user data collection required
+ * - Immediate access to app features
+ * - Can be upgraded to permanent account later
+ * - Useful for guest users and app trials
+ *
+ * **Account Upgrade:**
+ * Anonymous accounts can be upgraded to permanent accounts by calling methods like:
+ * - [signInAndLinkWithCredential] with email/password or social credentials
+ * - [createOrLinkUserWithEmailAndPassword] for email/password accounts
+ * - [signInWithPhoneAuthCredential] for phone authentication
+ *
+ * **Example: Basic anonymous sign-in**
+ * ```kotlin
+ * try {
+ * firebaseAuthUI.signInAnonymously()
+ * // User is now signed in anonymously
+ * // Show app content or prompt for account creation
+ * } catch (e: AuthException.AuthCancelledException) {
+ * // User cancelled the sign-in process
+ * } catch (e: AuthException.NetworkException) {
+ * // Network error occurred
+ * }
+ * ```
+ *
+ * **Example: Anonymous sign-in with upgrade flow**
+ * ```kotlin
+ * // Step 1: Sign in anonymously
+ * firebaseAuthUI.signInAnonymously()
+ *
+ * // Step 2: Later, upgrade to permanent account
+ * try {
+ * firebaseAuthUI.createOrLinkUserWithEmailAndPassword(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * name = "John Doe",
+ * email = "john@example.com",
+ * password = "SecurePass123!"
+ * )
+ * // Anonymous account upgraded to permanent email/password account
+ * } catch (e: AuthException.AccountLinkingRequiredException) {
+ * // Email already exists - show account linking UI
+ * }
+ * ```
+ *
+ * @throws AuthException.AuthCancelledException if the coroutine is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ * @throws AuthException.UnknownException for other authentication errors
+ *
+ * @see signInAndLinkWithCredential for upgrading anonymous accounts
+ * @see createOrLinkUserWithEmailAndPassword for email/password upgrade
+ * @see signInWithPhoneAuthCredential for phone authentication upgrade
+ */
+internal suspend fun FirebaseAuthUI.signInAnonymously(config: AuthUIConfiguration) {
+ try {
+ updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningInAnonymously))
+ val result = auth.signInAnonymously().await()
+ updateAuthStateWithResult(result, defaultIsNewUser = true)
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign in anonymously was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt
new file mode 100644
index 0000000000..59bff5d731
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt
@@ -0,0 +1,1068 @@
+/*
+ * 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.configuration.auth_provider
+
+import android.app.Activity
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.graphics.Color
+import androidx.core.net.toUri
+import androidx.credentials.ClearCredentialStateRequest
+import androidx.credentials.CredentialManager
+import androidx.credentials.GetCredentialRequest
+import androidx.datastore.preferences.core.stringPreferencesKey
+import com.facebook.AccessToken
+import com.firebase.ui.auth.R
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.AuthUIConfigurationDsl
+import com.firebase.ui.auth.configuration.PasswordRule
+import com.firebase.ui.auth.configuration.theme.AuthUIAsset
+import com.firebase.ui.auth.util.ContinueUrlBuilder
+import com.firebase.ui.auth.util.PhoneNumberUtils
+import com.firebase.ui.auth.util.Preconditions
+import com.firebase.ui.auth.util.ProviderAvailability
+import com.google.android.gms.auth.api.identity.AuthorizationRequest
+import com.google.android.gms.auth.api.identity.Identity
+import com.google.android.gms.common.api.Scope
+import com.google.android.libraries.identity.googleid.GetGoogleIdOption
+import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
+import com.google.firebase.FirebaseException
+import com.google.firebase.auth.ActionCodeSettings
+import com.google.firebase.auth.AuthCredential
+import com.google.firebase.auth.EmailAuthProvider
+import com.google.firebase.auth.FacebookAuthProvider
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.GithubAuthProvider
+import com.google.firebase.auth.GoogleAuthProvider
+import com.google.firebase.auth.MultiFactorSession
+import com.google.firebase.auth.PhoneAuthCredential
+import com.google.firebase.auth.PhoneAuthOptions
+import com.google.firebase.auth.PhoneAuthProvider
+import com.google.firebase.auth.TwitterAuthProvider
+import com.google.firebase.auth.UserProfileChangeRequest
+import com.google.firebase.auth.actionCodeSettings
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.tasks.await
+import java.util.concurrent.TimeUnit
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlin.coroutines.suspendCoroutine
+
+@AuthUIConfigurationDsl
+class AuthProvidersBuilder {
+ private val providers = mutableListOf()
+
+ fun provider(provider: AuthProvider) {
+ providers.add(provider)
+ }
+
+ internal fun build(): List = providers.toList()
+}
+
+/**
+ * Enum class to represent all possible providers.
+ */
+internal enum class Provider(
+ val id: String,
+ val providerName: String,
+ val isSocialProvider: Boolean = false,
+) {
+ GOOGLE(GoogleAuthProvider.PROVIDER_ID, providerName = "Google", isSocialProvider = true),
+ FACEBOOK(FacebookAuthProvider.PROVIDER_ID, providerName = "Facebook", isSocialProvider = true),
+ TWITTER(TwitterAuthProvider.PROVIDER_ID, providerName = "Twitter", isSocialProvider = true),
+ GITHUB(GithubAuthProvider.PROVIDER_ID, providerName = "Github", isSocialProvider = true),
+ EMAIL(EmailAuthProvider.PROVIDER_ID, providerName = "Email"),
+ PHONE(PhoneAuthProvider.PROVIDER_ID, providerName = "Phone"),
+ ANONYMOUS("anonymous", providerName = "Anonymous"),
+ MICROSOFT("microsoft.com", providerName = "Microsoft", isSocialProvider = true),
+ YAHOO("yahoo.com", providerName = "Yahoo", isSocialProvider = true),
+ APPLE("apple.com", providerName = "Apple", isSocialProvider = true);
+
+ companion object {
+ fun fromId(id: String?): Provider? {
+ return entries.find { it.id == id }
+ }
+ }
+}
+
+/**
+ * Base abstract class for authentication providers.
+ */
+abstract class AuthProvider(open val providerId: String, open val providerName: String) {
+ /**
+ * Base abstract class for OAuth authentication providers with common properties.
+ */
+ abstract class OAuth(
+ override val providerId: String,
+
+ override val providerName: String,
+ open val scopes: List = emptyList(),
+ open val customParameters: Map = emptyMap(),
+ ) : AuthProvider(providerId = providerId, providerName = providerName)
+
+ /**
+ * Email/Password authentication provider configuration.
+ */
+ class Email(
+ /**
+ * Requires the user to provide a display name. Defaults to true.
+ */
+ val isDisplayNameRequired: Boolean = true,
+
+ /**
+ * Enables email link sign-in, Defaults to false.
+ */
+ val isEmailLinkSignInEnabled: Boolean = false,
+
+ /**
+ * Forces email link sign-in to complete on the same device that initiated it.
+ *
+ * When enabled, prevents email links from being opened on different devices,
+ * which is required for security when upgrading anonymous users. Defaults to true.
+ */
+ val isEmailLinkForceSameDeviceEnabled: Boolean = true,
+
+ /**
+ * Settings for email link actions.
+ */
+ val emailLinkActionCodeSettings: ActionCodeSettings?,
+
+ /**
+ * Allows new accounts to be created. Defaults to true.
+ */
+ val isNewAccountsAllowed: Boolean = true,
+
+ /**
+ * The minimum length for a password. Defaults to 6.
+ */
+ val minimumPasswordLength: Int = 6,
+
+ /**
+ * A list of custom password validation rules.
+ */
+ val passwordValidationRules: List,
+ ) : AuthProvider(providerId = Provider.EMAIL.id, providerName = Provider.EMAIL.providerName) {
+ companion object {
+ const val SESSION_ID_LENGTH = 10
+ val KEY_EMAIL = stringPreferencesKey("com.firebase.ui.auth.data.client.email")
+ val KEY_PROVIDER = stringPreferencesKey("com.firebase.ui.auth.data.client.provider")
+ val KEY_ANONYMOUS_USER_ID =
+ stringPreferencesKey("com.firebase.ui.auth.data.client.auid")
+ val KEY_SESSION_ID = stringPreferencesKey("com.firebase.ui.auth.data.client.sid")
+ val KEY_IDP_TOKEN = stringPreferencesKey("com.firebase.ui.auth.data.client.idpToken")
+ val KEY_IDP_SECRET = stringPreferencesKey("com.firebase.ui.auth.data.client.idpSecret")
+ }
+
+ internal fun validate(isAnonymousUpgradeEnabled: Boolean = false) {
+ if (isEmailLinkSignInEnabled) {
+ val actionCodeSettings = requireNotNull(emailLinkActionCodeSettings) {
+ "ActionCodeSettings cannot be null when using " +
+ "email link sign in."
+ }
+
+ check(actionCodeSettings.canHandleCodeInApp()) {
+ "You must set canHandleCodeInApp in your " +
+ "ActionCodeSettings to true for Email-Link Sign-in."
+ }
+
+ if (isAnonymousUpgradeEnabled) {
+ check(isEmailLinkForceSameDeviceEnabled) {
+ "You must force the same device flow when using email link sign in " +
+ "with anonymous user upgrade"
+ }
+ }
+ }
+ }
+
+ // For Send Email Link
+ internal fun addSessionInfoToActionCodeSettings(
+ sessionId: String,
+ anonymousUserId: String,
+ credentialForLinking: AuthCredential? = null,
+ ): ActionCodeSettings {
+ requireNotNull(emailLinkActionCodeSettings) {
+ "ActionCodeSettings is required for email link sign in"
+ }
+
+ val continueUrl = continueUrl(emailLinkActionCodeSettings.url) {
+ appendSessionId(sessionId)
+ appendAnonymousUserId(anonymousUserId)
+ appendForceSameDeviceBit(isEmailLinkForceSameDeviceEnabled)
+ // Only append providerId for linking flows (when credentialForLinking is not null)
+ if (credentialForLinking != null) {
+ appendProviderId(credentialForLinking.provider)
+ }
+ }
+
+ return actionCodeSettings {
+ url = continueUrl
+ handleCodeInApp = emailLinkActionCodeSettings.canHandleCodeInApp()
+ linkDomain = emailLinkActionCodeSettings.linkDomain
+ iosBundleId = emailLinkActionCodeSettings.iosBundle
+ setAndroidPackageName(
+ emailLinkActionCodeSettings.androidPackageName ?: "",
+ emailLinkActionCodeSettings.androidInstallApp,
+ emailLinkActionCodeSettings.androidMinimumVersion
+ )
+ }
+ }
+
+ // For Sign In With Email Link
+ internal fun isDifferentDevice(
+ sessionIdFromLocal: String?,
+ sessionIdFromLink: String,
+ ): Boolean {
+ return sessionIdFromLocal == null || sessionIdFromLocal.isEmpty()
+ || sessionIdFromLink.isEmpty()
+ || (sessionIdFromLink != sessionIdFromLocal)
+ }
+
+ private fun continueUrl(continueUrl: String, block: ContinueUrlBuilder.() -> Unit) =
+ ContinueUrlBuilder(continueUrl).apply(block).build()
+
+ /**
+ * An interface to wrap the static `EmailAuthProvider.getCredential` method to make it testable.
+ * @suppress
+ */
+ internal interface CredentialProvider {
+ fun getCredential(email: String, password: String): AuthCredential
+ }
+
+ /**
+ * The default implementation of [CredentialProvider] that calls the static method.
+ * @suppress
+ */
+ internal class DefaultCredentialProvider : CredentialProvider {
+ override fun getCredential(email: String, password: String): AuthCredential {
+ return EmailAuthProvider.getCredential(email, password)
+ }
+ }
+ }
+
+ /**
+ * Phone number authentication provider configuration.
+ */
+ class Phone(
+ /**
+ * The phone number in international format.
+ */
+ val defaultNumber: String?,
+
+ /**
+ * The default country code to pre-select.
+ */
+ val defaultCountryCode: String?,
+
+ /**
+ * A list of allowed country codes.
+ */
+ val allowedCountries: List?,
+
+ /**
+ * The expected length of the SMS verification code. Defaults to 6.
+ */
+ val smsCodeLength: Int = 6,
+
+ /**
+ * The timeout in seconds for receiving the SMS. Defaults to 60L.
+ */
+ val timeout: Long = 60L,
+
+ /**
+ * Enables instant verification of the phone number. Defaults to true.
+ */
+ val isInstantVerificationEnabled: Boolean = true,
+ ) : AuthProvider(providerId = Provider.PHONE.id, providerName = Provider.PHONE.providerName) {
+ /**
+ * Sealed class representing the result of phone number verification.
+ *
+ * Phone verification can complete in two ways:
+ * - [AutoVerified]: SMS was instantly retrieved and verified by the Firebase SDK
+ * - [NeedsManualVerification]: SMS code was sent, user must manually enter it
+ */
+ internal sealed class VerifyPhoneNumberResult {
+ /**
+ * Instant verification succeeded via SMS auto-retrieval.
+ *
+ * @property credential The [PhoneAuthCredential] that can be used to sign in
+ */
+ class AutoVerified(val credential: PhoneAuthCredential) : VerifyPhoneNumberResult()
+
+ /**
+ * Instant verification failed, manual code entry required.
+ *
+ * @property verificationId The verification ID to use when submitting the code
+ * @property token Token for resending the verification code
+ */
+ class NeedsManualVerification(
+ val verificationId: String,
+ val token: PhoneAuthProvider.ForceResendingToken,
+ ) : VerifyPhoneNumberResult()
+ }
+
+ internal fun validate() {
+ defaultNumber?.let {
+ check(PhoneNumberUtils.isValid(it)) {
+ "Invalid phone number: $it"
+ }
+ }
+
+ defaultCountryCode?.let {
+ check(PhoneNumberUtils.isValidIso(it)) {
+ "Invalid country iso: $it"
+ }
+ }
+
+ allowedCountries?.forEach { code ->
+ check(PhoneNumberUtils.isValidIso(code)) {
+ "Invalid input: You must provide a valid country iso (alpha-2) " +
+ "or code (e-164). e.g. 'us' or '+1'. Invalid code: $code"
+ }
+ }
+ }
+
+ /**
+ * Internal coroutine-based wrapper for Firebase Phone Authentication verification.
+ *
+ * This method wraps the callback-based Firebase Phone Auth API into a suspending function
+ * using Kotlin coroutines. It handles the Firebase [PhoneAuthProvider.OnVerificationStateChangedCallbacks]
+ * and converts them into a [VerifyPhoneNumberResult].
+ *
+ * **Callback mapping:**
+ * - `onVerificationCompleted` → [VerifyPhoneNumberResult.AutoVerified]
+ * - `onCodeSent` → [VerifyPhoneNumberResult.NeedsManualVerification]
+ * - `onVerificationFailed` → throws the exception
+ *
+ * This is a private helper method used by [verifyPhoneNumber]. Callers should use
+ * [verifyPhoneNumber] instead as it handles state management and error handling.
+ *
+ * @param auth The [FirebaseAuth] instance to use for verification
+ * @param phoneNumber The phone number to verify in E.164 format
+ * @param multiFactorSession Optional [MultiFactorSession] for MFA enrollment. When provided,
+ * Firebase verifies the phone number for enrolling as a second authentication factor
+ * instead of primary sign-in. Pass null for standard phone authentication.
+ * @param forceResendingToken Optional token from previous verification for resending
+ *
+ * @return [VerifyPhoneNumberResult] indicating auto-verified or manual verification needed
+ * @throws FirebaseException if verification fails
+ */
+ internal suspend fun verifyPhoneNumberAwait(
+ auth: FirebaseAuth,
+ activity: Activity?,
+ phoneNumber: String,
+ multiFactorSession: MultiFactorSession? = null,
+ forceResendingToken: PhoneAuthProvider.ForceResendingToken?,
+ verifier: Verifier = DefaultVerifier(),
+ ): VerifyPhoneNumberResult {
+ return verifier.verifyPhoneNumber(
+ auth,
+ activity,
+ phoneNumber,
+ timeout,
+ forceResendingToken,
+ multiFactorSession,
+ isInstantVerificationEnabled
+ )
+ }
+
+ /**
+ * @suppress
+ */
+ internal interface Verifier {
+ suspend fun verifyPhoneNumber(
+ auth: FirebaseAuth,
+ activity: Activity?,
+ phoneNumber: String,
+ timeout: Long,
+ forceResendingToken: PhoneAuthProvider.ForceResendingToken?,
+ multiFactorSession: MultiFactorSession?,
+ isInstantVerificationEnabled: Boolean,
+ ): VerifyPhoneNumberResult
+ }
+
+ /**
+ * @suppress
+ */
+ internal class DefaultVerifier : Verifier {
+ override suspend fun verifyPhoneNumber(
+ auth: FirebaseAuth,
+ activity: Activity?,
+ phoneNumber: String,
+ timeout: Long,
+ forceResendingToken: PhoneAuthProvider.ForceResendingToken?,
+ multiFactorSession: MultiFactorSession?,
+ isInstantVerificationEnabled: Boolean,
+ ): VerifyPhoneNumberResult {
+ return suspendCoroutine { continuation ->
+ val options = PhoneAuthOptions.newBuilder(auth)
+ .setPhoneNumber(phoneNumber)
+ .requireSmsValidation(!isInstantVerificationEnabled)
+ .setTimeout(timeout, TimeUnit.SECONDS)
+ .setCallbacks(object :
+ PhoneAuthProvider.OnVerificationStateChangedCallbacks() {
+ override fun onVerificationCompleted(credential: PhoneAuthCredential) {
+ continuation.resume(VerifyPhoneNumberResult.AutoVerified(credential))
+ }
+
+ override fun onVerificationFailed(e: FirebaseException) {
+ continuation.resumeWithException(e)
+ }
+
+ override fun onCodeSent(
+ verificationId: String,
+ token: PhoneAuthProvider.ForceResendingToken,
+ ) {
+ continuation.resume(
+ VerifyPhoneNumberResult.NeedsManualVerification(
+ verificationId,
+ token
+ )
+ )
+ }
+ })
+ .apply {
+ activity?.let {
+ setActivity(it)
+ }
+ forceResendingToken?.let {
+ setForceResendingToken(it)
+ }
+ multiFactorSession?.let {
+ setMultiFactorSession(it)
+ }
+ }
+ .build()
+ PhoneAuthProvider.verifyPhoneNumber(options)
+ }
+ }
+ }
+
+ /**
+ * An interface to wrap the static `PhoneAuthProvider.getCredential` method to make it testable.
+ * @suppress
+ */
+ internal interface CredentialProvider {
+ fun getCredential(verificationId: String, smsCode: String): PhoneAuthCredential
+ }
+
+ /**
+ * The default implementation of [CredentialProvider] that calls the static method.
+ * @suppress
+ */
+ internal class DefaultCredentialProvider : CredentialProvider {
+ override fun getCredential(
+ verificationId: String,
+ smsCode: String,
+ ): PhoneAuthCredential {
+ return PhoneAuthProvider.getCredential(verificationId, smsCode)
+ }
+ }
+
+ }
+
+ /**
+ * Google Sign-In provider configuration.
+ */
+ class Google(
+ /**
+ * The list of scopes to request.
+ */
+ override val scopes: List,
+
+ /**
+ * The OAuth 2.0 client ID for your server.
+ */
+ var serverClientId: String?,
+
+ /**
+ * Whether to filter by authorized accounts.
+ * When true, only shows Google accounts that have previously authorized this app.
+ * Defaults to true, with automatic fallback to false if no authorized accounts found.
+ */
+ val filterByAuthorizedAccounts: Boolean = true,
+
+ /**
+ * Whether to enable auto-select for single account scenarios.
+ * When true, automatically selects the account if only one is available.
+ * Defaults to false for better user control.
+ */
+ val autoSelectEnabled: Boolean = false,
+
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map = emptyMap(),
+ ) : OAuth(
+ providerId = Provider.GOOGLE.id,
+ providerName = Provider.GOOGLE.providerName,
+ scopes = scopes,
+ customParameters = customParameters
+ ) {
+ internal fun validate(context: Context) {
+ if (serverClientId == null) {
+ Preconditions.checkConfigured(
+ context,
+ "Check your google-services plugin configuration, the" +
+ " default_web_client_id string wasn't populated.",
+ R.string.default_web_client_id
+ )
+ serverClientId = context.getString(R.string.default_web_client_id)
+ } else {
+ require(serverClientId!!.isNotBlank()) {
+ "Server client ID cannot be blank."
+ }
+ }
+
+ val hasEmailScope = scopes.contains("email")
+ if (!hasEmailScope) {
+ Log.w(
+ "AuthProvider.Google",
+ "The scopes do not include 'email'. In most cases this is a mistake!"
+ )
+ }
+ }
+
+ /**
+ * Result container for Google Sign-In credential flow.
+ * @suppress
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ data class GoogleSignInResult(
+ val credential: AuthCredential,
+ val idToken: String,
+ val displayName: String?,
+ val photoUrl: Uri?,
+ )
+
+ /**
+ * An interface to wrap the Authorization API for requesting OAuth scopes.
+ * @suppress
+ */
+ internal interface AuthorizationProvider {
+ suspend fun authorize(context: Context, scopes: List)
+ }
+
+ /**
+ * The default implementation of [AuthorizationProvider].
+ * @suppress
+ */
+ internal class DefaultAuthorizationProvider : AuthorizationProvider {
+ override suspend fun authorize(context: Context, scopes: List) {
+ val authorizationRequest = AuthorizationRequest.builder()
+ .setRequestedScopes(scopes)
+ .build()
+
+ Identity.getAuthorizationClient(context)
+ .authorize(authorizationRequest)
+ .await()
+ }
+ }
+
+ /**
+ * An interface to wrap the Credential Manager flow for Google Sign-In.
+ * @suppress
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ interface CredentialManagerProvider {
+ suspend fun getGoogleCredential(
+ context: Context,
+ credentialManager: CredentialManager,
+ serverClientId: String,
+ filterByAuthorizedAccounts: Boolean,
+ autoSelectEnabled: Boolean,
+ ): GoogleSignInResult
+
+ suspend fun clearCredentialState(
+ context: Context,
+ credentialManager: CredentialManager,
+ )
+ }
+
+ /**
+ * The default implementation of [CredentialManagerProvider].
+ * @suppress
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ class DefaultCredentialManagerProvider : CredentialManagerProvider {
+ override suspend fun getGoogleCredential(
+ context: Context,
+ credentialManager: CredentialManager,
+ serverClientId: String,
+ filterByAuthorizedAccounts: Boolean,
+ autoSelectEnabled: Boolean,
+ ): GoogleSignInResult {
+ val googleIdOption = GetGoogleIdOption.Builder()
+ .setServerClientId(serverClientId)
+ .setFilterByAuthorizedAccounts(filterByAuthorizedAccounts)
+ .setAutoSelectEnabled(autoSelectEnabled)
+ .build()
+
+ val request = GetCredentialRequest.Builder()
+ .addCredentialOption(googleIdOption)
+ .build()
+
+ val result = credentialManager.getCredential(context, request)
+ val googleIdTokenCredential =
+ GoogleIdTokenCredential.createFrom(result.credential.data)
+ val credential =
+ GoogleAuthProvider.getCredential(googleIdTokenCredential.idToken, null)
+
+ return GoogleSignInResult(
+ credential = credential,
+ idToken = googleIdTokenCredential.idToken,
+ displayName = googleIdTokenCredential.displayName,
+ photoUrl = googleIdTokenCredential.profilePictureUri,
+ )
+ }
+
+ override suspend fun clearCredentialState(
+ context: Context,
+ credentialManager: CredentialManager,
+ ) {
+ credentialManager.clearCredentialState(ClearCredentialStateRequest())
+ }
+ }
+ }
+
+ /**
+ * Facebook Login provider configuration.
+ */
+ class Facebook(
+ /**
+ * The list of scopes (permissions) to request. Defaults to email and public_profile.
+ */
+ override val scopes: List = listOf("email", "public_profile"),
+
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map = emptyMap(),
+ ) : OAuth(
+ providerId = Provider.FACEBOOK.id,
+ providerName = Provider.FACEBOOK.providerName,
+ scopes = scopes,
+ customParameters = customParameters
+ ) {
+ internal fun validate(context: Context) {
+ if (!ProviderAvailability.IS_FACEBOOK_AVAILABLE) {
+ throw RuntimeException(
+ "Facebook provider cannot be configured " +
+ "without dependency. Did you forget to add " +
+ "'com.facebook.android:facebook-login:VERSION' dependency?"
+ )
+ }
+
+ Preconditions.checkConfigured(
+ context,
+ "Facebook provider unconfigured. Make sure to " +
+ "add a `facebook_application_id` string to your strings.xml",
+ R.string.facebook_application_id
+ )
+
+ Preconditions.checkConfigured(
+ context,
+ "Facebook provider unconfigured. Make sure to " +
+ "add a `facebook_login_protocol_scheme` string to your strings.xml",
+ R.string.facebook_login_protocol_scheme
+ )
+
+ Preconditions.checkConfigured(
+ context,
+ "Facebook provider unconfigured. Make sure to " +
+ "add a `facebook_client_token` string to your strings.xml",
+ R.string.facebook_client_token
+ )
+ }
+
+ /**
+ * An interface to wrap Facebook LoginManager and credential operations to make them testable.
+ * @suppress
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ interface LoginManagerProvider {
+ fun getCredential(token: String): AuthCredential
+ fun logOut()
+ }
+
+ /**
+ * The default implementation of [LoginManagerProvider].
+ * @suppress
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ class DefaultLoginManagerProvider : LoginManagerProvider {
+ override fun getCredential(token: String): AuthCredential {
+ return FacebookAuthProvider.getCredential(token)
+ }
+
+ override fun logOut() {
+ com.facebook.login.LoginManager.getInstance().logOut()
+ }
+ }
+
+ /**
+ * Internal data class to hold Facebook profile information.
+ */
+ internal class FacebookProfileData(
+ val displayName: String?,
+ val email: String?,
+ val photoUrl: Uri?,
+ )
+
+ /**
+ * Fetches user profile data from Facebook Graph API.
+ *
+ * @param accessToken The Facebook access token
+ * @return FacebookProfileData containing user's display name, email, and photo URL
+ */
+ internal suspend fun fetchFacebookProfile(accessToken: AccessToken): FacebookProfileData? {
+ return suspendCancellableCoroutine { continuation ->
+ val request =
+ com.facebook.GraphRequest.newMeRequest(accessToken) { jsonObject, response ->
+ try {
+ val error = response?.error
+ if (error != null) {
+ Log.e(
+ "FirebaseAuthUI.signInWithFacebook",
+ "Graph API error: ${error.errorMessage}"
+ )
+ continuation.resume(null)
+ return@newMeRequest
+ }
+
+ if (jsonObject == null) {
+ Log.e(
+ "FirebaseAuthUI.signInWithFacebook",
+ "Graph API returned null response"
+ )
+ continuation.resume(null)
+ return@newMeRequest
+ }
+
+ val name = jsonObject.optString("name")
+ val email = jsonObject.optString("email")
+
+ // Extract photo URL from picture object
+ val photoUrl = try {
+ jsonObject.optJSONObject("picture")
+ ?.optJSONObject("data")
+ ?.optString("url")
+ ?.takeIf { it.isNotEmpty() }?.toUri()
+ } catch (e: Exception) {
+ Log.w(
+ "FirebaseAuthUI.signInWithFacebook",
+ "Error parsing photo URL",
+ e
+ )
+ null
+ }
+
+ Log.d(
+ "FirebaseAuthUI.signInWithFacebook",
+ "Profile fetched: name=$name, email=$email, hasPhoto=${photoUrl != null}"
+ )
+
+ continuation.resume(
+ FacebookProfileData(
+ displayName = name,
+ email = email,
+ photoUrl = photoUrl
+ )
+ )
+ } catch (e: Exception) {
+ Log.e(
+ "FirebaseAuthUI.signInWithFacebook",
+ "Error processing Graph API response",
+ e
+ )
+ continuation.resume(null)
+ }
+ }
+
+ // Request specific fields: id, name, email, and picture
+ val parameters = android.os.Bundle().apply {
+ putString("fields", "id,name,email,picture")
+ }
+ request.parameters = parameters
+ request.executeAsync()
+ }
+ }
+ }
+
+ /**
+ * Twitter/X authentication provider configuration.
+ */
+ class Twitter(
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map,
+ ) : OAuth(
+ providerId = Provider.TWITTER.id,
+ providerName = Provider.TWITTER.providerName,
+ customParameters = customParameters
+ )
+
+ /**
+ * Github authentication provider configuration.
+ */
+ class Github(
+ /**
+ * The list of scopes to request. Defaults to user:email.
+ */
+ override val scopes: List = listOf("user:email"),
+
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map,
+ ) : OAuth(
+ providerId = Provider.GITHUB.id,
+ providerName = Provider.GITHUB.providerName,
+ scopes = scopes,
+ customParameters = customParameters
+ )
+
+ /**
+ * Microsoft authentication provider configuration.
+ */
+ class Microsoft(
+ /**
+ * The list of scopes to request. Defaults to openid, profile, email.
+ */
+ override val scopes: List = listOf("openid", "profile", "email"),
+
+ /**
+ * The tenant ID for Azure Active Directory.
+ */
+ val tenant: String?,
+
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map,
+ ) : OAuth(
+ providerId = Provider.MICROSOFT.id,
+ providerName = Provider.MICROSOFT.providerName,
+ scopes = scopes,
+ customParameters = customParameters
+ )
+
+ /**
+ * Yahoo authentication provider configuration.
+ */
+ class Yahoo(
+ /**
+ * The list of scopes to request. Defaults to openid, profile, email.
+ */
+ override val scopes: List = listOf("openid", "profile", "email"),
+
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map,
+ ) : OAuth(
+ providerId = Provider.YAHOO.id,
+ providerName = Provider.YAHOO.providerName,
+ scopes = scopes,
+ customParameters = customParameters
+ )
+
+ /**
+ * Apple Sign-In provider configuration.
+ */
+ class Apple(
+ /**
+ * The list of scopes to request. Defaults to name and email.
+ */
+ override val scopes: List = listOf("name", "email"),
+
+ /**
+ * The locale for the sign-in page.
+ */
+ val locale: String?,
+
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map,
+ ) : OAuth(
+ providerId = Provider.APPLE.id,
+ providerName = Provider.APPLE.providerName,
+ scopes = scopes,
+ customParameters = customParameters
+ )
+
+ /**
+ * Anonymous authentication provider. It has no configurable properties.
+ */
+ object Anonymous : AuthProvider(
+ providerId = Provider.ANONYMOUS.id,
+ providerName = Provider.ANONYMOUS.providerName
+ ) {
+ internal fun validate(providers: List) {
+ if (providers.size == 1 && providers.first() is Anonymous) {
+ throw IllegalStateException(
+ "Sign in as guest cannot be the only sign in method. " +
+ "In this case, sign the user in anonymously your self; no UI is needed."
+ )
+ }
+ }
+ }
+
+ /**
+ * A generic OAuth provider for any unsupported provider.
+ */
+ class GenericOAuth(
+ /**
+ * The provider name.
+ */
+ override val providerName: String,
+
+ /**
+ * The provider ID as configured in the Firebase console.
+ */
+ override val providerId: String,
+
+ /**
+ * The list of scopes to request.
+ */
+ override val scopes: List,
+
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map,
+
+ /**
+ * The text to display on the provider button.
+ */
+ val buttonLabel: String,
+
+ /**
+ * An optional icon for the provider button.
+ */
+ val buttonIcon: AuthUIAsset?,
+
+ /**
+ * An optional background color for the provider button.
+ */
+ val buttonColor: Color?,
+
+ /**
+ * An optional content color for the provider button.
+ */
+ val contentColor: Color?,
+ ) : OAuth(
+ providerId = providerId,
+ providerName = providerName,
+ scopes = scopes,
+ customParameters = customParameters
+ ) {
+ internal fun validate() {
+ require(providerId.isNotBlank()) {
+ "Provider ID cannot be null or empty"
+ }
+
+ require(buttonLabel.isNotBlank()) {
+ "Button label cannot be null or empty"
+ }
+ }
+ }
+
+ companion object {
+ internal fun canUpgradeAnonymous(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean {
+ val currentUser = auth.currentUser
+ return config.isAnonymousUpgradeEnabled
+ && currentUser != null
+ && currentUser.isAnonymous
+ }
+
+ internal fun canLinkCredential(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean {
+ val currentUser = auth.currentUser
+ return config.isCredentialLinkingEnabled
+ && currentUser != null
+ && !currentUser.isAnonymous
+ }
+
+ /**
+ * Merges profile information (display name and photo URL) with the current user's profile.
+ *
+ * This method updates the user's profile only if the current profile is incomplete
+ * (missing display name or photo URL). This prevents overwriting existing profile data.
+ *
+ * **Use case:**
+ * After creating a new user account or linking credentials, update the profile with
+ * information from the sign-up form or social provider.
+ *
+ * @param auth The [FirebaseAuth] instance
+ * @param displayName The display name to set (if current is empty)
+ * @param photoUri The photo URL to set (if current is null)
+ *
+ * **Note:** This operation always succeeds to minimize login interruptions.
+ * Failures are logged but don't prevent sign-in completion.
+ */
+ internal suspend fun mergeProfile(
+ auth: FirebaseAuth,
+ displayName: String?,
+ photoUri: Uri?,
+ ) {
+ try {
+ val currentUser = auth.currentUser ?: return
+
+ // Only update if current profile is incomplete
+ val currentDisplayName = currentUser.displayName
+ val currentPhotoUrl = currentUser.photoUrl
+
+ if (!currentDisplayName.isNullOrEmpty() && currentPhotoUrl != null) {
+ // Profile is complete, no need to update
+ return
+ }
+
+ // Build profile update with provided values
+ val nameToSet =
+ if (currentDisplayName.isNullOrEmpty()) displayName else currentDisplayName
+ val photoToSet = currentPhotoUrl ?: photoUri
+
+ if (nameToSet != null || photoToSet != null) {
+ val profileUpdates = UserProfileChangeRequest.Builder()
+ .setDisplayName(nameToSet)
+ .setPhotoUri(photoToSet)
+ .build()
+
+ currentUser.updateProfile(profileUpdates).await()
+ }
+ } catch (e: Exception) {
+ // Log error but don't throw - profile update failure shouldn't prevent sign-in
+ Log.e("AuthProvider.Email", "Error updating profile", e)
+ }
+ }
+ }
+}
+
+/**
+ * Filters this provider list to only those whose [AuthProvider.providerId] matches a provider
+ * already linked to [user], as reported by [com.google.firebase.auth.FirebaseUser.providerData].
+ *
+ * Used by [com.firebase.ui.auth.FirebaseAuthUI.createReauthFlow] to restrict the reauthentication
+ * UI to methods the user has actually registered.
+ */
+internal fun List.filterToLinkedProviders(
+ user: com.google.firebase.auth.FirebaseUser,
+): List {
+ val linkedIds = user.providerData.map { it.providerId }.toSet()
+ return filter { it.providerId in linkedIds }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt
new file mode 100644
index 0000000000..1e480eda98
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt
@@ -0,0 +1,1281 @@
+/*
+ * 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.configuration.auth_provider
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import com.firebase.ui.auth.R
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canLinkCredential
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.mergeProfile
+import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException
+import com.firebase.ui.auth.credentialmanager.PasswordCredentialException
+import com.firebase.ui.auth.credentialmanager.PasswordCredentialHandler
+import com.firebase.ui.auth.util.EmailLinkPersistenceManager
+import com.firebase.ui.auth.util.EmailLinkParser
+import com.firebase.ui.auth.util.PersistenceManager
+import com.firebase.ui.auth.util.SessionUtils
+import com.firebase.ui.auth.util.SignInPreferenceManager
+import com.google.firebase.FirebaseApp
+import com.google.firebase.auth.ActionCodeSettings
+import com.google.firebase.auth.AuthCredential
+import com.google.firebase.auth.AuthResult
+import com.google.firebase.auth.EmailAuthProvider
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.FirebaseAuthMultiFactorException
+import com.google.firebase.auth.FirebaseAuthUserCollisionException
+import com.google.firebase.auth.SignInMethodQueryResult
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.tasks.await
+
+private const val TAG = "EmailAuthProvider"
+
+/**
+ * Signs in or reauthenticates with [credential] depending on [AuthUIConfiguration.isReauthenticationMode].
+ *
+ * - Normal mode: [com.google.firebase.auth.FirebaseAuth.signInWithCredential], returns [AuthResult].
+ * - Reauth mode: [com.google.firebase.auth.FirebaseUser.reauthenticate] (Task), returns null.
+ * Callers must reconstruct auth state from [com.google.firebase.auth.FirebaseAuth.currentUser].
+ */
+internal suspend fun FirebaseAuthUI.signInOrReauth(
+ credential: AuthCredential,
+ config: AuthUIConfiguration,
+): AuthResult? = if (config.isReauthenticationMode) {
+ val currentUser = auth.currentUser
+ ?: throw AuthException.UserNotFoundException(message = "No user is currently signed in for reauthentication")
+ currentUser.reauthenticate(credential).await()
+ null
+} else {
+ auth.signInWithCredential(credential).await()
+}
+
+/**
+ * Creates an email/password account or links the credential to an anonymous user.
+ *
+ * Mirrors the legacy email sign-up handler: validates password strength, validates custom
+ * password rules, checks if new accounts are allowed, chooses between
+ * `createUserWithEmailAndPassword` and `linkWithCredential`, merges the supplied display name
+ * into the Firebase profile, and throws [AuthException.AccountLinkingRequiredException] when
+ * anonymous upgrade encounters an existing account for the email.
+ *
+ * **Flow:**
+ * 1. Check if new accounts are allowed (for non-upgrade flows)
+ * 2. Validate password length against [AuthProvider.Email.minimumPasswordLength]
+ * 3. Validate password against custom [AuthProvider.Email.passwordValidationRules]
+ * 4. If upgrading anonymous user: link credential to existing anonymous account
+ * 5. Otherwise: create new account with `createUserWithEmailAndPassword`
+ * 6. Merge display name into user profile
+ *
+ * @param context Android [Context] for localized strings
+ * @param config Auth UI configuration describing provider settings
+ * @param provider Email provider configuration
+ * @param name Optional display name collected during sign-up
+ * @param email Email address for the new account
+ * @param password Password for the new account
+ *
+ * @return [AuthResult] containing the newly created or linked user, or null if failed
+ *
+ * @throws AuthException.UserNotFoundException if new accounts are not allowed
+ * @throws AuthException.WeakPasswordException if the password fails validation rules
+ * @throws AuthException.InvalidCredentialsException if the email or password is invalid
+ * @throws AuthException.EmailAlreadyInUseException if the email already exists
+ * @throws AuthException.AuthCancelledException if the coroutine is cancelled
+ * @throws AuthException.NetworkException for network-related failures
+ *
+ * **Example: Normal sign-up**
+ * ```kotlin
+ * try {
+ * val result = firebaseAuthUI.createOrLinkUserWithEmailAndPassword(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * name = "John Doe",
+ * email = "john@example.com",
+ * password = "SecurePass123!"
+ * )
+ * // User account created successfully
+ * } catch (e: AuthException.WeakPasswordException) {
+ * // Password doesn't meet validation rules
+ * } catch (e: AuthException.EmailAlreadyInUseException) {
+ * // Email already exists - redirect to sign-in
+ * }
+ * ```
+ *
+ * **Example: Anonymous user upgrade**
+ * ```kotlin
+ * // User is currently signed in anonymously
+ * try {
+ * val result = firebaseAuthUI.createOrLinkUserWithEmailAndPassword(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * name = "Jane Smith",
+ * email = "jane@example.com",
+ * password = "MyPassword456"
+ * )
+ * // Anonymous account upgraded to permanent email/password account
+ * } catch (e: AuthException.AccountLinkingRequiredException) {
+ * // Email already exists - show account linking UI
+ * // User needs to sign in with existing account to link
+ * }
+ * ```
+ */
+internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.Email,
+ name: String?,
+ email: String,
+ password: String,
+ credentialProvider: AuthProvider.Email.CredentialProvider = AuthProvider.Email.DefaultCredentialProvider(),
+): AuthResult? {
+ val canUpgrade = canUpgradeAnonymous(config, auth)
+ val canLink = canLinkCredential(config, auth)
+ val shouldLinkCredential = canUpgrade || canLink
+ val pendingCredential =
+ if (shouldLinkCredential) credentialProvider.getCredential(email, password) else null
+
+ try {
+ // Check if new accounts are allowed (only for non-upgrade/non-linking flows)
+ if (!shouldLinkCredential && !provider.isNewAccountsAllowed) {
+ throw AuthException.UserNotFoundException(
+ message = context.getString(R.string.fui_error_email_does_not_exist)
+ )
+ }
+
+ // Validate minimum password length
+ if (password.length < provider.minimumPasswordLength) {
+ throw AuthException.InvalidCredentialsException(
+ message = context.getString(R.string.fui_error_password_too_short)
+ .format(provider.minimumPasswordLength)
+ )
+ }
+
+ // Validate password against custom rules
+ for (rule in provider.passwordValidationRules) {
+ if (!rule.isValid(password)) {
+ throw AuthException.WeakPasswordException(
+ message = rule.getErrorMessage(config.stringProvider),
+ reason = "Password does not meet custom validation rules"
+ )
+ }
+ }
+
+ updateAuthState(AuthState.Loading(config.stringProvider.loadingCreatingUser))
+ val result = if (shouldLinkCredential) {
+ auth.currentUser?.linkWithCredential(requireNotNull(pendingCredential))?.await()
+ } else {
+ auth.createUserWithEmailAndPassword(email, password).await()
+ }.also { authResult ->
+ authResult?.user?.let {
+ // Merge display name into profile (photoUri is always null for email/password)
+ mergeProfile(auth, name, null)
+ }
+ }
+
+ // Save credentials to Credential Manager if enabled
+ if (config.isCredentialManagerEnabled) {
+ try {
+ val credentialHandler = PasswordCredentialHandler(context)
+ credentialHandler.savePassword(email, password)
+ Log.d(TAG, "Password credential saved successfully for: $email")
+ } catch (e: PasswordCredentialCancelledException) {
+ // User cancelled - this is fine, don't break the auth flow
+ Log.d(TAG, "User cancelled credential save for: $email")
+ } catch (e: PasswordCredentialException) {
+ // Failed to save - log but don't break the auth flow
+ Log.w(TAG, "Failed to save password credential for: $email", e)
+ }
+ }
+
+ // Save sign-in preference for "Continue as..." feature
+ if (result != null) {
+ try {
+ SignInPreferenceManager.saveLastSignIn(
+ context = context,
+ providerId = "password",
+ identifier = email
+ )
+ Log.d(TAG, "Sign-in preference saved for: $email")
+ } catch (e: Exception) {
+ // Failed to save preference - log but don't break auth flow
+ Log.w(TAG, "Failed to save sign-in preference for: $email", e)
+ }
+ }
+
+ updateAuthStateWithResult(result, defaultIsNewUser = true)
+ return result
+ } catch (e: FirebaseAuthUserCollisionException) {
+ // Account collision: email already exists
+ val accountLinkingException = AuthException.AccountLinkingRequiredException(
+ message = "An account already exists with this email. " +
+ "Please sign in with your existing account.",
+ email = e.email ?: email,
+ credential = when {
+ canUpgrade -> e.updatedCredential ?: pendingCredential
+ canLink -> pendingCredential
+ else -> null
+ },
+ cause = e
+ )
+ updateAuthState(AuthState.Error(accountLinkingException))
+ throw accountLinkingException
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Create or link user with email and password was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+/**
+ * Signs in a user with email and password, optionally linking a social credential.
+ *
+ * This method handles both normal sign-in and anonymous upgrade flows. In anonymous upgrade
+ * scenarios, it validates credentials in a scratch auth instance before throwing
+ * [AuthException.AccountLinkingRequiredException].
+ *
+ * **Flow:**
+ * 1. If anonymous upgrade:
+ * - Create scratch auth instance to validate credential
+ * - If linking social provider: sign in with email, then link social credential (safe link)
+ * - Otherwise: just validate email credential
+ * - Throw [AuthException.AccountLinkingRequiredException] after successful validation
+ * 2. If normal sign-in:
+ * - Sign in with email/password
+ * - If credential provided: link it and merge profile
+ *
+ * @param context Android [Context] for creating scratch auth instance
+ * @param config Auth UI configuration describing provider settings
+ * @param email Email address for sign-in
+ * @param password Password for sign-in
+ * @param credentialForLinking Optional social provider credential to link after sign-in
+ *
+ * @return [AuthResult] containing the signed-in user, or null if validation-only (anonymous upgrade)
+ *
+ * @throws AuthException.InvalidCredentialsException if email or password is incorrect
+ * @throws AuthException.UserNotFoundException if the user doesn't exist
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException for network-related failures
+ *
+ * **Example: Normal sign-in**
+ * ```kotlin
+ * try {
+ * val result = firebaseAuthUI.signInWithEmailAndPassword(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = "user@example.com",
+ * password = "password123"
+ * )
+ * // User signed in successfully
+ * } catch (e: AuthException.InvalidCredentialsException) {
+ * // Wrong password
+ * }
+ * ```
+ *
+ * **Example: Sign-in with social credential linking**
+ * ```kotlin
+ * // User tried to sign in with Google, but account exists with email/password
+ * // Prompt for password, then link Google credential
+ * val googleCredential = GoogleAuthProvider.getCredential(idToken, null)
+ *
+ * val result = firebaseAuthUI.signInWithEmailAndPassword(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = "user@example.com",
+ * password = "password123",
+ * credentialForLinking = googleCredential
+ * )
+ * // User signed in with email/password AND Google is now linked
+ * // Profile updated with Google display name and photo
+ * ```
+ *
+ * **Example: Anonymous upgrade validation**
+ * ```kotlin
+ * // User is anonymous, wants to upgrade with existing email/password account
+ * try {
+ * firebaseAuthUI.signInWithEmailAndPassword(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = "existing@example.com",
+ * password = "password123"
+ * )
+ * } catch (e: AuthException.AccountLinkingRequiredException) {
+ * // Account linking required - UI shows account linking screen
+ * // User needs to sign in with existing account to link anonymous account
+ * }
+ * ```
+ */
+internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
+ context: Context,
+ config: AuthUIConfiguration,
+ email: String,
+ password: String,
+ credentialForLinking: AuthCredential? = null,
+ skipCredentialSave: Boolean = false,
+): AuthResult? {
+ try {
+ updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningIn))
+ // In reauth mode build a credential and go through signInAndLinkWithCredential so
+ // signInOrReauth routes to FirebaseUser.reauthenticate() instead of signInWithCredential().
+ if (config.isReauthenticationMode) {
+ return signInAndLinkWithCredential(
+ config = config,
+ credential = EmailAuthProvider.getCredential(email, password),
+ )
+ }
+ return if (canUpgradeAnonymous(config, auth)) {
+ // Anonymous upgrade flow: validate credential in scratch auth
+ val credentialToValidate = EmailAuthProvider.getCredential(email, password)
+
+ // Check if we're linking a social provider credential
+ val isSocialProvider = credentialForLinking != null &&
+ (Provider.fromId(credentialForLinking.provider)?.isSocialProvider ?: false)
+
+ // Create scratch auth instance to avoid losing anonymous user state
+ val appExplicitlyForValidation = FirebaseApp.initializeApp(
+ context,
+ auth.app.options,
+ "FUIAuthScratchApp_${System.currentTimeMillis()}"
+ )
+ val authExplicitlyForValidation = FirebaseAuth
+ .getInstance(appExplicitlyForValidation)
+
+ if (isSocialProvider) {
+ // Safe link: sign in with email, then link social credential
+ authExplicitlyForValidation
+ .signInWithCredential(credentialToValidate).await()
+ .user?.linkWithCredential(credentialForLinking)?.await()
+ .also {
+ // Throw AccountLinkingRequiredException after successful validation
+ val accountLinkingException = AuthException.AccountLinkingRequiredException(
+ message = "An account already exists with this email. " +
+ "Please sign in with your existing account to upgrade your anonymous account.",
+ email = email,
+ credential = credentialToValidate,
+ cause = null
+ )
+ updateAuthState(AuthState.Error(accountLinkingException))
+ throw accountLinkingException
+ }
+ } else {
+ // Just validate the email credential
+ // No linking for non-federated IDPs
+ authExplicitlyForValidation
+ .signInWithCredential(credentialToValidate).await()
+ .also {
+ // Throw AccountLinkingRequiredException after successful validation
+ // Account exists and user is anonymous - needs to link accounts
+ val accountLinkingException = AuthException.AccountLinkingRequiredException(
+ message = "An account already exists with this email. " +
+ "Please sign in with your existing account to upgrade your anonymous account.",
+ email = email,
+ credential = credentialToValidate,
+ cause = null
+ )
+ updateAuthState(AuthState.Error(accountLinkingException))
+ throw accountLinkingException
+ }
+ }
+ } else {
+ // Normal sign-in
+ auth.signInWithEmailAndPassword(email, password).await()
+ .let { result ->
+ // If there's a credential to link, link it after sign-in
+ if (credentialForLinking != null) {
+ val linkResult = result.user
+ ?.linkWithCredential(credentialForLinking)
+ ?.await()
+
+ // Merge profile from social provider
+ linkResult?.user?.let { user ->
+ mergeProfile(
+ auth,
+ user.displayName,
+ user.photoUrl
+ )
+ }
+
+ linkResult ?: result
+ } else {
+ result
+ }
+ }
+ }.also { result ->
+ // Save credentials to Credential Manager if enabled
+ // Skip if user signed in with a retrieved credential (already saved)
+ if (config.isCredentialManagerEnabled && result != null && !skipCredentialSave) {
+ try {
+ val credentialHandler = PasswordCredentialHandler(context)
+ credentialHandler.savePassword(email, password)
+ Log.d(TAG, "Password credential saved successfully for: $email")
+ } catch (e: PasswordCredentialCancelledException) {
+ // User cancelled - this is fine, don't break the auth flow
+ Log.d(TAG, "User cancelled credential save for: $email")
+ } catch (e: PasswordCredentialException) {
+ // Failed to save - log but don't break the auth flow
+ Log.w(TAG, "Failed to save password credential for: $email", e)
+ }
+ }
+
+ // Save sign-in preference for "Continue as..." feature
+ if (result != null) {
+ try {
+ SignInPreferenceManager.saveLastSignIn(
+ context = context,
+ providerId = "password",
+ identifier = email
+ )
+ Log.d(TAG, "Sign-in preference saved for: $email")
+ } catch (e: Exception) {
+ // Failed to save preference - log but don't break auth flow
+ Log.w(TAG, "Failed to save sign-in preference for: $email", e)
+ }
+ }
+
+ updateAuthStateWithResult(result)
+ }
+ } catch (e: FirebaseAuthMultiFactorException) {
+ // MFA required - extract resolver and update state
+ val resolver = e.resolver
+ val hint = resolver.hints.firstOrNull()?.displayName
+ updateAuthState(AuthState.RequiresMfa(resolver, hint))
+ return null
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign in with email and password was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = recoverLegacyDifferentSignInMethod(config, email, e)
+ ?: AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+private suspend fun FirebaseAuthUI.recoverLegacyDifferentSignInMethod(
+ config: AuthUIConfiguration,
+ email: String,
+ cause: Exception,
+): AuthException.DifferentSignInMethodRequiredException? {
+ if (!config.legacyFetchSignInWithEmail) {
+ return null
+ }
+
+ val authException = AuthException.from(cause)
+ if (authException !is AuthException.InvalidCredentialsException &&
+ authException !is AuthException.UserNotFoundException) {
+ return null
+ }
+
+ val signInMethods = fetchLegacySignInMethods(email)
+ val suggestedSignInMethod = selectSuggestedLegacySignInMethod(config, signInMethods) ?: return null
+
+ return AuthException.DifferentSignInMethodRequiredException(
+ message = config.stringProvider.accountLinkingRequiredRecoveryMessage,
+ email = email,
+ signInMethods = signInMethods,
+ suggestedSignInMethod = suggestedSignInMethod,
+ cause = cause
+ )
+}
+
+private fun selectSuggestedLegacySignInMethod(
+ config: AuthUIConfiguration,
+ signInMethods: List,
+): String? {
+ if (signInMethods.isEmpty() ||
+ EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD in signInMethods) {
+ return null
+ }
+
+ val emailProvider = config.providers.filterIsInstance().firstOrNull()
+ val configuredProviderIds = config.providers.map { it.providerId }.toSet()
+
+ return signInMethods.firstOrNull { signInMethod ->
+ when {
+ signInMethod == EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD -> {
+ emailProvider?.isEmailLinkSignInEnabled == true
+ }
+
+ signInMethod == EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD -> false
+ else -> signInMethod in configuredProviderIds
+ }
+ }
+}
+
+private suspend fun FirebaseAuthUI.fetchLegacySignInMethods(email: String): List {
+ return try {
+ @Suppress("DEPRECATION")
+ auth.fetchSignInMethodsForEmail(email)
+ .await()
+ .toSignInMethods()
+ } catch (fetchException: Exception) {
+ Log.w(TAG, "Legacy fetchSignInMethodsForEmail failed for: $email", fetchException)
+ emptyList()
+ }
+}
+
+private fun SignInMethodQueryResult?.toSignInMethods(): List =
+ this?.signInMethods?.filter { it.isNotBlank() } ?: emptyList()
+
+/**
+ * Signs in with a credential or links it to an existing anonymous user.
+ *
+ * This method handles both normal sign-in and anonymous upgrade flows. After successful
+ * authentication, it merges profile information (display name and photo URL) into the
+ * Firebase user profile if provided.
+ *
+ * **Flow:**
+ * 1. Check if user is anonymous and upgrade is enabled
+ * 2. If yes: Link credential to anonymous user
+ * 3. If no: Sign in with credential
+ * 4. Merge profile information (name, photo) into Firebase user
+ * 5. Handle collision exceptions by throwing [AuthException.AccountLinkingRequiredException]
+ *
+ * @param config The [AuthUIConfiguration] containing authentication settings
+ * @param credential The [AuthCredential] to use for authentication. Can be from any provider.
+ * @param displayName Optional display name from the provider to merge into the user profile
+ * @param photoUrl Optional photo URL from the provider to merge into the user profile
+ *
+ * @return [AuthResult] containing the authenticated user
+ *
+ * @throws AuthException.InvalidCredentialsException if credential is invalid or expired
+ * @throws AuthException.EmailAlreadyInUseException if linking and email is already in use
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ *
+ * **Example: Google Sign-In**
+ * ```kotlin
+ * val googleCredential = GoogleAuthProvider.getCredential(idToken, null)
+ * val displayName = "John Doe" // From Google profile
+ * val photoUrl = Uri.parse("https://...") // From Google profile
+ *
+ * val result = firebaseAuthUI.signInAndLinkWithCredential(
+ * config = authUIConfig,
+ * credential = googleCredential,
+ * displayName = displayName,
+ * photoUrl = photoUrl
+ * )
+ * // User signed in with Google AND profile updated with Google data
+ * ```
+ *
+ * **Example: Phone Auth**
+ * ```kotlin
+ * val phoneCredential = PhoneAuthProvider.getCredential(verificationId, code)
+ *
+ * val result = firebaseAuthUI.signInAndLinkWithCredential(
+ * config = authUIConfig,
+ * credential = phoneCredential
+ * )
+ * // User signed in with phone number
+ * ```
+ *
+ * **Example: Phone Auth with Collision (Anonymous Upgrade)**
+ * ```kotlin
+ * // User is currently anonymous, trying to link a phone number
+ * val phoneCredential = PhoneAuthProvider.getCredential(verificationId, code)
+ *
+ * try {
+ * firebaseAuthUI.signInAndLinkWithCredential(
+ * config = authUIConfig,
+ * credential = phoneCredential
+ * )
+ * } catch (e: AuthException.AccountLinkingRequiredException) {
+ * // Phone number already exists on another account
+ * // Account linking required - UI can show account linking screen
+ * // User needs to sign in with existing account to link
+ * }
+ * ```
+ *
+ * **Example: Email Link Sign-In**
+ * ```kotlin
+ * val emailLinkCredential = EmailAuthProvider.getCredentialWithLink(
+ * email = "user@example.com",
+ * emailLink = emailLink
+ * )
+ *
+ * val result = firebaseAuthUI.signInAndLinkWithCredential(
+ * config = authUIConfig,
+ * credential = emailLinkCredential
+ * )
+ * // User signed in with email link (passwordless)
+ * ```
+ */
+internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential(
+ config: AuthUIConfiguration,
+ credential: AuthCredential,
+ provider: AuthProvider? = null,
+ displayName: String? = null,
+ photoUrl: Uri? = null,
+): AuthResult? {
+ try {
+ updateAuthState(AuthState.Loading(config.stringProvider.loadingLinkingCredential))
+ val result = if (canUpgradeAnonymous(config, auth) || canLinkCredential(config, auth)) {
+ auth.currentUser?.linkWithCredential(credential)?.await()
+ } else {
+ signInOrReauth(credential, config)
+ }
+ // signInOrReauth returns null in reauth mode (Task has no AuthResult).
+ // Reconstruct success state from the now-reauthenticated current user.
+ if (result == null && config.isReauthenticationMode) {
+ auth.currentUser?.let {
+ updateAuthState(AuthState.Success(result = null, user = it, isNewUser = false))
+ }
+ return null
+ }
+ result?.user?.let { mergeProfile(auth, displayName, photoUrl) }
+ updateAuthStateWithResult(result)
+ return result
+ } catch (e: FirebaseAuthMultiFactorException) {
+ // MFA required - extract resolver and update state
+ val resolver = e.resolver
+ val hint = resolver.hints.firstOrNull()?.displayName
+ updateAuthState(AuthState.RequiresMfa(resolver, hint))
+ return null
+ } catch (e: FirebaseAuthUserCollisionException) {
+ // Account collision: account already exists with different sign-in method
+ // Create AccountLinkingRequiredException with credential for linking
+ val email = e.email
+ val credentialForException = if (canUpgradeAnonymous(config, auth)) {
+ // For anonymous upgrade, use the updated credential from the exception
+ e.updatedCredential ?: credential
+ } else {
+ // For non-anonymous, use the original credential
+ credential
+ }
+
+ val accountLinkingException = AuthException.AccountLinkingRequiredException(
+ message = "An account already exists with the email ${email ?: ""}. " +
+ "Please sign in with your existing account to link " +
+ "your ${provider?.providerName ?: "this provider"} account.",
+ email = email,
+ credential = credentialForException,
+ cause = e
+ )
+ updateAuthState(AuthState.Error(accountLinkingException))
+ throw accountLinkingException
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign in and link with credential was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+/**
+ * Sends a passwordless sign-in link to the specified email address.
+ *
+ * This method initiates the email-link (passwordless) authentication flow by sending
+ * an email containing a magic link. The link includes session information for validation
+ * and security.
+ *
+ * **How it works:**
+ * 1. Generates a unique session ID for same-device validation
+ * 2. Retrieves anonymous user ID if upgrading anonymous account
+ * 3. Enriches the [ActionCodeSettings] URL with session data (session ID, anonymous user ID, force same-device flag)
+ * 4. Sends the email via [com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail]
+ * 5. Saves session data to DataStore for validation when the user clicks the link
+ * 6. User receives email with a magic link containing the session information
+ * 7. When user clicks link, app opens via deep link and calls [signInWithEmailLink] to complete authentication
+ *
+ * **Account Linking Support:**
+ * If a user tries to sign in with a social provider (Google, Facebook) but an email link
+ * account already exists with that email, the social provider implementation should:
+ * 1. Catch the [FirebaseAuthUserCollisionException] from the sign-in attempt
+ * 2. Call [EmailLinkPersistenceManager.default.saveCredentialForLinking] with the provider tokens
+ * 3. Call this method to send the email link
+ * 4. When [signInWithEmailLink] completes, it automatically retrieves and links the saved credential
+ *
+ * **Session Security:**
+ * - **Session ID**: Random 10-character string for same-device validation
+ * - **Anonymous User ID**: Stored if upgrading anonymous account to prevent account hijacking
+ * - **Force Same Device**: Can be configured via [AuthProvider.Email.isEmailLinkForceSameDeviceEnabled]
+ * - All session data is validated in [signInWithEmailLink] before completing authentication
+ *
+ * @param context Android [Context] for DataStore access
+ * @param config The [AuthUIConfiguration] containing authentication settings
+ * @param provider The [AuthProvider.Email] configuration with [ActionCodeSettings]
+ * @param email The email address to send the sign-in link to
+ * @param credentialForLinking Optional [AuthCredential] from a social provider to link after email sign-in.
+ * If provided, the credential is saved to DataStore and automatically linked
+ * when [signInWithEmailLink] completes. Used for account linking flows.
+ *
+ * @throws AuthException.InvalidCredentialsException if email is invalid
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ * @throws IllegalStateException if ActionCodeSettings is not configured
+ *
+ * **Example 1: Basic email link sign-in**
+ * ```kotlin
+ * // Send the email link
+ * firebaseAuthUI.sendSignInLinkToEmail(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = "user@example.com"
+ * )
+ * // Show "Check your email" UI to user
+ *
+ * // Later, when user clicks the link in their email:
+ * // (In your deep link handling Activity)
+ * val emailLink = intent.data.toString()
+ * firebaseAuthUI.signInWithEmailLink(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = "user@example.com",
+ * emailLink = emailLink
+ * )
+ * // User is now signed in
+ * ```
+ *
+ * **Example 2: Anonymous user upgrade**
+ * ```kotlin
+ * // User is currently signed in anonymously
+ * // Send email link to upgrade anonymous account to permanent email account
+ * firebaseAuthUI.sendSignInLinkToEmail(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = "user@example.com"
+ * )
+ * // Session includes anonymous user ID for validation
+ * // When user clicks link, anonymous account is upgraded to permanent account
+ * ```
+ *
+ * **Example 3: Social provider linking**
+ * ```kotlin
+ * try {
+ * // Try to sign in with Google
+ * authUI.signInWithGoogle(...)
+ * } catch (e: FirebaseAuthUserCollisionException) {
+ * // Email already exists with email-link provider
+ * val googleCredential = e.updatedCredential
+ *
+ * // Save credential for linking
+ * EmailLinkPersistenceManager.default.saveCredentialForLinking(
+ * context = context,
+ * providerType = "google.com",
+ * idToken = (googleCredential as GoogleAuthCredential).idToken,
+ * accessToken = null
+ * )
+ *
+ * // Send email link with credential
+ * firebaseAuthUI.sendSignInLinkToEmail(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = e.email!!,
+ * credentialForLinking = googleCredential
+ * )
+ * // When user clicks link and signs in, Google is automatically linked
+ * }
+ * ```
+ *
+ * @see signInWithEmailLink
+ * @see EmailLinkPersistenceManager
+ * @see com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail
+ */
+internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.Email,
+ email: String,
+ credentialForLinking: AuthCredential?,
+ persistenceManager: PersistenceManager = EmailLinkPersistenceManager.default,
+) {
+ try {
+ updateAuthState(AuthState.Loading(config.stringProvider.loadingSendingEmailLink))
+
+ // Get anonymousUserId if can upgrade anonymously else default to empty string.
+ // NOTE: check for empty string instead of null to validate anonymous user ID matches
+ // when sign in from email link
+ val anonymousUserId =
+ if (canUpgradeAnonymous(config, auth)) (auth.currentUser?.uid
+ ?: "") else ""
+
+ // Generate sessionId
+ val sessionId =
+ SessionUtils.generateRandomAlphaNumericString(AuthProvider.Email.SESSION_ID_LENGTH)
+
+ // Modify actionCodeSettings Url to include sessionId, anonymousUserId, force same
+ // device flag
+ val updatedActionCodeSettings =
+ provider.addSessionInfoToActionCodeSettings(
+ sessionId = sessionId,
+ anonymousUserId = anonymousUserId,
+ credentialForLinking = credentialForLinking
+ )
+
+ auth.sendSignInLinkToEmail(email, updatedActionCodeSettings).await()
+
+ // Save Email to dataStore for use in signInWithEmailLink
+ persistenceManager.saveEmail(context, email, sessionId, anonymousUserId)
+
+ updateAuthState(AuthState.EmailSignInLinkSent())
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Send sign in link to email was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+/**
+ * Signs in a user using an email link (passwordless authentication).
+ *
+ * This method completes the email link sign-in flow after the user clicks the magic link
+ * sent to their email. It validates the link, extracts session information, and either
+ * signs in the user normally or upgrades an anonymous account based on configuration.
+ *
+ * **Flow:**
+ * 1. User receives email with magic link
+ * 2. User clicks link, app opens via deep link
+ * 3. Activity extracts emailLink from Intent.data
+ * 4. This method validates and completes sign-in
+ *
+ * **Same-Device Flow:**
+ * - Email is retrieved from DataStore automatically
+ * - Session ID from link matches stored session ID
+ * - User is signed in immediately without additional input
+ *
+ * **Cross-Device Flow:**
+ * - Session ID from link doesn't match (or no local session exists)
+ * - If [email] is empty: throws [AuthException.EmailLinkPromptForEmailException]
+ * - User must provide their email address
+ * - Call this method again with user-provided email to complete sign-in
+ *
+ * @param context Android [Context] for DataStore access
+ * @param config The [AuthUIConfiguration] containing authentication settings
+ * @param provider The [AuthProvider.Email] configuration with email-link settings
+ * @param email The email address of the user. On same-device, retrieved from DataStore.
+ * On cross-device first call, pass empty string to trigger validation.
+ * On cross-device second call, pass user-provided email.
+ * @param emailLink The complete deep link URL received from the Intent.
+ * @param persistenceManager Optional [PersistenceManager] for testing. Defaults to [EmailLinkPersistenceManager.default]
+ *
+ * This URL contains:
+ * - Firebase action code (oobCode) for authentication
+ * - Session ID (ui_sid) for same-device validation
+ * - Anonymous user ID (ui_auid) if upgrading anonymous account
+ * - Force same-device flag (ui_sd) for security enforcement
+ * - Provider ID (ui_pid) if linking social provider credential
+ *
+ * Example:
+ * `https://yourapp.page.link/__/auth/action?oobCode=ABC123&continueUrl=https://yourapp.com?ui_sid=123456&ui_auid=anon-uid`
+ *
+ * @return [AuthResult] containing the signed-in user, or null if cross-device validation is required
+ *
+ * @throws AuthException.InvalidEmailLinkException if the email link is invalid or expired
+ * @throws AuthException.EmailLinkPromptForEmailException if cross-device and email is empty
+ * @throws AuthException.EmailLinkWrongDeviceException if force same-device is enabled on different device
+ * @throws AuthException.EmailLinkCrossDeviceLinkingException if trying to link provider on different device
+ * @throws AuthException.EmailLinkDifferentAnonymousUserException if anonymous user ID doesn't match
+ * @throws AuthException.EmailMismatchException if email is empty on same-device flow
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ * @throws AuthException.UnknownException for other errors
+ *
+ * **Example 1: Same-device sign-in (automatic)**
+ * ```kotlin
+ * // In your deep link handler Activity:
+ * val emailLink = intent.data.toString()
+ * val savedEmail = EmailLinkPersistenceManager.default.retrieveSessionRecord(context)?.email
+ *
+ * if (savedEmail != null) {
+ * // Same device - email and session are stored
+ * val result = firebaseAuthUI.signInWithEmailLink(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = savedEmail,
+ * emailLink = emailLink
+ * )
+ * // User is signed in automatically
+ * }
+ * ```
+ *
+ * **Example 2: Cross-device sign-in (with email prompt)**
+ * ```kotlin
+ * // First call with empty email to validate link
+ * try {
+ * firebaseAuthUI.signInWithEmailLink(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = "", // Empty email on different device
+ * emailLink = emailLink
+ * )
+ * } catch (e: AuthException.EmailLinkPromptForEmailException) {
+ * // Show dialog asking user to enter their email
+ * val userEmail = showEmailInputDialog()
+ *
+ * // Second call with user-provided email
+ * val result = firebaseAuthUI.signInWithEmailLink(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = userEmail, // User provided email
+ * emailLink = emailLink
+ * )
+ * // User is now signed in
+ * }
+ * ```
+ *
+ * @see sendSignInLinkToEmail for sending the initial email link
+ * @see EmailLinkPersistenceManager for session data management
+ */
+internal suspend fun FirebaseAuthUI.signInWithEmailLink(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.Email,
+ email: String,
+ emailLink: String,
+ persistenceManager: PersistenceManager = EmailLinkPersistenceManager.default,
+): AuthResult? {
+ try {
+ updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningInWithEmailLink))
+
+ // Validate link format
+ if (!auth.isSignInWithEmailLink(emailLink)) {
+ throw AuthException.InvalidEmailLinkException()
+ }
+
+ // Parse email link for session data
+ val parser = EmailLinkParser(emailLink)
+ val sessionIdFromLink = parser.sessionId
+ val anonymousUserIdFromLink = parser.anonymousUserId
+ val oobCode = parser.oobCode
+ val providerIdFromLink = parser.providerId
+ val isEmailLinkForceSameDeviceEnabled = parser.forceSameDeviceBit
+
+ // Retrieve stored session record from DataStore
+ val sessionRecord = persistenceManager.retrieveSessionRecord(context)
+ val storedSessionId = sessionRecord?.sessionId
+
+ // Check if this is a different device flow
+ val isDifferentDevice = provider.isDifferentDevice(
+ sessionIdFromLocal = storedSessionId,
+ sessionIdFromLink = sessionIdFromLink ?: "" // Convert null to empty string to match legacy behavior
+ )
+
+ if (isDifferentDevice) {
+ // Handle cross-device flow
+ // Session ID must always be present in the link
+ if (sessionIdFromLink.isNullOrEmpty()) {
+ val exception = AuthException.InvalidEmailLinkException()
+ updateAuthState(AuthState.Error(exception))
+ throw exception
+ }
+
+ // These scenarios require same-device flow
+ if (isEmailLinkForceSameDeviceEnabled || !anonymousUserIdFromLink.isNullOrEmpty()) {
+ val exception = AuthException.EmailLinkWrongDeviceException()
+ updateAuthState(AuthState.Error(exception))
+ throw exception
+ }
+
+ // If we have no SessionRecord/there is a session ID mismatch, this means that we were
+ // not the ones to send the link. The only way forward is to prompt the user for their
+ // email before continuing the flow. We should only do that after validating the link.
+ // However, if email is already provided (cross-device with user input), skip validation
+ if (email.isEmpty()) {
+ handleDifferentDeviceErrorFlow(oobCode, providerIdFromLink, emailLink)
+ return null
+ }
+ // Email provided - validate it and continue with normal flow
+ }
+
+ // Validate email is not empty (same-device flow only)
+ if (email.isEmpty()) {
+ throw AuthException.EmailMismatchException()
+ }
+
+ // Validate anonymous user ID matches (same-device flow)
+ if (!anonymousUserIdFromLink.isNullOrEmpty()) {
+ val currentUser = auth.currentUser
+ if (currentUser == null
+ || !currentUser.isAnonymous
+ || currentUser.uid != anonymousUserIdFromLink
+ ) {
+ val exception = AuthException.EmailLinkDifferentAnonymousUserException()
+ updateAuthState(AuthState.Error(exception))
+ throw exception
+ }
+ }
+
+ // Get credential for linking from session record
+ val storedCredentialForLink = sessionRecord?.credentialForLinking
+ val emailLinkCredential = EmailAuthProvider.getCredentialWithLink(email, emailLink)
+
+ val result = if (storedCredentialForLink == null) {
+ // Normal Flow: Just sign in with email link
+ handleEmailLinkNormalFlow(config, emailLinkCredential)
+ } else {
+ // Linking Flow: Sign in with email link, then link the social credential
+ handleEmailLinkCredentialLinkingFlow(
+ context = context,
+ config = config,
+ email = email,
+ emailLinkCredential = emailLinkCredential,
+ storedCredentialForLink = storedCredentialForLink,
+ )
+ }
+ // Clear DataStore after success
+ persistenceManager.clear(context)
+ updateAuthStateWithResult(result)
+ return result
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign in with email link was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+private suspend fun FirebaseAuthUI.handleDifferentDeviceErrorFlow(
+ oobCode: String,
+ providerIdFromLink: String?,
+ emailLink: String
+) {
+ // Validate the action code
+ try {
+ auth.checkActionCode(oobCode).await()
+ } catch (e: Exception) {
+ // Invalid action code
+ val exception = AuthException.InvalidEmailLinkException(cause = e)
+ updateAuthState(AuthState.Error(exception))
+ throw exception
+ }
+
+ // If there's a provider ID, this is a linking flow which can't be done cross-device
+ if (!providerIdFromLink.isNullOrEmpty()) {
+ val providerNameForMessage =
+ Provider.fromId(providerIdFromLink)?.providerName ?: providerIdFromLink
+ val exception = AuthException.EmailLinkCrossDeviceLinkingException(
+ providerName = providerNameForMessage,
+ emailLink = emailLink
+ )
+ updateAuthState(AuthState.Error(exception))
+ throw exception
+ }
+
+ // Link is valid but we need the user to provide their email
+ val exception = AuthException.EmailLinkPromptForEmailException(
+ cause = null,
+ emailLink = emailLink
+ )
+ updateAuthState(AuthState.Error(exception))
+ throw exception
+}
+
+private suspend fun FirebaseAuthUI.handleEmailLinkNormalFlow(
+ config: AuthUIConfiguration,
+ emailLinkCredential: AuthCredential,
+): AuthResult? {
+ return signInAndLinkWithCredential(config, emailLinkCredential)
+}
+
+private suspend fun FirebaseAuthUI.handleEmailLinkCredentialLinkingFlow(
+ context: Context,
+ config: AuthUIConfiguration,
+ email: String,
+ emailLinkCredential: AuthCredential,
+ storedCredentialForLink: AuthCredential,
+): AuthResult? {
+ return if (canUpgradeAnonymous(config, auth)) {
+ // Anonymous upgrade: Use safe link pattern with scratch auth
+ val appExplicitlyForValidation = FirebaseApp.initializeApp(
+ context,
+ auth.app.options,
+ "FUIAuthScratchApp_${System.currentTimeMillis()}"
+ )
+ val authExplicitlyForValidation = FirebaseAuth
+ .getInstance(appExplicitlyForValidation)
+
+ // Safe link: Validate that both credentials can be linked
+ authExplicitlyForValidation
+ .signInWithCredential(emailLinkCredential).await()
+ .user?.linkWithCredential(storedCredentialForLink)?.await()
+ .also { result ->
+ // If safe link succeeds, throw AccountLinkingRequiredException for UI to handle
+ val accountLinkingException = AuthException.AccountLinkingRequiredException(
+ message = "An account already exists with this email. " +
+ "Please sign in with your existing account to upgrade your anonymous account.",
+ email = email,
+ credential = storedCredentialForLink,
+ cause = null
+ )
+ updateAuthState(AuthState.Error(accountLinkingException))
+ throw accountLinkingException
+ }
+ } else {
+ // Non-upgrade: Sign in with email link, then link social credential
+ auth.signInWithCredential(emailLinkCredential).await()
+ // Link the social credential
+ .user?.linkWithCredential(storedCredentialForLink)?.await()
+ .also { result ->
+ result?.user?.let { user ->
+ // Merge profile from the linked social credential
+ mergeProfile(
+ auth,
+ user.displayName,
+ user.photoUrl
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Sends a password reset email to the specified email address.
+ *
+ * This method initiates the "forgot password" flow by sending an email to the user
+ * with a link to reset their password. The user will receive an email from Firebase
+ * containing a link that allows them to set a new password for their account.
+ *
+ * **Flow:**
+ * 1. Validate the email address exists in Firebase Auth
+ * 2. Send password reset email to the user
+ * 3. Emit [AuthState.PasswordResetLinkSent] state
+ * 4. User clicks link in email to reset password
+ * 5. User is redirected to Firebase-hosted password reset page (or custom URL if configured)
+ *
+ * **Error Handling:**
+ * - If the email doesn't exist: throws [AuthException.UserNotFoundException]
+ * - If the email is invalid: throws [AuthException.InvalidCredentialsException]
+ * - If network error occurs: throws [AuthException.NetworkException]
+ *
+ * @param email The email address to send the password reset email to
+ * @param actionCodeSettings Optional [ActionCodeSettings] to configure the password reset link.
+ * Use this to customize the continue URL, dynamic link domain, and other settings.
+ *
+ * @throws AuthException.UserNotFoundException if no account exists with this email
+ * @throws AuthException.InvalidCredentialsException if the email format is invalid
+ * @throws AuthException.NetworkException if a network error occurs
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.UnknownException for other errors
+ *
+ * **Example 1: Basic password reset**
+ * ```kotlin
+ * try {
+ * firebaseAuthUI.sendPasswordResetEmail(
+ * email = "user@example.com"
+ * )
+ * // Show success message: "Password reset email sent to $email"
+ * } catch (e: AuthException.UserNotFoundException) {
+ * // Show error: "No account exists with this email"
+ * } catch (e: AuthException.InvalidCredentialsException) {
+ * // Show error: "Invalid email address"
+ * }
+ * ```
+ *
+ * **Example 2: Custom password reset with ActionCodeSettings**
+ * ```kotlin
+ * val actionCodeSettings = ActionCodeSettings.newBuilder()
+ * .setUrl("https://myapp.com/resetPassword") // Continue URL after reset
+ * .setHandleCodeInApp(false) // Use Firebase-hosted reset page
+ * .setAndroidPackageName(
+ * "com.myapp",
+ * true, // Install if not available
+ * null // Minimum version
+ * )
+ * .build()
+ *
+ * firebaseAuthUI.sendPasswordResetEmail(
+ * email = "user@example.com",
+ * actionCodeSettings = actionCodeSettings
+ * )
+ * // User receives email with custom continue URL
+ * ```
+ *
+ * @see com.google.firebase.auth.ActionCodeSettings
+ */
+internal suspend fun FirebaseAuthUI.sendPasswordResetEmail(
+ email: String,
+ config: AuthUIConfiguration,
+ actionCodeSettings: ActionCodeSettings? = null,
+) {
+ try {
+ updateAuthState(AuthState.Loading(config.stringProvider.loadingSendingPasswordResetEmail))
+ auth.sendPasswordResetEmail(email, actionCodeSettings).await()
+ updateAuthState(AuthState.PasswordResetLinkSent())
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Send password reset email was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt
new file mode 100644
index 0000000000..d6a9622e72
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt
@@ -0,0 +1,239 @@
+/*
+ * 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.configuration.auth_provider
+
+import android.content.Context
+import android.util.Log
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import com.facebook.AccessToken
+import com.facebook.CallbackManager
+import com.facebook.FacebookCallback
+import com.facebook.FacebookException
+import com.facebook.login.LoginManager
+import com.facebook.login.LoginResult
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.util.EmailLinkPersistenceManager
+import com.firebase.ui.auth.util.SignInPreferenceManager
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+
+/**
+ * Creates a remembered launcher function for Facebook sign-in.
+ *
+ * Returns a launcher function that initiates the Facebook sign-in flow. Automatically handles
+ * profile data fetching, Firebase credential creation, anonymous account upgrades, and account
+ * linking when an email collision occurs.
+ *
+ * @param context Android context for DataStore access when saving credentials for linking
+ * @param config The [AuthUIConfiguration] containing authentication settings
+ * @param provider The [AuthProvider.Facebook] configuration with scopes and credential provider
+ * @param loginManagerProvider Provides logout operations to clear stale Facebook sessions
+ *
+ * @return A launcher function that starts the Facebook sign-in flow when invoked
+ *
+ * @see signInWithFacebook
+ */
+@Composable
+internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.Facebook,
+ loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(),
+): () -> Unit {
+ val coroutineScope = rememberCoroutineScope()
+ val callbackManager = remember { CallbackManager.Factory.create() }
+ val loginManager = LoginManager.getInstance()
+
+ val launcher = rememberLauncherForActivityResult(
+ loginManager.createLogInActivityResultContract(
+ callbackManager,
+ null
+ ),
+ onResult = {},
+ )
+
+ DisposableEffect(config) {
+ loginManager.registerCallback(
+ callbackManager,
+ object : FacebookCallback {
+ override fun onSuccess(result: LoginResult) {
+ coroutineScope.launch {
+ try {
+ signInWithFacebook(
+ context = context,
+ config = config,
+ provider = provider,
+ accessToken = result.accessToken,
+ )
+ } catch (e: AuthException) {
+ // Already an AuthException, don't re-wrap it
+ updateAuthState(AuthState.Error(e))
+ } catch (e: Exception) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ }
+ }
+ }
+
+ override fun onCancel() {
+ updateAuthState(AuthState.Idle)
+ }
+
+ override fun onError(error: FacebookException) {
+ Log.e("FacebookAuthProvider", "Error during Facebook sign in", error)
+ val authException = AuthException.from(error, context)
+ updateAuthState(
+ AuthState.Error(
+ authException
+ )
+ )
+ }
+ })
+
+ onDispose { loginManager.unregisterCallback(callbackManager) }
+ }
+
+ return {
+ updateAuthState(
+ AuthState.Loading(config.stringProvider.loadingSigningInWithFacebook)
+ )
+ try {
+ (testLoginManagerProvider ?: loginManagerProvider).logOut()
+ } catch (e: Exception) {
+ Log.w("FacebookAuthProvider", "Failed to clear Facebook session before sign in", e)
+ }
+ launcher.launch(provider.scopes)
+ }
+}
+
+/**
+ * Signs in a user with Facebook by converting a Facebook access token to a Firebase credential.
+ *
+ * Fetches user profile data from Facebook Graph API, creates a Firebase credential, and signs in
+ * or upgrades an anonymous account. Handles account collisions by saving the Facebook credential
+ * for linking and throwing [AuthException.AccountLinkingRequiredException].
+ *
+ * @param context Android context for DataStore access when saving credentials for linking
+ * @param config The [AuthUIConfiguration] containing authentication settings
+ * @param provider The [AuthProvider.Facebook] configuration
+ * @param accessToken The Facebook [AccessToken] from successful login
+ * @param credentialProvider Creates Firebase credentials from Facebook tokens
+ *
+ * @throws AuthException.AccountLinkingRequiredException if an account exists with the same email
+ * @throws AuthException.AuthCancelledException if the coroutine is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ * @throws AuthException.InvalidCredentialsException if the Facebook token is invalid
+ *
+ * @see rememberSignInWithFacebookLauncher
+ * @see signInAndLinkWithCredential
+ */
+internal suspend fun FirebaseAuthUI.signInWithFacebook(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.Facebook,
+ accessToken: AccessToken,
+ credentialProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(),
+) {
+ try {
+ updateAuthState(
+ AuthState.Loading(config.stringProvider.loadingSigningInWithFacebook)
+ )
+ val profileData = provider.fetchFacebookProfile(accessToken)
+ val credential = credentialProvider.getCredential(accessToken.token)
+ signInAndLinkWithCredential(
+ config = config,
+ credential = credential,
+ provider = provider,
+ displayName = profileData?.displayName,
+ photoUrl = profileData?.photoUrl,
+ )
+
+ // Save sign-in preference for "Continue as..." feature
+ try {
+ val user = auth.currentUser
+ val identifier = user?.email
+ if (identifier != null) {
+ SignInPreferenceManager.saveLastSignIn(
+ context = context,
+ providerId = provider.providerId,
+ identifier = identifier
+ )
+ android.util.Log.d("FacebookAuthProvider", "Sign-in preference saved for: $identifier")
+ }
+ } catch (e: Exception) {
+ // Failed to save preference - log but don't break auth flow
+ android.util.Log.w("FacebookAuthProvider", "Failed to save sign-in preference", e)
+ }
+ } catch (e: AuthException.AccountLinkingRequiredException) {
+ // Account collision occurred - save Facebook credential for linking after email link sign-in
+ // This happens when a user tries to sign in with Facebook but an email link account exists
+ EmailLinkPersistenceManager.default.saveCredentialForLinking(
+ context = context,
+ providerType = provider.providerId,
+ idToken = null,
+ accessToken = accessToken.token
+ )
+
+ // Re-throw to let UI handle the account linking flow
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: FacebookException) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign in with facebook was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+/**
+ * Signs out the current user from Facebook.
+ *
+ * Invokes Facebook's LoginManager to log out the user from their Facebook session.
+ * This method silently catches and ignores any exceptions that may occur during the
+ * logout process to ensure the sign-out flow continues even if Facebook logout fails.
+ *
+ * This is typically called as part of the overall sign-out flow when a user signs out
+ * from Firebase Authentication.
+ */
+internal fun FirebaseAuthUI.signOutFromFacebook(
+ loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(),
+) {
+ try {
+ if (Provider.fromId(getCurrentUser()?.providerId) != Provider.FACEBOOK) return
+ (testLoginManagerProvider ?: loginManagerProvider).logOut()
+ } catch (e: Exception) {
+ Log.e("FacebookAuthProvider", "Error during Facebook sign out", e)
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt
new file mode 100644
index 0000000000..89837df3e2
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt
@@ -0,0 +1,266 @@
+package com.firebase.ui.auth.configuration.auth_provider
+
+import android.content.Context
+import android.util.Log
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.credentials.CredentialManager
+import androidx.credentials.exceptions.GetCredentialException
+import androidx.credentials.exceptions.NoCredentialException
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.util.EmailLinkPersistenceManager
+import com.firebase.ui.auth.util.SignInPreferenceManager
+import com.google.android.gms.common.api.Scope
+import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+
+/**
+ * Creates a remembered callback for Google Sign-In that can be invoked from UI components.
+ *
+ * This Composable function returns a lambda that, when invoked, initiates the Google Sign-In
+ * flow using [signInWithGoogle]. The callback is stable across recompositions and automatically
+ * handles coroutine scoping and error state management.
+ *
+ * **Usage:**
+ * ```kotlin
+ * val onSignInWithGoogle = authUI.rememberGoogleSignInHandler(
+ * context = context,
+ * config = configuration,
+ * provider = googleProvider
+ * )
+ *
+ * Button(onClick = onSignInWithGoogle) {
+ * Text("Sign in with Google")
+ * }
+ * ```
+ *
+ * **Error Handling:**
+ * - Catches all exceptions and converts them to [AuthException]
+ * - Automatically updates [AuthState.Error] on failures
+ * - Logs errors for debugging purposes
+ *
+ * @param context Android context for Credential Manager
+ * @param config Authentication UI configuration
+ * @param provider Google provider configuration with server client ID and optional scopes
+ * @return A callback function that initiates Google Sign-In when invoked
+ *
+ * @see signInWithGoogle
+ * @see AuthProvider.Google
+ */
+@Composable
+internal fun FirebaseAuthUI.rememberGoogleSignInHandler(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.Google,
+): () -> Unit {
+ val coroutineScope = rememberCoroutineScope()
+ return remember(this, config) {
+ {
+ coroutineScope.launch {
+ try {
+ signInWithGoogle(context, config, provider)
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ } catch (e: Exception) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Signs in with Google using Credential Manager and optionally requests OAuth scopes.
+ *
+ * This function implements Google Sign-In using Android's Credential Manager API with
+ * comprehensive error handling.
+ *
+ * **Flow:**
+ * 1. If [AuthProvider.Google.scopes] are specified, requests OAuth authorization first
+ * 2. Attempts sign-in using Credential Manager
+ * 3. Creates Firebase credential and calls [signInAndLinkWithCredential]
+ *
+ * **Scopes Behavior:**
+ * - If [AuthProvider.Google.scopes] is not empty, requests OAuth authorization before sign-in
+ * - Basic profile, email, and ID token are always included automatically
+ * - Scopes are requested using the AuthorizationClient API
+ *
+ * **Error Handling:**
+ * - [GoogleIdTokenParsingException]: Library version mismatch
+ * - [NoCredentialException]: No Google accounts on device
+ * - [GetCredentialException]: User cancellation, configuration errors, or no credentials
+ * - Configuration errors trigger detailed developer guidance logs
+ *
+ * @param context Android context for Credential Manager
+ * @param config Authentication UI configuration
+ * @param provider Google provider configuration with optional scopes
+ * @param authorizationProvider Provider for OAuth scopes authorization (for testing)
+ * @param credentialManagerProvider Provider for Credential Manager flow (for testing)
+ *
+ * @throws AuthException.InvalidCredentialsException if token parsing fails
+ * @throws AuthException.AuthCancelledException if user cancels or no accounts found
+ * @throws AuthException if sign-in or linking fails
+ *
+ * @see AuthProvider.Google
+ * @see signInAndLinkWithCredential
+ */
+internal suspend fun FirebaseAuthUI.signInWithGoogle(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.Google,
+ authorizationProvider: AuthProvider.Google.AuthorizationProvider = AuthProvider.Google.DefaultAuthorizationProvider(),
+ credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider = AuthProvider.Google.DefaultCredentialManagerProvider(),
+) {
+ var idTokenFromResult: String? = null
+ try {
+ updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningInWithGoogle))
+
+ // Request OAuth scopes if specified (before sign-in)
+ if (provider.scopes.isNotEmpty()) {
+ try {
+ val requestedScopes = provider.scopes.map { Scope(it) }
+ authorizationProvider.authorize(context, requestedScopes)
+ } catch (e: Exception) {
+ // Continue with sign-in even if scope authorization fails
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ }
+ }
+
+ // Try with configured filterByAuthorizedAccounts setting
+ // If default (true), fallback to false if no authorized accounts found
+ // See: https://developer.android.com/identity/sign-in/credential-manager-siwg#siwg-button
+ val result = if (provider.filterByAuthorizedAccounts) {
+ // Default behavior: Try authorized accounts first, fallback to all accounts
+ try {
+ (testCredentialManagerProvider ?: credentialManagerProvider).getGoogleCredential(
+ context = context,
+ credentialManager = CredentialManager.create(context),
+ serverClientId = provider.serverClientId!!,
+ filterByAuthorizedAccounts = true,
+ autoSelectEnabled = provider.autoSelectEnabled
+ )
+ } catch (e: NoCredentialException) {
+ // No authorized accounts found, try again with all accounts for sign-up flow
+ Log.d("GoogleAuthProvider", "No authorized accounts found, showing all Google accounts for sign-up")
+ try {
+ (testCredentialManagerProvider ?: credentialManagerProvider).getGoogleCredential(
+ context = context,
+ credentialManager = CredentialManager.create(context),
+ serverClientId = provider.serverClientId!!,
+ filterByAuthorizedAccounts = false,
+ autoSelectEnabled = provider.autoSelectEnabled
+ )
+ } catch (fallbackException: NoCredentialException) {
+ // No Google accounts available on device at all
+ throw AuthException.UnknownException(
+ message = "No Google accounts available.\n\nPlease add a Google account to your device and try again.",
+ cause = fallbackException
+ )
+ }
+ }
+ } else {
+ // Developer explicitly wants to show all accounts (no fallback needed)
+ (testCredentialManagerProvider ?: credentialManagerProvider).getGoogleCredential(
+ context = context,
+ credentialManager = CredentialManager.create(context),
+ serverClientId = provider.serverClientId!!,
+ filterByAuthorizedAccounts = false,
+ autoSelectEnabled = provider.autoSelectEnabled
+ )
+ }
+ idTokenFromResult = result.idToken
+
+ signInAndLinkWithCredential(
+ config = config,
+ credential = result.credential,
+ provider = provider,
+ displayName = result.displayName,
+ photoUrl = result.photoUrl,
+ )
+
+ // Save sign-in preference for "Continue as..." feature
+ try {
+ val user = auth.currentUser
+ val identifier = user?.email
+ if (identifier != null) {
+ SignInPreferenceManager.saveLastSignIn(
+ context = context,
+ providerId = provider.providerId,
+ identifier = identifier
+ )
+ Log.d("GoogleAuthProvider", "Sign-in preference saved for: $identifier")
+ }
+ } catch (e: Exception) {
+ // Failed to save preference - log but don't break auth flow
+ android.util.Log.w("GoogleAuthProvider", "Failed to save sign-in preference", e)
+ }
+ } catch (e: AuthException.AccountLinkingRequiredException) {
+ // Account collision occurred - save Facebook credential for linking after email link sign-in
+ // This happens when a user tries to sign in with Facebook but an email link account exists
+ EmailLinkPersistenceManager.default.saveCredentialForLinking(
+ context = context,
+ providerType = provider.providerId,
+ idToken = idTokenFromResult,
+ accessToken = null
+ )
+
+ // Re-throw to let UI handle the account linking flow
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign in with google was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+
+ } catch (e: Exception) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+/**
+ * Signs out from Google and clears credential state.
+ *
+ * This function clears the cached Google credentials, ensuring that the account picker
+ * will be shown on the next sign-in attempt instead of automatically signing in with
+ * the previously used account.
+ *
+ * **When to call:**
+ * - After user explicitly signs out
+ * - Before allowing user to select a different Google account
+ * - When switching between accounts
+ *
+ * **Note:** This does not sign out from Firebase Auth itself. Call [FirebaseAuthUI.signOut]
+ * separately if you need to sign out from Firebase.
+ *
+ * @param context Android context for Credential Manager
+ */
+internal suspend fun FirebaseAuthUI.signOutFromGoogle(
+ context: Context,
+ credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider = AuthProvider.Google.DefaultCredentialManagerProvider(),
+) {
+ try {
+ if (Provider.fromId(getCurrentUser()?.providerId) != Provider.GOOGLE) return
+ (testCredentialManagerProvider ?: credentialManagerProvider).clearCredentialState(
+ context = context,
+ credentialManager = CredentialManager.create(context)
+ )
+ } catch (e: Exception) {
+ Log.e("GoogleAuthProvider", "Error during Google sign out", e)
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt
new file mode 100644
index 0000000000..add6bb2355
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt
@@ -0,0 +1,242 @@
+package com.firebase.ui.auth.configuration.auth_provider
+
+import android.app.Activity
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous
+import com.firebase.ui.auth.util.SignInPreferenceManager
+import com.google.firebase.auth.FirebaseAuthUserCollisionException
+import com.google.firebase.auth.OAuthCredential
+import com.google.firebase.auth.OAuthProvider
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+/**
+ * Creates a Composable handler for OAuth provider sign-in.
+ *
+ * This function creates a remember-scoped sign-in handler that can be invoked
+ * from button clicks or other UI events. It automatically handles:
+ * - Activity retrieval from LocalActivity
+ * - Coroutine scope management
+ * - Error handling and state updates
+ *
+ * **Usage:**
+ * ```kotlin
+ * val onSignInWithGitHub = authUI.rememberOAuthSignInHandler(
+ * config = configuration,
+ * provider = githubProvider
+ * )
+ *
+ * Button(onClick = onSignInWithGitHub) {
+ * Text("Sign in with GitHub")
+ * }
+ * ```
+ *
+ * @param config Authentication UI configuration
+ * @param provider OAuth provider configuration
+ *
+ * @return Lambda that triggers OAuth sign-in when invoked
+ *
+ * @throws IllegalStateException if LocalActivity.current is null
+ *
+ * @see signInWithProvider
+ */
+@Composable
+internal fun FirebaseAuthUI.rememberOAuthSignInHandler(
+ context: Context,
+ activity: Activity?,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.OAuth,
+): () -> Unit {
+ val coroutineScope = rememberCoroutineScope()
+ activity ?: throw IllegalStateException(
+ "OAuth sign-in requires an Activity. " +
+ "Ensure FirebaseAuthScreen is used within an Activity."
+ )
+
+ return remember(this, provider.providerId, config) {
+ {
+ coroutineScope.launch {
+ try {
+ signInWithProvider(
+ context = context,
+ config = config,
+ activity = activity,
+ provider = provider
+ )
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ } catch (e: Exception) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Signs in with an OAuth provider (GitHub, Microsoft, Yahoo, Apple, Twitter).
+ *
+ * This function implements OAuth provider authentication using Firebase's native OAuthProvider.
+ * It handles both normal sign-in flow and anonymous user upgrade flow.
+ *
+ * **Supported Providers:**
+ * - GitHub (github.com)
+ * - Microsoft (microsoft.com)
+ * - Yahoo (yahoo.com)
+ * - Apple (apple.com)
+ * - Twitter (twitter.com)
+ *
+ * **Flow:**
+ * 1. Checks for pending auth results (e.g., from app restart during OAuth flow)
+ * 2. If anonymous upgrade is enabled and user is anonymous, links credential to anonymous account
+ * 3. Otherwise, performs normal sign-in
+ * 4. Updates auth state to Idle on success
+ *
+ * **Anonymous Upgrade:**
+ * If [AuthUIConfiguration.isAnonymousUpgradeEnabled] is true and a user is currently signed in
+ * anonymously, this will attempt to link the OAuth credential to the anonymous account instead
+ * of creating a new account.
+ *
+ * **Error Handling:**
+ * - [AuthException.AuthCancelledException]: User cancelled OAuth flow
+ * - [AuthException.AccountLinkingRequiredException]: Account collision (email already exists)
+ * - [AuthException]: Other authentication errors
+ *
+ * @param config Authentication UI configuration
+ * @param activity Activity for OAuth flow
+ * @param provider OAuth provider configuration with scopes and custom parameters
+ *
+ * @throws AuthException.AuthCancelledException if user cancels
+ * @throws AuthException.AccountLinkingRequiredException if account collision occurs
+ * @throws AuthException if OAuth flow or sign-in fails
+ *
+ * @see AuthProvider.OAuth
+ * @see signInAndLinkWithCredential
+ */
+internal suspend fun FirebaseAuthUI.signInWithProvider(
+ context: Context,
+ config: AuthUIConfiguration,
+ activity: Activity,
+ provider: AuthProvider.OAuth,
+) {
+ try {
+ updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningInWithProvider(provider.providerName)))
+
+ // Build OAuth provider with scopes and custom parameters
+ val oauthProvider = OAuthProvider
+ .newBuilder(provider.providerId)
+ .apply {
+ // Add scopes if provided
+ if (provider.scopes.isNotEmpty()) {
+ scopes = provider.scopes
+ }
+ // Add custom parameters if provided
+ provider.customParameters.forEach { (key, value) ->
+ addCustomParameter(key, value)
+ }
+ }
+ .build()
+
+ // Check for pending auth result (e.g., app was killed during OAuth flow)
+ val pendingResult = auth.pendingAuthResult
+ if (pendingResult != null) {
+ val authResult = pendingResult.await()
+ val credential = authResult.credential as? OAuthCredential
+
+ if (credential != null) {
+ // Complete the pending sign-in/link flow
+ signInAndLinkWithCredential(
+ config = config,
+ credential = credential,
+ provider = provider,
+ displayName = authResult.user?.displayName,
+ photoUrl = authResult.user?.photoUrl,
+ )
+ }
+ return
+ }
+
+ // Determine if we should upgrade anonymous user, reauthenticate, or do normal sign-in
+ val authResult = when {
+ canUpgradeAnonymous(config, auth) ->
+ auth.currentUser?.startActivityForLinkWithProvider(activity, oauthProvider)?.await()
+ config.isReauthenticationMode -> {
+ val currentUser = auth.currentUser
+ ?: throw AuthException.UserNotFoundException(message = "No user is currently signed in for reauthentication")
+ currentUser.startActivityForReauthenticateWithProvider(activity, oauthProvider).await()
+ }
+ else ->
+ auth.startActivityForSignInWithProvider(activity, oauthProvider).await()
+ }
+
+ // Extract OAuth credential and complete sign-in
+ val credential = authResult?.credential as? OAuthCredential
+ if (credential != null) {
+ // The user is already signed in via startActivityForSignInWithProvider/startActivityForLinkWithProvider
+
+ // Save sign-in preference for "Continue as..." feature
+ try {
+ val user = auth.currentUser
+ val identifier = user?.email
+ if (identifier != null) {
+ SignInPreferenceManager.saveLastSignIn(
+ context = context,
+ providerId = provider.providerId,
+ identifier = identifier
+ )
+ android.util.Log.d("OAuthProvider", "Sign-in preference saved for: $identifier (${provider.providerId})")
+ }
+ } catch (e: Exception) {
+ // Failed to save preference - log but don't break auth flow
+ android.util.Log.w("OAuthProvider", "Failed to save sign-in preference", e)
+ }
+
+ updateAuthStateWithResult(authResult)
+ } else {
+ throw AuthException.UnknownException(
+ message = "OAuth sign-in did not return a valid credential"
+ )
+ }
+
+ } catch (e: FirebaseAuthUserCollisionException) {
+ // Account collision: account already exists with different sign-in method
+ val email = e.email
+ val credential = e.updatedCredential
+
+ val accountLinkingException = AuthException.AccountLinkingRequiredException(
+ message = "An account already exists with the email ${email ?: ""}. " +
+ "Please sign in with your existing account to link " +
+ "your ${provider.providerName} account.",
+ email = email,
+ credential = credential,
+ cause = e
+ )
+ updateAuthState(AuthState.Error(accountLinkingException))
+ throw accountLinkingException
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Signing in with ${provider.providerName} was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+
+ } catch (e: Exception) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt
new file mode 100644
index 0000000000..24487dd583
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt
@@ -0,0 +1,342 @@
+package com.firebase.ui.auth.configuration.auth_provider
+
+import android.app.Activity
+import android.content.Context
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.util.SignInPreferenceManager
+import com.google.firebase.auth.AuthResult
+import com.google.firebase.auth.MultiFactorSession
+import com.google.firebase.auth.PhoneAuthCredential
+import com.google.firebase.auth.PhoneAuthProvider
+import kotlinx.coroutines.CancellationException
+
+/**
+ * Initiates phone number verification with Firebase Phone Authentication.
+ *
+ * This method starts the phone verification flow, which can complete in two ways:
+ * 1. **Instant verification** (auto): Firebase SDK automatically retrieves and verifies
+ * the SMS code without user interaction. This happens when Google Play services can
+ * detect the incoming SMS automatically.
+ * 2. **Manual verification**: SMS code is sent to the user's device, and the user must
+ * manually enter the code via [submitVerificationCode].
+ *
+ * **Flow:**
+ * - Call this method with the phone number
+ * - Firebase SDK attempts instant verification
+ * - If instant verification succeeds:
+ * - Emits [AuthState.SMSAutoVerified] with the credential
+ * - UI should observe this state and call [signInWithPhoneAuthCredential]
+ * - If instant verification fails:
+ * - Emits [AuthState.PhoneNumberVerificationRequired] with verification details
+ * - UI should show code entry screen
+ * - User enters code → call [submitVerificationCode]
+ *
+ * **Resending codes:**
+ * To resend a verification code, call this method again with:
+ * - `forceResendingToken` = the token from [AuthState.PhoneNumberVerificationRequired]
+ *
+ * **Example: Basic phone verification**
+ * ```kotlin
+ * // Step 1: Start verification
+ * firebaseAuthUI.verifyPhoneNumber(
+ * provider = phoneProvider,
+ * phoneNumber = "+1234567890",
+ * )
+ *
+ * // Step 2: Observe AuthState
+ * authUI.authStateFlow().collect { state ->
+ * when (state) {
+ * is AuthState.SMSAutoVerified -> {
+ * // Instant verification succeeded!
+ * showToast("Phone number verified automatically")
+ * // Now sign in with the credential
+ * firebaseAuthUI.signInWithPhoneAuthCredential(
+ * config = authUIConfig,
+ * credential = state.credential
+ * )
+ * }
+ * is AuthState.PhoneNumberVerificationRequired -> {
+ * // Show code entry screen
+ * showCodeEntryScreen(
+ * verificationId = state.verificationId,
+ * forceResendingToken = state.forceResendingToken
+ * )
+ * }
+ * is AuthState.Error -> {
+ * // Handle error
+ * showError(state.exception.message)
+ * }
+ * }
+ * }
+ *
+ * // Step 3: When user enters code
+ * firebaseAuthUI.submitVerificationCode(
+ * config = authUIConfig,
+ * verificationId = verificationId,
+ * code = userEnteredCode
+ * )
+ * ```
+ *
+ * **Example: Resending verification code**
+ * ```kotlin
+ * // User didn't receive the code, wants to resend
+ * firebaseAuthUI.verifyPhoneNumber(
+ * provider = phoneProvider,
+ * phoneNumber = "+1234567890",
+ * forceResendingToken = savedToken // From PhoneNumberVerificationRequired state
+ * )
+ * ```
+ *
+ * @param provider The [AuthProvider.Phone] configuration containing timeout and other settings
+ * @param phoneNumber The phone number to verify in E.164 format (e.g., "+1234567890")
+ * @param multiFactorSession Optional [MultiFactorSession] for MFA enrollment. When provided,
+ * this initiates phone verification for enrolling a second factor rather than primary sign-in.
+ * Obtain this from `FirebaseUser.multiFactor.session` when enrolling MFA.
+ * @param forceResendingToken Optional token from previous verification for resending SMS
+ *
+ * @throws AuthException.InvalidCredentialsException if the phone number is invalid
+ * @throws AuthException.TooManyRequestsException if SMS quota is exceeded
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ */
+internal suspend fun FirebaseAuthUI.verifyPhoneNumber(
+ provider: AuthProvider.Phone,
+ activity: Activity?,
+ phoneNumber: String,
+ config: AuthUIConfiguration,
+ multiFactorSession: MultiFactorSession? = null,
+ forceResendingToken: PhoneAuthProvider.ForceResendingToken? = null,
+ verifier: AuthProvider.Phone.Verifier = AuthProvider.Phone.DefaultVerifier(),
+) {
+ try {
+ updateAuthState(AuthState.Loading(config.stringProvider.loadingVerifyingPhoneNumber))
+ val result = provider.verifyPhoneNumberAwait(
+ auth = auth,
+ activity = activity,
+ phoneNumber = phoneNumber,
+ multiFactorSession = multiFactorSession,
+ forceResendingToken = forceResendingToken,
+ verifier = verifier
+ )
+ when (result) {
+ is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> {
+ updateAuthState(AuthState.SMSAutoVerified(credential = result.credential))
+ }
+
+ is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> {
+ updateAuthState(
+ AuthState.PhoneNumberVerificationRequired(
+ verificationId = result.verificationId,
+ forceResendingToken = result.token,
+ )
+ )
+ }
+ }
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Verify phone number was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+/**
+ * Submits a verification code entered by the user and signs them in.
+ *
+ * This method is called after [verifyPhoneNumber] emits [AuthState.PhoneNumberVerificationRequired],
+ * indicating that manual code entry is needed. It creates a [PhoneAuthCredential] from the
+ * verification ID and user-entered code, then signs in the user by calling
+ * [signInWithPhoneAuthCredential].
+ *
+ * **Flow:**
+ * 1. User receives SMS with 6-digit code
+ * 2. User enters code in UI
+ * 3. UI calls this method with the code
+ * 4. Credential is created and used to sign in
+ * 5. Returns [AuthResult] with signed-in user
+ *
+ * This method handles both normal sign-in and anonymous account upgrade scenarios based
+ * on the [AuthUIConfiguration] settings.
+ *
+ * **Example: Manual code entry flow*
+ * ```
+ * val userEnteredCode = "123456"
+ * try {
+ * val result = firebaseAuthUI.submitVerificationCode(
+ * config = authUIConfig,
+ * verificationId = savedVerificationId!!,
+ * code = userEnteredCode
+ * )
+ * // User is now signed in
+ * } catch (e: AuthException.InvalidCredentialsException) {
+ * // Wrong code entered
+ * showError("Invalid verification code")
+ * } catch (e: AuthException.SessionExpiredException) {
+ * // Code expired
+ * showError("Verification code expired. Please request a new one.")
+ * }
+ * ```
+ *
+ * @param config The [AuthUIConfiguration] containing authentication settings
+ * @param verificationId The verification ID from [AuthState.PhoneNumberVerificationRequired]
+ * @param code The 6-digit verification code entered by the user
+ *
+ * @return [AuthResult] containing the signed-in user
+ *
+ * @throws AuthException.InvalidCredentialsException if the code is incorrect or expired
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ */
+internal suspend fun FirebaseAuthUI.submitVerificationCode(
+ context: Context,
+ config: AuthUIConfiguration,
+ verificationId: String,
+ code: String,
+ credentialProvider: AuthProvider.Phone.CredentialProvider = AuthProvider.Phone.DefaultCredentialProvider(),
+): AuthResult? {
+ try {
+ updateAuthState(AuthState.Loading(config.stringProvider.loadingSubmittingVerificationCode))
+ val credential = credentialProvider.getCredential(verificationId, code)
+ return signInWithPhoneAuthCredential(
+ context = context,
+ config = config,
+ credential = credential
+ )
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Submit verification code was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+/**
+ * Signs in a user with a phone authentication credential.
+ *
+ * This method is the final step in the phone authentication flow. It takes a
+ * [PhoneAuthCredential] (either from instant verification or manual code entry) and
+ * signs in the user. The method handles both normal sign-in and anonymous account
+ * upgrade scenarios by delegating to [signInAndLinkWithCredential].
+ *
+ * **When to call this:**
+ * - After [verifyPhoneNumber] emits [AuthState.SMSAutoVerified] (instant verification)
+ * - Called internally by [submitVerificationCode] (manual verification)
+ *
+ * The method automatically handles:
+ * - Normal sign-in for new or returning users
+ * - Linking phone credential to anonymous accounts (if enabled in config)
+ * - Throwing [AuthException.AccountLinkingRequiredException] if phone number already exists on another account
+ *
+ * **Example: Sign in after instant verification**
+ * ```kotlin
+ * authUI.authStateFlow().collect { state ->
+ * when (state) {
+ * is AuthState.SMSAutoVerified -> {
+ * // Phone was instantly verified
+ * showToast("Phone verified automatically!")
+ *
+ * // Now sign in with the credential
+ * val result = firebaseAuthUI.signInWithPhoneAuthCredential(
+ * config = authUIConfig,
+ * credential = state.credential
+ * )
+ * // User is now signed in
+ * }
+ * }
+ * }
+ * ```
+ *
+ * **Example: Anonymous upgrade with collision**
+ * ```kotlin
+ * // User is currently anonymous
+ * try {
+ * firebaseAuthUI.signInWithPhoneAuthCredential(
+ * config = authUIConfig,
+ * credential = phoneCredential
+ * )
+ * } catch (e: AuthException.AccountLinkingRequiredException) {
+ * // Phone number already exists on another account
+ * // Account linking required - show account linking screen
+ * // User needs to sign in with existing account to link
+ * }
+ * ```
+ *
+ * @param config The [AuthUIConfiguration] containing authentication settings
+ * @param credential The [PhoneAuthCredential] to use for signing in
+ *
+ * @return [AuthResult] containing the signed-in user, or null if anonymous upgrade collision occurred
+ *
+ * @throws AuthException.InvalidCredentialsException if the credential is invalid or expired
+ * @throws AuthException.EmailAlreadyInUseException if phone number is linked to another account
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ */
+internal suspend fun FirebaseAuthUI.signInWithPhoneAuthCredential(
+ context: Context,
+ config: AuthUIConfiguration,
+ credential: PhoneAuthCredential,
+): AuthResult? {
+ try {
+ updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningInWithPhone))
+ val result = signInAndLinkWithCredential(
+ config = config,
+ credential = credential,
+ )
+
+ // Save sign-in preference for "Continue as..." feature
+ if (result != null) {
+ try {
+ val user = auth.currentUser
+ val identifier = user?.phoneNumber
+ if (identifier != null) {
+ SignInPreferenceManager.saveLastSignIn(
+ context = context,
+ providerId = "phone",
+ identifier = identifier
+ )
+ android.util.Log.d("PhoneAuthProvider", "Sign-in preference saved for: $identifier")
+ }
+ } catch (e: Exception) {
+ // Failed to save preference - log but don't break auth flow
+ android.util.Log.w("PhoneAuthProvider", "Failed to save sign-in preference", e)
+ }
+ }
+
+ return result
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign in with phone was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt
new file mode 100644
index 0000000000..bc2ca3b43f
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt
@@ -0,0 +1,639 @@
+/*
+ * 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.configuration.string_provider
+
+import androidx.compose.runtime.staticCompositionLocalOf
+
+/**
+ * CompositionLocal for providing [AuthUIStringProvider] throughout the Compose tree.
+ *
+ * This allows accessing localized strings without manually passing the provider through
+ * every composable. The provider is set at the top level in FirebaseAuthScreen and can
+ * be accessed anywhere in the auth UI using `LocalAuthUIStringProvider.current`.
+ *
+ * **Usage:**
+ * ```kotlin
+ * @Composable
+ * fun MyAuthComponent() {
+ * val stringProvider = LocalAuthUIStringProvider.current
+ * Text(stringProvider.signInWithGoogle)
+ * }
+ * ```
+ *
+ * @since 10.0.0
+ */
+val LocalAuthUIStringProvider = staticCompositionLocalOf {
+ error("No AuthUIStringProvider provided. Ensure FirebaseAuthScreen is used as the root composable.")
+}
+
+/**
+ * An interface for providing localized string resources. This interface defines methods for all
+ * user-facing strings, such as initializing(), signInWithGoogle(), invalidEmailAddress(),
+ * passwordsDoNotMatch(), etc., allowing for complete localization of the UI.
+ *
+ * @sample AuthUIStringProviderSample
+ */
+interface AuthUIStringProvider {
+ /** Loading text displayed during initialization or processing states */
+ val initializing: String
+
+ /** Progress dialog message shown while signing in anonymously */
+ val loadingSigningInAnonymously: String
+
+ /** Progress dialog message shown while signing in with Google */
+ val loadingSigningInWithGoogle: String
+
+ /** Progress dialog message shown while signing in with Facebook */
+ val loadingSigningInWithFacebook: String
+
+ /** Progress dialog message shown while signing in with a named OAuth provider. [providerName] is the display name of the provider. */
+ fun loadingSigningInWithProvider(providerName: String): String
+
+ /** Progress dialog message shown while verifying a phone number */
+ val loadingVerifyingPhoneNumber: String
+
+ /** Progress dialog message shown while submitting an SMS verification code */
+ val loadingSubmittingVerificationCode: String
+
+ /** Progress dialog message shown while completing phone number sign-in */
+ val loadingSigningInWithPhone: String
+
+ /** Progress dialog message shown while creating a new user account */
+ val loadingCreatingUser: String
+
+ /** Progress dialog message shown while signing in with email and password */
+ val loadingSigningIn: String
+
+ /** Progress dialog message shown while linking a credential to the current account */
+ val loadingLinkingCredential: String
+
+ /** Progress dialog message shown while sending the sign-in email link */
+ val loadingSendingEmailLink: String
+
+ /** Progress dialog message shown while completing an email link sign-in */
+ val loadingSigningInWithEmailLink: String
+
+ /** Progress dialog message shown while sending the password reset email */
+ val loadingSendingPasswordResetEmail: String
+
+ /** Progress dialog message shown while signing the user out */
+ val loadingSigningOut: String
+
+ /** Progress dialog message shown while deleting the user's account */
+ val loadingDeletingAccount: String
+
+ /** Text for Google Provider */
+ val googleProvider: String
+
+ /** Text for Facebook Provider */
+ val facebookProvider: String
+
+ /** Text for Twitter Provider */
+ val twitterProvider: String
+
+ /** Text for Github Provider */
+ val githubProvider: String
+
+ /** Text for Phone Provider */
+ val phoneProvider: String
+
+ /** Text for Email Provider */
+ val emailProvider: String
+
+ /** Button text for Google sign-in option */
+ val signInWithGoogle: String
+
+ /** Button text for Facebook sign-in option */
+ val signInWithFacebook: String
+
+ /** Button text for Twitter sign-in option */
+ val signInWithTwitter: String
+
+ /** Button text for Github sign-in option */
+ val signInWithGithub: String
+
+ /** Button text for Email sign-in option */
+ val signInWithEmail: String
+
+ /** Button text for Phone sign-in option */
+ val signInWithPhone: String
+
+ /** Button text for Anonymous sign-in option */
+ val signInAnonymously: String
+
+ /** Button text for Apple sign-in option */
+ val signInWithApple: String
+
+ /** Button text for Microsoft sign-in option */
+ val signInWithMicrosoft: String
+
+ /** Button text for Yahoo sign-in option */
+ val signInWithYahoo: String
+
+ /** Button text for Google continue option */
+ val continueWithGoogle: String
+
+ /** Button text for Facebook continue option */
+ val continueWithFacebook: String
+
+ /** Button text for Twitter continue option */
+ val continueWithTwitter: String
+
+ /** Button text for Github continue option */
+ val continueWithGithub: String
+
+ /** Button text for Email continue option */
+ val continueWithEmail: String
+
+ /** Button text for Phone continue option */
+ val continueWithPhone: String
+
+ /** Button text for Apple continue option */
+ val continueWithApple: String
+
+ /** Button text for Microsoft continue option */
+ val continueWithMicrosoft: String
+
+ /** Button text for Yahoo continue option */
+ val continueWithYahoo: String
+
+ /** Error message when email address field is empty */
+ val missingEmailAddress: String
+
+ /** Error message when email address format is invalid */
+ val invalidEmailAddress: String
+
+ /** Generic error message for incorrect password during sign-in */
+ val invalidPassword: String
+
+ /** Error message when password confirmation doesn't match the original password */
+ val passwordsDoNotMatch: String
+
+ /** Error message when password doesn't meet minimum length requirement. Should support string formatting with minimum length parameter. */
+ fun passwordTooShort(minimumLength: Int): String
+
+ /** Error message when password is missing at least one uppercase letter (A-Z) */
+ val passwordMissingUppercase: String
+
+ /** Error message when password is missing at least one lowercase letter (a-z) */
+ val passwordMissingLowercase: String
+
+ /** Error message when password is missing at least one numeric digit (0-9) */
+ val passwordMissingDigit: String
+
+ /** Error message when password is missing at least one special character */
+ val passwordMissingSpecialCharacter: String
+
+ // Email Authentication Strings
+ /** Title for email signup form */
+ val signupPageTitle: String
+
+ /** Hint for email input field */
+ val emailHint: String
+
+ /** Hint for password input field */
+ val passwordHint: String
+
+ /** Hint for confirm password input field */
+ val confirmPasswordHint: String
+
+ /** Hint for new password input field */
+ val newPasswordHint: String
+
+ /** Hint for name input field */
+ val nameHint: String
+
+ /** Button text to save form */
+ val buttonTextSave: String
+
+ /** Welcome back header for email users */
+ val welcomeBackEmailHeader: String
+
+ /** Trouble signing in link text */
+ val troubleSigningIn: String
+
+ /** Title for recover password page */
+ val recoverPasswordPageTitle: String
+
+ /** Button text for reset password */
+ val sendButtonText: String
+
+ /** Title for recover password link sent dialog */
+ val recoverPasswordLinkSentDialogTitle: String
+
+ /** Body for recover password link sent dialog */
+ fun recoverPasswordLinkSentDialogBody(email: String): String
+
+ /** Title for email sign in link sent dialog */
+ val emailSignInLinkSentDialogTitle: String
+
+ /** Body for email sign in link sent dialog */
+ fun emailSignInLinkSentDialogBody(email: String): String
+
+ /** Divider text for alternate sign-in options */
+ val orContinueWith: String
+
+ /** Button text to sign in with email link */
+ val signInWithEmailLink: String
+
+ /** Button text to sign in with password */
+ val signInWithPassword: String
+
+ /** Title shown when prompting the user to confirm their email for cross-device flows */
+ val emailLinkPromptForEmailTitle: String
+
+ /** Message shown when prompting the user to confirm their email for cross-device flows */
+ val emailLinkPromptForEmailMessage: String
+
+ /** Title shown when email link must be opened on same device */
+ val emailLinkWrongDeviceTitle: String
+
+ /** Message shown when email link must be opened on same device */
+ val emailLinkWrongDeviceMessage: String
+
+ /** Title shown when the anonymous session differs */
+ val emailLinkDifferentAnonymousUserTitle: String
+
+ /** Message shown when the anonymous session differs */
+ val emailLinkDifferentAnonymousUserMessage: String
+
+ /** Message shown for cross-device linking flows with the provider name */
+ fun emailLinkCrossDeviceLinkingMessage(providerName: String): String
+
+ /** Title shown when email link is invalid */
+ val emailLinkInvalidLinkTitle: String
+
+ /** Message shown when email link is invalid */
+ val emailLinkInvalidLinkMessage: String
+
+ /** Message shown when email mismatch occurs */
+ val emailMismatchMessage: String
+
+ // Phone Authentication Strings
+ /** Phone number entry form title */
+ val verifyPhoneNumberTitle: String
+
+ /** Hint for phone input field */
+ val phoneHint: String
+
+ /** Hint for country input field */
+ val countryHint: String
+
+ /** Invalid phone number error */
+ val invalidPhoneNumber: String
+
+ /** Missing phone number error */
+ val missingPhoneNumber: String
+
+ /** Phone verification code entry form title */
+ val enterConfirmationCode: String
+
+ /** Button text to verify phone number */
+ val verifyPhoneNumber: String
+
+ /** Resend code countdown timer */
+ val resendCodeIn: String
+
+ /** Resend code link text */
+ val resendCode: String
+
+ /** Resend code with timer */
+ fun resendCodeTimer(timeFormatted: String): String
+
+ /** Verifying progress text */
+ val verifying: String
+
+ /** Wrong verification code error */
+ val incorrectCodeDialogBody: String
+
+ /** SMS terms of service warning */
+ val smsTermsOfService: String
+
+ /** Enter phone number title */
+ val enterPhoneNumberTitle: String
+
+ /** Phone number hint */
+ val phoneNumberHint: String
+
+ /** Send verification code button text */
+ val sendVerificationCode: String
+
+ /** Enter verification code title with phone number */
+ fun enterVerificationCodeTitle(phoneNumber: String): String
+
+ /** Verification code hint */
+ val verificationCodeHint: String
+
+ /** Change phone number link text */
+ val changePhoneNumber: String
+
+ /** Missing verification code error */
+ val missingVerificationCode: String
+
+ /** Invalid verification code error */
+ val invalidVerificationCode: String
+
+ /** Select country modal sheet title */
+ val countrySelectorModalTitle: String
+
+ /** Select country modal sheet input field hint */
+ val searchCountriesHint: String
+
+ // Provider Picker Strings
+ /** Common button text for sign in */
+ val signInDefault: String
+
+ /** Common button text for continue */
+ val continueText: String
+
+ /** Common button text for next */
+ val nextDefault: String
+
+ // General Error Messages
+ /** General unknown error message */
+ val errorUnknown: String
+
+ /** Required field error */
+ val requiredField: String
+
+ /** Loading progress text */
+ val progressDialogLoading: String
+
+ /** Label shown when the user is signed in. String should contain a single %s placeholder. */
+ fun signedInAs(userIdentifier: String): String
+
+ /** Action text for managing multi-factor authentication settings. */
+ val manageMfaAction: String
+
+ /** Action text for signing out. */
+ val signOutAction: String
+
+ /** Instruction shown when the user must verify their email. Accepts the email value. */
+ fun verifyEmailInstruction(email: String): String
+
+ /** Action text for resending the verification email. */
+ val resendVerificationEmailAction: String
+
+ /** Action text once the user has verified their email. */
+ val verifiedEmailAction: String
+
+ /** Message shown when profile completion is required. */
+ val profileCompletionMessage: String
+
+ /** Message listing missing profile fields. Accepts a comma-separated list. */
+ fun profileMissingFieldsMessage(fields: String): String
+
+ /** Action text for skipping an optional step. */
+ val skipAction: String
+
+ /** Action text for removing an item (for example, an MFA factor). */
+ val removeAction: String
+
+ /** Action text for navigating back. */
+ val backAction: String
+
+ /** Action text for confirming verification. */
+ val verifyAction: String
+
+ /** Action text for choosing a different factor during MFA challenge. */
+ val useDifferentMethodAction: String
+
+ /** Action text for confirming recovery codes have been saved. */
+ val recoveryCodesSavedAction: String
+
+ /** Label for secret key text displayed during TOTP setup. */
+ val secretKeyLabel: String
+
+ /** Label for verification code input fields. */
+ val verificationCodeLabel: String
+
+ /** Generic identity verified confirmation message. */
+ val identityVerifiedMessage: String
+
+ /** Title for the manage MFA screen. */
+ val mfaManageFactorsTitle: String
+
+ /** Helper description for the manage MFA screen. */
+ val mfaManageFactorsDescription: String
+
+ /** Header for the list of currently enrolled MFA factors. */
+ val mfaActiveMethodsTitle: String
+
+ /** Header for the list of available MFA factors to enroll. */
+ val mfaAddNewMethodTitle: String
+
+ /** Message shown when all factors are already enrolled. */
+ val mfaAllMethodsEnrolledMessage: String
+
+ /** Label for SMS MFA factor. */
+ val smsAuthenticationLabel: String
+
+ /** Label for authenticator-app MFA factor. */
+ val totpAuthenticationLabel: String
+
+ /** Label used when the factor type is unknown. */
+ val unknownMethodLabel: String
+
+ /** Label describing the enrollment date. Accepts a formatted date string. */
+ fun enrolledOnDateLabel(date: String): String
+
+ /** Description displayed during authenticator app setup. */
+ val setupAuthenticatorDescription: String
+
+ /** Network error message */
+ val noInternet: String
+
+ /** TOTP Code prompt */
+ val enterTOTPCode: String
+
+ // Error Recovery Dialog Strings
+ /** Error dialog title */
+ val errorDialogTitle: String
+
+ /** Retry action button text */
+ val retryAction: String
+
+ /** Dismiss action button text */
+ val dismissAction: String
+
+ /** Network error recovery message */
+ val networkErrorRecoveryMessage: String
+
+ /** Invalid credentials recovery message */
+ val invalidCredentialsRecoveryMessage: String
+
+ /** User not found recovery message */
+ val userNotFoundRecoveryMessage: String
+
+ /** Weak password recovery message */
+ val weakPasswordRecoveryMessage: String
+
+ /** Email already in use recovery message */
+ val emailAlreadyInUseRecoveryMessage: String
+
+ /** Too many requests recovery message */
+ val tooManyRequestsRecoveryMessage: String
+
+ /** MFA required recovery message */
+ val mfaRequiredRecoveryMessage: String
+
+ /** Account linking required recovery message */
+ val accountLinkingRequiredRecoveryMessage: String
+
+ /** Auth cancelled recovery message */
+ val authCancelledRecoveryMessage: String
+
+ /** Unknown error recovery message */
+ val unknownErrorRecoveryMessage: String
+
+ // MFA Enrollment Step Titles
+ /** Title for MFA factor selection step */
+ val mfaStepSelectFactorTitle: String
+
+ /** Title for SMS MFA configuration step */
+ val mfaStepConfigureSmsTitle: String
+
+ /** Title for TOTP MFA configuration step */
+ val mfaStepConfigureTotpTitle: String
+
+ /** Title for MFA verification step */
+ val mfaStepVerifyFactorTitle: String
+
+ /** Title for recovery codes step */
+ val mfaStepShowRecoveryCodesTitle: String
+
+ // MFA Enrollment Helper Text
+ /** Helper text for selecting MFA factor */
+ val mfaStepSelectFactorHelper: String
+
+ /** Helper text for SMS configuration */
+ val mfaStepConfigureSmsHelper: String
+
+ /** Helper text for TOTP configuration */
+ val mfaStepConfigureTotpHelper: String
+
+ /** Helper text for SMS verification */
+ val mfaStepVerifyFactorSmsHelper: String
+
+ /** Helper text for TOTP verification */
+ val mfaStepVerifyFactorTotpHelper: String
+
+ /** Generic helper text for factor verification */
+ val mfaStepVerifyFactorGenericHelper: String
+
+ /** Helper text for recovery codes */
+ val mfaStepShowRecoveryCodesHelper: String
+
+ // MFA Enrollment Screen Titles
+ /** Title for MFA phone number enrollment screen (top app bar) */
+ val mfaEnrollmentEnterPhoneNumber: String
+
+ /** Title for MFA SMS verification screen (top app bar) */
+ val mfaEnrollmentVerifySmsCode: String
+
+ // MFA Error Messages
+ /** Error message when MFA enrollment requires recent authentication */
+ val mfaErrorRecentLoginRequired: String
+
+ /** Error message when MFA enrollment fails due to invalid verification code */
+ val mfaErrorInvalidVerificationCode: String
+
+ /** Error message when MFA enrollment fails due to network issues */
+ val mfaErrorNetwork: String
+
+ /** Generic error message for MFA enrollment failures */
+ val mfaErrorGeneric: String
+
+ // Re-authentication Dialog
+ /** Title displayed in the re-authentication dialog. */
+ val reauthDialogTitle: String
+
+ /** Descriptive message shown in the re-authentication dialog. */
+ val reauthDialogMessage: String
+
+ /** Label showing the account email being re-authenticated. */
+ fun reauthAccountLabel(email: String): String
+
+ /** Error message shown when the provided password is incorrect. */
+ val incorrectPasswordError: String
+
+ /** General error message for re-authentication failures. */
+ val reauthGenericError: String
+
+ // Terms of Service and Privacy Policy
+ /** Terms of Service link text */
+ val termsOfService: String
+
+ /** Privacy Policy link text */
+ val privacyPolicy: String
+
+ /** ToS and Privacy Policy combined message with placeholders for links */
+ fun tosAndPrivacyPolicy(termsOfServiceLabel: String, privacyPolicyLabel: String): String
+
+ /** Tooltip message shown when new account sign-up is disabled */
+ val newAccountsDisabledTooltip: String
+
+ /** Tooltip message shown when MFA is disabled */
+ val mfaDisabledTooltip: String
+
+ // =============================================================================================
+ // AuthException error messages
+ // =============================================================================================
+
+ /** Error when a user account has been disabled by an administrator. */
+ val errorUserDisabled: String
+
+ /** Error when provided credentials are invalid. Return empty to use the Firebase SDK message. */
+ val errorInvalidCredentials: String
+
+ /** Error when the user account does not exist. Return empty to use the Firebase SDK message. */
+ val errorUserNotFound: String
+
+ /** Generic error for unexpected user account issues. Return empty to use the Firebase SDK message. */
+ val errorUserAccountGeneric: String
+
+ /** Error when the password is too weak. Return empty to use the Firebase SDK message. */
+ val errorWeakPasswordGeneric: String
+
+ /** Error when the email address is already registered. Return empty to use the Firebase SDK message. */
+ val errorEmailAlreadyInUse: String
+
+ /** Error when an account already exists with a different sign-in method. Return empty to use the Firebase SDK message. */
+ val errorAccountExistsDifferentCredential: String
+
+ /** Error when a credential is already linked to another account. Return empty to use the Firebase SDK message. */
+ val errorCredentialAlreadyInUse: String
+
+ /** Generic error for account collision issues. Return empty to use the Firebase SDK message. */
+ val errorAccountCollisionGeneric: String
+
+ /** Error when multi-factor authentication is required. Return empty to use the Firebase SDK message. */
+ val errorMfaRequiredFallback: String
+
+ /** Error when the operation requires a recent sign-in. Return empty to use the Firebase SDK message. */
+ val errorRecentLoginRequired: String
+
+ /** Error when sign-in is blocked due to too many attempts. Return empty to use the Firebase SDK message. */
+ val errorTooManyRequests: String
+
+ /** Generic unknown authentication error. Return empty to use the Firebase SDK message. */
+ val errorUnknownAuth: String
+
+ /** Error for network failures during authentication. Return empty to use the Firebase SDK message. */
+ val errorNetworkGeneric: String
+
+ /** Error when authentication is cancelled. Return empty to use the Firebase SDK message. */
+ val errorAuthCancelled: String
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProviderSample.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProviderSample.kt
new file mode 100644
index 0000000000..cb82bee993
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProviderSample.kt
@@ -0,0 +1,59 @@
+/*
+ * 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.configuration.string_provider
+
+import android.content.Context
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+
+class AuthUIStringProviderSample {
+ /**
+ * Override specific strings while delegating others to default provider
+ */
+ class CustomAuthUIStringProvider(
+ private val defaultProvider: AuthUIStringProvider
+ ) : AuthUIStringProvider by defaultProvider {
+
+ // Override only the strings you want to customize
+ override val signInWithGoogle: String = "Continue with Google • MyApp"
+ override val signInWithFacebook: String = "Continue with Facebook • MyApp"
+
+ // Add custom branding to common actions
+ override val continueText: String = "Continue to MyApp"
+ override val signInDefault: String = "Sign in to MyApp"
+
+ // Custom MFA messaging
+ override val enterTOTPCode: String =
+ "Enter the 6-digit code from your authenticator app to secure your MyApp account"
+ }
+
+ fun createCustomConfiguration(applicationContext: Context): AuthUIConfiguration {
+ val customStringProvider =
+ CustomAuthUIStringProvider(DefaultAuthUIStringProvider(applicationContext))
+ return authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Google(
+ scopes = listOf(),
+ serverClientId = ""
+ )
+ )
+ }
+ stringProvider = customStringProvider
+ }
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt
new file mode 100644
index 0000000000..cda581acb8
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt
@@ -0,0 +1,576 @@
+/*
+ * 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.configuration.string_provider
+
+import android.content.Context
+import android.content.res.Configuration
+import com.firebase.ui.auth.R
+import java.util.Locale
+
+class DefaultAuthUIStringProvider(
+ context: Context,
+ locale: Locale? = null,
+) : AuthUIStringProvider {
+ /**
+ * Allows overriding locale.
+ */
+ private val localizedContext = locale?.let { locale ->
+ context.createConfigurationContext(
+ Configuration(context.resources.configuration).apply {
+ setLocale(locale)
+ }
+ )
+ } ?: context
+
+ /**
+ * Common Strings
+ */
+ override val initializing: String
+ get() = localizedContext.getString(R.string.fui_initializing)
+
+ /**
+ * Loading State Strings
+ */
+ override val loadingSigningInAnonymously: String
+ get() = localizedContext.getString(R.string.fui_loading_signing_in_anonymously)
+ override val loadingSigningInWithGoogle: String
+ get() = localizedContext.getString(R.string.fui_loading_signing_in_with_google)
+ override val loadingSigningInWithFacebook: String
+ get() = localizedContext.getString(R.string.fui_loading_signing_in_with_facebook)
+ override fun loadingSigningInWithProvider(providerName: String): String =
+ localizedContext.getString(R.string.fui_loading_signing_in_with_provider, providerName)
+ override val loadingVerifyingPhoneNumber: String
+ get() = localizedContext.getString(R.string.fui_loading_verifying_phone_number)
+ override val loadingSubmittingVerificationCode: String
+ get() = localizedContext.getString(R.string.fui_loading_submitting_verification_code)
+ override val loadingSigningInWithPhone: String
+ get() = localizedContext.getString(R.string.fui_loading_signing_in_with_phone)
+ override val loadingCreatingUser: String
+ get() = localizedContext.getString(R.string.fui_loading_creating_user)
+ override val loadingSigningIn: String
+ get() = localizedContext.getString(R.string.fui_loading_signing_in)
+ override val loadingLinkingCredential: String
+ get() = localizedContext.getString(R.string.fui_loading_linking_credential)
+ override val loadingSendingEmailLink: String
+ get() = localizedContext.getString(R.string.fui_loading_sending_email_link)
+ override val loadingSigningInWithEmailLink: String
+ get() = localizedContext.getString(R.string.fui_loading_signing_in_with_email_link)
+ override val loadingSendingPasswordResetEmail: String
+ get() = localizedContext.getString(R.string.fui_loading_sending_password_reset)
+ override val loadingSigningOut: String
+ get() = localizedContext.getString(R.string.fui_loading_signing_out)
+ override val loadingDeletingAccount: String
+ get() = localizedContext.getString(R.string.fui_loading_deleting_account)
+
+ /**
+ * Auth Provider strings
+ */
+ override val googleProvider: String
+ get() = localizedContext.getString(R.string.fui_idp_name_google)
+ override val facebookProvider: String
+ get() = localizedContext.getString(R.string.fui_idp_name_facebook)
+ override val twitterProvider: String
+ get() = localizedContext.getString(R.string.fui_idp_name_twitter)
+ override val githubProvider: String
+ get() = localizedContext.getString(R.string.fui_idp_name_github)
+ override val phoneProvider: String
+ get() = localizedContext.getString(R.string.fui_idp_name_phone)
+ override val emailProvider: String
+ get() = localizedContext.getString(R.string.fui_idp_name_email)
+
+ /**
+ * Auth Provider Button Strings
+ */
+ override val signInWithGoogle: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_google)
+ override val signInWithFacebook: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_facebook)
+ override val signInWithTwitter: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_twitter)
+ override val signInWithGithub: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_github)
+ override val signInWithEmail: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_email)
+ override val signInWithPhone: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_phone)
+ override val signInAnonymously: String
+ get() = localizedContext.getString(R.string.fui_sign_in_anonymously)
+ override val signInWithApple: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_apple)
+ override val signInWithMicrosoft: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_microsoft)
+ override val signInWithYahoo: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_yahoo)
+
+ /**
+ * Auth Provider "Continue With" Button Strings
+ */
+ override val continueWithGoogle: String
+ get() = localizedContext.getString(R.string.fui_continue_with_google)
+ override val continueWithFacebook: String
+ get() = localizedContext.getString(R.string.fui_continue_with_facebook)
+ override val continueWithTwitter: String
+ get() = localizedContext.getString(R.string.fui_continue_with_twitter)
+ override val continueWithGithub: String
+ get() = localizedContext.getString(R.string.fui_continue_with_github)
+ override val continueWithEmail: String
+ get() = localizedContext.getString(R.string.fui_continue_with_email)
+ override val continueWithPhone: String
+ get() = localizedContext.getString(R.string.fui_continue_with_phone)
+ override val continueWithApple: String
+ get() = localizedContext.getString(R.string.fui_continue_with_apple)
+ override val continueWithMicrosoft: String
+ get() = localizedContext.getString(R.string.fui_continue_with_microsoft)
+ override val continueWithYahoo: String
+ get() = localizedContext.getString(R.string.fui_continue_with_yahoo)
+
+ /**
+ * Email Validator Strings
+ */
+ override val missingEmailAddress: String
+ get() = localizedContext.getString(R.string.fui_missing_email_address)
+ override val invalidEmailAddress: String
+ get() = localizedContext.getString(R.string.fui_invalid_email_address)
+
+ /**
+ * Password Validator Strings
+ */
+ override val invalidPassword: String
+ get() = localizedContext.getString(R.string.fui_error_invalid_password)
+ override val passwordsDoNotMatch: String
+ get() = localizedContext.getString(R.string.fui_passwords_do_not_match)
+
+ override fun passwordTooShort(minimumLength: Int): String =
+ localizedContext.getString(R.string.fui_error_password_too_short, minimumLength)
+
+ override val passwordMissingUppercase: String
+ get() = localizedContext.getString(R.string.fui_error_password_missing_uppercase)
+ override val passwordMissingLowercase: String
+ get() = localizedContext.getString(R.string.fui_error_password_missing_lowercase)
+ override val passwordMissingDigit: String
+ get() = localizedContext.getString(R.string.fui_error_password_missing_digit)
+ override val passwordMissingSpecialCharacter: String
+ get() = localizedContext.getString(R.string.fui_error_password_missing_special_character)
+
+ /**
+ * Email Authentication Strings
+ */
+ override val signupPageTitle: String
+ get() = localizedContext.getString(R.string.fui_title_register_email)
+ override val emailHint: String
+ get() = localizedContext.getString(R.string.fui_email_hint)
+ override val passwordHint: String
+ get() = localizedContext.getString(R.string.fui_password_hint)
+ override val confirmPasswordHint: String
+ get() = localizedContext.getString(R.string.fui_confirm_password_hint)
+ override val newPasswordHint: String
+ get() = localizedContext.getString(R.string.fui_new_password_hint)
+ override val nameHint: String
+ get() = localizedContext.getString(R.string.fui_name_hint)
+ override val buttonTextSave: String
+ get() = localizedContext.getString(R.string.fui_button_text_save)
+ override val welcomeBackEmailHeader: String
+ get() = localizedContext.getString(R.string.fui_welcome_back_email_header)
+ override val troubleSigningIn: String
+ get() = localizedContext.getString(R.string.fui_trouble_signing_in)
+
+ override val recoverPasswordPageTitle: String
+ get() = localizedContext.getString(R.string.fui_title_recover_password_activity)
+
+ override val sendButtonText: String
+ get() = localizedContext.getString(R.string.fui_button_text_send)
+
+ override val recoverPasswordLinkSentDialogTitle: String
+ get() = localizedContext.getString(R.string.fui_title_confirm_recover_password)
+
+ override fun recoverPasswordLinkSentDialogBody(email: String): String =
+ localizedContext.getString(R.string.fui_confirm_recovery_body, email)
+
+ override val emailSignInLinkSentDialogTitle: String
+ get() = localizedContext.getString(R.string.fui_email_link_header)
+
+ override fun emailSignInLinkSentDialogBody(email: String): String =
+ localizedContext.getString(R.string.fui_email_link_email_sent, email)
+
+ override val orContinueWith: String
+ get() = localizedContext.getString(R.string.fui_or_continue_with)
+
+ override val signInWithEmailLink: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_email_link)
+
+ override val signInWithPassword: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_password)
+
+ override val emailLinkPromptForEmailTitle: String
+ get() = localizedContext.getString(R.string.fui_email_link_confirm_email_header)
+
+ override val emailLinkPromptForEmailMessage: String
+ get() = localizedContext.getString(R.string.fui_email_link_confirm_email_message)
+
+ override val emailLinkWrongDeviceTitle: String
+ get() = localizedContext.getString(R.string.fui_email_link_wrong_device_header)
+
+ override val emailLinkWrongDeviceMessage: String
+ get() = localizedContext.getString(R.string.fui_email_link_wrong_device_message)
+
+ override val emailLinkDifferentAnonymousUserTitle: String
+ get() = localizedContext.getString(R.string.fui_email_link_different_anonymous_user_header)
+
+ override val emailLinkDifferentAnonymousUserMessage: String
+ get() = localizedContext.getString(R.string.fui_email_link_different_anonymous_user_message)
+
+ override fun emailLinkCrossDeviceLinkingMessage(providerName: String): String =
+ localizedContext.getString(
+ R.string.fui_email_link_cross_device_linking_text,
+ providerName
+ )
+
+ override val emailLinkInvalidLinkTitle: String
+ get() = localizedContext.getString(R.string.fui_email_link_invalid_link_header)
+
+ override val emailLinkInvalidLinkMessage: String
+ get() = localizedContext.getString(R.string.fui_email_link_invalid_link_message)
+
+ override val emailMismatchMessage: String
+ get() = localizedContext.getString(R.string.fui_error_unknown)
+
+ /**
+ * Phone Authentication Strings
+ */
+ override val verifyPhoneNumberTitle: String
+ get() = localizedContext.getString(R.string.fui_verify_phone_number_title)
+ override val phoneHint: String
+ get() = localizedContext.getString(R.string.fui_phone_hint)
+ override val countryHint: String
+ get() = localizedContext.getString(R.string.fui_country_hint)
+ override val invalidPhoneNumber: String
+ get() = localizedContext.getString(R.string.fui_invalid_phone_number)
+ override val missingPhoneNumber: String
+ get() = localizedContext.getString(R.string.fui_required_field)
+ override val enterConfirmationCode: String
+ get() = localizedContext.getString(R.string.fui_enter_confirmation_code)
+ override val verifyPhoneNumber: String
+ get() = localizedContext.getString(R.string.fui_verify_phone_number)
+ override val resendCodeIn: String
+ get() = localizedContext.getString(R.string.fui_resend_code_in)
+ override val resendCode: String
+ get() = localizedContext.getString(R.string.fui_resend_code)
+
+ override fun resendCodeTimer(timeFormatted: String): String =
+ localizedContext.getString(R.string.fui_resend_code_in, timeFormatted)
+
+ override val verifying: String
+ get() = localizedContext.getString(R.string.fui_verifying)
+ override val incorrectCodeDialogBody: String
+ get() = localizedContext.getString(R.string.fui_incorrect_code_dialog_body)
+ override val smsTermsOfService: String
+ get() = localizedContext.getString(R.string.fui_sms_terms_of_service)
+
+ override val enterPhoneNumberTitle: String
+ get() = localizedContext.getString(R.string.fui_verify_phone_number_title)
+
+ override val phoneNumberHint: String
+ get() = localizedContext.getString(R.string.fui_phone_hint)
+
+ override val sendVerificationCode: String
+ get() = localizedContext.getString(R.string.fui_next_default)
+
+ override fun enterVerificationCodeTitle(phoneNumber: String): String =
+ localizedContext.getString(R.string.fui_enter_confirmation_code) + " " + phoneNumber
+
+ override val verificationCodeHint: String
+ get() = localizedContext.getString(R.string.fui_enter_confirmation_code)
+
+ override val changePhoneNumber: String
+ get() = localizedContext.getString(R.string.fui_change_phone_number)
+
+ override val missingVerificationCode: String
+ get() = localizedContext.getString(R.string.fui_required_field)
+
+ override val invalidVerificationCode: String
+ get() = localizedContext.getString(R.string.fui_incorrect_code_dialog_body)
+
+ override val countrySelectorModalTitle: String
+ get() = localizedContext.getString(R.string.fui_country_selector_title)
+
+ override val searchCountriesHint: String
+ get() = localizedContext.getString(R.string.fui_search_country_field_hint)
+
+ /**
+ * Multi-Factor Authentication Strings
+ */
+ override val enterTOTPCode: String
+ get() = localizedContext.getString(R.string.fui_enter_totp_code)
+
+ /**
+ * Provider Picker Strings
+ */
+ override val signInDefault: String
+ get() = localizedContext.getString(R.string.fui_sign_in_default)
+ override val continueText: String
+ get() = localizedContext.getString(R.string.fui_continue)
+ override val nextDefault: String
+ get() = localizedContext.getString(R.string.fui_next_default)
+
+ /**
+ * General Error Messages
+ */
+ override val errorUnknown: String
+ get() = localizedContext.getString(R.string.fui_error_unknown)
+ override val requiredField: String
+ get() = localizedContext.getString(R.string.fui_required_field)
+ override val progressDialogLoading: String
+ get() = localizedContext.getString(R.string.fui_progress_dialog_loading)
+
+ override fun signedInAs(userIdentifier: String): String =
+ localizedContext.getString(R.string.fui_signed_in_as, userIdentifier)
+
+ override val manageMfaAction: String
+ get() = localizedContext.getString(R.string.fui_manage_mfa_action)
+
+ override val signOutAction: String
+ get() = localizedContext.getString(R.string.fui_sign_out_action)
+
+ override fun verifyEmailInstruction(email: String): String =
+ localizedContext.getString(R.string.fui_verify_email_instruction, email)
+
+ override val resendVerificationEmailAction: String
+ get() = localizedContext.getString(R.string.fui_resend_verification_email_action)
+
+ override val verifiedEmailAction: String
+ get() = localizedContext.getString(R.string.fui_verified_email_action)
+
+ override val profileCompletionMessage: String
+ get() = localizedContext.getString(R.string.fui_profile_completion_message)
+
+ override fun profileMissingFieldsMessage(fields: String): String =
+ localizedContext.getString(R.string.fui_profile_missing_fields_message, fields)
+
+ override val skipAction: String
+ get() = localizedContext.getString(R.string.fui_skip_action)
+
+ override val removeAction: String
+ get() = localizedContext.getString(R.string.fui_remove_action)
+
+ override val backAction: String
+ get() = localizedContext.getString(R.string.fui_back_action)
+
+ override val verifyAction: String
+ get() = localizedContext.getString(R.string.fui_verify_action)
+
+ override val useDifferentMethodAction: String
+ get() = localizedContext.getString(R.string.fui_use_different_method_action)
+
+ override val recoveryCodesSavedAction: String
+ get() = localizedContext.getString(R.string.fui_recovery_codes_saved_action)
+
+ override val secretKeyLabel: String
+ get() = localizedContext.getString(R.string.fui_secret_key_label)
+
+ override val verificationCodeLabel: String
+ get() = localizedContext.getString(R.string.fui_verification_code_label)
+
+ override val identityVerifiedMessage: String
+ get() = localizedContext.getString(R.string.fui_identity_verified_message)
+
+ override val mfaManageFactorsTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_manage_factors_title)
+
+ override val mfaManageFactorsDescription: String
+ get() = localizedContext.getString(R.string.fui_mfa_manage_factors_description)
+
+ override val mfaActiveMethodsTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_active_methods_title)
+
+ override val mfaAddNewMethodTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_add_new_method_title)
+
+ override val mfaAllMethodsEnrolledMessage: String
+ get() = localizedContext.getString(R.string.fui_mfa_all_methods_enrolled_message)
+
+ override val smsAuthenticationLabel: String
+ get() = localizedContext.getString(R.string.fui_mfa_label_sms_authentication)
+
+ override val totpAuthenticationLabel: String
+ get() = localizedContext.getString(R.string.fui_mfa_label_totp_authentication)
+
+ override val unknownMethodLabel: String
+ get() = localizedContext.getString(R.string.fui_mfa_label_unknown_method)
+
+ override fun enrolledOnDateLabel(date: String): String =
+ localizedContext.getString(R.string.fui_mfa_enrolled_on, date)
+
+ override val setupAuthenticatorDescription: String
+ get() = localizedContext.getString(R.string.fui_mfa_setup_authenticator_description)
+ override val noInternet: String
+ get() = localizedContext.getString(R.string.fui_no_internet)
+
+ /**
+ * Error Recovery Dialog Strings
+ */
+ override val errorDialogTitle: String
+ get() = localizedContext.getString(R.string.fui_error_dialog_title)
+ override val retryAction: String
+ get() = localizedContext.getString(R.string.fui_error_retry_action)
+ override val dismissAction: String
+ get() = localizedContext.getString(R.string.fui_email_link_dismiss_button)
+ override val networkErrorRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_no_internet)
+ override val invalidCredentialsRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_error_invalid_password)
+ override val userNotFoundRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_error_email_does_not_exist)
+ override val weakPasswordRecoveryMessage: String
+ get() = localizedContext.resources.getQuantityString(
+ R.plurals.fui_error_weak_password,
+ 6,
+ 6
+ )
+ override val emailAlreadyInUseRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_email_account_creation_error)
+ override val tooManyRequestsRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_error_too_many_attempts)
+ override val mfaRequiredRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_error_mfa_required_message)
+ override val accountLinkingRequiredRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_error_account_linking_required_message)
+ override val authCancelledRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_error_auth_cancelled_message)
+ override val unknownErrorRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_error_unknown)
+
+ /**
+ * MFA Enrollment Step Titles
+ */
+ override val mfaStepSelectFactorTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_select_factor_title)
+ override val mfaStepConfigureSmsTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_configure_sms_title)
+ override val mfaStepConfigureTotpTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_configure_totp_title)
+ override val mfaStepVerifyFactorTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_title)
+ override val mfaStepShowRecoveryCodesTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_show_recovery_codes_title)
+
+ /**
+ * MFA Enrollment Helper Text
+ */
+ override val mfaStepSelectFactorHelper: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_select_factor_helper)
+ override val mfaStepConfigureSmsHelper: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_configure_sms_helper)
+ override val mfaStepConfigureTotpHelper: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_configure_totp_helper)
+ override val mfaStepVerifyFactorSmsHelper: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_sms_helper)
+ override val mfaStepVerifyFactorTotpHelper: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_totp_helper)
+ override val mfaStepVerifyFactorGenericHelper: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_generic_helper)
+ override val mfaStepShowRecoveryCodesHelper: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_show_recovery_codes_helper)
+
+ // MFA Enrollment Screen Titles
+ override val mfaEnrollmentEnterPhoneNumber: String
+ get() = localizedContext.getString(R.string.fui_mfa_enrollment_enter_phone_number)
+ override val mfaEnrollmentVerifySmsCode: String
+ get() = localizedContext.getString(R.string.fui_mfa_enrollment_verify_sms_code)
+
+ // MFA Error Messages
+ override val mfaErrorRecentLoginRequired: String
+ get() = localizedContext.getString(R.string.fui_mfa_error_recent_login_required)
+ override val mfaErrorInvalidVerificationCode: String
+ get() = localizedContext.getString(R.string.fui_mfa_error_invalid_verification_code)
+ override val mfaErrorNetwork: String
+ get() = localizedContext.getString(R.string.fui_mfa_error_network)
+ override val mfaErrorGeneric: String
+ get() = localizedContext.getString(R.string.fui_mfa_error_generic)
+
+ override val reauthDialogTitle: String
+ get() = localizedContext.getString(R.string.fui_reauth_dialog_title)
+
+ override val reauthDialogMessage: String
+ get() = localizedContext.getString(R.string.fui_reauth_dialog_message)
+
+ override fun reauthAccountLabel(email: String): String =
+ localizedContext.getString(R.string.fui_reauth_account_label, email)
+
+ override val incorrectPasswordError: String
+ get() = localizedContext.getString(R.string.fui_incorrect_password_error)
+
+ override val reauthGenericError: String
+ get() = localizedContext.getString(R.string.fui_reauth_generic_error)
+
+ override val termsOfService: String
+ get() = localizedContext.getString(R.string.fui_terms_of_service)
+
+ override val privacyPolicy: String
+ get() = localizedContext.getString(R.string.fui_privacy_policy)
+
+ override fun tosAndPrivacyPolicy(termsOfServiceLabel: String, privacyPolicyLabel: String): String =
+ localizedContext.getString(R.string.fui_tos_and_pp, termsOfServiceLabel, privacyPolicyLabel)
+
+ override val newAccountsDisabledTooltip: String
+ get() = localizedContext.getString(R.string.fui_new_accounts_disabled_tooltip)
+
+ override val mfaDisabledTooltip: String
+ get() = localizedContext.getString(R.string.fui_mfa_disabled_tooltip)
+
+ override val errorUserDisabled: String
+ get() = localizedContext.getString(R.string.fui_error_user_disabled)
+
+ override val errorInvalidCredentials: String
+ get() = localizedContext.getString(R.string.fui_error_invalid_credentials)
+
+ override val errorUserNotFound: String
+ get() = localizedContext.getString(R.string.fui_error_user_not_found)
+
+ override val errorUserAccountGeneric: String
+ get() = localizedContext.getString(R.string.fui_error_user_account_generic)
+
+ override val errorWeakPasswordGeneric: String
+ get() = localizedContext.getString(R.string.fui_error_weak_password_generic)
+
+ override val errorEmailAlreadyInUse: String
+ get() = localizedContext.getString(R.string.fui_error_email_already_in_use)
+
+ override val errorAccountExistsDifferentCredential: String
+ get() = localizedContext.getString(R.string.fui_error_account_exists_different_credential)
+
+ override val errorCredentialAlreadyInUse: String
+ get() = localizedContext.getString(R.string.fui_error_credential_already_in_use)
+
+ override val errorAccountCollisionGeneric: String
+ get() = localizedContext.getString(R.string.fui_error_account_collision_generic)
+
+ override val errorMfaRequiredFallback: String
+ get() = localizedContext.getString(R.string.fui_error_mfa_required_fallback)
+
+ override val errorRecentLoginRequired: String
+ get() = localizedContext.getString(R.string.fui_error_recent_login_required)
+
+ override val errorTooManyRequests: String
+ get() = localizedContext.getString(R.string.fui_error_too_many_requests)
+
+ override val errorUnknownAuth: String
+ get() = localizedContext.getString(R.string.fui_error_unknown_auth)
+
+ override val errorNetworkGeneric: String
+ get() = localizedContext.getString(R.string.fui_error_network_generic)
+
+ override val errorAuthCancelled: String
+ get() = localizedContext.getString(R.string.fui_error_auth_cancelled)
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUIAsset.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUIAsset.kt
new file mode 100644
index 0000000000..c59fcf66e4
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUIAsset.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.configuration.theme
+
+import androidx.annotation.DrawableRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.res.painterResource
+
+/**
+ * Represents a visual asset used in the authentication UI.
+ *
+ * This sealed class allows specifying icons and images from either Android drawable
+ * resources ([Resource]) or Jetpack Compose [ImageVector]s ([Vector]). The [painter]
+ * property provides a unified way to get a [Painter] for the asset within a composable.
+ *
+ * **Example usage:**
+ * ```kotlin
+ * // To use a drawable resource:
+ * val asset = AuthUIAsset.Resource(R.drawable.my_logo)
+ *
+ * // To use a vector asset:
+ * val vectorAsset = AuthUIAsset.Vector(Icons.Default.Info)
+ * ```
+ */
+sealed class AuthUIAsset {
+ /**
+ * An asset loaded from a drawable resource.
+ *
+ * @param resId The resource ID of the drawable (e.g., `R.drawable.my_icon`).
+ */
+ class Resource(@param:DrawableRes val resId: Int) : AuthUIAsset()
+
+ /**
+ * An asset represented by an [ImageVector].
+ *
+ * @param image The [ImageVector] to be displayed.
+ */
+ class Vector(val image: ImageVector) : AuthUIAsset()
+
+ /**
+ * A [Painter] that can be used to draw this asset in a composable.
+ *
+ * This property automatically resolves the asset type and returns the appropriate
+ * [Painter] for rendering.
+ */
+ @get:Composable
+ internal val painter: Painter
+ get() = when (this) {
+ is Resource -> painterResource(resId)
+ is Vector -> rememberVectorPainter(image)
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUITheme.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUITheme.kt
new file mode 100644
index 0000000000..79a7db901b
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUITheme.kt
@@ -0,0 +1,276 @@
+/*
+ * 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.configuration.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Shapes
+import androidx.compose.material3.TopAppBarColors
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.Typography
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * CompositionLocal providing access to the current AuthUITheme.
+ * This allows components to access theme configuration including provider styles and shapes.
+ */
+val LocalAuthUITheme = staticCompositionLocalOf { AuthUITheme.Default }
+
+/**
+ * Theming configuration for the entire Auth UI.
+ */
+class AuthUITheme(
+ /**
+ * The color scheme to use.
+ */
+ val colorScheme: ColorScheme,
+
+ /**
+ * The typography to use.
+ */
+ val typography: Typography,
+
+ /**
+ * The shapes to use for UI elements (text fields, cards, etc.).
+ */
+ val shapes: Shapes,
+
+ /**
+ * A map of provider IDs to custom styling. Use this to customize individual
+ * provider buttons with specific colors, icons, shapes, and elevation.
+ *
+ * Example:
+ * ```kotlin
+ * providerStyles = mapOf(
+ * "google.com" to ProviderStyleDefaults.Google.copy(
+ * shape = RoundedCornerShape(12.dp)
+ * )
+ * )
+ * ```
+ */
+ val providerStyles: Map = emptyMap(),
+
+ /**
+ * Default shape for all provider buttons. If not set, defaults to RoundedCornerShape(4.dp).
+ * Individual provider styles can override this shape.
+ *
+ * Example:
+ * ```kotlin
+ * providerButtonShape = RoundedCornerShape(12.dp)
+ * ```
+ */
+ val providerButtonShape: Shape? = null,
+
+ /**
+ * Custom colors for the top app bar shown on auth screens. If null, falls back to
+ * colors derived from [colorScheme] (see [AuthUITheme.topAppBarColors]).
+ */
+ val topAppBarColors: TopAppBarColors? = null,
+) {
+
+ /**
+ * Creates a copy of this AuthUITheme, optionally overriding specific properties.
+ *
+ * @param colorScheme The color scheme to use. Defaults to this theme's color scheme.
+ * @param typography The typography to use. Defaults to this theme's typography.
+ * @param shapes The shapes to use. Defaults to this theme's shapes.
+ * @param providerStyles Custom styling for individual providers. Defaults to this theme's provider styles.
+ * @param providerButtonShape Default shape for provider buttons. Defaults to this theme's provider button shape.
+ * @param topAppBarColors Custom top app bar colors. Defaults to this theme's top app bar colors.
+ * @return A new AuthUITheme instance with the specified properties.
+ */
+ fun copy(
+ colorScheme: ColorScheme = this.colorScheme,
+ typography: Typography = this.typography,
+ shapes: Shapes = this.shapes,
+ providerStyles: Map = this.providerStyles,
+ providerButtonShape: Shape? = this.providerButtonShape,
+ topAppBarColors: TopAppBarColors? = this.topAppBarColors,
+ ): AuthUITheme {
+ return AuthUITheme(
+ colorScheme = colorScheme,
+ typography = typography,
+ shapes = shapes,
+ providerStyles = providerStyles,
+ providerButtonShape = providerButtonShape,
+ topAppBarColors = topAppBarColors
+ )
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is AuthUITheme) return false
+
+ if (colorScheme != other.colorScheme) return false
+ if (typography != other.typography) return false
+ if (shapes != other.shapes) return false
+ if (providerStyles != other.providerStyles) return false
+ if (providerButtonShape != other.providerButtonShape) return false
+ if (topAppBarColors != other.topAppBarColors) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = colorScheme.hashCode()
+ result = 31 * result + typography.hashCode()
+ result = 31 * result + shapes.hashCode()
+ result = 31 * result + providerStyles.hashCode()
+ result = 31 * result + (providerButtonShape?.hashCode() ?: 0)
+ result = 31 * result + (topAppBarColors?.hashCode() ?: 0)
+ return result
+ }
+
+ override fun toString(): String {
+ return "AuthUITheme(colorScheme=$colorScheme, typography=$typography, shapes=$shapes, " +
+ "providerStyles=$providerStyles, providerButtonShape=$providerButtonShape, " +
+ "topAppBarColors=$topAppBarColors)"
+ }
+
+ /**
+ * A class nested within AuthUITheme that defines the visual appearance of a specific
+ * provider button, allowing for per-provider branding and customization.
+ */
+ data class ProviderStyle(
+ /**
+ * The provider's icon.
+ */
+ val icon: AuthUIAsset?,
+
+ /**
+ * The background color of the button.
+ */
+ val backgroundColor: Color,
+
+ /**
+ * The color of the text label on the button.
+ */
+ val contentColor: Color,
+
+ /**
+ * An optional tint color for the provider's icon. If null,
+ * the icon's intrinsic color is used.
+ */
+ val iconTint: Color? = null,
+
+ /**
+ * The shape of the button container. If null, uses the theme's providerButtonShape
+ * or falls back to RoundedCornerShape(4.dp).
+ */
+ val shape: Shape? = null,
+
+ /**
+ * The shadow elevation for the button. Defaults to 2.dp.
+ */
+ val elevation: Dp = 2.dp,
+ ) {
+ internal companion object {
+ /**
+ * A fallback style for unknown providers with no icon, white background,
+ * and black text.
+ */
+ val Empty = ProviderStyle(
+ icon = null,
+ backgroundColor = Color.White,
+ contentColor = Color.Black,
+ )
+ }
+ }
+
+ companion object {
+ /**
+ * A standard light theme with Material 3 defaults and
+ * pre-configured provider styles.
+ */
+ val Default = AuthUITheme(
+ colorScheme = lightColorScheme(),
+ typography = Typography(),
+ shapes = Shapes(),
+ providerStyles = ProviderStyleDefaults.default
+ )
+
+ val DefaultDark = AuthUITheme(
+ colorScheme = darkColorScheme(),
+ typography = Typography(),
+ shapes = Shapes(),
+ providerStyles = ProviderStyleDefaults.default
+ )
+
+ val Adaptive: AuthUITheme
+ @Composable get() = if (isSystemInDarkTheme()) DefaultDark else Default
+
+ /**
+ * Creates a theme inheriting the app's current Material Theme settings.
+ *
+ * @param providerStyles Custom styling for individual providers. Defaults to standard provider styles.
+ * @param providerButtonShape Default shape for all provider buttons. If null, uses RoundedCornerShape(4.dp).
+ */
+ @Composable
+ fun fromMaterialTheme(
+ providerStyles: Map = ProviderStyleDefaults.default,
+ providerButtonShape: Shape? = null,
+ ): AuthUITheme {
+ return AuthUITheme(
+ colorScheme = MaterialTheme.colorScheme,
+ typography = MaterialTheme.typography,
+ shapes = MaterialTheme.shapes,
+ providerStyles = providerStyles,
+ providerButtonShape = providerButtonShape
+ )
+ }
+
+ @get:Composable
+ val topAppBarColors
+ get() = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ titleContentColor = MaterialTheme.colorScheme.onPrimary,
+ navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
+ )
+
+ /**
+ * Resolves the top app bar colors to use for the current [LocalAuthUITheme], falling back
+ * to [topAppBarColors] when the current theme doesn't specify its own.
+ */
+ @get:Composable
+ val resolvedTopAppBarColors: TopAppBarColors
+ get() = LocalAuthUITheme.current.topAppBarColors ?: topAppBarColors
+ }
+}
+
+@Composable
+fun AuthUITheme(
+ theme: AuthUITheme = AuthUITheme.Adaptive,
+ content: @Composable () -> Unit,
+) {
+ CompositionLocalProvider(
+ LocalAuthUITheme provides theme
+ ) {
+ MaterialTheme(
+ colorScheme = theme.colorScheme,
+ typography = theme.typography,
+ shapes = theme.shapes,
+ content = content
+ )
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/theme/ProviderStyleDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/ProviderStyleDefaults.kt
new file mode 100644
index 0000000000..c4721b395c
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/ProviderStyleDefaults.kt
@@ -0,0 +1,108 @@
+/*
+ * 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.configuration.theme
+
+import androidx.compose.ui.graphics.Color
+import com.firebase.ui.auth.R
+import com.firebase.ui.auth.configuration.auth_provider.Provider
+
+/**
+ * Default provider styling configurations for authentication providers.
+ *
+ * This object provides brand-appropriate visual styling for each supported authentication
+ * provider, including background colors, text colors, and other visual properties that
+ * match each provider's brand guidelines.
+ *
+ * The styles are automatically applied when using [AuthUITheme.Default] or can be
+ * customized by passing a modified map to [AuthUITheme.fromMaterialTheme].
+ *
+ * Individual provider styles can be accessed and customized using the public properties
+ * (e.g., [Google], [Facebook]) and then modified using the [AuthUITheme.ProviderStyle.copy] method.
+ */
+object ProviderStyleDefaults {
+ val Google = AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_googleg_color_24dp),
+ backgroundColor = Color.White,
+ contentColor = Color(0xFF757575)
+ )
+
+ val Facebook = AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_facebook_white_22dp),
+ backgroundColor = Color(0xFF1877F2),
+ contentColor = Color.White
+ )
+
+ val Twitter = AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_twitter_x_white_24dp),
+ backgroundColor = Color.Black,
+ contentColor = Color.White
+ )
+
+ val Github = AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_github_white_24dp),
+ backgroundColor = Color(0xFF24292E),
+ contentColor = Color.White
+ )
+
+ val Email = AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_mail_white_24dp),
+ backgroundColor = Color(0xFFD0021B),
+ contentColor = Color.White
+ )
+
+ val Phone = AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_phone_white_24dp),
+ backgroundColor = Color(0xFF43C5A5),
+ contentColor = Color.White
+ )
+
+ val Anonymous = AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_anonymous_white_24dp),
+ backgroundColor = Color(0xFFF4B400),
+ contentColor = Color.White
+ )
+
+ val Microsoft = AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_microsoft_24dp),
+ backgroundColor = Color(0xFF2F2F2F),
+ contentColor = Color.White
+ )
+
+ val Yahoo = AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_yahoo_24dp),
+ backgroundColor = Color(0xFF720E9E),
+ contentColor = Color.White
+ )
+
+ val Apple = AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_apple_white_24dp),
+ backgroundColor = Color.Black,
+ contentColor = Color.White
+ )
+
+ val default: Map
+ get() = mapOf(
+ Provider.GOOGLE.id to Google,
+ Provider.FACEBOOK.id to Facebook,
+ Provider.TWITTER.id to Twitter,
+ Provider.GITHUB.id to Github,
+ Provider.EMAIL.id to Email,
+ Provider.PHONE.id to Phone,
+ Provider.ANONYMOUS.id to Anonymous,
+ Provider.MICROSOFT.id to Microsoft,
+ Provider.YAHOO.id to Yahoo,
+ Provider.APPLE.id to Apple
+ )
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/EmailValidator.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/EmailValidator.kt
new file mode 100644
index 0000000000..0bcee25bcd
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/EmailValidator.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.configuration.validators
+
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+internal class EmailValidator(override val stringProvider: AuthUIStringProvider) : FieldValidator {
+ private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+
+ override val hasError: Boolean
+ get() = _validationStatus.hasError
+
+ override val errorMessage: String
+ get() = _validationStatus.errorMessage ?: ""
+
+ override fun validate(value: String): Boolean {
+ if (value.isEmpty()) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.missingEmailAddress
+ )
+ return false
+ }
+
+ if (!android.util.Patterns.EMAIL_ADDRESS.matcher(value).matches()) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.invalidEmailAddress
+ )
+ return false
+ }
+
+ _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+ return true
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/field_validators/package-info.java b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/FieldValidationStatus.kt
similarity index 63%
rename from auth/src/main/java/com/firebase/ui/auth/ui/email/field_validators/package-info.java
rename to auth/src/main/java/com/firebase/ui/auth/configuration/validators/FieldValidationStatus.kt
index b06d98dc25..a72313560c 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/field_validators/package-info.java
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/FieldValidationStatus.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2016 Google Inc. All Rights Reserved.
+ * 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
@@ -12,9 +12,13 @@
* limitations under the License.
*/
+package com.firebase.ui.auth.configuration.validators
+
/**
- * Contains utility classes for validating {@link android.widget.EditText} field contents.
- * The contents of this package should be considered an implementation detail and not part of
- * the main API.
+ * Class for encapsulating [hasError] and [errorMessage] properties in
+ * internal FieldValidator subclasses.
*/
-package com.firebase.ui.auth.ui.email.field_validators;
\ No newline at end of file
+internal class FieldValidationStatus(
+ val hasError: Boolean,
+ val errorMessage: String? = null,
+)
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/FieldValidator.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/FieldValidator.kt
new file mode 100644
index 0000000000..4a6924f507
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/FieldValidator.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.configuration.validators
+
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+/**
+ * An interface for validating input fields.
+ */
+interface FieldValidator {
+ val stringProvider: AuthUIStringProvider
+
+ /**
+ * Returns true if the last validation failed.
+ */
+ val hasError: Boolean
+
+ /**
+ * The error message for the current state.
+ */
+ val errorMessage: String
+
+ /**
+ * Runs validation on a value and returns true if valid.
+ */
+ fun validate(value: String): Boolean
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/GeneralFieldValidator.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/GeneralFieldValidator.kt
new file mode 100644
index 0000000000..a72c9f80df
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/GeneralFieldValidator.kt
@@ -0,0 +1,52 @@
+/*
+ * 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.configuration.validators
+
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+internal class GeneralFieldValidator(
+ override val stringProvider: AuthUIStringProvider,
+ val isValid: ((String) -> Boolean)? = null,
+ val customMessage: String? = null,
+) : FieldValidator {
+ private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+
+ override val hasError: Boolean
+ get() = _validationStatus.hasError
+
+ override val errorMessage: String
+ get() = _validationStatus.errorMessage ?: ""
+
+ override fun validate(value: String): Boolean {
+ if (value.isEmpty()) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.requiredField
+ )
+ return false
+ }
+
+ if (isValid != null && !isValid(value)) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = customMessage
+ )
+ return false
+ }
+
+ _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+ return true
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PasswordValidator.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PasswordValidator.kt
new file mode 100644
index 0000000000..a7d7e698e3
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PasswordValidator.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.configuration.validators
+
+import com.firebase.ui.auth.configuration.PasswordRule
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+class PasswordValidator(
+ override val stringProvider: AuthUIStringProvider,
+ private val rules: List
+) : FieldValidator {
+ private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+
+ override val hasError: Boolean
+ get() = _validationStatus.hasError
+
+ override val errorMessage: String
+ get() = _validationStatus.errorMessage ?: ""
+
+ override fun validate(value: String): Boolean {
+ if (value.isEmpty()) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.invalidPassword
+ )
+ return false
+ }
+
+ for (rule in rules) {
+ if (!rule.isValid(value)) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = rule.getErrorMessage(stringProvider)
+ )
+ return false
+ }
+ }
+
+ _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+ return true
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PhoneNumberValidator.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PhoneNumberValidator.kt
new file mode 100644
index 0000000000..1d2484bbc4
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PhoneNumberValidator.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.configuration.validators
+
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.firebase.ui.auth.data.CountryData
+import com.google.i18n.phonenumbers.NumberParseException
+import com.google.i18n.phonenumbers.PhoneNumberUtil
+
+internal class PhoneNumberValidator(
+ override val stringProvider: AuthUIStringProvider,
+ val selectedCountry: CountryData,
+) :
+ FieldValidator {
+ private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+ private val phoneNumberUtil = PhoneNumberUtil.getInstance()
+
+ override val hasError: Boolean
+ get() = _validationStatus.hasError
+
+ override val errorMessage: String
+ get() = _validationStatus.errorMessage ?: ""
+
+ override fun validate(value: String): Boolean {
+ if (value.isEmpty()) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.missingPhoneNumber
+ )
+ return false
+ }
+
+ try {
+ val phoneNumber = phoneNumberUtil.parse(value, selectedCountry.countryCode)
+ val isValid = phoneNumberUtil.isValidNumber(phoneNumber)
+
+ if (!isValid) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.invalidPhoneNumber
+ )
+ return false
+ }
+ } catch (_: NumberParseException) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.invalidPhoneNumber
+ )
+ return false
+ }
+
+ _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+ return true
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/VerificationCodeValidator.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/VerificationCodeValidator.kt
new file mode 100644
index 0000000000..9824a81618
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/VerificationCodeValidator.kt
@@ -0,0 +1,51 @@
+/*
+ * 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.configuration.validators
+
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+internal class VerificationCodeValidator(override val stringProvider: AuthUIStringProvider) :
+ FieldValidator {
+ private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+
+ override val hasError: Boolean
+ get() = _validationStatus.hasError
+
+ override val errorMessage: String
+ get() = _validationStatus.errorMessage ?: ""
+
+ override fun validate(value: String): Boolean {
+ if (value.isEmpty()) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.missingVerificationCode
+ )
+ return false
+ }
+
+ // Verification codes are typically 6 digits
+ val digitsOnly = value.replace(Regex("[^0-9]"), "")
+ if (digitsOnly.length != 6) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.invalidVerificationCode
+ )
+ return false
+ }
+
+ _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+ return true
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/provider/package-info.java b/auth/src/main/java/com/firebase/ui/auth/credentialmanager/PasswordCredential.kt
similarity index 57%
rename from auth/src/main/java/com/firebase/ui/auth/provider/package-info.java
rename to auth/src/main/java/com/firebase/ui/auth/credentialmanager/PasswordCredential.kt
index de8ba4b1b4..535ace99d7 100644
--- a/auth/src/main/java/com/firebase/ui/auth/provider/package-info.java
+++ b/auth/src/main/java/com/firebase/ui/auth/credentialmanager/PasswordCredential.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2016 Google Inc. All Rights Reserved.
+ * 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
@@ -12,8 +12,15 @@
* limitations under the License.
*/
+package com.firebase.ui.auth.credentialmanager
+
/**
- * IDP-specific interactions for signing in users. The contents of this package should
- * be considered an implementation detail and not part of the main API.
+ * Represents a password credential retrieved from the system credential manager.
+ *
+ * @property username The username/identifier associated with the credential
+ * @property password The password associated with the credential
*/
-package com.firebase.ui.auth.provider;
\ No newline at end of file
+data class PasswordCredential(
+ val username: String,
+ val password: String
+)
diff --git a/auth/src/main/java/com/firebase/ui/auth/credentialmanager/PasswordCredentialHandler.kt b/auth/src/main/java/com/firebase/ui/auth/credentialmanager/PasswordCredentialHandler.kt
new file mode 100644
index 0000000000..c83f9a280c
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/credentialmanager/PasswordCredentialHandler.kt
@@ -0,0 +1,200 @@
+/*
+ * 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.credentialmanager
+
+import android.content.Context
+import androidx.credentials.CreatePasswordRequest
+import androidx.credentials.CredentialManager
+import androidx.credentials.GetCredentialRequest
+import androidx.credentials.GetPasswordOption
+import androidx.credentials.PasswordCredential as AndroidPasswordCredential
+import androidx.credentials.exceptions.CreateCredentialCancellationException
+import androidx.credentials.exceptions.CreateCredentialException
+import androidx.credentials.exceptions.GetCredentialCancellationException
+import androidx.credentials.exceptions.GetCredentialException
+import androidx.credentials.exceptions.NoCredentialException
+import com.firebase.ui.auth.util.CredentialPersistenceManager
+
+/**
+ * Provider interface for obtaining CredentialManager instances.
+ * This allows test code to inject mock CredentialManager instances.
+ */
+interface CredentialManagerProvider {
+ fun getCredentialManager(context: Context): CredentialManager
+}
+
+/**
+ * Default implementation that creates a real CredentialManager instance.
+ */
+class DefaultCredentialManagerProvider : CredentialManagerProvider {
+ override fun getCredentialManager(context: Context): CredentialManager {
+ return CredentialManager.create(context)
+ }
+}
+
+/**
+ * Handler for password credential operations using Android's Credential Manager.
+ *
+ * This class provides methods to save and retrieve password credentials through
+ * the system credential manager, which displays native UI prompts to the user.
+ *
+ * @property context The Android context used for credential operations
+ * @property provider Optional provider for testing purposes
+ */
+class PasswordCredentialHandler(
+ private val context: Context,
+ provider: CredentialManagerProvider? = null
+) {
+ companion object {
+ /**
+ * Test-only provider for injecting mock CredentialManager instances.
+ * Set this in your test setup to override the default CredentialManager.
+ *
+ * Example:
+ * ```
+ * PasswordCredentialHandler.testCredentialManagerProvider = object : CredentialManagerProvider {
+ * override fun getCredentialManager(context: Context) = mockCredentialManager
+ * }
+ * ```
+ */
+ @Volatile
+ var testCredentialManagerProvider: CredentialManagerProvider? = null
+
+ /**
+ * Checks if credentials have been saved at least once.
+ * This prevents unnecessary credential retrieval attempts.
+ *
+ * @param context The Android context
+ * @return true if credentials have been saved, false otherwise
+ */
+ suspend fun hasSavedCredentials(context: Context): Boolean {
+ return CredentialPersistenceManager.hasSavedCredentials(context)
+ }
+
+ /**
+ * Clears the saved credentials flag.
+ * Useful for testing or when user signs out permanently.
+ *
+ * @param context The Android context
+ */
+ suspend fun clearSavedCredentialsFlag(context: Context) {
+ CredentialPersistenceManager.clearSavedCredentialsFlag(context)
+ }
+ }
+
+ private val credentialManager: CredentialManager =
+ provider?.getCredentialManager(context)
+ ?: testCredentialManagerProvider?.getCredentialManager(context)
+ ?: CredentialManager.create(context)
+
+ /**
+ * Saves a password credential to the system credential manager.
+ *
+ * This method displays a system prompt to the user asking if they want to save
+ * the credential. The operation is performed asynchronously using Kotlin coroutines.
+ *
+ * @param username The username/identifier for the credential
+ * @param password The password to save
+ * @throws CreateCredentialException if the credential cannot be saved
+ * @throws CreateCredentialCancellationException if the user cancels the save operation
+ * @throws IllegalArgumentException if username or password is blank
+ */
+ suspend fun savePassword(username: String, password: String) {
+ require(username.isNotBlank()) { "Username cannot be blank" }
+ require(password.isNotBlank()) { "Password cannot be blank" }
+
+ val request = CreatePasswordRequest(
+ id = username,
+ password = password
+ )
+
+ try {
+ credentialManager.createCredential(context, request)
+ // Mark that credentials have been saved successfully
+ CredentialPersistenceManager.setCredentialsSaved(context)
+ } catch (e: CreateCredentialCancellationException) {
+ // User cancelled the save operation
+ throw PasswordCredentialCancelledException("User cancelled password save operation", e)
+ } catch (e: CreateCredentialException) {
+ // Other credential creation errors
+ throw PasswordCredentialException("Failed to save password credential", e)
+ }
+ }
+
+ /**
+ * Retrieves a password credential from the system credential manager.
+ *
+ * This method displays a system prompt showing available credentials for the user
+ * to select from. The operation is performed asynchronously using Kotlin coroutines.
+ *
+ * @return PasswordCredential containing the username and password
+ * @throws NoCredentialException if no credentials are available
+ * @throws GetCredentialCancellationException if the user cancels the retrieval operation
+ * @throws GetCredentialException if the credential cannot be retrieved
+ */
+ suspend fun getPassword(): PasswordCredential {
+ val getPasswordOption = GetPasswordOption()
+ val request = GetCredentialRequest.Builder()
+ .addCredentialOption(getPasswordOption)
+ .build()
+
+ try {
+ val result = credentialManager.getCredential(context, request)
+ val credential = result.credential
+
+ if (credential is AndroidPasswordCredential) {
+ return PasswordCredential(
+ username = credential.id,
+ password = credential.password
+ )
+ } else {
+ throw PasswordCredentialException("Retrieved credential is not a password credential")
+ }
+ } catch (e: GetCredentialCancellationException) {
+ // User cancelled the retrieval operation
+ throw PasswordCredentialCancelledException("User cancelled password retrieval operation", e)
+ } catch (e: NoCredentialException) {
+ // No credentials available
+ throw PasswordCredentialNotFoundException("No password credentials found", e)
+ } catch (e: GetCredentialException) {
+ // Other credential retrieval errors
+ throw PasswordCredentialException("Failed to retrieve password credential", e)
+ }
+ }
+}
+
+/**
+ * Base exception for password credential operations.
+ */
+open class PasswordCredentialException(
+ message: String,
+ cause: Throwable? = null
+) : Exception(message, cause)
+
+/**
+ * Exception thrown when a password credential operation is cancelled by the user.
+ */
+class PasswordCredentialCancelledException(
+ message: String,
+ cause: Throwable? = null
+) : PasswordCredentialException(message, cause)
+
+/**
+ * Exception thrown when no password credentials are found.
+ */
+class PasswordCredentialNotFoundException(
+ message: String,
+ cause: Throwable? = null
+) : PasswordCredentialException(message, cause)
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/Countries.kt b/auth/src/main/java/com/firebase/ui/auth/data/Countries.kt
new file mode 100644
index 0000000000..e6400cc600
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/data/Countries.kt
@@ -0,0 +1,260 @@
+/*
+ * 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.data
+
+/**
+ * Complete list of countries with their dial codes and ISO country codes.
+ * Auto-generated from ISO 3166-1 standard.
+ */
+val ALL_COUNTRIES: List = listOf(
+ CountryData("Afghanistan", "+93", "AF", countryCodeToFlagEmoji("AF")),
+ CountryData("Albania", "+355", "AL", countryCodeToFlagEmoji("AL")),
+ CountryData("Algeria", "+213", "DZ", countryCodeToFlagEmoji("DZ")),
+ CountryData("American Samoa", "+684", "AS", countryCodeToFlagEmoji("AS")),
+ CountryData("Andorra", "+376", "AD", countryCodeToFlagEmoji("AD")),
+ CountryData("Angola", "+244", "AO", countryCodeToFlagEmoji("AO")),
+ CountryData("Anguilla", "+264", "AI", countryCodeToFlagEmoji("AI")),
+ CountryData("Antigua and Barbuda", "+268", "AG", countryCodeToFlagEmoji("AG")),
+ CountryData("Argentina", "+54", "AR", countryCodeToFlagEmoji("AR")),
+ CountryData("Armenia", "+374", "AM", countryCodeToFlagEmoji("AM")),
+ CountryData("Aruba", "+297", "AW", countryCodeToFlagEmoji("AW")),
+ CountryData("Australia", "+61", "AU", countryCodeToFlagEmoji("AU")),
+ CountryData("Austria", "+43", "AT", countryCodeToFlagEmoji("AT")),
+ CountryData("Azerbaijan", "+994", "AZ", countryCodeToFlagEmoji("AZ")),
+ CountryData("Bahamas", "+242", "BS", countryCodeToFlagEmoji("BS")),
+ CountryData("Bahrain", "+973", "BH", countryCodeToFlagEmoji("BH")),
+ CountryData("Bangladesh", "+880", "BD", countryCodeToFlagEmoji("BD")),
+ CountryData("Barbados", "+246", "BB", countryCodeToFlagEmoji("BB")),
+ CountryData("Belarus", "+375", "BY", countryCodeToFlagEmoji("BY")),
+ CountryData("Belgium", "+32", "BE", countryCodeToFlagEmoji("BE")),
+ CountryData("Belize", "+501", "BZ", countryCodeToFlagEmoji("BZ")),
+ CountryData("Benin", "+229", "BJ", countryCodeToFlagEmoji("BJ")),
+ CountryData("Bermuda", "+441", "BM", countryCodeToFlagEmoji("BM")),
+ CountryData("Bhutan", "+975", "BT", countryCodeToFlagEmoji("BT")),
+ CountryData("Bolivia", "+591", "BO", countryCodeToFlagEmoji("BO")),
+ CountryData("Bosnia and Herzegovina", "+387", "BA", countryCodeToFlagEmoji("BA")),
+ CountryData("Botswana", "+267", "BW", countryCodeToFlagEmoji("BW")),
+ CountryData("Brazil", "+55", "BR", countryCodeToFlagEmoji("BR")),
+ CountryData("British Indian Ocean Territory", "+246", "IO", countryCodeToFlagEmoji("IO")),
+ CountryData("Brunei", "+673", "BN", countryCodeToFlagEmoji("BN")),
+ CountryData("Bulgaria", "+359", "BG", countryCodeToFlagEmoji("BG")),
+ CountryData("Burkina Faso", "+226", "BF", countryCodeToFlagEmoji("BF")),
+ CountryData("Burundi", "+257", "BI", countryCodeToFlagEmoji("BI")),
+ CountryData("Cambodia", "+855", "KH", countryCodeToFlagEmoji("KH")),
+ CountryData("Cameroon", "+237", "CM", countryCodeToFlagEmoji("CM")),
+ CountryData("Canada", "+1", "CA", countryCodeToFlagEmoji("CA")),
+ CountryData("Cape Verde", "+238", "CV", countryCodeToFlagEmoji("CV")),
+ CountryData("Cayman Islands", "+345", "KY", countryCodeToFlagEmoji("KY")),
+ CountryData("Central African Republic", "+236", "CF", countryCodeToFlagEmoji("CF")),
+ CountryData("Chad", "+235", "TD", countryCodeToFlagEmoji("TD")),
+ CountryData("Chile", "+56", "CL", countryCodeToFlagEmoji("CL")),
+ CountryData("China", "+86", "CN", countryCodeToFlagEmoji("CN")),
+ CountryData("Colombia", "+57", "CO", countryCodeToFlagEmoji("CO")),
+ CountryData("Comoros", "+269", "KM", countryCodeToFlagEmoji("KM")),
+ CountryData("Congo", "+242", "CG", countryCodeToFlagEmoji("CG")),
+ CountryData("Congo (DRC)", "+243", "CD", countryCodeToFlagEmoji("CD")),
+ CountryData("Cook Islands", "+682", "CK", countryCodeToFlagEmoji("CK")),
+ CountryData("Costa Rica", "+506", "CR", countryCodeToFlagEmoji("CR")),
+ CountryData("Côte d'Ivoire", "+225", "CI", countryCodeToFlagEmoji("CI")),
+ CountryData("Croatia", "+385", "HR", countryCodeToFlagEmoji("HR")),
+ CountryData("Cuba", "+53", "CU", countryCodeToFlagEmoji("CU")),
+ CountryData("Curaçao", "+599", "CW", countryCodeToFlagEmoji("CW")),
+ CountryData("Cyprus", "+357", "CY", countryCodeToFlagEmoji("CY")),
+ CountryData("Czech Republic", "+420", "CZ", countryCodeToFlagEmoji("CZ")),
+ CountryData("Denmark", "+45", "DK", countryCodeToFlagEmoji("DK")),
+ CountryData("Djibouti", "+253", "DJ", countryCodeToFlagEmoji("DJ")),
+ CountryData("Dominica", "+767", "DM", countryCodeToFlagEmoji("DM")),
+ CountryData("Dominican Republic", "+809", "DO", countryCodeToFlagEmoji("DO")),
+ CountryData("Ecuador", "+593", "EC", countryCodeToFlagEmoji("EC")),
+ CountryData("Egypt", "+20", "EG", countryCodeToFlagEmoji("EG")),
+ CountryData("El Salvador", "+503", "SV", countryCodeToFlagEmoji("SV")),
+ CountryData("Equatorial Guinea", "+240", "GQ", countryCodeToFlagEmoji("GQ")),
+ CountryData("Eritrea", "+291", "ER", countryCodeToFlagEmoji("ER")),
+ CountryData("Estonia", "+372", "EE", countryCodeToFlagEmoji("EE")),
+ CountryData("Ethiopia", "+251", "ET", countryCodeToFlagEmoji("ET")),
+ CountryData("Falkland Islands", "+500", "FK", countryCodeToFlagEmoji("FK")),
+ CountryData("Faroe Islands", "+298", "FO", countryCodeToFlagEmoji("FO")),
+ CountryData("Fiji", "+679", "FJ", countryCodeToFlagEmoji("FJ")),
+ CountryData("Finland", "+358", "FI", countryCodeToFlagEmoji("FI")),
+ CountryData("France", "+33", "FR", countryCodeToFlagEmoji("FR")),
+ CountryData("French Guiana", "+594", "GF", countryCodeToFlagEmoji("GF")),
+ CountryData("French Polynesia", "+689", "PF", countryCodeToFlagEmoji("PF")),
+ CountryData("Gabon", "+241", "GA", countryCodeToFlagEmoji("GA")),
+ CountryData("Gambia", "+220", "GM", countryCodeToFlagEmoji("GM")),
+ CountryData("Georgia", "+995", "GE", countryCodeToFlagEmoji("GE")),
+ CountryData("Germany", "+49", "DE", countryCodeToFlagEmoji("DE")),
+ CountryData("Ghana", "+233", "GH", countryCodeToFlagEmoji("GH")),
+ CountryData("Gibraltar", "+350", "GI", countryCodeToFlagEmoji("GI")),
+ CountryData("Greece", "+30", "GR", countryCodeToFlagEmoji("GR")),
+ CountryData("Greenland", "+299", "GL", countryCodeToFlagEmoji("GL")),
+ CountryData("Grenada", "+473", "GD", countryCodeToFlagEmoji("GD")),
+ CountryData("Guadeloupe", "+590", "GP", countryCodeToFlagEmoji("GP")),
+ CountryData("Guam", "+671", "GU", countryCodeToFlagEmoji("GU")),
+ CountryData("Guatemala", "+502", "GT", countryCodeToFlagEmoji("GT")),
+ CountryData("Guernsey", "+1481", "GG", countryCodeToFlagEmoji("GG")),
+ CountryData("Guinea", "+224", "GN", countryCodeToFlagEmoji("GN")),
+ CountryData("Guinea-Bissau", "+245", "GW", countryCodeToFlagEmoji("GW")),
+ CountryData("Guyana", "+592", "GY", countryCodeToFlagEmoji("GY")),
+ CountryData("Haiti", "+509", "HT", countryCodeToFlagEmoji("HT")),
+ CountryData("Honduras", "+504", "HN", countryCodeToFlagEmoji("HN")),
+ CountryData("Hong Kong", "+852", "HK", countryCodeToFlagEmoji("HK")),
+ CountryData("Hungary", "+36", "HU", countryCodeToFlagEmoji("HU")),
+ CountryData("Iceland", "+354", "IS", countryCodeToFlagEmoji("IS")),
+ CountryData("India", "+91", "IN", countryCodeToFlagEmoji("IN")),
+ CountryData("Indonesia", "+62", "ID", countryCodeToFlagEmoji("ID")),
+ CountryData("Iran", "+98", "IR", countryCodeToFlagEmoji("IR")),
+ CountryData("Iraq", "+964", "IQ", countryCodeToFlagEmoji("IQ")),
+ CountryData("Ireland", "+353", "IE", countryCodeToFlagEmoji("IE")),
+ CountryData("Isle of Man", "+44", "IM", countryCodeToFlagEmoji("IM")),
+ CountryData("Israel", "+972", "IL", countryCodeToFlagEmoji("IL")),
+ CountryData("Italy", "+39", "IT", countryCodeToFlagEmoji("IT")),
+ CountryData("Jamaica", "+876", "JM", countryCodeToFlagEmoji("JM")),
+ CountryData("Japan", "+81", "JP", countryCodeToFlagEmoji("JP")),
+ CountryData("Jersey", "+44", "JE", countryCodeToFlagEmoji("JE")),
+ CountryData("Jordan", "+962", "JO", countryCodeToFlagEmoji("JO")),
+ CountryData("Kazakhstan", "+7", "KZ", countryCodeToFlagEmoji("KZ")),
+ CountryData("Kenya", "+254", "KE", countryCodeToFlagEmoji("KE")),
+ CountryData("Kiribati", "+686", "KI", countryCodeToFlagEmoji("KI")),
+ CountryData("Kosovo", "+383", "XK", countryCodeToFlagEmoji("XK")),
+ CountryData("Kuwait", "+965", "KW", countryCodeToFlagEmoji("KW")),
+ CountryData("Kyrgyzstan", "+996", "KG", countryCodeToFlagEmoji("KG")),
+ CountryData("Laos", "+856", "LA", countryCodeToFlagEmoji("LA")),
+ CountryData("Latvia", "+371", "LV", countryCodeToFlagEmoji("LV")),
+ CountryData("Lebanon", "+961", "LB", countryCodeToFlagEmoji("LB")),
+ CountryData("Lesotho", "+266", "LS", countryCodeToFlagEmoji("LS")),
+ CountryData("Liberia", "+231", "LR", countryCodeToFlagEmoji("LR")),
+ CountryData("Libya", "+218", "LY", countryCodeToFlagEmoji("LY")),
+ CountryData("Liechtenstein", "+423", "LI", countryCodeToFlagEmoji("LI")),
+ CountryData("Lithuania", "+370", "LT", countryCodeToFlagEmoji("LT")),
+ CountryData("Luxembourg", "+352", "LU", countryCodeToFlagEmoji("LU")),
+ CountryData("Macao", "+853", "MO", countryCodeToFlagEmoji("MO")),
+ CountryData("Macedonia", "+389", "MK", countryCodeToFlagEmoji("MK")),
+ CountryData("Madagascar", "+261", "MG", countryCodeToFlagEmoji("MG")),
+ CountryData("Malawi", "+265", "MW", countryCodeToFlagEmoji("MW")),
+ CountryData("Malaysia", "+60", "MY", countryCodeToFlagEmoji("MY")),
+ CountryData("Maldives", "+960", "MV", countryCodeToFlagEmoji("MV")),
+ CountryData("Mali", "+223", "ML", countryCodeToFlagEmoji("ML")),
+ CountryData("Malta", "+356", "MT", countryCodeToFlagEmoji("MT")),
+ CountryData("Marshall Islands", "+692", "MH", countryCodeToFlagEmoji("MH")),
+ CountryData("Martinique", "+596", "MQ", countryCodeToFlagEmoji("MQ")),
+ CountryData("Mauritania", "+222", "MR", countryCodeToFlagEmoji("MR")),
+ CountryData("Mauritius", "+230", "MU", countryCodeToFlagEmoji("MU")),
+ CountryData("Mayotte", "+262", "YT", countryCodeToFlagEmoji("YT")),
+ CountryData("Mexico", "+52", "MX", countryCodeToFlagEmoji("MX")),
+ CountryData("Micronesia", "+691", "FM", countryCodeToFlagEmoji("FM")),
+ CountryData("Moldova", "+373", "MD", countryCodeToFlagEmoji("MD")),
+ CountryData("Monaco", "+377", "MC", countryCodeToFlagEmoji("MC")),
+ CountryData("Mongolia", "+976", "MN", countryCodeToFlagEmoji("MN")),
+ CountryData("Montenegro", "+382", "ME", countryCodeToFlagEmoji("ME")),
+ CountryData("Montserrat", "+664", "MS", countryCodeToFlagEmoji("MS")),
+ CountryData("Morocco", "+212", "MA", countryCodeToFlagEmoji("MA")),
+ CountryData("Mozambique", "+258", "MZ", countryCodeToFlagEmoji("MZ")),
+ CountryData("Myanmar", "+95", "MM", countryCodeToFlagEmoji("MM")),
+ CountryData("Namibia", "+264", "NA", countryCodeToFlagEmoji("NA")),
+ CountryData("Nauru", "+674", "NR", countryCodeToFlagEmoji("NR")),
+ CountryData("Nepal", "+977", "NP", countryCodeToFlagEmoji("NP")),
+ CountryData("Netherlands", "+31", "NL", countryCodeToFlagEmoji("NL")),
+ CountryData("New Caledonia", "+687", "NC", countryCodeToFlagEmoji("NC")),
+ CountryData("New Zealand", "+64", "NZ", countryCodeToFlagEmoji("NZ")),
+ CountryData("Nicaragua", "+505", "NI", countryCodeToFlagEmoji("NI")),
+ CountryData("Niger", "+227", "NE", countryCodeToFlagEmoji("NE")),
+ CountryData("Nigeria", "+234", "NG", countryCodeToFlagEmoji("NG")),
+ CountryData("Niue", "+683", "NU", countryCodeToFlagEmoji("NU")),
+ CountryData("Norfolk Island", "+672", "NF", countryCodeToFlagEmoji("NF")),
+ CountryData("North Korea", "+850", "KP", countryCodeToFlagEmoji("KP")),
+ CountryData("Northern Mariana Islands", "+670", "MP", countryCodeToFlagEmoji("MP")),
+ CountryData("Norway", "+47", "NO", countryCodeToFlagEmoji("NO")),
+ CountryData("Oman", "+968", "OM", countryCodeToFlagEmoji("OM")),
+ CountryData("Pakistan", "+92", "PK", countryCodeToFlagEmoji("PK")),
+ CountryData("Palau", "+680", "PW", countryCodeToFlagEmoji("PW")),
+ CountryData("Palestine", "+970", "PS", countryCodeToFlagEmoji("PS")),
+ CountryData("Panama", "+507", "PA", countryCodeToFlagEmoji("PA")),
+ CountryData("Papua New Guinea", "+675", "PG", countryCodeToFlagEmoji("PG")),
+ CountryData("Paraguay", "+595", "PY", countryCodeToFlagEmoji("PY")),
+ CountryData("Peru", "+51", "PE", countryCodeToFlagEmoji("PE")),
+ CountryData("Philippines", "+63", "PH", countryCodeToFlagEmoji("PH")),
+ CountryData("Poland", "+48", "PL", countryCodeToFlagEmoji("PL")),
+ CountryData("Portugal", "+351", "PT", countryCodeToFlagEmoji("PT")),
+ CountryData("Puerto Rico", "+787", "PR", countryCodeToFlagEmoji("PR")),
+ CountryData("Qatar", "+974", "QA", countryCodeToFlagEmoji("QA")),
+ CountryData("Réunion", "+262", "RE", countryCodeToFlagEmoji("RE")),
+ CountryData("Romania", "+40", "RO", countryCodeToFlagEmoji("RO")),
+ CountryData("Russia", "+7", "RU", countryCodeToFlagEmoji("RU")),
+ CountryData("Rwanda", "+250", "RW", countryCodeToFlagEmoji("RW")),
+ CountryData("Saint Barthélemy", "+590", "BL", countryCodeToFlagEmoji("BL")),
+ CountryData("Saint Helena", "+290", "SH", countryCodeToFlagEmoji("SH")),
+ CountryData("Saint Kitts and Nevis", "+869", "KN", countryCodeToFlagEmoji("KN")),
+ CountryData("Saint Lucia", "+758", "LC", countryCodeToFlagEmoji("LC")),
+ CountryData("Saint Martin", "+590", "MF", countryCodeToFlagEmoji("MF")),
+ CountryData("Saint Pierre and Miquelon", "+508", "PM", countryCodeToFlagEmoji("PM")),
+ CountryData("Saint Vincent and the Grenadines", "+784", "VC", countryCodeToFlagEmoji("VC")),
+ CountryData("Samoa", "+685", "WS", countryCodeToFlagEmoji("WS")),
+ CountryData("San Marino", "+378", "SM", countryCodeToFlagEmoji("SM")),
+ CountryData("Sao Tome and Principe", "+239", "ST", countryCodeToFlagEmoji("ST")),
+ CountryData("Saudi Arabia", "+966", "SA", countryCodeToFlagEmoji("SA")),
+ CountryData("Senegal", "+221", "SN", countryCodeToFlagEmoji("SN")),
+ CountryData("Serbia", "+381", "RS", countryCodeToFlagEmoji("RS")),
+ CountryData("Seychelles", "+248", "SC", countryCodeToFlagEmoji("SC")),
+ CountryData("Sierra Leone", "+232", "SL", countryCodeToFlagEmoji("SL")),
+ CountryData("Singapore", "+65", "SG", countryCodeToFlagEmoji("SG")),
+ CountryData("Sint Maarten", "+599", "SX", countryCodeToFlagEmoji("SX")),
+ CountryData("Slovakia", "+421", "SK", countryCodeToFlagEmoji("SK")),
+ CountryData("Slovenia", "+386", "SI", countryCodeToFlagEmoji("SI")),
+ CountryData("Solomon Islands", "+677", "SB", countryCodeToFlagEmoji("SB")),
+ CountryData("Somalia", "+252", "SO", countryCodeToFlagEmoji("SO")),
+ CountryData("South Africa", "+27", "ZA", countryCodeToFlagEmoji("ZA")),
+ CountryData("South Korea", "+82", "KR", countryCodeToFlagEmoji("KR")),
+ CountryData("South Sudan", "+211", "SS", countryCodeToFlagEmoji("SS")),
+ CountryData("Spain", "+34", "ES", countryCodeToFlagEmoji("ES")),
+ CountryData("Sri Lanka", "+94", "LK", countryCodeToFlagEmoji("LK")),
+ CountryData("Sudan", "+249", "SD", countryCodeToFlagEmoji("SD")),
+ CountryData("Suriname", "+597", "SR", countryCodeToFlagEmoji("SR")),
+ CountryData("Swaziland", "+268", "SZ", countryCodeToFlagEmoji("SZ")),
+ CountryData("Sweden", "+46", "SE", countryCodeToFlagEmoji("SE")),
+ CountryData("Switzerland", "+41", "CH", countryCodeToFlagEmoji("CH")),
+ CountryData("Syria", "+963", "SY", countryCodeToFlagEmoji("SY")),
+ CountryData("Taiwan", "+886", "TW", countryCodeToFlagEmoji("TW")),
+ CountryData("Tajikistan", "+992", "TJ", countryCodeToFlagEmoji("TJ")),
+ CountryData("Tanzania", "+255", "TZ", countryCodeToFlagEmoji("TZ")),
+ CountryData("Thailand", "+66", "TH", countryCodeToFlagEmoji("TH")),
+ CountryData("Timor-Leste", "+670", "TL", countryCodeToFlagEmoji("TL")),
+ CountryData("Togo", "+228", "TG", countryCodeToFlagEmoji("TG")),
+ CountryData("Tokelau", "+690", "TK", countryCodeToFlagEmoji("TK")),
+ CountryData("Tonga", "+676", "TO", countryCodeToFlagEmoji("TO")),
+ CountryData("Trinidad and Tobago", "+868", "TT", countryCodeToFlagEmoji("TT")),
+ CountryData("Tunisia", "+216", "TN", countryCodeToFlagEmoji("TN")),
+ CountryData("Turkey", "+90", "TR", countryCodeToFlagEmoji("TR")),
+ CountryData("Turkmenistan", "+993", "TM", countryCodeToFlagEmoji("TM")),
+ CountryData("Turks and Caicos Islands", "+649", "TC", countryCodeToFlagEmoji("TC")),
+ CountryData("Tuvalu", "+688", "TV", countryCodeToFlagEmoji("TV")),
+ CountryData("Uganda", "+256", "UG", countryCodeToFlagEmoji("UG")),
+ CountryData("Ukraine", "+380", "UA", countryCodeToFlagEmoji("UA")),
+ CountryData("United Arab Emirates", "+971", "AE", countryCodeToFlagEmoji("AE")),
+ CountryData("United Kingdom", "+44", "GB", countryCodeToFlagEmoji("GB")),
+ CountryData("United States", "+1", "US", countryCodeToFlagEmoji("US")),
+ CountryData("Uruguay", "+598", "UY", countryCodeToFlagEmoji("UY")),
+ CountryData("Uzbekistan", "+998", "UZ", countryCodeToFlagEmoji("UZ")),
+ CountryData("Vanuatu", "+678", "VU", countryCodeToFlagEmoji("VU")),
+ CountryData("Vatican City", "+379", "VA", countryCodeToFlagEmoji("VA")),
+ CountryData("Venezuela", "+58", "VE", countryCodeToFlagEmoji("VE")),
+ CountryData("Vietnam", "+84", "VN", countryCodeToFlagEmoji("VN")),
+ CountryData("Virgin Islands (British)", "+284", "VG", countryCodeToFlagEmoji("VG")),
+ CountryData("Virgin Islands (U.S.)", "+340", "VI", countryCodeToFlagEmoji("VI")),
+ CountryData("Wallis and Futuna", "+681", "WF", countryCodeToFlagEmoji("WF")),
+ CountryData("Western Sahara", "+212", "EH", countryCodeToFlagEmoji("EH")),
+ CountryData("Yemen", "+967", "YE", countryCodeToFlagEmoji("YE")),
+ CountryData("Zambia", "+260", "ZM", countryCodeToFlagEmoji("ZM")),
+ CountryData("Zimbabwe", "+263", "ZW", countryCodeToFlagEmoji("ZW"))
+)
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/CountryData.kt b/auth/src/main/java/com/firebase/ui/auth/data/CountryData.kt
new file mode 100644
index 0000000000..e171f47a8c
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/data/CountryData.kt
@@ -0,0 +1,62 @@
+/*
+ * 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.data
+
+/**
+ * Represents country information for phone number authentication.
+ *
+ * @property name The display name of the country (e.g., "United States").
+ * @property dialCode The international dialing code (e.g., "+1").
+ * @property countryCode The ISO 3166-1 alpha-2 country code (e.g., "US").
+ * @property flagEmoji The flag emoji for the country (e.g., "🇺🇸").
+ */
+data class CountryData(
+ val name: String,
+ val dialCode: String,
+ val countryCode: String,
+ val flagEmoji: String
+) {
+ /**
+ * Returns a formatted display string combining flag emoji and country name.
+ */
+ fun getDisplayName(): String = "$flagEmoji $name"
+
+ /**
+ * Returns a formatted string with dial code.
+ */
+ fun getDisplayNameWithDialCode(): String = "$flagEmoji $name ($dialCode)"
+}
+
+/**
+ * Converts an ISO 3166-1 alpha-2 country code to its corresponding flag emoji.
+ *
+ * @param countryCode The two-letter country code (e.g., "US", "GB", "FR").
+ * @return The flag emoji string, or an empty string if the code is invalid.
+ */
+fun countryCodeToFlagEmoji(countryCode: String): String {
+ if (countryCode.length != 2) return ""
+
+ val uppercaseCode = countryCode.uppercase()
+ val baseCodePoint = 0x1F1E6 // Regional Indicator Symbol Letter A
+ val charCodeOffset = 'A'.code
+
+ val firstChar = uppercaseCode[0].code
+ val secondChar = uppercaseCode[1].code
+
+ val firstCodePoint = baseCodePoint + (firstChar - charCodeOffset)
+ val secondCodePoint = baseCodePoint + (secondChar - charCodeOffset)
+
+ return String(intArrayOf(firstCodePoint, secondCodePoint), 0, 2)
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/MfaChallengeContentState.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaChallengeContentState.kt
new file mode 100644
index 0000000000..4311a7926f
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaChallengeContentState.kt
@@ -0,0 +1,115 @@
+/*
+ * 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.mfa
+
+import com.firebase.ui.auth.configuration.MfaFactor
+
+/**
+ * State class containing all the necessary information to render a custom UI for the
+ * Multi-Factor Authentication (MFA) challenge flow during sign-in.
+ *
+ * This class is passed to the content slot of the MfaChallengeScreen composable, providing
+ * access to the current factor, user input values, callbacks for actions, and loading/error states.
+ *
+ * The challenge flow is simpler than enrollment as the user has already configured their MFA:
+ * 1. User enters their verification code (SMS or TOTP)
+ * 2. System verifies the code and completes sign-in
+ *
+ * ```kotlin
+ * MfaChallengeScreen(resolver, onSuccess, onCancel, onError) { state ->
+ * Column {
+ * Text("Enter your ${state.factorType} code")
+ * TextField(
+ * value = state.verificationCode,
+ * onValueChange = state.onVerificationCodeChange
+ * )
+ * if (state.canResend) {
+ * TextButton(onClick = state.onResendCodeClick) {
+ * Text("Resend code")
+ * }
+ * }
+ * Button(
+ * onClick = state.onVerifyClick,
+ * enabled = !state.isLoading && state.isValid
+ * ) {
+ * Text("Verify")
+ * }
+ * }
+ * }
+ * ```
+ *
+ * @property factorType The type of MFA factor being challenged (SMS or TOTP)
+ * @property maskedPhoneNumber For SMS factors, the masked phone number (e.g., "+1••••••890")
+ * @property isLoading `true` when verification is in progress. Use this to show loading indicators.
+ * @property error An optional error message to display to the user. Will be `null` if there's no error.
+ * @property verificationCode The current value of the verification code input field.
+ * @property resendTimer The number of seconds remaining before the "Resend" action is available. Will be 0 when resend is allowed.
+ * @property onVerificationCodeChange Callback invoked when the verification code input changes.
+ * @property onVerifyClick Callback to verify the entered code and complete sign-in.
+ * @property onResendCodeClick For SMS only: Callback to resend the verification code. `null` for TOTP.
+ * @property onCancelClick Callback to cancel the MFA challenge and return to sign-in.
+ *
+ * @since 10.0.0
+ */
+data class MfaChallengeContentState(
+ /** The type of MFA factor being challenged (SMS or TOTP). */
+ val factorType: MfaFactor,
+
+ /** For SMS: the masked phone number. For TOTP: null. */
+ val maskedPhoneNumber: String? = null,
+
+ /** `true` when verification is in progress. Use to show loading indicators. */
+ val isLoading: Boolean = false,
+
+ /** Optional error message to display. `null` if no error. */
+ val error: String? = null,
+
+ /** The current value of the verification code input field. */
+ val verificationCode: String = "",
+
+ /** The number of seconds remaining before resend is available. 0 when ready. */
+ val resendTimer: Int = 0,
+
+ /** Callback invoked when the verification code input changes. */
+ val onVerificationCodeChange: (String) -> Unit = {},
+
+ /** Callback to verify the code and complete sign-in. */
+ val onVerifyClick: () -> Unit = {},
+
+ /** For SMS only: Callback to resend the code. `null` for TOTP. */
+ val onResendCodeClick: (() -> Unit)? = null,
+
+ /** Callback to cancel the challenge and return to sign-in. */
+ val onCancelClick: () -> Unit = {}
+) {
+ /**
+ * Returns true if the current state is valid for verification.
+ * The code must be 6 digits long.
+ */
+ val isValid: Boolean
+ get() = verificationCode.length == 6 && verificationCode.all { it.isDigit() }
+
+ /**
+ * Returns true if there is an error in the current state.
+ */
+ val hasError: Boolean
+ get() = !error.isNullOrBlank()
+
+ /**
+ * Returns true if the resend action is available (SMS only).
+ */
+ val canResend: Boolean
+ get() = factorType == MfaFactor.Sms && onResendCodeClick != null
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentState.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentState.kt
new file mode 100644
index 0000000000..674cb42e60
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentState.kt
@@ -0,0 +1,172 @@
+/*
+ * 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.mfa
+
+import com.firebase.ui.auth.configuration.MfaFactor
+import com.firebase.ui.auth.data.CountryData
+import com.google.firebase.auth.MultiFactorInfo
+
+/**
+ * State class containing all the necessary information to render a custom UI for the
+ * Multi-Factor Authentication (MFA) enrollment flow.
+ *
+ * This class is passed to the content slot of the MfaEnrollmentScreen composable, providing
+ * access to the current step, user input values, callbacks for actions, and loading/error states.
+ *
+ * Use a `when` expression on [step] to determine which UI to render:
+ *
+ * ```kotlin
+ * MfaEnrollmentScreen(user, config, onComplete, onSkip) { state ->
+ * when (state.step) {
+ * MfaEnrollmentStep.SelectFactor -> {
+ * // Render factor selection UI using state.availableFactors
+ * }
+ * MfaEnrollmentStep.ConfigureTotp -> {
+ * // Render TOTP setup UI using state.totpSecret and state.totpQrCodeUrl
+ * }
+ * MfaEnrollmentStep.VerifyFactor -> {
+ * // Render verification UI using state.verificationCode
+ * }
+ * // ... other steps
+ * }
+ * }
+ * ```
+ *
+ * @property step The current step in the enrollment flow. Use this to determine which UI to display.
+ * @property isLoading `true` when an asynchronous operation (like generating a secret or verifying a code) is in progress. Use this to show loading indicators.
+ * @property error An optional error message to display to the user. Will be `null` if there's no error.
+ * @property onBackClick Callback to navigate to the previous step in the flow. Invoked when the user clicks a back button.
+ *
+ * @property availableFactors (Step: [MfaEnrollmentStep.SelectFactor]) A list of MFA factors the user can choose from (e.g., SMS, TOTP). Determined by [com.firebase.ui.auth.configuration.MfaConfiguration.allowedFactors].
+ * @property onFactorSelected (Step: [MfaEnrollmentStep.SelectFactor]) Callback invoked when the user selects an MFA factor. Receives the selected [MfaFactor].
+ * @property onSkipClick (Step: [MfaEnrollmentStep.SelectFactor]) Callback for the "Skip" action. Will be `null` if MFA enrollment is required via [com.firebase.ui.auth.configuration.MfaConfiguration.requireEnrollment].
+ *
+ * @property phoneNumber (Step: [MfaEnrollmentStep.ConfigureSms]) The current value of the phone number input field. Does not include country code prefix.
+ * @property onPhoneNumberChange (Step: [MfaEnrollmentStep.ConfigureSms]) Callback invoked when the phone number input changes. Receives the new phone number string.
+ * @property selectedCountry (Step: [MfaEnrollmentStep.ConfigureSms]) The currently selected country for phone number formatting. Contains dial code, country code, and flag.
+ * @property onCountrySelected (Step: [MfaEnrollmentStep.ConfigureSms]) Callback invoked when the user selects a different country. Receives the new [CountryData].
+ * @property onSendSmsCodeClick (Step: [MfaEnrollmentStep.ConfigureSms]) Callback to send the SMS verification code to the entered phone number.
+ *
+ * @property totpSecret (Step: [MfaEnrollmentStep.ConfigureTotp]) The TOTP secret containing the shared key and configuration. Use this to display the secret key or access the underlying Firebase TOTP secret.
+ * @property totpQrCodeUrl (Step: [MfaEnrollmentStep.ConfigureTotp]) A URI that can be rendered as a QR code or used as a deep link to open authenticator apps. Generated via [TotpSecret.generateQrCodeUrl].
+ * @property onContinueToVerifyClick (Step: [MfaEnrollmentStep.ConfigureTotp]) Callback to proceed to the verification step after the user has scanned the QR code or entered the secret.
+ *
+ * @property verificationCode (Step: [MfaEnrollmentStep.VerifyFactor]) The current value of the verification code input field. Should be a 6-digit string.
+ * @property onVerificationCodeChange (Step: [MfaEnrollmentStep.VerifyFactor]) Callback invoked when the verification code input changes. Receives the new code string.
+ * @property onVerifyClick (Step: [MfaEnrollmentStep.VerifyFactor]) Callback to verify the entered code and finalize MFA enrollment.
+ * @property selectedFactor (Step: [MfaEnrollmentStep.VerifyFactor]) The MFA factor being verified (SMS or TOTP). Use this to customize UI messages.
+ * @property resendTimer (Step: [MfaEnrollmentStep.VerifyFactor], SMS only) The number of seconds remaining before the "Resend" action is available. Will be 0 when resend is allowed.
+ * @property onResendCodeClick (Step: [MfaEnrollmentStep.VerifyFactor], SMS only) Callback to resend the SMS verification code. Will be `null` for TOTP verification.
+ *
+ * @property recoveryCodes (Step: [MfaEnrollmentStep.ShowRecoveryCodes]) A list of one-time backup codes the user should save. Only present if [com.firebase.ui.auth.configuration.MfaConfiguration.enableRecoveryCodes] is `true`.
+ * @property onCodesSavedClick (Step: [MfaEnrollmentStep.ShowRecoveryCodes]) Callback invoked when the user confirms they have saved their recovery codes. Completes the enrollment flow.
+ *
+ * @since 10.0.0
+ */
+data class MfaEnrollmentContentState(
+ /** The current step in the enrollment flow. Use this to determine which UI to display. */
+ val step: MfaEnrollmentStep,
+
+ /** `true` when an async operation is in progress. Use to show loading indicators. */
+ val isLoading: Boolean = false,
+
+ /** Optional error message to display. `null` if no error. */
+ val error: String? = null,
+
+ /** The last exception encountered during enrollment, if available. */
+ val exception: Exception? = null,
+
+ /** Callback to navigate to the previous step. */
+ val onBackClick: () -> Unit = {},
+
+ // SelectFactor step
+ val availableFactors: List = emptyList(),
+
+ val enrolledFactors: List = emptyList(),
+
+ val onFactorSelected: (MfaFactor) -> Unit = {},
+
+ val onUnenrollFactor: (MultiFactorInfo) -> Unit = {},
+
+ val onSkipClick: (() -> Unit)? = null,
+
+ // ConfigureSms step
+ val phoneNumber: String = "",
+
+ val onPhoneNumberChange: (String) -> Unit = {},
+
+ val selectedCountry: CountryData? = null,
+
+ val onCountrySelected: (CountryData) -> Unit = {},
+
+ val onSendSmsCodeClick: () -> Unit = {},
+
+ // ConfigureTotp step
+ val totpSecret: TotpSecret? = null,
+
+ val totpQrCodeUrl: String? = null,
+
+ val onContinueToVerifyClick: () -> Unit = {},
+
+ // VerifyFactor step
+ val verificationCode: String = "",
+
+ val onVerificationCodeChange: (String) -> Unit = {},
+
+ val onVerifyClick: () -> Unit = {},
+
+ val selectedFactor: MfaFactor? = null,
+
+ val resendTimer: Int = 0,
+
+ val onResendCodeClick: (() -> Unit)? = null,
+
+ // ShowRecoveryCodes step
+ val recoveryCodes: List? = null,
+
+ val onCodesSavedClick: () -> Unit = {}
+) {
+ /**
+ * Returns true if the current state is valid for the current step.
+ *
+ * This can be used to enable/disable action buttons in the UI.
+ */
+ val isValid: Boolean
+ get() = when (step) {
+ MfaEnrollmentStep.SelectFactor -> availableFactors.isNotEmpty()
+ MfaEnrollmentStep.ConfigureSms -> phoneNumber.isNotBlank()
+ MfaEnrollmentStep.ConfigureTotp -> totpSecret != null && totpQrCodeUrl != null
+ MfaEnrollmentStep.VerifyFactor -> verificationCode.length == 6
+ MfaEnrollmentStep.ShowRecoveryCodes -> !recoveryCodes.isNullOrEmpty()
+ }
+
+ /**
+ * Returns true if there is an error in the current state.
+ */
+ val hasError: Boolean
+ get() = !error.isNullOrBlank()
+
+ /**
+ * Returns true if the skip action is available (only for SelectFactor step when not required).
+ */
+ val canSkip: Boolean
+ get() = step == MfaEnrollmentStep.SelectFactor && onSkipClick != null
+
+ /**
+ * Returns true if the back action is available (for all steps except SelectFactor).
+ */
+ val canGoBack: Boolean
+ get() = step != MfaEnrollmentStep.SelectFactor
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentStep.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentStep.kt
new file mode 100644
index 0000000000..8d64da6202
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentStep.kt
@@ -0,0 +1,98 @@
+/*
+ * 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.mfa
+
+import com.firebase.ui.auth.configuration.MfaFactor
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+/**
+ * Represents the different steps in the Multi-Factor Authentication (MFA) enrollment flow.
+ *
+ * This enum defines the sequence of UI states that users progress through when enrolling
+ * in MFA, from selecting a factor to completing the setup with recovery codes.
+ *
+ * @since 10.0.0
+ */
+enum class MfaEnrollmentStep {
+ /**
+ * The user is presented with a selection of available MFA factors to enroll in.
+ * The available factors are determined by the [com.firebase.ui.auth.configuration.MfaConfiguration].
+ */
+ SelectFactor,
+
+ /**
+ * The user is configuring SMS-based MFA by entering their phone number.
+ * This step prepares to send an SMS verification code to the provided number.
+ */
+ ConfigureSms,
+
+ /**
+ * The user is configuring TOTP (Time-based One-Time Password) MFA.
+ * This step presents the TOTP secret (as both text and QR code) for the user
+ * to scan into their authenticator app.
+ */
+ ConfigureTotp,
+
+ /**
+ * The user is verifying their chosen MFA factor by entering a verification code.
+ * For SMS, this is the code received via text message.
+ * For TOTP, this is the code generated by their authenticator app.
+ */
+ VerifyFactor,
+
+ /**
+ * The enrollment is complete and recovery codes are displayed to the user.
+ * These backup codes can be used to sign in if the primary MFA method is unavailable.
+ * This step only appears if recovery codes are enabled in the configuration.
+ */
+ ShowRecoveryCodes
+}
+
+/**
+ * Returns the localized title text for this enrollment step.
+ *
+ * @param stringProvider The string provider for localized strings
+ * @return The localized title for this step
+ */
+fun MfaEnrollmentStep.getTitle(stringProvider: AuthUIStringProvider): String = when (this) {
+ MfaEnrollmentStep.SelectFactor -> stringProvider.mfaStepSelectFactorTitle
+ MfaEnrollmentStep.ConfigureSms -> stringProvider.mfaStepConfigureSmsTitle
+ MfaEnrollmentStep.ConfigureTotp -> stringProvider.mfaStepConfigureTotpTitle
+ MfaEnrollmentStep.VerifyFactor -> stringProvider.mfaStepVerifyFactorTitle
+ MfaEnrollmentStep.ShowRecoveryCodes -> stringProvider.mfaStepShowRecoveryCodesTitle
+}
+
+/**
+ * Returns localized helper text providing instructions for this step.
+ *
+ * @param stringProvider The string provider for localized strings
+ * @param selectedFactor The MFA factor being configured or verified. Used for [MfaEnrollmentStep.VerifyFactor]
+ * to provide factor-specific instructions. Ignored for other steps.
+ * @return Localized instructional text appropriate for this step
+ */
+fun MfaEnrollmentStep.getHelperText(
+ stringProvider: AuthUIStringProvider,
+ selectedFactor: MfaFactor? = null
+): String = when (this) {
+ MfaEnrollmentStep.SelectFactor -> stringProvider.mfaStepSelectFactorHelper
+ MfaEnrollmentStep.ConfigureSms -> stringProvider.mfaStepConfigureSmsHelper
+ MfaEnrollmentStep.ConfigureTotp -> stringProvider.mfaStepConfigureTotpHelper
+ MfaEnrollmentStep.VerifyFactor -> when (selectedFactor) {
+ MfaFactor.Sms -> stringProvider.mfaStepVerifyFactorSmsHelper
+ MfaFactor.Totp -> stringProvider.mfaStepVerifyFactorTotpHelper
+ null -> stringProvider.mfaStepVerifyFactorGenericHelper
+ }
+ MfaEnrollmentStep.ShowRecoveryCodes -> stringProvider.mfaStepShowRecoveryCodesHelper
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/MfaErrorMapper.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaErrorMapper.kt
new file mode 100644
index 0000000000..7776702ee6
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaErrorMapper.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.mfa
+
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.google.firebase.FirebaseNetworkException
+import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException
+import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException
+import java.io.IOException
+
+/**
+ * Maps Firebase Auth exceptions to localized error messages for MFA enrollment.
+ *
+ * @param stringProvider Provider for localized strings
+ * @return Localized error message appropriate for the exception type
+ */
+fun Exception.toMfaErrorMessage(stringProvider: AuthUIStringProvider): String {
+ return when (this) {
+ is FirebaseAuthRecentLoginRequiredException ->
+ stringProvider.mfaErrorRecentLoginRequired
+ is FirebaseAuthInvalidCredentialsException ->
+ stringProvider.mfaErrorInvalidVerificationCode
+ is IOException, is FirebaseNetworkException ->
+ stringProvider.mfaErrorNetwork
+ else -> stringProvider.mfaErrorGeneric
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt
new file mode 100644
index 0000000000..8aec7c9c3c
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt
@@ -0,0 +1,376 @@
+/*
+ * 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.mfa
+
+import android.app.Activity
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.mfa.SmsEnrollmentHandler.Companion.RESEND_DELAY_SECONDS
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.PhoneAuthCredential
+import com.google.firebase.auth.PhoneAuthProvider
+import com.google.firebase.auth.PhoneMultiFactorGenerator
+import kotlinx.coroutines.tasks.await
+
+/**
+ * Handler for SMS multi-factor authentication enrollment.
+ *
+ * This class manages the complete SMS enrollment flow, including:
+ * - Sending SMS verification codes to phone numbers
+ * - Resending codes with timer support
+ * - Verifying SMS codes entered by users
+ * - Finalizing enrollment with Firebase Authentication
+ *
+ * This handler uses the existing [AuthProvider.Phone.verifyPhoneNumberAwait] infrastructure
+ * for sending and verifying SMS codes, ensuring consistency with the primary phone auth flow.
+ *
+ * **Usage:**
+ * ```kotlin
+ * val handler = SmsEnrollmentHandler(auth, user)
+ *
+ * // Step 1: Send verification code
+ * val session = handler.sendVerificationCode("+1234567890")
+ *
+ * // Step 2: Display masked phone number and wait for user input
+ * val masked = session.getMaskedPhoneNumber()
+ *
+ * // Step 3: If needed, resend code after timer expires
+ * val newSession = handler.resendVerificationCode(session)
+ *
+ * // Step 4: Verify the code entered by the user
+ * val verificationCode = "123456" // From user input
+ * handler.enrollWithVerificationCode(session, verificationCode, "My Phone")
+ * ```
+ *
+ * @property auth The [FirebaseAuth] instance
+ * @property user The [FirebaseUser] to enroll in SMS MFA
+ *
+ * @since 10.0.0
+ * @see TotpEnrollmentHandler
+ * @see AuthProvider.Phone.verifyPhoneNumberAwait
+ */
+class SmsEnrollmentHandler(
+ private val activity: Activity,
+ private val auth: FirebaseAuth,
+ private val user: FirebaseUser
+) {
+ private val phoneProvider = AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null,
+ smsCodeLength = SMS_CODE_LENGTH,
+ timeout = VERIFICATION_TIMEOUT_SECONDS,
+ isInstantVerificationEnabled = true
+ )
+ /**
+ * Sends an SMS verification code to the specified phone number.
+ *
+ * This method initiates the SMS enrollment process by sending a verification code
+ * to the provided phone number. The code will be sent via SMS and should be
+ * displayed to the user for entry.
+ *
+ * **Important:** The user must re-authenticate before calling this method if their
+ * session is not recent. Use [FirebaseUser.reauthenticate] if needed.
+ *
+ * @param phoneNumber The phone number in E.164 format (e.g., "+1234567890")
+ * @return An [SmsEnrollmentSession] containing the verification ID and metadata
+ * @throws Exception if the user needs to re-authenticate, phone number is invalid,
+ * or SMS sending fails
+ *
+ * @see resendVerificationCode
+ * @see SmsEnrollmentSession.getMaskedPhoneNumber
+ */
+ suspend fun sendVerificationCode(phoneNumber: String): SmsEnrollmentSession {
+ require(isValidPhoneNumber(phoneNumber)) {
+ "Phone number must be in E.164 format (e.g., +1234567890)"
+ }
+
+ val multiFactorSession = user.multiFactor.session.await()
+ val result = phoneProvider.verifyPhoneNumberAwait(
+ auth = auth,
+ activity = activity,
+ phoneNumber = phoneNumber,
+ multiFactorSession = multiFactorSession,
+ forceResendingToken = null
+ )
+
+ return when (result) {
+ is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> {
+ SmsEnrollmentSession(
+ verificationId = "", // Not needed when auto-verified
+ phoneNumber = phoneNumber,
+ forceResendingToken = null,
+ sentAt = System.currentTimeMillis(),
+ autoVerifiedCredential = result.credential
+ )
+ }
+ is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> {
+ SmsEnrollmentSession(
+ verificationId = result.verificationId,
+ phoneNumber = phoneNumber,
+ forceResendingToken = result.token,
+ sentAt = System.currentTimeMillis()
+ )
+ }
+ }
+ }
+
+ /**
+ * Resends the SMS verification code to the phone number.
+ *
+ * This method uses the force resending token from the original session to
+ * explicitly request a new SMS code. This should only be called after the
+ * [RESEND_DELAY_SECONDS] has elapsed to respect rate limits.
+ *
+ * @param session The original [SmsEnrollmentSession] from [sendVerificationCode]
+ * @return A new [SmsEnrollmentSession] with updated verification ID and timestamp
+ * @throws Exception if resending fails or if the session doesn't have a resend token
+ *
+ * @see sendVerificationCode
+ */
+ suspend fun resendVerificationCode(session: SmsEnrollmentSession): SmsEnrollmentSession {
+ require(session.forceResendingToken != null) {
+ "Cannot resend code without a force resending token"
+ }
+
+ val multiFactorSession = user.multiFactor.session.await()
+ val result = phoneProvider.verifyPhoneNumberAwait(
+ auth = auth,
+ activity = activity,
+ phoneNumber = session.phoneNumber,
+ multiFactorSession = multiFactorSession,
+ forceResendingToken = session.forceResendingToken
+ )
+
+ return when (result) {
+ is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> {
+ SmsEnrollmentSession(
+ verificationId = "", // Not needed when auto-verified
+ phoneNumber = session.phoneNumber,
+ forceResendingToken = session.forceResendingToken,
+ sentAt = System.currentTimeMillis(),
+ autoVerifiedCredential = result.credential
+ )
+ }
+ is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> {
+ SmsEnrollmentSession(
+ verificationId = result.verificationId,
+ phoneNumber = session.phoneNumber,
+ forceResendingToken = result.token,
+ sentAt = System.currentTimeMillis()
+ )
+ }
+ }
+ }
+
+ /**
+ * Verifies an SMS code and completes the enrollment process.
+ *
+ * This method creates a multi-factor assertion using the provided session and
+ * verification code, then enrolls the user in SMS MFA with Firebase Authentication.
+ *
+ * @param session The [SmsEnrollmentSession] from [sendVerificationCode] or [resendVerificationCode]
+ * @param verificationCode The 6-digit code from the SMS message
+ * @param displayName Optional friendly name for this MFA factor (e.g., "My Phone")
+ * @throws Exception if the verification code is invalid or if enrollment fails
+ *
+ * @see sendVerificationCode
+ * @see resendVerificationCode
+ */
+ suspend fun enrollWithVerificationCode(
+ session: SmsEnrollmentSession,
+ verificationCode: String,
+ displayName: String? = null
+ ) {
+ require(isValidCodeFormat(verificationCode)) {
+ "Verification code must be 6 digits"
+ }
+
+ val credential = session.autoVerifiedCredential
+ ?: PhoneAuthProvider.getCredential(session.verificationId, verificationCode)
+
+ val multiFactorAssertion = PhoneMultiFactorGenerator.getAssertion(credential)
+ user.multiFactor.enroll(multiFactorAssertion, displayName).await()
+ }
+
+ /**
+ * Validates that a verification code has the correct format for SMS.
+ *
+ * This method performs basic client-side validation to ensure the code:
+ * - Is not null or empty
+ * - Contains only digits
+ * - Has exactly 6 digits (the standard SMS code length)
+ *
+ * **Note:** This does not verify the code against the server. Use
+ * [enrollWithVerificationCode] to perform actual verification with Firebase.
+ *
+ * @param code The verification code to validate
+ * @return `true` if the code has a valid format, `false` otherwise
+ */
+ fun isValidCodeFormat(code: String): Boolean {
+ return code.isNotBlank() &&
+ code.length == SMS_CODE_LENGTH &&
+ code.all { it.isDigit() }
+ }
+
+ /**
+ * Validates that a phone number is in the correct E.164 format.
+ *
+ * E.164 format requirements:
+ * - Starts with "+"
+ * - Followed by 1-15 digits
+ * - No spaces, hyphens, or other characters
+ * - Minimum 4 digits total (country code + subscriber number)
+ *
+ * Examples of valid numbers:
+ * - +1234567890 (US)
+ * - +447911123456 (UK)
+ * - +33612345678 (France)
+ *
+ * @param phoneNumber The phone number to validate
+ * @return `true` if the phone number is in E.164 format, `false` otherwise
+ */
+ fun isValidPhoneNumber(phoneNumber: String): Boolean {
+ return phoneNumber.matches(Regex("^\\+[1-9]\\d{3,14}$"))
+ }
+
+ companion object {
+ /**
+ * The standard length for SMS verification codes.
+ */
+ const val SMS_CODE_LENGTH = 6
+
+ /**
+ * The verification timeout in seconds for phone authentication.
+ * This is how long Firebase will wait for auto-verification before
+ * falling back to manual code entry.
+ */
+ const val VERIFICATION_TIMEOUT_SECONDS = 60L
+
+ /**
+ * The recommended delay in seconds before allowing code resend.
+ * This prevents users from spamming the resend functionality and
+ * respects carrier rate limits.
+ */
+ const val RESEND_DELAY_SECONDS = 30
+
+ /**
+ * The Firebase factor ID for SMS multi-factor authentication.
+ */
+ const val FACTOR_ID = PhoneMultiFactorGenerator.FACTOR_ID
+ }
+}
+
+/**
+ * Represents an active SMS enrollment session with verification state.
+ *
+ * This class holds all the information needed to complete an SMS enrollment,
+ * including the verification ID, phone number, and resend token.
+ *
+ * @property verificationId The verification ID from Firebase
+ * @property phoneNumber The phone number being verified in E.164 format
+ * @property forceResendingToken Optional token for resending the SMS code
+ * @property sentAt Timestamp in milliseconds when the code was sent
+ * @property autoVerifiedCredential Optional credential if auto-verification succeeded
+ *
+ * @since 10.0.0
+ */
+data class SmsEnrollmentSession(
+ val verificationId: String,
+ val phoneNumber: String,
+ val forceResendingToken: PhoneAuthProvider.ForceResendingToken?,
+ val sentAt: Long,
+ val autoVerifiedCredential: PhoneAuthCredential? = null
+) {
+ /**
+ * Returns a masked version of the phone number for display purposes.
+ *
+ * Masks the middle digits of the phone number while keeping the country code
+ * and last few digits visible for user confirmation.
+ *
+ * Examples:
+ * - "+1234567890" → "+1••••••890"
+ * - "+447911123456" → "+44•••••••456"
+ *
+ * @return The masked phone number string
+ */
+ fun getMaskedPhoneNumber(): String {
+ return maskPhoneNumber(phoneNumber)
+ }
+
+ /**
+ * Checks if the resend delay has elapsed since the code was sent.
+ *
+ * @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS])
+ * @return `true` if enough time has passed to allow resending
+ */
+ fun canResend(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Boolean {
+ val elapsed = (System.currentTimeMillis() - sentAt) / 1000
+ return elapsed >= delaySec
+ }
+
+ /**
+ * Returns the remaining seconds until resend is allowed.
+ *
+ * @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS])
+ * @return The number of seconds remaining, or 0 if resend is already allowed
+ */
+ fun getRemainingResendSeconds(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Int {
+ val elapsed = (System.currentTimeMillis() - sentAt) / 1000
+ return maxOf(0, delaySec - elapsed.toInt())
+ }
+}
+
+/**
+ * Masks the middle digits of a phone number for privacy.
+ *
+ * The function keeps the country code (first 1-3 characters after +) and
+ * the last 2-4 digits visible, masking everything in between with bullets.
+ * Longer phone numbers show more last digits for better user confirmation.
+ *
+ * Examples:
+ * - "+1234567890" → "+1••••••890" (11 chars, last 3 digits)
+ * - "+447911123456" → "+44•••••••456" (13 chars, last 3 digits)
+ * - "+33612345678" → "+33•••••••678" (12 chars, last 3 digits)
+ * - "+8861234567890" → "+88••••••••7890" (14+ chars, last 4 digits)
+ *
+ * @param phoneNumber The phone number to mask in E.164 format
+ * @return The masked phone number string
+ */
+fun maskPhoneNumber(phoneNumber: String): String {
+ if (!phoneNumber.startsWith("+") || phoneNumber.length < 8) {
+ return phoneNumber
+ }
+
+ // Determine country code length (typically 1-3 digits after +)
+ val digitsOnly = phoneNumber.substring(1) // Remove +
+ val countryCodeLength = when {
+ digitsOnly.length > 10 -> 2 // Likely 2-digit country code
+ digitsOnly[0] == '1' -> 1 // North America
+ else -> 2 // Most other countries
+ }
+
+ val countryCode = phoneNumber.substring(0, countryCodeLength + 1) // Include +
+ // Keep last 3-4 digits visible, with longer numbers showing more
+ val lastDigitsCount = when {
+ phoneNumber.length >= 14 -> 4 // Long numbers show 4 digits
+ phoneNumber.length >= 11 -> 3 // Medium numbers show 3 digits
+ else -> 2 // Short numbers show 2 digits
+ }
+ val lastDigits = phoneNumber.takeLast(lastDigitsCount)
+ val maskedLength = phoneNumber.length - countryCode.length - lastDigitsCount
+
+ return "$countryCode${"•".repeat(maskedLength)}$lastDigits"
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/TotpEnrollmentHandler.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/TotpEnrollmentHandler.kt
new file mode 100644
index 0000000000..33bc874c62
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/TotpEnrollmentHandler.kt
@@ -0,0 +1,151 @@
+/*
+ * 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.mfa
+
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.MultiFactorAssertion
+import com.google.firebase.auth.TotpMultiFactorGenerator
+import kotlinx.coroutines.tasks.await
+
+/**
+ * Handler for TOTP (Time-based One-Time Password) multi-factor authentication enrollment.
+ *
+ * This class manages the complete TOTP enrollment flow, including:
+ * - Generating TOTP secrets
+ * - Creating QR codes for authenticator apps
+ * - Verifying TOTP codes with clock drift tolerance
+ * - Finalizing enrollment with Firebase Authentication
+ *
+ * **Usage:**
+ * ```kotlin
+ * val handler = TotpEnrollmentHandler(auth, user)
+ *
+ * // Step 1: Generate a TOTP secret
+ * val totpSecret = handler.generateSecret()
+ *
+ * // Step 2: Display QR code to user
+ * val qrCodeUrl = totpSecret.generateQrCodeUrl(user.email, "My App")
+ *
+ * // Step 3: Verify the code entered by the user
+ * val verificationCode = "123456" // From user input
+ * handler.enrollWithVerificationCode(totpSecret, verificationCode, "My Authenticator")
+ * ```
+ *
+ * @property auth The [FirebaseAuth] instance
+ * @property user The [FirebaseUser] to enroll in TOTP MFA
+ *
+ * @since 10.0.0
+ */
+class TotpEnrollmentHandler(
+ private val auth: FirebaseAuth,
+ private val user: FirebaseUser
+) {
+ /**
+ * Generates a new TOTP secret for the current user.
+ *
+ * This method initiates the TOTP enrollment process by creating a new secret that
+ * can be shared with an authenticator app. The secret must be displayed to the user
+ * (either as text or a QR code) so they can add it to their authenticator app.
+ *
+ * **Important:** The user must re-authenticate before calling this method if their
+ * session is not recent. Use [FirebaseUser.reauthenticate] if needed.
+ *
+ * @return A [TotpSecret] containing the shared secret and configuration parameters
+ * @throws Exception if the user needs to re-authenticate or if secret generation fails
+ *
+ * @see TotpSecret.generateQrCodeUrl
+ * @see TotpSecret.openInOtpApp
+ */
+ suspend fun generateSecret(): TotpSecret {
+ // Get the multi-factor session
+ val multiFactorSession = user.multiFactor.session.await()
+
+ // Generate the TOTP secret
+ val firebaseTotpSecret = TotpMultiFactorGenerator.generateSecret(multiFactorSession).await()
+
+ return TotpSecret.from(firebaseTotpSecret)
+ }
+
+ /**
+ * Verifies a TOTP code and completes the enrollment process.
+ *
+ * This method creates a multi-factor assertion using the provided TOTP secret and
+ * verification code, then enrolls the user in TOTP MFA with Firebase Authentication.
+ *
+ * The verification includes clock drift tolerance as configured in your Firebase project,
+ * allowing codes from adjacent time windows to be accepted. This accommodates minor
+ * time synchronization differences between the server and the user's device.
+ *
+ * @param totpSecret The [TotpSecret] generated in the first step
+ * @param verificationCode The 6-digit code from the user's authenticator app
+ * @param displayName Optional friendly name for this MFA factor (e.g., "Google Authenticator")
+ * @throws Exception if the verification code is invalid or if enrollment fails
+ *
+ * @see generateSecret
+ */
+ suspend fun enrollWithVerificationCode(
+ totpSecret: TotpSecret,
+ verificationCode: String,
+ displayName: String? = null
+ ) {
+ // Create the multi-factor assertion for enrollment
+ val multiFactorAssertion: MultiFactorAssertion =
+ TotpMultiFactorGenerator.getAssertionForEnrollment(
+ totpSecret.getFirebaseTotpSecret(),
+ verificationCode
+ )
+
+ // Enroll the user with the TOTP factor
+ user.multiFactor.enroll(multiFactorAssertion, displayName).await()
+ }
+
+ /**
+ * Validates that a verification code has the correct format for TOTP.
+ *
+ * This method performs basic client-side validation to ensure the code:
+ * - Is not null or empty
+ * - Contains only digits
+ * - Has exactly 6 digits (the standard TOTP code length)
+ *
+ * **Note:** This does not verify the code against the TOTP secret. Use
+ * [enrollWithVerificationCode] to perform actual verification with Firebase.
+ *
+ * @param code The verification code to validate
+ * @return `true` if the code has a valid format, `false` otherwise
+ */
+ fun isValidCodeFormat(code: String): Boolean {
+ return code.isNotBlank() &&
+ code.length == 6 &&
+ code.all { it.isDigit() }
+ }
+
+ companion object {
+ /**
+ * The standard length for TOTP verification codes.
+ */
+ const val TOTP_CODE_LENGTH = 6
+
+ /**
+ * The standard time interval in seconds for TOTP codes.
+ */
+ const val TOTP_TIME_INTERVAL_SECONDS = 30
+
+ /**
+ * The Firebase factor ID for TOTP multi-factor authentication.
+ */
+ const val FACTOR_ID = TotpMultiFactorGenerator.FACTOR_ID
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/TotpSecret.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/TotpSecret.kt
new file mode 100644
index 0000000000..7a4121ba8b
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/TotpSecret.kt
@@ -0,0 +1,104 @@
+/*
+ * 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.mfa
+
+import android.content.Intent
+import android.net.Uri
+import com.google.firebase.auth.TotpSecret as FirebaseTotpSecret
+
+/**
+ * Wrapper class for Firebase TOTP secret that provides additional utility methods
+ * for enrollment and integration with authenticator apps.
+ *
+ * This class encapsulates the Firebase [FirebaseTotpSecret] and provides methods to:
+ * - Access the shared secret key
+ * - Generate QR code URLs for easy scanning
+ * - Open authenticator apps for automatic configuration
+ * - Generate hashing algorithm and code generation parameters
+ *
+ * @property firebaseTotpSecret The underlying Firebase TOTP secret
+ *
+ * @since 10.0.0
+ */
+class TotpSecret internal constructor(
+ private val firebaseTotpSecret: FirebaseTotpSecret
+) {
+ /**
+ * The shared secret key that should be entered into an authenticator app.
+ * This is a base32-encoded string that can be manually typed if QR scanning is not available.
+ */
+ val sharedSecretKey: String
+ get() = firebaseTotpSecret.sharedSecretKey
+
+ /**
+ * Generates a Google Authenticator-compatible URI that can be encoded as a QR code
+ * or used to automatically configure an authenticator app.
+ *
+ * The generated URI follows the format:
+ * `otpauth://totp/{accountName}?secret={secret}&issuer={issuer}&algorithm={algorithm}&digits={digits}&period={period}`
+ *
+ * @param accountName The account identifier, typically the user's email address
+ * @param issuer The name of your application or service
+ * @return A URI string that can be converted to a QR code or used as a deep link
+ *
+ * @see openInOtpApp
+ */
+ fun generateQrCodeUrl(accountName: String, issuer: String): String {
+ return firebaseTotpSecret.generateQrCodeUrl(accountName, issuer)
+ }
+
+ /**
+ * Attempts to open the device's default authenticator app with the TOTP configuration.
+ *
+ * This method creates an Intent with the provided QR code URL and attempts to open
+ * an authenticator app (such as Google Authenticator) that can handle the
+ * `otpauth://` URI scheme. If successful, the app will be pre-configured with the
+ * TOTP secret without requiring the user to manually scan a QR code.
+ *
+ * **Note:** This method may fail silently if no compatible authenticator app is installed
+ * or if the app doesn't support automatic configuration via URI.
+ *
+ * @param qrCodeUrl The OTP auth URL generated by [generateQrCodeUrl]
+ *
+ * @see generateQrCodeUrl
+ */
+ fun openInOtpApp(qrCodeUrl: String) {
+ firebaseTotpSecret.openInOtpApp(qrCodeUrl)
+ }
+
+ /**
+ * Gets the underlying Firebase TOTP secret for use in enrollment operations.
+ *
+ * This method is primarily used internally by the enrollment handler to complete
+ * the TOTP enrollment with Firebase Authentication.
+ *
+ * @return The underlying [FirebaseTotpSecret] instance
+ */
+ internal fun getFirebaseTotpSecret(): FirebaseTotpSecret {
+ return firebaseTotpSecret
+ }
+
+ companion object {
+ /**
+ * Creates a [TotpSecret] instance from a Firebase TOTP secret.
+ *
+ * @param firebaseTotpSecret The Firebase TOTP secret to wrap
+ * @return A new [TotpSecret] instance
+ */
+ internal fun from(firebaseTotpSecret: FirebaseTotpSecret): TotpSecret {
+ return TotpSecret(firebaseTotpSecret)
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/package-info.java b/auth/src/main/java/com/firebase/ui/auth/package-info.java
index c13dd16baa..20360b5e48 100644
--- a/auth/src/main/java/com/firebase/ui/auth/package-info.java
+++ b/auth/src/main/java/com/firebase/ui/auth/package-info.java
@@ -13,7 +13,7 @@
*/
/**
- * The Firebase AuthUI library. See the {@link com.firebase.ui.auth.AuthUI} entry class
- * for information on using the library to manage signed-in user state.
+ * The Firebase AuthUI library. See the {@link com.firebase.ui.auth.FirebaseAuthUI} entry class for
+ * information on using the library to manage signed-in user state.
*/
-package com.firebase.ui.auth;
\ No newline at end of file
+package com.firebase.ui.auth;
diff --git a/auth/src/main/java/com/firebase/ui/auth/provider/AuthCredentialHelper.java b/auth/src/main/java/com/firebase/ui/auth/provider/AuthCredentialHelper.java
deleted file mode 100644
index d65af6450a..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/provider/AuthCredentialHelper.java
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright 2016 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.provider;
-
-import android.support.annotation.Nullable;
-
-import com.firebase.ui.auth.IdpResponse;
-import com.google.firebase.auth.AuthCredential;
-import com.google.firebase.auth.FacebookAuthProvider;
-import com.google.firebase.auth.GoogleAuthProvider;
-import com.google.firebase.auth.TwitterAuthProvider;
-
-public class AuthCredentialHelper {
- @Nullable
- public static AuthCredential getAuthCredential(IdpResponse idpResponse) {
- switch (idpResponse.getProviderType()) {
- case GoogleAuthProvider.PROVIDER_ID:
- return GoogleProvider.createAuthCredential(idpResponse);
- case FacebookAuthProvider.PROVIDER_ID:
- return FacebookProvider.createAuthCredential(idpResponse);
- case TwitterAuthProvider.PROVIDER_ID:
- return TwitterProvider.createAuthCredential(idpResponse);
- default:
- return null;
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/provider/FacebookProvider.java b/auth/src/main/java/com/firebase/ui/auth/provider/FacebookProvider.java
deleted file mode 100644
index 46476233b2..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/provider/FacebookProvider.java
+++ /dev/null
@@ -1,190 +0,0 @@
-/*
- * Copyright 2016 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.provider;
-
-import android.app.Activity;
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.support.annotation.StyleRes;
-import android.util.Log;
-
-import com.facebook.CallbackManager;
-import com.facebook.FacebookCallback;
-import com.facebook.FacebookException;
-import com.facebook.FacebookRequestError;
-import com.facebook.FacebookSdk;
-import com.facebook.GraphRequest;
-import com.facebook.GraphResponse;
-import com.facebook.login.LoginManager;
-import com.facebook.login.LoginResult;
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.BuildConfig;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.google.firebase.auth.AuthCredential;
-import com.google.firebase.auth.FacebookAuthProvider;
-
-import org.json.JSONException;
-import org.json.JSONObject;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class FacebookProvider implements IdpProvider, FacebookCallback {
- protected static final String ERROR = "err";
- protected static final String ERROR_MSG = "err_msg";
-
- private static final String TAG = "FacebookProvider";
- private static final String EMAIL = "email";
- private static final String PUBLIC_PROFILE = "public_profile";
- private static final CallbackManager sCallbackManager = CallbackManager.Factory.create();
-
- private final List mScopes;
- private IdpCallback mCallbackObject;
-
- public FacebookProvider(Context appContext, AuthUI.IdpConfig idpConfig, @StyleRes int theme) {
- appContext = appContext.getApplicationContext();
-
- if (appContext.getResources().getIdentifier(
- "facebook_permissions", "array", appContext.getPackageName()) != 0) {
- Log.w(TAG, "DEVELOPER WARNING: You have defined R.array.facebook_permissions but that"
- + " is no longer respected as of FirebaseUI 1.0.0. Please see README for IDP"
- + " scope configuration instructions.");
- }
-
- List scopes = idpConfig.getScopes();
- if (scopes == null) {
- mScopes = new ArrayList<>();
- } else {
- mScopes = scopes;
- }
- FacebookSdk.sdkInitialize(appContext);
- FacebookSdk.setWebDialogTheme(theme);
- }
-
- @Override
- public String getName(Context context) {
- return context.getResources().getString(R.string.idp_name_facebook);
- }
-
- @Override
- public String getProviderId() {
- return FacebookAuthProvider.PROVIDER_ID;
- }
-
- @Override
- public void startLogin(Activity activity) {
- LoginManager loginManager = LoginManager.getInstance();
- loginManager.registerCallback(sCallbackManager, this);
-
- List permissionsList = new ArrayList<>(mScopes);
-
- // Ensure we have email and public_profile scopes
- if (!permissionsList.contains(EMAIL)) {
- permissionsList.add(EMAIL);
- }
-
- if (!permissionsList.contains(PUBLIC_PROFILE)) {
- permissionsList.add(PUBLIC_PROFILE);
- }
-
- // Log in with permissions
- loginManager.logInWithReadPermissions(activity, permissionsList);
- }
-
- @Override
- public void setAuthenticationCallback(IdpCallback callback) {
- this.mCallbackObject = callback;
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, Intent data) {
- sCallbackManager.onActivityResult(requestCode, resultCode, data);
- }
-
- @Override
- public void onSuccess(final LoginResult loginResult) {
- if (BuildConfig.DEBUG) {
- Log.d(TAG, "Login to facebook successful with Application Id: "
- + loginResult.getAccessToken().getApplicationId()
- + " with Token: "
- + loginResult.getAccessToken().getToken());
- }
-
- GraphRequest request = GraphRequest.newMeRequest(
- loginResult.getAccessToken(),
- new GraphRequest.GraphJSONObjectCallback() {
- @Override
- public void onCompleted(JSONObject object, GraphResponse response) {
- FacebookRequestError requestError = response.getError();
- if (requestError != null) {
- Log.e(TAG, "Received Facebook error: " + requestError.getErrorMessage());
- mCallbackObject.onFailure(new Bundle());
- return;
- }
- if (object == null) {
- Log.w(TAG, "Received null response from Facebook GraphRequest");
- mCallbackObject.onFailure(new Bundle());
- } else {
- try {
- String email = object.getString("email");
- mCallbackObject.onSuccess(createIDPResponse(loginResult, email));
- } catch (JSONException e) {
- Log.e(TAG, "JSON Exception reading from Facebook GraphRequest", e);
- mCallbackObject.onFailure(new Bundle());
- }
- }
- }
- });
-
- Bundle parameters = new Bundle();
- parameters.putString("fields", "id,name,email");
- request.setParameters(parameters);
- request.executeAsync();
- }
-
- private IdpResponse createIDPResponse(LoginResult loginResult, String email) {
- return new IdpResponse(
- FacebookAuthProvider.PROVIDER_ID,
- email,
- loginResult.getAccessToken().getToken());
- }
-
- public static AuthCredential createAuthCredential(IdpResponse response) {
- if (!response.getProviderType().equals(FacebookAuthProvider.PROVIDER_ID)) {
- return null;
- }
- return FacebookAuthProvider
- .getCredential(response.getIdpToken());
- }
-
- @Override
- public void onCancel() {
- Bundle extra = new Bundle();
- extra.putString(ERROR, "cancelled");
- mCallbackObject.onFailure(extra);
-
- }
-
- @Override
- public void onError(FacebookException error) {
- Log.e(TAG, "Error logging in with Facebook. " + error.getMessage());
- Bundle extra = new Bundle();
- extra.putString(ERROR, "error");
- extra.putString(ERROR_MSG, error.getMessage());
- mCallbackObject.onFailure(extra);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/provider/GoogleProvider.java b/auth/src/main/java/com/firebase/ui/auth/provider/GoogleProvider.java
deleted file mode 100644
index 3ddc5d08b6..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/provider/GoogleProvider.java
+++ /dev/null
@@ -1,164 +0,0 @@
-/*
- * Copyright 2016 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.provider;
-
-import android.app.Activity;
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
-import android.support.v4.app.FragmentActivity;
-import android.text.TextUtils;
-import android.util.Log;
-import android.view.View;
-import android.view.View.OnClickListener;
-
-import com.firebase.ui.auth.AuthUI.IdpConfig;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.util.GoogleApiConstants;
-import com.google.android.gms.auth.api.Auth;
-import com.google.android.gms.auth.api.signin.GoogleSignInAccount;
-import com.google.android.gms.auth.api.signin.GoogleSignInOptions;
-import com.google.android.gms.auth.api.signin.GoogleSignInResult;
-import com.google.android.gms.common.ConnectionResult;
-import com.google.android.gms.common.api.GoogleApiClient;
-import com.google.android.gms.common.api.Scope;
-import com.google.firebase.auth.AuthCredential;
-import com.google.firebase.auth.GoogleAuthProvider;
-
-public class GoogleProvider implements
- IdpProvider, OnClickListener, GoogleApiClient.OnConnectionFailedListener {
-
- private static final String TAG = "GoogleProvider";
- private static final int RC_SIGN_IN = 20;
- private static final String ERROR_KEY = "error";
- private GoogleApiClient mGoogleApiClient;
- private Activity mActivity;
- private IdpCallback mIDPCallback;
-
- public GoogleProvider(FragmentActivity activity, IdpConfig idpConfig) {
- this(activity, idpConfig, null);
- }
-
- public GoogleProvider(FragmentActivity activity, IdpConfig idpConfig, @Nullable String email) {
- mActivity = activity;
- String clientId = activity.getString(R.string.default_web_client_id);
-
- GoogleSignInOptions.Builder builder =
- new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
- .requestEmail()
- .requestIdToken(clientId);
-
- if (activity.getResources().getIdentifier(
- "google_permissions", "array", activity.getPackageName()) != 0) {
- Log.w(TAG, "DEVELOPER WARNING: You have defined R.array.google_permissions but that is"
- + " no longer respected as of FirebaseUI 1.0.0. Please see README for IDP scope"
- + " configuration instructions.");
- }
-
- // Add additional scopes
- for (String scopeString : idpConfig.getScopes()) {
- builder.requestScopes(new Scope(scopeString));
- }
-
- if (!TextUtils.isEmpty(email)) {
- builder.setAccountName(email);
- }
-
- mGoogleApiClient = new GoogleApiClient.Builder(activity)
- .enableAutoManage(activity, GoogleApiConstants.AUTO_MANAGE_ID0, this)
- .addApi(Auth.GOOGLE_SIGN_IN_API, builder.build())
- .build();
- }
-
- public String getName(Context context) {
- return context.getResources().getString(R.string.idp_name_google);
- }
-
- @Override
- public String getProviderId() {
- return GoogleAuthProvider.PROVIDER_ID;
- }
-
-
- public static AuthCredential createAuthCredential(IdpResponse response) {
- return GoogleAuthProvider.getCredential(response.getIdpToken(), null);
- }
-
- @Override
- public void setAuthenticationCallback(IdpCallback callback) {
- mIDPCallback = callback;
- }
-
- public void disconnect() {
- if (mGoogleApiClient != null) {
- mGoogleApiClient.disconnect();
- mGoogleApiClient = null;
- }
- }
-
- private IdpResponse createIDPResponse(GoogleSignInAccount account) {
- return new IdpResponse(
- GoogleAuthProvider.PROVIDER_ID, account.getEmail(), account.getIdToken());
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, Intent data) {
- if (requestCode == RC_SIGN_IN) {
- GoogleSignInResult result = Auth.GoogleSignInApi.getSignInResultFromIntent(data);
- if (result != null) {
- if (result.isSuccess()) {
- mIDPCallback.onSuccess(createIDPResponse(result.getSignInAccount()));
- } else {
- onError(result);
- }
- } else {
- onError("No result found in intent");
- }
- }
- }
-
- @Override
- public void startLogin(Activity activity) {
- Intent signInIntent = Auth.GoogleSignInApi.getSignInIntent(mGoogleApiClient);
- activity.startActivityForResult(signInIntent, RC_SIGN_IN);
- }
-
- private void onError(GoogleSignInResult result) {
- String errorMessage = result.getStatus().getStatusMessage();
- onError(String.valueOf(result.getStatus().getStatusCode()) + " " + errorMessage);
- }
-
- private void onError(String errorMessage) {
- Log.e(TAG, "Error logging in with Google. " + errorMessage);
- Bundle extra = new Bundle();
- extra.putString(ERROR_KEY, errorMessage);
- mIDPCallback.onFailure(extra);
- }
-
- @Override
- public void onClick(View view) {
- Auth.GoogleSignInApi.signOut(mGoogleApiClient);
- startLogin(mActivity);
- }
-
- @Override
- public void onConnectionFailed(@NonNull ConnectionResult connectionResult) {
- Log.w(TAG, "onConnectionFailed:" + connectionResult);
- }
-}
-
diff --git a/auth/src/main/java/com/firebase/ui/auth/provider/IdpProvider.java b/auth/src/main/java/com/firebase/ui/auth/provider/IdpProvider.java
deleted file mode 100644
index e185a3bc18..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/provider/IdpProvider.java
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * Copyright 2016 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.provider;
-
-import android.app.Activity;
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-
-import com.firebase.ui.auth.IdpResponse;
-
-public interface IdpProvider {
-
- /**
- * Retrieves the name of the IDP, for display on-screen.
- */
- String getName(Context context);
-
- String getProviderId();
-
- void setAuthenticationCallback(IdpCallback callback);
-
- void onActivityResult(int requestCode, int resultCode, Intent data);
-
- void startLogin(Activity activity);
-
- interface IdpCallback {
- void onSuccess(IdpResponse idpResponse);
-
- void onFailure(Bundle extra);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/provider/TwitterProvider.java b/auth/src/main/java/com/firebase/ui/auth/provider/TwitterProvider.java
deleted file mode 100644
index a35ba6af8f..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/provider/TwitterProvider.java
+++ /dev/null
@@ -1,90 +0,0 @@
-package com.firebase.ui.auth.provider;
-
-import android.app.Activity;
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.util.Log;
-
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.google.firebase.auth.AuthCredential;
-import com.google.firebase.auth.TwitterAuthProvider;
-import com.twitter.sdk.android.Twitter;
-import com.twitter.sdk.android.core.Callback;
-import com.twitter.sdk.android.core.Result;
-import com.twitter.sdk.android.core.TwitterAuthConfig;
-import com.twitter.sdk.android.core.TwitterException;
-import com.twitter.sdk.android.core.TwitterSession;
-import com.twitter.sdk.android.core.identity.TwitterAuthClient;
-
-import io.fabric.sdk.android.Fabric;
-
-public class TwitterProvider extends Callback implements IdpProvider {
- private static final String TAG = "TwitterProvider";
-
- private IdpCallback mCallbackObject;
- private TwitterAuthClient mTwitterAuthClient;
-
- public TwitterProvider(Context appContext) {
- TwitterAuthConfig authConfig = new TwitterAuthConfig(
- appContext.getString(R.string.twitter_consumer_key),
- appContext.getString(R.string.twitter_consumer_secret));
- Fabric.with(appContext.getApplicationContext(), new Twitter(authConfig));
- mTwitterAuthClient = new TwitterAuthClient();
- }
-
- @Override
- public String getName(Context context) {
- return context.getString(R.string.idp_name_twitter);
- }
-
- @Override
- public String getProviderId() {
- return TwitterAuthProvider.PROVIDER_ID;
- }
-
- @Override
- public void setAuthenticationCallback(IdpCallback callback) {
- this.mCallbackObject = callback;
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, Intent data) {
- mTwitterAuthClient.onActivityResult(requestCode, resultCode, data);
- }
-
- @Override
- public void startLogin(Activity activity) {
- mTwitterAuthClient.authorize(activity, this);
- }
-
- @Override
- public void success(Result result) {
- mCallbackObject.onSuccess(createIDPResponse(result.data));
- }
-
- @Override
- public void failure(TwitterException exception) {
- Log.e(TAG, "Failure logging in to Twitter. " + exception.getMessage());
- mCallbackObject.onFailure(new Bundle());
- }
-
- public static AuthCredential createAuthCredential(IdpResponse response) {
- if (!response.getProviderType().equalsIgnoreCase(TwitterAuthProvider.PROVIDER_ID)){
- return null;
- }
- return TwitterAuthProvider.getCredential(
- response.getIdpToken(),
- response.getIdpSecret());
- }
-
-
- private IdpResponse createIDPResponse(TwitterSession twitterSession) {
- return new IdpResponse(
- TwitterAuthProvider.PROVIDER_ID,
- null,
- twitterSession.getAuthToken().token,
- twitterSession.getAuthToken().secret);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/ActivityHelper.java b/auth/src/main/java/com/firebase/ui/auth/ui/ActivityHelper.java
deleted file mode 100644
index f3b9c9cf8c..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/ActivityHelper.java
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * Copyright 2016 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;
-
-import android.content.Intent;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
-
-import com.firebase.ui.auth.util.signincontainer.SaveSmartLock;
-import com.google.firebase.auth.FirebaseUser;
-
-public class ActivityHelper extends BaseHelper {
- private AppCompatBase mActivity;
-
- public ActivityHelper(AppCompatBase activity, Intent intent) {
- super(activity, (FlowParameters) intent.getParcelableExtra(ExtraConstants.EXTRA_FLOW_PARAMS));
- mActivity = activity;
- }
-
- public void startActivityForResult(Intent intent, int requestCode) {
- mActivity.startActivityForResult(intent, requestCode);
- }
-
- public void finish(int resultCode, Intent intent) {
- finishActivity(mActivity, resultCode, intent);
- }
-
- public SaveSmartLock getSaveSmartLockInstance() {
- return getSaveSmartLockInstance(mActivity);
- }
-
- public void saveCredentialsOrFinish(
- @Nullable SaveSmartLock saveSmartLock,
- FirebaseUser firebaseUser,
- @NonNull String password) {
- saveCredentialsOrFinish(saveSmartLock, mActivity, firebaseUser, password, null);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/AppCompatBase.java b/auth/src/main/java/com/firebase/ui/auth/ui/AppCompatBase.java
deleted file mode 100644
index 4fb0ba6402..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/AppCompatBase.java
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright 2016 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;
-
-import android.content.Intent;
-import android.os.Bundle;
-import android.support.v7.app.AppCompatActivity;
-
-public class AppCompatBase extends AppCompatActivity {
- protected ActivityHelper mActivityHelper;
-
- @Override
- protected void onCreate(Bundle savedInstance) {
- super.onCreate(savedInstance);
- mActivityHelper = new ActivityHelper(this, getIntent());
- mActivityHelper.configureTheme();
- }
-
- @Override
- protected void onDestroy() {
- super.onDestroy();
- mActivityHelper.dismissDialog();
- }
-
- public void finish(int resultCode, Intent intent) {
- mActivityHelper.finish(resultCode, intent);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/BaseDialog.java b/auth/src/main/java/com/firebase/ui/auth/ui/BaseDialog.java
deleted file mode 100644
index a28aedd201..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/BaseDialog.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.firebase.ui.auth.ui;
-
-import android.content.Intent;
-import android.os.Bundle;
-import android.support.annotation.Nullable;
-import android.support.v4.app.DialogFragment;
-
-public class BaseDialog extends DialogFragment {
- protected FragmentHelper mHelper;
-
- @Override
- public void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- mHelper = new FragmentHelper(this);
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
- mHelper.dismissDialog();
- }
-
- public void finish(int resultCode, Intent resultIntent) {
- mHelper.finish(resultCode, resultIntent);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/BaseFragment.java b/auth/src/main/java/com/firebase/ui/auth/ui/BaseFragment.java
deleted file mode 100644
index eb3bd4541f..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/BaseFragment.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.firebase.ui.auth.ui;
-
-import android.content.Intent;
-import android.os.Bundle;
-import android.support.annotation.Nullable;
-import android.support.v4.app.Fragment;
-
-public class BaseFragment extends Fragment {
- protected FragmentHelper mHelper;
-
- @Override
- public void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- mHelper = new FragmentHelper(this);
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
- mHelper.dismissDialog();
- }
-
- public void finish(int resultCode, Intent resultIntent) {
- mHelper.finish(resultCode, resultIntent);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/BaseHelper.java b/auth/src/main/java/com/firebase/ui/auth/ui/BaseHelper.java
deleted file mode 100644
index c2d53be7ae..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/BaseHelper.java
+++ /dev/null
@@ -1,128 +0,0 @@
-package com.firebase.ui.auth.ui;
-
-import android.app.Activity;
-import android.app.ProgressDialog;
-import android.content.Context;
-import android.content.Intent;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
-import android.support.annotation.StringRes;
-import android.support.v4.app.FragmentActivity;
-
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.util.signincontainer.SaveSmartLock;
-import com.google.android.gms.auth.api.Auth;
-import com.google.android.gms.auth.api.credentials.CredentialsApi;
-import com.google.firebase.FirebaseApp;
-import com.google.firebase.auth.FirebaseAuth;
-import com.google.firebase.auth.FirebaseUser;
-
-import static android.app.Activity.RESULT_OK;
-import static com.firebase.ui.auth.util.Preconditions.checkNotNull;
-
-public class BaseHelper {
- protected Context mContext;
- private final FlowParameters mFlowParams;
- private ProgressDialog mProgressDialog;
-
- public BaseHelper(Context context, FlowParameters parameters) {
- mContext = context;
- mFlowParams = parameters;
- }
-
- public void configureTheme() {
- mContext.setTheme(mFlowParams.themeId);
- }
-
- public FlowParameters getFlowParams() {
- return mFlowParams;
- }
-
- public void finishActivity(Activity activity, int resultCode, Intent intent) {
- activity.setResult(resultCode, intent);
- activity.finish();
- }
-
- public void showLoadingDialog(String message) {
- dismissDialog();
- mProgressDialog = ProgressDialog.show(mContext, "", message, true);
- }
-
- public void showLoadingDialog(@StringRes int stringResource) {
- showLoadingDialog(mContext.getString(stringResource));
- }
-
- public void dismissDialog() {
- if (mProgressDialog != null) {
- mProgressDialog.dismiss();
- mProgressDialog = null;
- }
- }
-
- public boolean isProgressDialogShowing() {
- return mProgressDialog != null && mProgressDialog.isShowing();
- }
-
- public Context getApplicationContext() {
- return mContext.getApplicationContext();
- }
-
- public String getAppName() {
- return mFlowParams.appName;
- }
-
- public FirebaseApp getFirebaseApp() {
- return FirebaseApp.getInstance(mFlowParams.appName);
- }
-
- public FirebaseAuth getFirebaseAuth() {
- return FirebaseAuth.getInstance(getFirebaseApp());
- }
-
- public CredentialsApi getCredentialsApi() {
- return Auth.CredentialsApi;
- }
-
- public FirebaseUser getCurrentUser() {
- return getFirebaseAuth().getCurrentUser();
- }
-
- public static Intent createBaseIntent(
- @NonNull Context context,
- @NonNull Class extends Activity> target,
- @NonNull FlowParameters flowParams) {
- return new Intent(
- checkNotNull(context, "context cannot be null"),
- checkNotNull(target, "target activity cannot be null"))
- .putExtra(ExtraConstants.EXTRA_FLOW_PARAMS,
- checkNotNull(flowParams, "flowParams cannot be null"));
- }
-
- public SaveSmartLock getSaveSmartLockInstance(FragmentActivity activity) {
- return SaveSmartLock.getInstance(activity, getFlowParams());
- }
-
- public void saveCredentialsOrFinish(
- @Nullable SaveSmartLock saveSmartLock,
- Activity activity,
- FirebaseUser firebaseUser,
- @NonNull IdpResponse response) {
- saveCredentialsOrFinish(saveSmartLock, activity, firebaseUser, null, response);
- }
-
- public void saveCredentialsOrFinish(
- @Nullable SaveSmartLock saveSmartLock,
- Activity activity,
- FirebaseUser firebaseUser,
- @Nullable String password,
- @Nullable IdpResponse response) {
- if (saveSmartLock == null) {
- finishActivity(activity, RESULT_OK, new Intent());
- } else {
- saveSmartLock.saveCredentialsOrFinish(
- firebaseUser,
- password,
- response);
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/ExtraConstants.java b/auth/src/main/java/com/firebase/ui/auth/ui/ExtraConstants.java
deleted file mode 100644
index 6d144eed1d..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/ExtraConstants.java
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
- * Copyright 2016 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;
-
-/**
- * Constants used for passing Intent extra params between authentication flow activities.
- */
-public class ExtraConstants {
- public static final String EXTRA_EMAIL = "extra_email";
- public static final String EXTRA_ERROR_MESSAGE = "extra_error_msg";
- public static final String EXTRA_FLOW_PARAMS = "extra_flow_params";
- public static final String EXTRA_IDP_RESPONSE = "extra_idp_response";
- public static final String EXTRA_PROVIDER = "extra_provider";
- public static final String HAS_EXISTING_INSTANCE = "has_existing_instance";
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/FlowParameters.java b/auth/src/main/java/com/firebase/ui/auth/ui/FlowParameters.java
deleted file mode 100644
index e119887a26..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/FlowParameters.java
+++ /dev/null
@@ -1,107 +0,0 @@
-/*
- * Copyright 2016 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;
-
-import android.os.Parcel;
-import android.os.Parcelable;
-import android.support.annotation.DrawableRes;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
-import android.support.annotation.StyleRes;
-
-import com.firebase.ui.auth.AuthUI.IdpConfig;
-import com.firebase.ui.auth.util.Preconditions;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Encapsulates the core parameters and data captured during the authentication flow, in
- * a serializable manner, in order to pass data between activities.
- */
-public class FlowParameters implements Parcelable {
-
- @NonNull
- public final String appName;
-
- @NonNull
- public final List providerInfo;
-
- @StyleRes
- public final int themeId;
-
- @DrawableRes
- public final int logoId;
-
- @Nullable
- public final String termsOfServiceUrl;
-
- public final boolean smartLockEnabled;
-
- public FlowParameters(
- @NonNull String appName,
- @NonNull List providerInfo,
- @StyleRes int themeId,
- @DrawableRes int logoId,
- @Nullable String termsOfServiceUrl,
- boolean smartLockEnabled) {
- this.appName = Preconditions.checkNotNull(appName, "appName cannot be null");
- this.providerInfo = Preconditions.checkNotNull(providerInfo, "providerInfo cannot be null");
- this.themeId = themeId;
- this.logoId = logoId;
- this.termsOfServiceUrl = termsOfServiceUrl;
- this.smartLockEnabled = smartLockEnabled;
- }
-
- @Override
- public void writeToParcel(Parcel dest, int flags) {
- dest.writeString(appName);
- dest.writeTypedList(providerInfo);
- dest.writeInt(themeId);
- dest.writeInt(logoId);
- dest.writeString(termsOfServiceUrl);
- dest.writeInt(smartLockEnabled ? 1 : 0);
- }
-
- @Override
- public int describeContents() {
- return 0;
- }
-
- public static final Creator CREATOR = new Creator() {
- @Override
- public FlowParameters createFromParcel(Parcel in) {
- String appName = in.readString();
- List providerInfo = in.createTypedArrayList(IdpConfig.CREATOR);
- int themeId = in.readInt();
- int logoId = in.readInt();
- String termsOfServiceUrl = in.readString();
- int smartLockEnabledInt = in.readInt();
- boolean smartLockEnabled = (smartLockEnabledInt != 0);
-
- return new FlowParameters(
- appName,
- providerInfo,
- themeId,
- logoId,
- termsOfServiceUrl,
- smartLockEnabled);
- }
-
- @Override
- public FlowParameters[] newArray(int size) {
- return new FlowParameters[size];
- }
- };
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/FragmentHelper.java b/auth/src/main/java/com/firebase/ui/auth/ui/FragmentHelper.java
deleted file mode 100644
index 6868b38af6..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/FragmentHelper.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.firebase.ui.auth.ui;
-
-import android.content.Intent;
-import android.content.IntentSender;
-import android.os.Bundle;
-import android.support.v4.app.Fragment;
-
-public class FragmentHelper extends BaseHelper {
- private Fragment mFragment;
-
- public FragmentHelper(Fragment fragment) {
- super(fragment.getContext(), (FlowParameters) fragment.getArguments()
- .getParcelable(ExtraConstants.EXTRA_FLOW_PARAMS));
- mFragment = fragment;
- }
-
- public void finish(int resultCode, Intent intent) {
- finishActivity(mFragment.getActivity(), resultCode, intent);
- }
-
- public static Bundle getFlowParamsBundle(FlowParameters params) {
- Bundle bundle = new Bundle();
- bundle.putParcelable(ExtraConstants.EXTRA_FLOW_PARAMS, params);
- return bundle;
- }
-
- public void startIntentSenderForResult(IntentSender sender, int requestCode)
- throws IntentSender.SendIntentException {
- mFragment.startIntentSenderForResult(sender, requestCode, null, 0, 0, 0, null);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/ResultCodes.java b/auth/src/main/java/com/firebase/ui/auth/ui/ResultCodes.java
deleted file mode 100644
index afe14a25f7..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/ResultCodes.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.firebase.ui.auth.ui;
-
-import com.firebase.ui.auth.AuthUI;
-
-/**
- * Result codes returned when using {@link AuthUI.SignInIntentBuilder#build()} with
- * {@code startActivityForResult}.
- */
-public class ResultCodes {
-
- /** Sign in failed due to lack of network connection **/
- public static final int RESULT_NO_NETWORK = 10;
-
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/TaskFailureLogger.java b/auth/src/main/java/com/firebase/ui/auth/ui/TaskFailureLogger.java
deleted file mode 100644
index 734e52e467..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/TaskFailureLogger.java
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright 2016 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;
-
-import android.support.annotation.NonNull;
-import android.util.Log;
-
-import com.google.android.gms.tasks.OnFailureListener;
-
-public class TaskFailureLogger implements OnFailureListener {
- private String mTag;
- private String mMessage;
-
- public TaskFailureLogger(String tag, String message) {
- mTag = tag;
- mMessage = message;
- }
-
- @Override
- public void onFailure(@NonNull Exception e) {
- Log.w(mTag, mMessage, e);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/account_link/WelcomeBackIdpPrompt.java b/auth/src/main/java/com/firebase/ui/auth/ui/account_link/WelcomeBackIdpPrompt.java
deleted file mode 100644
index 6ddcfb13ac..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/account_link/WelcomeBackIdpPrompt.java
+++ /dev/null
@@ -1,219 +0,0 @@
-/*
- * Copyright 2016 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.account_link;
-
-import android.app.Activity;
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.support.annotation.NonNull;
-import android.util.Log;
-import android.view.View;
-import android.view.View.OnClickListener;
-import android.widget.TextView;
-import android.widget.Toast;
-
-import com.firebase.ui.auth.AuthUI.IdpConfig;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.provider.AuthCredentialHelper;
-import com.firebase.ui.auth.provider.FacebookProvider;
-import com.firebase.ui.auth.provider.GoogleProvider;
-import com.firebase.ui.auth.provider.IdpProvider;
-import com.firebase.ui.auth.provider.IdpProvider.IdpCallback;
-import com.firebase.ui.auth.provider.TwitterProvider;
-import com.firebase.ui.auth.ui.AppCompatBase;
-import com.firebase.ui.auth.ui.BaseHelper;
-import com.firebase.ui.auth.ui.ExtraConstants;
-import com.firebase.ui.auth.ui.FlowParameters;
-import com.firebase.ui.auth.ui.TaskFailureLogger;
-import com.google.android.gms.tasks.OnCompleteListener;
-import com.google.android.gms.tasks.Task;
-import com.google.firebase.auth.AuthCredential;
-import com.google.firebase.auth.AuthResult;
-import com.google.firebase.auth.FacebookAuthProvider;
-import com.google.firebase.auth.FirebaseAuth;
-import com.google.firebase.auth.FirebaseUser;
-import com.google.firebase.auth.GoogleAuthProvider;
-import com.google.firebase.auth.TwitterAuthProvider;
-
-public class WelcomeBackIdpPrompt extends AppCompatBase
- implements View.OnClickListener, IdpCallback {
-
- private static final String TAG = "WelcomeBackIDPPrompt";
- private IdpProvider mIdpProvider;
- private IdpResponse mPrevIdpResponse;
- private AuthCredential mPrevCredential;
-
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- String providerId = getProviderIdFromIntent();
- mPrevIdpResponse = getIntent().getParcelableExtra(ExtraConstants.EXTRA_IDP_RESPONSE);
- setContentView(R.layout.welcome_back_idp_prompt_layout);
-
- mIdpProvider = null;
- for (IdpConfig idpConfig : mActivityHelper.getFlowParams().providerInfo) {
- if (providerId.equals(idpConfig.getProviderId())) {
- switch (providerId) {
- case GoogleAuthProvider.PROVIDER_ID:
- mIdpProvider = new GoogleProvider(this, idpConfig, getEmailFromIntent());
- break;
- case FacebookAuthProvider.PROVIDER_ID:
- mIdpProvider = new FacebookProvider(
- this, idpConfig, mActivityHelper.getFlowParams().themeId);
- break;
- case TwitterAuthProvider.PROVIDER_ID:
- mIdpProvider = new TwitterProvider(this);
- break;
- default:
- Log.w(TAG, "Unknown provider: " + providerId);
- finish(RESULT_CANCELED, getIntent());
- return;
- }
- }
- }
-
- if (mPrevIdpResponse != null) {
- mPrevCredential = AuthCredentialHelper.getAuthCredential(mPrevIdpResponse);
- }
-
- if (mIdpProvider == null) {
- getIntent().putExtra(
- ExtraConstants.EXTRA_ERROR_MESSAGE,
- "Firebase login successful. Account linking failed due to provider not enabled by application");
- finish(RESULT_CANCELED, getIntent());
- return;
- }
-
- ((TextView) findViewById(R.id.welcome_back_idp_prompt))
- .setText(getIdpPromptString(getEmailFromIntent()));
-
- mIdpProvider.setAuthenticationCallback(this);
- findViewById(R.id.welcome_back_idp_button).setOnClickListener(new OnClickListener() {
- @Override
- public void onClick(View view) {
- mActivityHelper.showLoadingDialog(R.string.progress_dialog_signing_in);
- mIdpProvider.startLogin(WelcomeBackIdpPrompt.this);
- }
- });
- }
-
- private String getIdpPromptString(String email) {
- String promptStringTemplate = getResources().getString(R.string.welcome_back_idp_prompt);
- return String.format(promptStringTemplate, email, mIdpProvider.getName(this));
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- mIdpProvider.onActivityResult(requestCode, resultCode, data);
- }
-
- @Override
- public void onClick(View view) {
- next(mPrevIdpResponse);
- }
-
- @Override
- public void onSuccess(IdpResponse idpResponse) {
- next(idpResponse);
- }
-
- @Override
- public void onFailure(Bundle extra) {
- Toast.makeText(getApplicationContext(), "Error signing in", Toast.LENGTH_LONG).show();
- finish(RESULT_CANCELED, new Intent());
- }
-
- private String getProviderIdFromIntent() {
- return getIntent().getStringExtra(ExtraConstants.EXTRA_PROVIDER);
- }
-
- private String getEmailFromIntent() {
- return getIntent().getStringExtra(ExtraConstants.EXTRA_EMAIL);
- }
-
- private void next(final IdpResponse newIdpResponse) {
- if (newIdpResponse == null) {
- return; // do nothing
- }
-
- AuthCredential newCredential = AuthCredentialHelper.getAuthCredential(newIdpResponse);
- if (newCredential == null) {
- Log.e(TAG, "No credential returned");
- finish(Activity.RESULT_FIRST_USER, new Intent());
- return;
- }
-
- final FirebaseAuth firebaseAuth = mActivityHelper.getFirebaseAuth();
- FirebaseUser currentUser = firebaseAuth.getCurrentUser();
-
- if (currentUser == null) {
- Task authResultTask = firebaseAuth.signInWithCredential(newCredential);
- authResultTask.addOnCompleteListener(new OnCompleteListener() {
- @Override
- public void onComplete(@NonNull Task task) {
- if (task.isSuccessful() && mPrevCredential != null) {
- FirebaseUser firebaseUser = task.getResult().getUser();
- firebaseUser.linkWithCredential(mPrevCredential);
- firebaseAuth.signOut();
- firebaseAuth
- .signInWithCredential(mPrevCredential)
- .addOnFailureListener(new TaskFailureLogger(
- TAG, "Error signing in with previous credential"))
- .addOnCompleteListener(new FinishListener(newIdpResponse));
- } else {
- finish(Activity.RESULT_OK, new Intent().putExtra(
- ExtraConstants.EXTRA_IDP_RESPONSE, newIdpResponse));
- }
- }
- }).addOnFailureListener(
- new TaskFailureLogger(TAG, "Error signing in with new credential"));
- } else {
- Task authResultTask = currentUser.linkWithCredential(newCredential);
- authResultTask
- .addOnFailureListener(
- new TaskFailureLogger(TAG, "Error linking with credential"))
- .addOnCompleteListener(new FinishListener(newIdpResponse));
- }
- }
-
- public static Intent createIntent(
- Context context,
- FlowParameters flowParams,
- String providerId,
- IdpResponse idpResponse,
- String email) {
- return BaseHelper.createBaseIntent(context, WelcomeBackIdpPrompt.class, flowParams)
- .putExtra(ExtraConstants.EXTRA_PROVIDER, providerId)
- .putExtra(ExtraConstants.EXTRA_IDP_RESPONSE, idpResponse)
- .putExtra(ExtraConstants.EXTRA_EMAIL, email);
- }
-
- private class FinishListener implements OnCompleteListener {
- private final IdpResponse mIdpResponse;
-
- FinishListener(IdpResponse idpResponse) {
- mIdpResponse = idpResponse;
- }
-
- public void onComplete(@NonNull Task task) {
- finish(Activity.RESULT_OK,
- new Intent().putExtra(ExtraConstants.EXTRA_IDP_RESPONSE, mIdpResponse));
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/account_link/WelcomeBackPasswordPrompt.java b/auth/src/main/java/com/firebase/ui/auth/ui/account_link/WelcomeBackPasswordPrompt.java
deleted file mode 100644
index 9ff7bc4fda..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/account_link/WelcomeBackPasswordPrompt.java
+++ /dev/null
@@ -1,166 +0,0 @@
-/*
- * Copyright 2016 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.account_link;
-
-import android.content.Context;
-import android.content.Intent;
-import android.graphics.Typeface;
-import android.os.Bundle;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
-import android.support.design.widget.TextInputLayout;
-import android.text.Spannable;
-import android.text.SpannableStringBuilder;
-import android.text.TextUtils;
-import android.text.style.StyleSpan;
-import android.view.View;
-import android.widget.EditText;
-import android.widget.TextView;
-
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.provider.AuthCredentialHelper;
-import com.firebase.ui.auth.ui.AppCompatBase;
-import com.firebase.ui.auth.ui.BaseHelper;
-import com.firebase.ui.auth.ui.ExtraConstants;
-import com.firebase.ui.auth.ui.FlowParameters;
-import com.firebase.ui.auth.ui.TaskFailureLogger;
-import com.firebase.ui.auth.ui.email.RecoverPasswordActivity;
-import com.firebase.ui.auth.util.signincontainer.SaveSmartLock;
-import com.google.android.gms.tasks.OnFailureListener;
-import com.google.android.gms.tasks.OnSuccessListener;
-import com.google.firebase.auth.AuthCredential;
-import com.google.firebase.auth.AuthResult;
-import com.google.firebase.auth.FirebaseAuth;
-
-/**
- * Activity to link a pre-existing email/password account to a new IDP sign-in by confirming
- * the password before initiating a link.
- */
-public class WelcomeBackPasswordPrompt extends AppCompatBase implements View.OnClickListener {
- private static final String TAG = "WelcomeBackPassword";
- private static final StyleSpan BOLD = new StyleSpan(Typeface.BOLD);
-
- private String mEmail;
- private TextInputLayout mPasswordLayout;
- private EditText mPasswordField;
- private IdpResponse mIdpResponse;
- @Nullable
- private SaveSmartLock mSaveSmartLock;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.welcome_back_password_prompt_layout);
- mSaveSmartLock = mActivityHelper.getSaveSmartLockInstance();
- mPasswordLayout = (TextInputLayout) findViewById(R.id.password_layout);
- mPasswordField = (EditText) findViewById(R.id.password);
-
- mIdpResponse = getIntent().getParcelableExtra(ExtraConstants.EXTRA_IDP_RESPONSE);
- mEmail = mIdpResponse.getEmail();
-
- // Create welcome back text with email bolded
- String bodyText = getResources().getString(R.string.welcome_back_password_prompt_body);
- bodyText = String.format(bodyText, mEmail);
- SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(bodyText);
- int emailStart = bodyText.indexOf(mEmail);
- spannableStringBuilder.setSpan(BOLD,
- emailStart,
- emailStart + mEmail.length(),
- Spannable.SPAN_INCLUSIVE_INCLUSIVE);
-
- TextView bodyTextView = ((TextView) findViewById(R.id.welcome_back_password_body));
- bodyTextView.setText(spannableStringBuilder);
-
- // Click listeners
- findViewById(R.id.button_done).setOnClickListener(this);
- findViewById(R.id.trouble_signing_in).setOnClickListener(this);
- }
-
- @Override
- public void onClick(View view) {
- final int id = view.getId();
- if (id == R.id.button_done) {
- mActivityHelper.showLoadingDialog(R.string.progress_dialog_signing_in);
- next(mEmail, mPasswordField.getText().toString());
- } else if (id == R.id.trouble_signing_in) {
- startActivity(RecoverPasswordActivity.createIntent(
- getApplicationContext(),
- mActivityHelper.getFlowParams(),
- mEmail));
- finish(RESULT_OK, new Intent());
- }
- }
-
- private void next(final String email, final String password) {
- final FirebaseAuth firebaseAuth = mActivityHelper.getFirebaseAuth();
-
- // Check for null or empty password
- if (TextUtils.isEmpty(password)) {
- mPasswordField.setError(getString(R.string.required_field));
- return;
- } else {
- mPasswordField.setError(null);
- }
-
- // Sign in with known email and the password provided
- firebaseAuth.signInWithEmailAndPassword(email, password)
- .addOnFailureListener(
- new TaskFailureLogger(TAG, "Error signing in with email and password"))
- .addOnSuccessListener(new OnSuccessListener() {
- @Override
- public void onSuccess(AuthResult authResult) {
- // Get the social AuthCredential from the IDPResponse object, link
- // it to the email/password account.
- AuthCredential authCredential =
- AuthCredentialHelper.getAuthCredential(mIdpResponse);
- authResult.getUser().linkWithCredential(authCredential);
- firebaseAuth.signOut();
-
- // Sign in with the credential
- firebaseAuth.signInWithCredential(authCredential)
- .addOnFailureListener(
- new TaskFailureLogger(TAG,
- "Error signing in with credential"))
- .addOnSuccessListener(
- new OnSuccessListener() {
- @Override
- public void onSuccess(AuthResult authResult) {
- mActivityHelper.saveCredentialsOrFinish(
- mSaveSmartLock,
- authResult.getUser(),
- password);
- }
- });
- }
- })
- .addOnFailureListener(new OnFailureListener() {
- @Override
- public void onFailure(@NonNull Exception e) {
- mActivityHelper.dismissDialog();
- String error = e.getLocalizedMessage();
- mPasswordLayout.setError(error);
- }
- });
- }
-
- public static Intent createIntent(
- Context context,
- FlowParameters flowParams,
- IdpResponse response) {
- return BaseHelper.createBaseIntent(context, WelcomeBackPasswordPrompt.class, flowParams)
- .putExtra(ExtraConstants.EXTRA_IDP_RESPONSE, response);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/account_link/package-info.java b/auth/src/main/java/com/firebase/ui/auth/ui/account_link/package-info.java
deleted file mode 100644
index ceef27622e..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/account_link/package-info.java
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * Copyright 2016 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.
- */
-
-/**
- * Activities related to linking a new authentication method to an existing account.
- */
-package com.firebase.ui.auth.ui.account_link;
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthProviderButton.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthProviderButton.kt
new file mode 100644
index 0000000000..9a3f5e1a27
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthProviderButton.kt
@@ -0,0 +1,379 @@
+/*
+ * 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.components
+
+import android.content.Context
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.auth_provider.Provider
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUIAsset
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.configuration.theme.LocalAuthUITheme
+import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults
+
+/**
+ * A customizable button for an authentication provider.
+ *
+ * This button displays the icon and name of an authentication provider (e.g., Google, Facebook).
+ * It is designed to be used within a list of sign-in options. The button's appearance can be
+ * customized using the [style] parameter, and its text is localized via the [stringProvider].
+ *
+ * **Example usage:**
+ * ```kotlin
+ * AuthProviderButton(
+ * provider = AuthProvider.Facebook(),
+ * onClick = { /* Handle Facebook sign-in */ },
+ * stringProvider = DefaultAuthUIStringProvider(LocalContext.current)
+ * )
+ * ```
+ *
+ * @param modifier A modifier for the button
+ * @param provider The provider to represent.
+ * @param onClick A callback when the button is clicked
+ * @param enabled If the button is enabled. Defaults to true.
+ * @param style Optional custom styling for the button.
+ * @param stringProvider The [AuthUIStringProvider] for localized strings
+ * @param subtitle Optional subtitle text to display below the provider label (e.g., user email)
+ * @param label Optional custom label to override the default provider label
+ *
+ * @since 10.0.0
+ */
+@Composable
+fun AuthProviderButton(
+ modifier: Modifier = Modifier,
+ provider: AuthProvider,
+ onClick: () -> Unit,
+ enabled: Boolean = true,
+ style: AuthUITheme.ProviderStyle? = null,
+ stringProvider: AuthUIStringProvider,
+ subtitle: String? = null,
+ label: String? = null,
+ showAsContinue: Boolean = false,
+) {
+ val context = LocalContext.current
+ val authTheme = LocalAuthUITheme.current
+ val providerLabel =
+ label ?: resolveProviderLabel(provider, stringProvider, context, showAsContinue)
+ val providerStyle = resolveProviderStyle(
+ provider = provider,
+ style = style,
+ providerStyles = authTheme.providerStyles,
+ defaultButtonShape = authTheme.providerButtonShape
+ )
+
+ Button(
+ modifier = modifier,
+ contentPadding = PaddingValues(
+ horizontal = 12.dp,
+ vertical = if (subtitle != null) 12.dp else 8.dp
+ ),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = providerStyle.backgroundColor,
+ contentColor = providerStyle.contentColor,
+ ),
+ shape = providerStyle.shape ?: RoundedCornerShape(4.dp),
+ elevation = ButtonDefaults.buttonElevation(
+ defaultElevation = providerStyle.elevation
+ ),
+ onClick = onClick,
+ enabled = enabled,
+ ) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start
+ ) {
+ val providerIcon = providerStyle.icon
+ if (providerIcon != null) {
+ val iconTint = providerStyle.iconTint
+ if (iconTint != null) {
+ Icon(
+ modifier = Modifier
+ .size(24.dp),
+ painter = providerIcon.painter,
+ contentDescription = providerLabel,
+ tint = iconTint
+ )
+ } else {
+ Image(
+ modifier = Modifier
+ .size(24.dp),
+ painter = providerIcon.painter,
+ contentDescription = providerLabel
+ )
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ }
+
+ if (subtitle != null) {
+ Column(
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = providerLabel,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ )
+ Text(
+ text = subtitle,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ } else {
+ Text(
+ text = providerLabel,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ )
+ }
+ }
+ }
+}
+
+internal fun resolveProviderStyle(
+ provider: AuthProvider,
+ style: AuthUITheme.ProviderStyle?,
+ providerStyles: Map,
+ defaultButtonShape: Shape?,
+): AuthUITheme.ProviderStyle {
+ // If explicit style is provided, use it but apply default shape if needed
+ if (style != null) {
+ return if (style.shape == null) {
+ style.copy(shape = defaultButtonShape ?: RoundedCornerShape(4.dp))
+ } else {
+ style
+ }
+ }
+
+ // Get the configured style from the theme or fall back to defaults
+ val configuredStyle = providerStyles[provider.providerId]
+ ?: ProviderStyleDefaults.default[provider.providerId]
+ ?: AuthUITheme.ProviderStyle.Empty
+
+ // Handle GenericOAuth providers with custom properties
+ val resolvedStyle = if (provider is AuthProvider.GenericOAuth) {
+ configuredStyle.copy(
+ icon = provider.buttonIcon ?: configuredStyle.icon,
+ backgroundColor = provider.buttonColor ?: configuredStyle.backgroundColor,
+ contentColor = provider.contentColor ?: configuredStyle.contentColor,
+ )
+ } else {
+ configuredStyle
+ }
+
+ // Apply default button shape if no shape is explicitly set
+ return if (resolvedStyle.shape == null) {
+ resolvedStyle.copy(shape = defaultButtonShape ?: RoundedCornerShape(4.dp))
+ } else {
+ resolvedStyle
+ }
+}
+
+internal fun resolveProviderLabel(
+ provider: AuthProvider,
+ stringProvider: AuthUIStringProvider,
+ context: Context,
+ showAsContinue: Boolean = false,
+): String = when (provider) {
+ is AuthProvider.GenericOAuth -> provider.buttonLabel
+ is AuthProvider.Apple -> {
+ // Use Apple-specific locale if provided, otherwise use default stringProvider
+ if (provider.locale != null) {
+ val appleLocale = java.util.Locale.forLanguageTag(provider.locale)
+ val appleStringProvider = DefaultAuthUIStringProvider(context, appleLocale)
+ if (showAsContinue) appleStringProvider.continueWithApple else appleStringProvider.signInWithApple
+ } else {
+ if (showAsContinue) stringProvider.continueWithApple else stringProvider.signInWithApple
+ }
+ }
+
+ else -> when (Provider.fromId(provider.providerId)) {
+ Provider.GOOGLE -> if (showAsContinue) stringProvider.continueWithGoogle else stringProvider.signInWithGoogle
+ Provider.FACEBOOK -> if (showAsContinue) stringProvider.continueWithFacebook else stringProvider.signInWithFacebook
+ Provider.TWITTER -> if (showAsContinue) stringProvider.continueWithTwitter else stringProvider.signInWithTwitter
+ Provider.GITHUB -> if (showAsContinue) stringProvider.continueWithGithub else stringProvider.signInWithGithub
+ Provider.EMAIL -> if (showAsContinue) stringProvider.continueWithEmail else stringProvider.signInWithEmail
+ Provider.PHONE -> if (showAsContinue) stringProvider.continueWithPhone else stringProvider.signInWithPhone
+ Provider.ANONYMOUS -> stringProvider.signInAnonymously
+ Provider.MICROSOFT -> if (showAsContinue) stringProvider.continueWithMicrosoft else stringProvider.signInWithMicrosoft
+ Provider.YAHOO -> if (showAsContinue) stringProvider.continueWithYahoo else stringProvider.signInWithYahoo
+ Provider.APPLE -> if (showAsContinue) stringProvider.continueWithApple else stringProvider.signInWithApple
+ null -> "Unknown Provider"
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PreviewAuthProviderButton() {
+ val context = LocalContext.current
+ Column(
+ modifier = Modifier
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ AuthProviderButton(
+ provider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null,
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Google(
+ scopes = emptyList(),
+ serverClientId = null
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Facebook(),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Twitter(
+ customParameters = emptyMap()
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Github(
+ customParameters = emptyMap()
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Microsoft(
+ tenant = null,
+ customParameters = emptyMap()
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Yahoo(
+ customParameters = emptyMap()
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Apple(
+ locale = null,
+ customParameters = emptyMap()
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Anonymous,
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.GenericOAuth(
+ providerName = "Generic Provider",
+ providerId = "google.com",
+ scopes = emptyList(),
+ customParameters = emptyMap(),
+ buttonLabel = "Generic Provider",
+ buttonIcon = AuthUIAsset.Vector(Icons.Default.Star),
+ buttonColor = Color.Gray,
+ contentColor = Color.White
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.GenericOAuth(
+ providerName = "Generic Provider",
+ providerId = "google.com",
+ scopes = emptyList(),
+ customParameters = emptyMap(),
+ buttonLabel = "Custom Style",
+ buttonIcon = AuthUIAsset.Vector(Icons.Default.Star),
+ buttonColor = Color.Gray,
+ contentColor = Color.White
+ ),
+ onClick = {},
+ style = AuthUITheme.ProviderStyle(
+ icon = AuthUITheme.Default.providerStyles[Provider.MICROSOFT.id]?.icon,
+ backgroundColor = AuthUITheme.Default.providerStyles[Provider.MICROSOFT.id]!!.backgroundColor,
+ contentColor = AuthUITheme.Default.providerStyles[Provider.MICROSOFT.id]!!.contentColor,
+ iconTint = Color.Red,
+ shape = RoundedCornerShape(24.dp),
+ elevation = 6.dp
+ ),
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.GenericOAuth(
+ providerName = "Generic Provider",
+ providerId = "unknown_provider",
+ scopes = emptyList(),
+ customParameters = emptyMap(),
+ buttonLabel = "Unsupported Provider",
+ buttonIcon = null,
+ buttonColor = null,
+ contentColor = null,
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt
new file mode 100644
index 0000000000..253a6e260a
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt
@@ -0,0 +1,258 @@
+/*
+ * 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.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Email
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.PasswordRule
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.configuration.validators.EmailValidator
+import com.firebase.ui.auth.configuration.validators.FieldValidator
+import com.firebase.ui.auth.configuration.validators.PasswordValidator
+
+/**
+ * A customizable input field with built-in validation display.
+ *
+ * **Example usage:**
+ * ```kotlin
+ * val emailTextValue = remember { mutableStateOf("") }
+ *
+ * val emailValidator = remember {
+ * EmailValidator(stringProvider = DefaultAuthUIStringProvider(context))
+ * }
+ *
+ * AuthTextField(
+ * value = emailTextValue,
+ * onValueChange = { emailTextValue.value = it },
+ * label = {
+ * Text("Email")
+ * },
+ * validator = emailValidator
+ * )
+ * ```
+ *
+ * @param modifier A modifier for the field.
+ * @param value The current value of the text field.
+ * @param onValueChange A callback when the value changes.
+ * @param label The label for the text field.
+ * @param enabled If the field is enabled.
+ * @param isError Manually set the error state.
+ * @param errorMessage A custom error message to display.
+ * @param validator A validator to automatically handle error state and messages.
+ * @param keyboardOptions Keyboard options for the field.
+ * @param keyboardActions Keyboard actions for the field.
+ * @param visualTransformation Visual transformation for the input (e.g., password).
+ * @param leadingIcon An optional icon to display at the start of the field.
+ * @param trailingIcon An optional icon to display at the start of the field.
+ */
+@Composable
+fun AuthTextField(
+ modifier: Modifier = Modifier,
+ value: String,
+ onValueChange: (String) -> Unit,
+ label: @Composable (() -> Unit)? = null,
+ isSecureTextField: Boolean = false,
+ enabled: Boolean = true,
+ isError: Boolean? = null,
+ errorMessage: String? = null,
+ validator: FieldValidator? = null,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ leadingIcon: @Composable (() -> Unit)? = null,
+ trailingIcon: @Composable (() -> Unit)? = null,
+) {
+ var passwordVisible by remember { mutableStateOf(false) }
+
+ // Automatically set the correct keyboard type based on validator or field type
+ val resolvedKeyboardOptions = remember(validator, isSecureTextField, keyboardOptions) {
+ when {
+ keyboardOptions != KeyboardOptions.Default -> keyboardOptions
+ validator is EmailValidator -> KeyboardOptions(
+ keyboardType = KeyboardType.Email,
+ imeAction = ImeAction.Next
+ )
+ isSecureTextField -> KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done
+ )
+ else -> keyboardOptions
+ }
+ }
+
+ TextField(
+ modifier = modifier
+ .fillMaxWidth(),
+ value = value,
+ onValueChange = { newValue ->
+ onValueChange(newValue)
+ validator?.validate(newValue)
+ },
+ label = label,
+ singleLine = true,
+ enabled = enabled,
+ isError = isError ?: validator?.hasError ?: false,
+ supportingText = {
+ if (validator?.hasError ?: false) {
+ Text(text = errorMessage ?: validator.errorMessage)
+ }
+ },
+ keyboardOptions = resolvedKeyboardOptions,
+ keyboardActions = keyboardActions,
+ visualTransformation = if (isSecureTextField && !passwordVisible)
+ PasswordVisualTransformation() else visualTransformation,
+ leadingIcon = leadingIcon ?: when {
+ validator is EmailValidator -> {
+ {
+ Icon(
+ imageVector = Icons.Default.Email,
+ contentDescription = "Email Input Icon"
+ )
+ }
+ }
+
+ isSecureTextField -> {
+ {
+ Icon(
+ imageVector = Icons.Default.Lock,
+ contentDescription = "Password Input Icon"
+ )
+ }
+ }
+
+ else -> null
+ },
+ trailingIcon = trailingIcon ?: {
+ if (isSecureTextField) {
+ IconButton(
+ onClick = {
+ passwordVisible = !passwordVisible
+ }
+ ) {
+ Icon(
+ imageVector = if (passwordVisible)
+ Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
+ contentDescription = if (passwordVisible) "Hide password" else "Show password"
+ )
+ }
+ }
+ },
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+internal fun PreviewAuthTextField() {
+ val context = LocalContext.current
+ val nameTextValue = remember { mutableStateOf("") }
+ val emailTextValue = remember { mutableStateOf("") }
+ val passwordTextValue = remember { mutableStateOf("") }
+ val emailValidator = remember {
+ EmailValidator(stringProvider = DefaultAuthUIStringProvider(context))
+ }
+ val passwordValidator = remember {
+ PasswordValidator(
+ stringProvider = DefaultAuthUIStringProvider(context),
+ rules = listOf(
+ PasswordRule.MinimumLength(8),
+ PasswordRule.RequireUppercase,
+ PasswordRule.RequireLowercase,
+ )
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ AuthTextField(
+ value = nameTextValue.value,
+ label = {
+ Text("Name")
+ },
+ onValueChange = { text ->
+ nameTextValue.value = text
+ },
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ AuthTextField(
+ value = emailTextValue.value,
+ validator = emailValidator,
+ label = {
+ Text("Email")
+ },
+ onValueChange = { text ->
+ emailTextValue.value = text
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Email,
+ contentDescription = ""
+ )
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ AuthTextField(
+ value = passwordTextValue.value,
+ validator = passwordValidator,
+ isSecureTextField = true,
+ label = {
+ Text("Password")
+ },
+ onValueChange = { text ->
+ passwordTextValue.value = text
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Lock,
+ contentDescription = ""
+ )
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/CountrySelector.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/CountrySelector.kt
new file mode 100644
index 0000000000..425aa32bc6
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/CountrySelector.kt
@@ -0,0 +1,218 @@
+/*
+ * 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.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.data.ALL_COUNTRIES
+import com.firebase.ui.auth.data.CountryData
+import com.firebase.ui.auth.util.CountryUtils
+import kotlinx.coroutines.launch
+
+/**
+ * A country selector component that displays the selected country's flag and dial code with a dropdown icon.
+ * Designed to be used as a leadingIcon in a TextField.
+ *
+ * @param selectedCountry The currently selected country.
+ * @param onCountrySelected Callback when a country is selected.
+ * @param enabled Whether the selector is enabled.
+ * @param allowedCountries Optional set of allowed country codes to filter the list.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CountrySelector(
+ selectedCountry: CountryData,
+ onCountrySelected: (CountryData) -> Unit,
+ enabled: Boolean = true,
+ allowedCountries: Set? = null,
+) {
+ val context = LocalContext.current
+ val stringProvider = LocalAuthUIStringProvider.current
+ val sheetState = rememberModalBottomSheetState()
+ val scope = rememberCoroutineScope()
+ var showBottomSheet by remember { mutableStateOf(false) }
+ var searchQuery by remember { mutableStateOf("") }
+
+ val countriesList = remember(allowedCountries) {
+ if (allowedCountries != null) {
+ CountryUtils.filterByAllowedCountries(allowedCountries)
+ } else {
+ ALL_COUNTRIES
+ }
+ }
+
+ val filteredCountries = remember(searchQuery, countriesList) {
+ if (searchQuery.isEmpty()) {
+ countriesList
+ } else {
+ CountryUtils.search(searchQuery).filter { country ->
+ countriesList.any { it.countryCode == country.countryCode }
+ }
+ }
+ }
+
+ // Clickable row showing flag, dial code and dropdown icon
+ Row(
+ modifier = Modifier
+ .fillMaxHeight()
+ .clickable(enabled = enabled) {
+ showBottomSheet = true
+ }
+ .padding(start = 8.dp)
+ .semantics {
+ role = Role.DropdownList
+ contentDescription = "Country selector"
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = selectedCountry.flagEmoji,
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Text(
+ text = selectedCountry.dialCode,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Icon(
+ imageVector = Icons.Default.ArrowDropDown,
+ contentDescription = "Select country",
+ modifier = Modifier.padding(PaddingValues.Zero)
+ )
+ }
+
+ if (showBottomSheet) {
+ ModalBottomSheet(
+ onDismissRequest = {
+ showBottomSheet = false
+ searchQuery = ""
+ },
+ sheetState = sheetState
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 16.dp)
+ ) {
+ Text(
+ text = stringProvider.countrySelectorModalTitle,
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ OutlinedTextField(
+ value = searchQuery,
+ onValueChange = { searchQuery = it },
+ label = { Text(stringProvider.searchCountriesHint) },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(500.dp)
+ .testTag("CountrySelector LazyColumn")
+ ) {
+ items(filteredCountries) { country ->
+ Button(
+ onClick = {
+ onCountrySelected(country)
+ scope.launch {
+ sheetState.hide()
+ showBottomSheet = false
+ searchQuery = ""
+ }
+ },
+ colors = ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ containerColor = Color.Transparent
+ ),
+ contentPadding = PaddingValues.Zero
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp, horizontal = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = country.flagEmoji,
+ style = MaterialTheme.typography.headlineMedium
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = country.name,
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+ Text(
+ text = country.dialCode,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt
new file mode 100644
index 0000000000..d0d707bda8
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt
@@ -0,0 +1,278 @@
+/*
+ * 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.components
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.window.DialogProperties
+import com.firebase.ui.auth.AuthException
+import com.google.firebase.auth.EmailAuthProvider
+import com.google.firebase.auth.FacebookAuthProvider
+import com.google.firebase.auth.GithubAuthProvider
+import com.google.firebase.auth.GoogleAuthProvider
+import com.google.firebase.auth.PhoneAuthProvider
+import com.google.firebase.auth.TwitterAuthProvider
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+/**
+ * A composable dialog for displaying authentication errors with recovery options.
+ *
+ * This dialog provides friendly error messages and actionable recovery suggestions
+ * based on the specific [AuthException] type. It integrates with [AuthUIStringProvider]
+ * for localization support.
+ *
+ * **Example usage:**
+ * ```kotlin
+ * var showError by remember { mutableStateOf(null) }
+ *
+ * if (showError != null) {
+ * ErrorRecoveryDialog(
+ * error = showError!!,
+ * stringProvider = stringProvider,
+ * onRetry = {
+ * showError = null
+ * // Retry authentication operation
+ * },
+ * onDismiss = {
+ * showError = null
+ * }
+ * )
+ * }
+ * ```
+ *
+ * @param error The [AuthException] to display recovery information for
+ * @param stringProvider The [AuthUIStringProvider] for localized strings
+ * @param onRetry Callback invoked when the user taps the retry action
+ * @param onDismiss Callback invoked when the user dismisses the dialog
+ * @param modifier Optional [Modifier] for the dialog
+ * @param onRecover Optional callback for custom recovery actions based on the exception type
+ * @param properties Optional [DialogProperties] for dialog configuration
+ *
+ * @since 10.0.0
+ */
+@Composable
+fun ErrorRecoveryDialog(
+ error: AuthException,
+ stringProvider: AuthUIStringProvider,
+ onRetry: (AuthException) -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+ onRecover: ((AuthException) -> Unit)? = null,
+ properties: DialogProperties = DialogProperties()
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ val view = LocalView.current
+ SideEffect { view.rootView.filterTouchesWhenObscured = true }
+ Text(
+ text = stringProvider.errorDialogTitle,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ },
+ text = {
+ Text(
+ text = getRecoveryMessage(error, stringProvider),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Start
+ )
+ },
+ confirmButton = {
+ if (isRecoverable(error)) {
+ TextButton(
+ onClick = {
+ onRecover?.invoke(error) ?: onRetry(error)
+ }
+ ) {
+ Text(
+ text = getRecoveryActionText(error, stringProvider),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(
+ text = stringProvider.dismissAction,
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ },
+ modifier = modifier,
+ properties = properties
+ )
+}
+
+/**
+ * Gets the appropriate recovery message for the given [AuthException].
+ *
+ * @param error The [AuthException] to get the message for
+ * @param stringProvider The [AuthUIStringProvider] for localized strings
+ * @return The localized recovery message
+ */
+private fun getRecoveryMessage(
+ error: AuthException,
+ stringProvider: AuthUIStringProvider
+): String {
+ return when (error) {
+ is AuthException.NetworkException -> stringProvider.networkErrorRecoveryMessage
+ is AuthException.InvalidCredentialsException -> {
+ // Use the actual error message from Firebase if available, otherwise fallback to generic message
+ error.message?.takeIf { it.isNotBlank() && it != "Invalid credentials provided" }
+ ?: stringProvider.invalidCredentialsRecoveryMessage
+ }
+ is AuthException.UserNotFoundException -> stringProvider.userNotFoundRecoveryMessage
+ is AuthException.WeakPasswordException -> {
+ // Include specific reason if available
+ val baseMessage = stringProvider.weakPasswordRecoveryMessage
+ error.reason?.let { reason ->
+ "$baseMessage\n\nReason: $reason"
+ } ?: baseMessage
+ }
+
+ is AuthException.PasswordPolicyViolationException -> {
+ error.message?.takeIf { it.isNotBlank() }
+ ?: stringProvider.weakPasswordRecoveryMessage
+ }
+
+ is AuthException.EmailAlreadyInUseException -> {
+ // Include email if available
+ val baseMessage = stringProvider.emailAlreadyInUseRecoveryMessage
+ error.email?.let { email ->
+ "$baseMessage ($email)"
+ } ?: baseMessage
+ }
+
+ is AuthException.TooManyRequestsException -> stringProvider.tooManyRequestsRecoveryMessage
+ is AuthException.PhoneVerificationCooldownException -> {
+ // Use the custom message which includes remaining cooldown time
+ error.message ?: stringProvider.unknownErrorRecoveryMessage
+ }
+ is AuthException.MfaRequiredException -> stringProvider.mfaRequiredRecoveryMessage
+ is AuthException.AccountLinkingRequiredException -> {
+ // Use the custom message which includes email and provider details
+ error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage
+ }
+ is AuthException.DifferentSignInMethodRequiredException -> {
+ error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage
+ }
+ is AuthException.EmailMismatchException -> stringProvider.emailMismatchMessage
+ is AuthException.InvalidEmailLinkException -> stringProvider.emailLinkInvalidLinkMessage
+ is AuthException.EmailLinkWrongDeviceException -> stringProvider.emailLinkWrongDeviceMessage
+ is AuthException.EmailLinkDifferentAnonymousUserException ->
+ stringProvider.emailLinkDifferentAnonymousUserMessage
+ is AuthException.EmailLinkPromptForEmailException -> stringProvider.emailLinkPromptForEmailMessage
+ is AuthException.EmailLinkCrossDeviceLinkingException -> {
+ val providerName = error.providerName ?: stringProvider.emailProvider
+ stringProvider.emailLinkCrossDeviceLinkingMessage(providerName)
+ }
+ is AuthException.AuthCancelledException -> stringProvider.authCancelledRecoveryMessage
+ is AuthException.UnknownException -> {
+ // Use custom message if available (e.g., for configuration errors)
+ error.message?.takeIf { it.isNotBlank() } ?: stringProvider.unknownErrorRecoveryMessage
+ }
+ else -> stringProvider.unknownErrorRecoveryMessage
+ }
+}
+
+/**
+ * Gets the appropriate recovery action text for the given [AuthException].
+ *
+ * @param error The [AuthException] to get the action text for
+ * @param stringProvider The [AuthUIStringProvider] for localized strings
+ * @return The localized action text
+ */
+private fun getRecoveryActionText(
+ error: AuthException,
+ stringProvider: AuthUIStringProvider
+): String {
+ return when (error) {
+ is AuthException.AuthCancelledException -> error.message ?: stringProvider.continueText
+ is AuthException.EmailAlreadyInUseException -> stringProvider.signInDefault // Use existing "Sign in" text
+ is AuthException.AccountLinkingRequiredException -> stringProvider.signInDefault // User needs to sign in to link accounts
+ is AuthException.DifferentSignInMethodRequiredException ->
+ getDifferentSignInMethodActionText(error.suggestedSignInMethod, stringProvider)
+ is AuthException.MfaRequiredException -> stringProvider.continueText // Use "Continue" for MFA
+ is AuthException.EmailLinkPromptForEmailException -> stringProvider.continueText
+ is AuthException.EmailLinkCrossDeviceLinkingException -> stringProvider.continueText
+ is AuthException.EmailLinkWrongDeviceException -> stringProvider.continueText
+ is AuthException.EmailLinkDifferentAnonymousUserException -> stringProvider.dismissAction
+ is AuthException.UserNotFoundException -> stringProvider.signupPageTitle // Navigate to sign-up when user not found
+ is AuthException.NetworkException,
+ is AuthException.InvalidCredentialsException,
+ is AuthException.WeakPasswordException,
+ is AuthException.PasswordPolicyViolationException,
+ is AuthException.TooManyRequestsException,
+ is AuthException.PhoneVerificationCooldownException -> stringProvider.retryAction
+ is AuthException.UnknownException -> stringProvider.retryAction
+
+ else -> stringProvider.retryAction
+ }
+}
+
+/**
+ * Determines if the given [AuthException] is recoverable through user action.
+ *
+ * @param error The [AuthException] to check
+ * @return `true` if the error is recoverable, `false` otherwise
+ */
+private fun isRecoverable(error: AuthException): Boolean {
+ return when (error) {
+ is AuthException.NetworkException -> true
+ is AuthException.InvalidCredentialsException -> true
+ is AuthException.UserNotFoundException -> true
+ is AuthException.WeakPasswordException -> true
+ is AuthException.PasswordPolicyViolationException -> true
+ is AuthException.EmailAlreadyInUseException -> true
+ is AuthException.TooManyRequestsException -> false // User must wait
+ is AuthException.PhoneVerificationCooldownException -> false // User must wait for cooldown
+ is AuthException.MfaRequiredException -> true
+ is AuthException.AccountLinkingRequiredException -> true
+ is AuthException.DifferentSignInMethodRequiredException -> true
+ is AuthException.AuthCancelledException -> true
+ is AuthException.EmailLinkPromptForEmailException -> true
+ is AuthException.EmailLinkCrossDeviceLinkingException -> true
+ is AuthException.EmailLinkWrongDeviceException -> true
+ is AuthException.EmailLinkDifferentAnonymousUserException -> false
+ is AuthException.UnknownException -> true
+ else -> true
+ }
+}
+
+private fun getDifferentSignInMethodActionText(
+ signInMethod: String,
+ stringProvider: AuthUIStringProvider,
+): String {
+ return when (signInMethod) {
+ GoogleAuthProvider.PROVIDER_ID -> stringProvider.continueWithGoogle
+ FacebookAuthProvider.PROVIDER_ID -> stringProvider.continueWithFacebook
+ TwitterAuthProvider.PROVIDER_ID -> stringProvider.continueWithTwitter
+ GithubAuthProvider.PROVIDER_ID -> stringProvider.continueWithGithub
+ PhoneAuthProvider.PROVIDER_ID -> stringProvider.continueWithPhone
+ "apple.com" -> stringProvider.continueWithApple
+ "microsoft.com" -> stringProvider.continueWithMicrosoft
+ "yahoo.com" -> stringProvider.continueWithYahoo
+ EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD -> stringProvider.signInWithEmailLink
+ else -> stringProvider.continueText
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/QrCodeImage.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/QrCodeImage.kt
new file mode 100644
index 0000000000..754aa5cc78
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/QrCodeImage.kt
@@ -0,0 +1,130 @@
+/*
+ * 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.components
+
+import android.graphics.Bitmap
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.EncodeHintType
+import com.google.zxing.WriterException
+import com.google.zxing.qrcode.QRCodeWriter
+
+/**
+ * Renders a QR code from the provided content string.
+ *
+ * This component is typically used to display TOTP enrollment URIs. The QR code is generated on the
+ * fly and memoized for the given [content].
+ *
+ * @param content The string content to encode into the QR code (for example the TOTP URI).
+ * @param modifier Optional [Modifier] applied to the QR container.
+ * @param size The size of the QR code square in density-independent pixels.
+ * @param foregroundColor Color used to render the QR pixels (defaults to black).
+ * @param backgroundColor Background color for the QR code (defaults to white).
+ */
+@Composable
+fun QrCodeImage(
+ content: String,
+ modifier: Modifier = Modifier,
+ size: Dp = 250.dp,
+ foregroundColor: Color = Color.Black,
+ backgroundColor: Color = Color.White
+) {
+ val bitmap = remember(content, size, foregroundColor, backgroundColor) {
+ generateQrCodeBitmap(
+ content = content,
+ sizePx = (size.value * 2).toInt(), // Render at 2x for better scaling quality.
+ foregroundColor = foregroundColor,
+ backgroundColor = backgroundColor
+ )
+ }
+
+ Box(
+ modifier = modifier
+ .size(size)
+ .background(backgroundColor),
+ contentAlignment = Alignment.Center
+ ) {
+ bitmap?.let {
+ Image(
+ bitmap = it.asImageBitmap(),
+ contentDescription = "QR code for authenticator app setup",
+ modifier = Modifier.size(size)
+ )
+ }
+ }
+}
+
+private fun generateQrCodeBitmap(
+ content: String,
+ sizePx: Int,
+ foregroundColor: Color,
+ backgroundColor: Color
+): Bitmap? {
+ return try {
+ val qrCodeWriter = QRCodeWriter()
+ val hints = mapOf(
+ EncodeHintType.MARGIN to 1 // Small margin keeps QR code compact while remaining scannable.
+ )
+
+ val bitMatrix = qrCodeWriter.encode(
+ content,
+ BarcodeFormat.QR_CODE,
+ sizePx,
+ sizePx,
+ hints
+ )
+
+ val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
+
+ val foregroundArgb = android.graphics.Color.argb(
+ (foregroundColor.alpha * 255).toInt(),
+ (foregroundColor.red * 255).toInt(),
+ (foregroundColor.green * 255).toInt(),
+ (foregroundColor.blue * 255).toInt()
+ )
+
+ val backgroundArgb = android.graphics.Color.argb(
+ (backgroundColor.alpha * 255).toInt(),
+ (backgroundColor.red * 255).toInt(),
+ (backgroundColor.green * 255).toInt(),
+ (backgroundColor.blue * 255).toInt()
+ )
+
+ for (x in 0 until sizePx) {
+ for (y in 0 until sizePx) {
+ bitmap.setPixel(
+ x,
+ y,
+ if (bitMatrix[x, y]) foregroundArgb else backgroundArgb
+ )
+ }
+ }
+
+ bitmap
+ } catch (e: WriterException) {
+ null
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt
new file mode 100644
index 0000000000..4622de9578
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt
@@ -0,0 +1,214 @@
+/*
+ * 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.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.google.firebase.auth.EmailAuthProvider
+import com.google.firebase.auth.FirebaseUser
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+/**
+ * Dialog presented when Firebase requires the current user to re-authenticate before performing
+ * a sensitive operation (for example, MFA enrollment).
+ */
+@Composable
+fun ReauthenticationDialog(
+ user: FirebaseUser,
+ onDismiss: () -> Unit,
+ onSuccess: () -> Unit,
+ onError: (Exception) -> Unit
+) {
+ var password by remember { mutableStateOf("") }
+ var isLoading by remember { mutableStateOf(false) }
+ var errorMessage by remember { mutableStateOf(null) }
+ val coroutineScope = rememberCoroutineScope()
+ val focusRequester = remember { FocusRequester() }
+ val stringProvider = LocalAuthUIStringProvider.current
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ AlertDialog(
+ onDismissRequest = { if (!isLoading) onDismiss() },
+ title = {
+ val view = LocalView.current
+ SideEffect { view.rootView.filterTouchesWhenObscured = true }
+ Text(
+ text = stringProvider.reauthDialogTitle,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ },
+ text = {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = stringProvider.reauthDialogMessage,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ user.email?.let { email ->
+ Text(
+ text = stringProvider.reauthAccountLabel(email),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ OutlinedTextField(
+ value = password,
+ onValueChange = {
+ password = it
+ errorMessage = null
+ },
+ label = { Text(stringProvider.passwordHint) },
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ if (password.isNotBlank() && !isLoading) {
+ coroutineScope.launch {
+ reauthenticate(
+ user = user,
+ password = password,
+ onLoading = { isLoading = it },
+ onSuccess = onSuccess,
+ onError = { error ->
+ errorMessage = error.toUserMessage(stringProvider)
+ onError(error)
+ }
+ )
+ }
+ }
+ }
+ ),
+ enabled = !isLoading,
+ isError = errorMessage != null,
+ supportingText = errorMessage?.let { message -> { Text(message) } },
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ )
+
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(top = 8.dp)
+ )
+ }
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ coroutineScope.launch {
+ reauthenticate(
+ user = user,
+ password = password,
+ onLoading = { isLoading = it },
+ onSuccess = onSuccess,
+ onError = { error ->
+ errorMessage = error.toUserMessage(stringProvider)
+ onError(error)
+ }
+ )
+ }
+ },
+ enabled = password.isNotBlank() && !isLoading
+ ) {
+ Text(stringProvider.verifyAction)
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = onDismiss,
+ enabled = !isLoading
+ ) {
+ Text(stringProvider.dismissAction)
+ }
+ }
+ )
+}
+
+private suspend fun reauthenticate(
+ user: FirebaseUser,
+ password: String,
+ onLoading: (Boolean) -> Unit,
+ onSuccess: () -> Unit,
+ onError: (Exception) -> Unit
+) {
+ try {
+ onLoading(true)
+ val email = requireNotNull(user.email) {
+ "Email must be available to re-authenticate with password."
+ }
+
+ val credential = EmailAuthProvider.getCredential(email, password)
+ user.reauthenticate(credential).await()
+ onSuccess()
+ } catch (e: Exception) {
+ onError(e)
+ } finally {
+ onLoading(false)
+ }
+}
+
+private fun Exception.toUserMessage(stringProvider: AuthUIStringProvider): String = when {
+ message?.contains("password", ignoreCase = true) == true ->
+ stringProvider.incorrectPasswordError
+ message?.contains("network", ignoreCase = true) == true ->
+ stringProvider.noInternet
+ else -> stringProvider.reauthGenericError
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/TermsAndPrivacyForm.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/TermsAndPrivacyForm.kt
new file mode 100644
index 0000000000..5cf33cd5a9
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/TermsAndPrivacyForm.kt
@@ -0,0 +1,75 @@
+/*
+ * 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.components
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.R
+
+@Composable
+fun TermsAndPrivacyForm(
+ modifier: Modifier = Modifier,
+ tosUrl: String?,
+ ppUrl: String?
+) {
+ val uriHandler = LocalUriHandler.current
+ Row(
+ modifier = modifier,
+ ) {
+ TextButton(
+ onClick = {
+ tosUrl?.let {
+ uriHandler.openUri(it)
+ }
+ },
+ contentPadding = PaddingValues.Zero,
+ ) {
+ Text(
+ text = stringResource(R.string.fui_terms_of_service),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ textDecoration = TextDecoration.Underline
+ )
+ }
+ Spacer(modifier = Modifier.width(24.dp))
+ TextButton(
+ onClick = {
+ ppUrl?.let {
+ uriHandler.openUri(it)
+ }
+ },
+ contentPadding = PaddingValues.Zero,
+ ) {
+ Text(
+ text = stringResource(R.string.fui_privacy_policy),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ textDecoration = TextDecoration.Underline
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt
new file mode 100644
index 0000000000..4cd0aadd8e
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt
@@ -0,0 +1,174 @@
+/*
+ * 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.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+/**
+ * CompositionLocal for accessing the top-level dialog controller from any composable.
+ */
+val LocalTopLevelDialogController = compositionLocalOf {
+ null
+}
+
+/**
+ * A top-level dialog controller that allows any child composable to show error recovery dialogs.
+ *
+ * It provides a single point of control for showing dialogs from anywhere in the composition tree,
+ * preventing duplicate dialogs when multiple screens observe the same error state.
+ *
+ * **Usage:**
+ * ```kotlin
+ * // At the root of your auth flow (FirebaseAuthScreen):
+ * val dialogController = rememberTopLevelDialogController(stringProvider)
+ *
+ * CompositionLocalProvider(LocalTopLevelDialogController provides dialogController) {
+ * // Your auth screens...
+ *
+ * // Show dialog at root level (only one instance)
+ * dialogController.CurrentDialog()
+ * }
+ *
+ * // In any child screen (EmailAuthScreen, PhoneAuthScreen, etc.):
+ * val dialogController = LocalTopLevelDialogController.current
+ *
+ * LaunchedEffect(error) {
+ * error?.let { exception ->
+ * dialogController?.showErrorDialog(
+ * exception = exception,
+ * onRetry = { ... },
+ * onRecover = { ... },
+ * onDismiss = { ... }
+ * )
+ * }
+ * }
+ * ```
+ *
+ * @since 10.0.0
+ */
+class TopLevelDialogController(
+ private val stringProvider: AuthUIStringProvider,
+ private val authState: AuthState
+) {
+ private var dialogState by mutableStateOf(null)
+ private val shownErrorStates = mutableSetOf()
+
+ /**
+ * Shows an error recovery dialog at the top level using [ErrorRecoveryDialog].
+ * Automatically prevents duplicate dialogs for the same AuthState.Error instance.
+ *
+ * @param exception The auth exception to display
+ * @param onRetry Callback when user clicks retry button
+ * @param onRecover Callback when user clicks recover button (e.g., navigate to different screen)
+ * @param onDismiss Callback when dialog is dismissed
+ */
+ fun showErrorDialog(
+ exception: AuthException,
+ onRetry: (AuthException) -> Unit = {},
+ onRecover: ((AuthException) -> Unit)? = null,
+ onDismiss: () -> Unit = {}
+ ) {
+ // Get current error state
+ val currentErrorState = authState as? AuthState.Error
+
+ // If this exact error state has already been shown, skip
+ if (currentErrorState != null && currentErrorState in shownErrorStates) {
+ return
+ }
+
+ // Mark this error state as shown
+ currentErrorState?.let { shownErrorStates.add(it) }
+
+ dialogState = DialogState.ErrorDialog(
+ exception = exception,
+ onRetry = onRetry,
+ onRecover = onRecover,
+ onDismiss = {
+ dialogState = null
+ onDismiss()
+ }
+ )
+ }
+
+ /**
+ * Dismisses the currently shown dialog.
+ */
+ fun dismissDialog() {
+ dialogState = null
+ }
+
+ /**
+ * Composable that renders the current dialog, if any.
+ * This should be called once at the root level of your auth flow.
+ *
+ * Uses the existing [ErrorRecoveryDialog] component.
+ */
+ @Composable
+ fun CurrentDialog() {
+ val state = dialogState
+ when (state) {
+ is DialogState.ErrorDialog -> {
+ ErrorRecoveryDialog(
+ error = state.exception,
+ stringProvider = stringProvider,
+ onRetry = { exception ->
+ state.onRetry(exception)
+ state.onDismiss()
+ },
+ onRecover = state.onRecover?.let { onRecover ->
+ { exception ->
+ onRecover(exception)
+ state.onDismiss()
+ }
+ },
+ onDismiss = state.onDismiss
+ )
+ }
+ null -> {
+ // No dialog to show
+ }
+ }
+ }
+
+ private sealed class DialogState {
+ data class ErrorDialog(
+ val exception: AuthException,
+ val onRetry: (AuthException) -> Unit,
+ val onRecover: ((AuthException) -> Unit)?,
+ val onDismiss: () -> Unit
+ ) : DialogState()
+ }
+}
+
+/**
+ * Creates and remembers a [TopLevelDialogController].
+ */
+@Composable
+fun rememberTopLevelDialogController(
+ stringProvider: AuthUIStringProvider,
+ authState: AuthState
+): TopLevelDialogController {
+ return remember(stringProvider, authState) {
+ TopLevelDialogController(stringProvider, authState)
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt
new file mode 100644
index 0000000000..ab79e8954c
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt
@@ -0,0 +1,396 @@
+package com.firebase.ui.auth.ui.components
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.ui.draw.clip
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Remove
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.core.text.isDigitsOnly
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.configuration.validators.FieldValidator
+
+@Composable
+fun VerificationCodeInputField(
+ modifier: Modifier = Modifier,
+ codeLength: Int = 6,
+ validator: FieldValidator? = null,
+ isError: Boolean = false,
+ errorMessage: String? = null,
+ onCodeComplete: (String) -> Unit = {},
+ onCodeChange: (String) -> Unit = {},
+) {
+ val code = remember { mutableStateOf(List(codeLength) { null }) }
+ val focusedIndex = remember { mutableStateOf(null) }
+ val focusRequesters = remember { (1..codeLength).map { FocusRequester() } }
+ val keyboardManager = LocalSoftwareKeyboardController.current
+
+ // Derive validation state
+ val currentCodeString = remember { mutableStateOf("") }
+ val validationError = remember { mutableStateOf(null) }
+
+ // Auto-focus first field on initial composition
+ LaunchedEffect(Unit) {
+ focusRequesters.firstOrNull()?.requestFocus()
+ }
+
+ // Handle focus changes
+ LaunchedEffect(focusedIndex.value) {
+ focusedIndex.value?.let { index ->
+ focusRequesters.getOrNull(index)?.requestFocus()
+ }
+ }
+
+ // Handle code completion and validation
+ LaunchedEffect(code.value) {
+ val codeString = code.value.mapNotNull { it }.joinToString("")
+ currentCodeString.value = codeString
+ onCodeChange(codeString)
+
+ // Run validation if validator is provided
+ validator?.let {
+ val isValid = it.validate(codeString)
+ validationError.value = if (!isValid && codeString.length == codeLength) {
+ it.errorMessage
+ } else {
+ null
+ }
+ }
+
+ val allNumbersEntered = code.value.none { it == null }
+ if (allNumbersEntered) {
+ keyboardManager?.hide()
+ onCodeComplete(codeString)
+ }
+ }
+
+ // Determine error state: use validator if provided, otherwise use explicit isError
+ val showError = if (validator != null) {
+ validationError.value != null
+ } else {
+ isError
+ }
+
+ val displayErrorMessage = if (validator != null) {
+ validationError.value
+ } else {
+ errorMessage
+ }
+
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
+ ) {
+ code.value.forEachIndexed { index, number ->
+ SingleDigitField(
+ modifier = Modifier
+ .weight(1f)
+ .aspectRatio(1f),
+ number = number,
+ isError = showError,
+ focusRequester = focusRequesters[index],
+ onFocusChanged = { isFocused ->
+ if (isFocused) {
+ focusedIndex.value = index
+ }
+ },
+ onNumberChanged = { value ->
+ val oldValue = code.value[index]
+ val newCode = code.value.toMutableList()
+ newCode[index] = value
+ code.value = newCode
+
+ // Move focus to next field if number was entered (and field was previously empty)
+ if (value != null && oldValue == null) {
+ focusedIndex.value = getNextFocusedIndex(newCode, index)
+ }
+ },
+ onKeyboardBack = {
+ val previousIndex = getPreviousFocusedIndex(index)
+ if (previousIndex != null) {
+ val newCode = code.value.toMutableList()
+ newCode[previousIndex] = null
+ code.value = newCode
+ focusedIndex.value = previousIndex
+ }
+ },
+ onNumberEntered = {
+ focusRequesters[index].freeFocus()
+ }
+ )
+ }
+ }
+
+ if (showError && displayErrorMessage != null) {
+ Text(
+ modifier = Modifier.padding(top = 8.dp),
+ text = displayErrorMessage,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+}
+
+@Composable
+private fun SingleDigitField(
+ modifier: Modifier = Modifier,
+ number: Int?,
+ isError: Boolean = false,
+ focusRequester: FocusRequester,
+ onFocusChanged: (Boolean) -> Unit,
+ onNumberChanged: (Int?) -> Unit,
+ onKeyboardBack: () -> Unit,
+ onNumberEntered: () -> Unit,
+) {
+ val text = remember { mutableStateOf(TextFieldValue()) }
+ val isFocused = remember { mutableStateOf(false) }
+
+ // Update text field value when number changes externally
+ LaunchedEffect(number) {
+ text.value = TextFieldValue(
+ text = number?.toString().orEmpty(),
+ selection = TextRange(
+ index = if (number != null) 1 else 0
+ )
+ )
+ }
+
+ val borderColor = if (isError) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.primary
+ }
+
+// val backgroundColor = if (isError) {
+// MaterialTheme.colorScheme.errorContainer
+// } else {
+// MaterialTheme.colorScheme.primaryContainer
+// }
+
+ val textColor = if (isError) {
+ MaterialTheme.colorScheme.onErrorContainer
+ } else {
+ MaterialTheme.colorScheme.primary
+ }
+
+ val targetBorderWidth = if (isError || isFocused.value || number != null) 2.dp else 1.dp
+ val animatedBorderWidth by animateDpAsState(
+ targetValue = targetBorderWidth,
+ animationSpec = tween(durationMillis = 150),
+ label = "borderWidth"
+ )
+
+ val shape = RoundedCornerShape(8.dp)
+
+ Box(
+ modifier = modifier
+ .clip(shape)
+ .border(
+ width = animatedBorderWidth,
+ shape = shape,
+ color = borderColor,
+ ),
+ //.background(backgroundColor),
+ contentAlignment = Alignment.Center
+ ) {
+ BasicTextField(
+ modifier = Modifier
+ .fillMaxSize()
+ .wrapContentSize()
+ .semantics {
+ contentDescription = "Verification code digit"
+ }
+ .focusRequester(focusRequester)
+ .onFocusChanged {
+ isFocused.value = it.isFocused
+ onFocusChanged(it.isFocused)
+ }
+ .onPreviewKeyEvent { event ->
+ val isDelete = event.key == Key.Backspace || event.key == Key.Delete
+ val isInitialDown = event.type == KeyEventType.KeyDown &&
+ event.nativeKeyEvent.repeatCount == 0
+
+ if (isDelete && isInitialDown && number == null) {
+ onKeyboardBack()
+ return@onPreviewKeyEvent true
+ }
+ false
+ },
+ value = text.value,
+ onValueChange = { value ->
+ val newNumber = value.text
+ if (newNumber.length <= 1 && newNumber.isDigitsOnly()) {
+ val digit = newNumber.toIntOrNull()
+ onNumberChanged(digit)
+ if (digit != null) {
+ onNumberEntered()
+ }
+ }
+ },
+ cursorBrush = SolidColor(textColor),
+ singleLine = true,
+ textStyle = MaterialTheme.typography.bodyMedium.copy(
+ textAlign = TextAlign.Center,
+ fontWeight = FontWeight.Normal,
+ fontSize = 24.sp,
+ color = textColor,
+ lineHeight = 24.sp,
+ ),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.NumberPassword
+ ),
+ decorationBox = { innerTextField ->
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ innerTextField()
+ }
+ }
+ )
+ }
+}
+
+private fun getPreviousFocusedIndex(currentIndex: Int): Int? {
+ return currentIndex.minus(1).takeIf { it >= 0 }
+}
+
+private fun getNextFocusedIndex(code: List, currentIndex: Int): Int? {
+ if (currentIndex >= code.size - 1) return currentIndex
+
+ for (i in (currentIndex + 1) until code.size) {
+ if (code[i] == null) {
+ return i
+ }
+ }
+ return currentIndex
+}
+
+@Preview
+@Composable
+private fun PreviewVerificationCodeInputFieldExample() {
+ val completedCode = remember { mutableStateOf(null) }
+ val currentCode = remember { mutableStateOf("") }
+ val isError = remember { mutableStateOf(false) }
+
+ AuthUITheme {
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .consumeWindowInsets(innerPadding)
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ VerificationCodeInputField(
+ modifier = Modifier.padding(16.dp),
+ isError = isError.value,
+ errorMessage = if (isError.value) "Invalid verification code" else null,
+ onCodeComplete = { code ->
+ completedCode.value = code
+ // Simulate validation - in real app this would be async
+ isError.value = code != "123456"
+ },
+ onCodeChange = { code ->
+ currentCode.value = code
+ // Clear error on change
+ if (isError.value) {
+ isError.value = false
+ }
+ }
+ )
+
+ if (!isError.value) {
+ completedCode.value?.let { code ->
+ Text(
+ modifier = Modifier.padding(top = 16.dp),
+ text = "Code entered: $code",
+ color = MaterialTheme.colorScheme.primary,
+ fontSize = 16.sp,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PreviewVerificationCodeInputFieldError() {
+ AuthUITheme {
+ VerificationCodeInputField(
+ modifier = Modifier.padding(16.dp),
+ isError = true,
+ errorMessage = "Invalid verification code"
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PreviewVerificationCodeInputField() {
+ AuthUITheme {
+ VerificationCodeInputField(
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/AcquireEmailHelper.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/AcquireEmailHelper.java
deleted file mode 100644
index a46bf8aa99..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/AcquireEmailHelper.java
+++ /dev/null
@@ -1,108 +0,0 @@
-/*
- * Copyright 2016 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.email;
-
-import android.content.Intent;
-import android.support.annotation.NonNull;
-import android.text.TextUtils;
-
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.ui.ActivityHelper;
-import com.firebase.ui.auth.ui.TaskFailureLogger;
-import com.firebase.ui.auth.ui.account_link.WelcomeBackIdpPrompt;
-import com.google.android.gms.tasks.OnCompleteListener;
-import com.google.android.gms.tasks.Task;
-import com.google.firebase.auth.EmailAuthProvider;
-import com.google.firebase.auth.FirebaseAuth;
-import com.google.firebase.auth.ProviderQueryResult;
-
-import java.util.Arrays;
-import java.util.List;
-
-public class AcquireEmailHelper {
- private static final String TAG = "AcquireEmailHelper";
- private static final int RC_REGISTER_ACCOUNT = 14;
- private static final int RC_WELCOME_BACK_IDP = 15;
- static final int RC_SIGN_IN = 16;
- private static final List REQUEST_CODES = Arrays.asList(
- RC_REGISTER_ACCOUNT,
- RC_WELCOME_BACK_IDP,
- RC_SIGN_IN
- );
-
- private ActivityHelper mActivityHelper;
-
- public AcquireEmailHelper(ActivityHelper activityHelper) {
- mActivityHelper = activityHelper;
- }
-
- public void checkAccountExists(final String email) {
- FirebaseAuth firebaseAuth = mActivityHelper.getFirebaseAuth();
- mActivityHelper.showLoadingDialog(R.string.progress_dialog_loading);
- if (!TextUtils.isEmpty(email)) {
- firebaseAuth
- .fetchProvidersForEmail(email)
- .addOnFailureListener(
- new TaskFailureLogger(TAG, "Error fetching providers for email"))
- .addOnCompleteListener(
- new OnCompleteListener() {
- @Override
- public void onComplete(@NonNull Task task) {
- if (task.isSuccessful()) {
- startEmailHandler(email, task.getResult().getProviders());
- } else {
- mActivityHelper.dismissDialog();
- }
- }
- });
- }
- }
-
- private void startEmailHandler(String email, List providers) {
- mActivityHelper.dismissDialog();
- if (providers == null || providers.isEmpty()) {
- // account doesn't exist yet
- Intent registerIntent = RegisterEmailActivity.createIntent(
- mActivityHelper.getApplicationContext(),
- mActivityHelper.getFlowParams(),
- email);
- mActivityHelper.startActivityForResult(registerIntent, RC_REGISTER_ACCOUNT);
- } else {
- // account does exist
- String provider = providers.get(0);
- if (provider.equalsIgnoreCase(EmailAuthProvider.PROVIDER_ID)) {
- Intent signInIntent = SignInActivity.createIntent(
- mActivityHelper.getApplicationContext(),
- mActivityHelper.getFlowParams(),
- email);
- mActivityHelper.startActivityForResult(signInIntent, RC_SIGN_IN);
- } else {
- Intent intent = WelcomeBackIdpPrompt.createIntent(
- mActivityHelper.getApplicationContext(),
- mActivityHelper.getFlowParams(),
- provider,
- null,
- email);
- mActivityHelper.startActivityForResult(intent, RC_WELCOME_BACK_IDP);
- }
- }
- }
-
- public void onActivityResult(int requestCode, int resultCode, Intent data) {
- if (REQUEST_CODES.contains(requestCode)) {
- mActivityHelper.finish(resultCode, data);
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailHintContainerActivity.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailHintContainerActivity.java
deleted file mode 100644
index 4ce1ea1bd8..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailHintContainerActivity.java
+++ /dev/null
@@ -1,84 +0,0 @@
-/*
- * Copyright 2016 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.email;
-
-import android.app.PendingIntent;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentSender;
-import android.os.Bundle;
-import android.util.Log;
-
-import com.firebase.ui.auth.ui.AppCompatBase;
-import com.firebase.ui.auth.ui.BaseHelper;
-import com.firebase.ui.auth.ui.FlowParameters;
-import com.firebase.ui.auth.util.FirebaseAuthWrapper;
-import com.firebase.ui.auth.util.FirebaseAuthWrapperFactory;
-import com.google.android.gms.auth.api.credentials.Credential;
-
-public class EmailHintContainerActivity extends AppCompatBase {
- private static final String TAG = "EmailHintContainer";
- private static final int RC_HINT = 13;
- private AcquireEmailHelper mAcquireEmailHelper;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- mAcquireEmailHelper = new AcquireEmailHelper(mActivityHelper);
- FirebaseAuthWrapper apiWrapper =
- FirebaseAuthWrapperFactory.getFirebaseAuthWrapper(mActivityHelper.getAppName());
-
- PendingIntent hintIntent = apiWrapper.getEmailHintIntent(this);
- if (hintIntent != null) {
- try {
- startIntentSenderForResult(hintIntent.getIntentSender(), RC_HINT, null, 0, 0, 0);
- return;
- } catch (IntentSender.SendIntentException e) {
- Log.e(TAG, "Unable to start hint intent", e);
- }
- }
- finish(RESULT_CANCELED, new Intent());
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- if (requestCode == RC_HINT && data != null) {
- Credential credential = data.getParcelableExtra(Credential.EXTRA_KEY);
- if (credential == null) {
- // If the hint picker is cancelled show the SignInNoPasswordActivity
- startActivityForResult(
- SignInNoPasswordActivity.createIntent(
- this,
- mActivityHelper.getFlowParams(),
- null),
- AcquireEmailHelper.RC_SIGN_IN);
- return;
- }
- mAcquireEmailHelper.checkAccountExists(credential.getId());
- } else {
- mAcquireEmailHelper.onActivityResult(requestCode, resultCode, data);
- }
- }
-
- public static Intent createIntent(
- Context context,
- FlowParameters flowParams) {
- return BaseHelper.createBaseIntent(
- context,
- EmailHintContainerActivity.class,
- flowParams);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/RecoverPasswordActivity.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/RecoverPasswordActivity.java
deleted file mode 100644
index f7e04cf750..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/RecoverPasswordActivity.java
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- * Copyright 2016 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.email;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.support.annotation.NonNull;
-import android.support.design.widget.TextInputLayout;
-import android.view.View;
-import android.widget.EditText;
-
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.ui.AppCompatBase;
-import com.firebase.ui.auth.ui.BaseHelper;
-import com.firebase.ui.auth.ui.ExtraConstants;
-import com.firebase.ui.auth.ui.FlowParameters;
-import com.firebase.ui.auth.ui.TaskFailureLogger;
-import com.firebase.ui.auth.ui.email.field_validators.EmailFieldValidator;
-import com.google.android.gms.tasks.OnFailureListener;
-import com.google.android.gms.tasks.OnSuccessListener;
-import com.google.firebase.auth.FirebaseAuth;
-import com.google.firebase.auth.FirebaseAuthInvalidUserException;
-
-/**
- * Activity to initiate the "forgot password" flow by asking for the user's email.
- */
-public class RecoverPasswordActivity extends AppCompatBase implements View.OnClickListener {
- private static final String TAG = "RecoverPasswordActivity";
-
- private EditText mEmailEditText;
- private EmailFieldValidator mEmailFieldValidator;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.forgot_password_layout);
- String email = getIntent().getStringExtra(ExtraConstants.EXTRA_EMAIL);
-
- mEmailFieldValidator = new EmailFieldValidator(
- (TextInputLayout) findViewById(R.id.email_layout));
-
- mEmailEditText = (EditText) findViewById(R.id.email);
- if (email != null) {
- mEmailEditText.setText(email);
- }
-
- findViewById(R.id.button_done).setOnClickListener(this);
- }
-
- private void next(final String email) {
- FirebaseAuth firebaseAuth = mActivityHelper.getFirebaseAuth();
- firebaseAuth.sendPasswordResetEmail(email)
- .addOnFailureListener(
- new TaskFailureLogger(TAG, "Error sending password reset email"))
- .addOnSuccessListener(new OnSuccessListener() {
- @Override
- public void onSuccess(Void aVoid) {
- mActivityHelper.dismissDialog();
- RecoveryEmailSentDialog.show(email, getSupportFragmentManager());
- }
- })
- .addOnFailureListener(this, new OnFailureListener() {
- @Override
- public void onFailure(@NonNull Exception e) {
- mActivityHelper.dismissDialog();
-
- if (e instanceof FirebaseAuthInvalidUserException) {
- // No FirebaseUser exists with this email address, show error.
- mEmailEditText.setError(getString(R.string.error_email_does_not_exist));
- }
- }
- });
- }
-
-
- @Override
- public void onClick(View view) {
- if (view.getId() == R.id.button_done) {
- if (!mEmailFieldValidator.validate(mEmailEditText.getText())) {
- return;
- }
- mActivityHelper.showLoadingDialog(R.string.progress_dialog_sending);
- next(mEmailEditText.getText().toString());
- }
- }
-
- public static Intent createIntent(Context context, FlowParameters flowParams, String email) {
- return BaseHelper.createBaseIntent(context, RecoverPasswordActivity.class, flowParams)
- .putExtra(ExtraConstants.EXTRA_EMAIL, email);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/RecoveryEmailSentDialog.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/RecoveryEmailSentDialog.java
deleted file mode 100644
index 8ea407aee1..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/RecoveryEmailSentDialog.java
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.firebase.ui.auth.ui.email;
-
-import android.app.Activity;
-import android.app.Dialog;
-import android.content.DialogInterface;
-import android.content.Intent;
-import android.os.Bundle;
-import android.support.annotation.NonNull;
-import android.support.v4.app.FragmentManager;
-import android.support.v7.app.AlertDialog;
-
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.ui.BaseDialog;
-import com.firebase.ui.auth.ui.ExtraConstants;
-
-public class RecoveryEmailSentDialog extends BaseDialog {
- private static final String TAG = "RecoveryEmailSentDialog";
-
- @NonNull
- @Override
- public Dialog onCreateDialog(Bundle savedInstanceState) {
- return new AlertDialog.Builder(getContext(), R.style.FirebaseUI_Dialog)
- .setTitle(R.string.title_confirm_recover_password_activity)
- .setMessage(String.format(getString(R.string.confirm_recovery_body),
- getArguments().getString(ExtraConstants.EXTRA_EMAIL)))
- .setOnDismissListener(new DialogInterface.OnDismissListener() {
- @Override
- public void onDismiss(DialogInterface anInterface) {
- finish(Activity.RESULT_OK, new Intent());
- }
- })
- .setPositiveButton(android.R.string.ok, null)
- .show();
- }
-
- public static void show(String email, FragmentManager manager) {
- RecoveryEmailSentDialog result = new RecoveryEmailSentDialog();
- Bundle bundle = new Bundle();
- bundle.putString(ExtraConstants.EXTRA_EMAIL, email);
- result.setArguments(bundle);
- result.show(manager, TAG);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/RegisterEmailActivity.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/RegisterEmailActivity.java
deleted file mode 100644
index cccdad925d..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/RegisterEmailActivity.java
+++ /dev/null
@@ -1,204 +0,0 @@
-/*
- * Copyright 2016 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.email;
-
-import android.content.Context;
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Bundle;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
-import android.support.design.widget.TextInputLayout;
-import android.support.v4.content.ContextCompat;
-import android.text.SpannableStringBuilder;
-import android.text.style.ForegroundColorSpan;
-import android.view.View;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.TextView;
-
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.ui.AppCompatBase;
-import com.firebase.ui.auth.ui.BaseHelper;
-import com.firebase.ui.auth.ui.ExtraConstants;
-import com.firebase.ui.auth.ui.FlowParameters;
-import com.firebase.ui.auth.ui.TaskFailureLogger;
-import com.firebase.ui.auth.ui.email.field_validators.EmailFieldValidator;
-import com.firebase.ui.auth.ui.email.field_validators.PasswordFieldValidator;
-import com.firebase.ui.auth.ui.email.field_validators.RequiredFieldValidator;
-import com.firebase.ui.auth.util.signincontainer.SaveSmartLock;
-import com.google.android.gms.tasks.OnCompleteListener;
-import com.google.android.gms.tasks.OnFailureListener;
-import com.google.android.gms.tasks.OnSuccessListener;
-import com.google.android.gms.tasks.Task;
-import com.google.firebase.auth.AuthResult;
-import com.google.firebase.auth.FirebaseAuth;
-import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException;
-import com.google.firebase.auth.FirebaseAuthUserCollisionException;
-import com.google.firebase.auth.FirebaseAuthWeakPasswordException;
-import com.google.firebase.auth.FirebaseUser;
-import com.google.firebase.auth.UserProfileChangeRequest;
-
-/**
- * Activity displaying a form to create a new email/password account.
- */
-public class RegisterEmailActivity extends AppCompatBase implements View.OnClickListener {
- private static final String TAG = "RegisterEmailActivity";
-
- private EditText mEmailEditText;
- private EditText mPasswordEditText;
- private EditText mNameEditText;
- private EmailFieldValidator mEmailFieldValidator;
- private PasswordFieldValidator mPasswordFieldValidator;
- private RequiredFieldValidator mNameValidator;
- @Nullable
- private SaveSmartLock mSaveSmartLock;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.register_email_layout);
-
- mSaveSmartLock = mActivityHelper.getSaveSmartLockInstance();
-
- mEmailEditText = (EditText) findViewById(R.id.email);
- mNameEditText = (EditText) findViewById(R.id.name);
- mPasswordEditText = (EditText) findViewById(R.id.password);
-
- mPasswordFieldValidator = new PasswordFieldValidator(
- (TextInputLayout) findViewById(R.id.password_layout),
- getResources().getInteger(R.integer.min_password_length));
- mNameValidator = new RequiredFieldValidator((TextInputLayout) findViewById(R.id.name_layout));
- mEmailFieldValidator = new EmailFieldValidator((TextInputLayout) findViewById(R.id.email_layout));
-
- String email = getIntent().getStringExtra(ExtraConstants.EXTRA_EMAIL);
- if (email != null) {
- mEmailEditText.setText(email);
- mEmailEditText.setEnabled(false);
- }
- setUpTermsOfService();
- Button createButton = (Button) findViewById(R.id.button_create);
- createButton.setOnClickListener(this);
- }
-
- private void setUpTermsOfService() {
- if (mActivityHelper.getFlowParams().termsOfServiceUrl == null) {
- return;
- }
- ForegroundColorSpan foregroundColorSpan = new ForegroundColorSpan(ContextCompat.getColor
- (getApplicationContext(), R.color.linkColor));
-
- String preamble = getResources().getString(R.string.create_account_preamble);
- String link = getResources().getString(R.string.terms_of_service);
- SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(preamble + link);
- int start = preamble.length();
- spannableStringBuilder.setSpan(foregroundColorSpan, start, start + link.length(), 0);
-
- TextView agreementText = (TextView) findViewById(R.id.create_account_text);
- agreementText.setText(spannableStringBuilder);
- agreementText.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View view) {
- Intent intent = new Intent(Intent.ACTION_VIEW).setData(Uri.parse
- (mActivityHelper.getFlowParams().termsOfServiceUrl));
- startActivity(intent);
- }
- });
- }
-
- private void registerUser(final String email, final String name, final String password) {
- final FirebaseAuth firebaseAuth = mActivityHelper.getFirebaseAuth();
- // create the user
- firebaseAuth.createUserWithEmailAndPassword(email, password)
- .addOnFailureListener(new TaskFailureLogger(TAG, "Error creating user"))
- .addOnSuccessListener(new OnSuccessListener() {
- @Override
- public void onSuccess(AuthResult authResult) {
- final FirebaseUser firebaseUser = authResult.getUser();
- UserProfileChangeRequest changeNameRequest =
- new UserProfileChangeRequest.Builder()
- .setDisplayName(name).build();
-
- // Set display name
- firebaseUser.updateProfile(changeNameRequest)
- .addOnFailureListener(new TaskFailureLogger(
- TAG, "Error setting display name"))
- .addOnCompleteListener(new OnCompleteListener() {
- @Override
- public void onComplete(@NonNull Task task) {
- // This executes even if the name change fails, since
- // the account creation succeeded and we want to save
- // the credential to SmartLock (if enabled).
- mActivityHelper.saveCredentialsOrFinish(
- mSaveSmartLock,
- firebaseUser,
- password);
- }
- });
- }
- })
- .addOnFailureListener(new OnFailureListener() {
- @Override
- public void onFailure(@NonNull Exception e) {
- mActivityHelper.dismissDialog();
-
- TextInputLayout emailInput =
- (TextInputLayout) findViewById(R.id.email_layout);
- TextInputLayout passwordInput =
- (TextInputLayout) findViewById(R.id.password_layout);
-
- if (e instanceof FirebaseAuthWeakPasswordException) {
- // Password too weak
- passwordInput.setError(getString(R.string.error_weak_password));
- } else if (e instanceof FirebaseAuthInvalidCredentialsException) {
- // Email address is malformed
- emailInput.setError(getString(R.string.invalid_email_address));
- } else if (e instanceof FirebaseAuthUserCollisionException) {
- // Collision with existing user email
- emailInput.setError(getString(R.string.error_user_collision));
- } else {
- // General error message, this branch should not be invoked but
- // covers future API changes
- emailInput.setError(getString(R.string.email_account_creation_error));
- }
- }
- });
- }
-
- @Override
- public void onClick(View view) {
- if (view.getId() == R.id.button_create) {
- String email = mEmailEditText.getText().toString();
- String password = mPasswordEditText.getText().toString();
- String name = mNameEditText.getText().toString();
-
- boolean emailValid = mEmailFieldValidator.validate(email);
- boolean passwordValid = mPasswordFieldValidator.validate(password);
- boolean nameValid = mNameValidator.validate(name);
- if (emailValid && passwordValid && nameValid) {
- mActivityHelper.showLoadingDialog(R.string.progress_dialog_signing_up);
- registerUser(email, name, password);
- }
- }
- }
-
- public static Intent createIntent(
- Context context,
- FlowParameters flowParams,
- String email) {
- return BaseHelper.createBaseIntent(context, RegisterEmailActivity.class, flowParams)
- .putExtra(ExtraConstants.EXTRA_EMAIL, email);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/SignInActivity.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/SignInActivity.java
deleted file mode 100644
index f8b17d3658..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/SignInActivity.java
+++ /dev/null
@@ -1,128 +0,0 @@
-/*
- * Copyright 2016 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.email;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
-import android.support.design.widget.TextInputLayout;
-import android.view.View;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.TextView;
-
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.ui.AppCompatBase;
-import com.firebase.ui.auth.ui.BaseHelper;
-import com.firebase.ui.auth.ui.ExtraConstants;
-import com.firebase.ui.auth.ui.FlowParameters;
-import com.firebase.ui.auth.ui.TaskFailureLogger;
-import com.firebase.ui.auth.ui.email.field_validators.EmailFieldValidator;
-import com.firebase.ui.auth.ui.email.field_validators.RequiredFieldValidator;
-import com.firebase.ui.auth.util.signincontainer.SaveSmartLock;
-import com.google.android.gms.tasks.OnFailureListener;
-import com.google.android.gms.tasks.OnSuccessListener;
-import com.google.firebase.auth.AuthResult;
-
-/**
- * Activity to sign in with email and password.
- */
-public class SignInActivity extends AppCompatBase implements View.OnClickListener {
- private static final String TAG = "SignInActivity";
-
- private EditText mEmailEditText;
- private EditText mPasswordEditText;
- private EmailFieldValidator mEmailValidator;
- private RequiredFieldValidator mPasswordValidator;
- @Nullable
- private SaveSmartLock mSaveSmartLock;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.sign_in_layout);
-
- mSaveSmartLock = mActivityHelper.getSaveSmartLockInstance();
-
- mEmailEditText = (EditText) findViewById(R.id.email);
- mPasswordEditText = (EditText) findViewById(R.id.password);
- mEmailValidator = new EmailFieldValidator((TextInputLayout) findViewById(R.id.email_layout));
- mPasswordValidator = new RequiredFieldValidator((TextInputLayout) findViewById(R.id.password_layout));
-
- String email = getIntent().getStringExtra(ExtraConstants.EXTRA_EMAIL);
- if (email != null) {
- mEmailEditText.setText(email);
- }
- Button signInButton = (Button) findViewById(R.id.button_done);
- TextView recoveryButton = (TextView) findViewById(R.id.trouble_signing_in);
- signInButton.setOnClickListener(this);
- recoveryButton.setOnClickListener(this);
- }
-
- private void signIn(final String email, final String password) {
- mActivityHelper.getFirebaseAuth()
- .signInWithEmailAndPassword(email, password)
- .addOnFailureListener(
- new TaskFailureLogger(TAG, "Error signing in with email and password"))
- .addOnSuccessListener(new OnSuccessListener() {
- @Override
- public void onSuccess(AuthResult authResult) {
- // Save credential in SmartLock (if enabled)
- mActivityHelper.saveCredentialsOrFinish(
- mSaveSmartLock,
- authResult.getUser(),
- password);
- }
- })
- .addOnFailureListener(new OnFailureListener() {
- @Override
- public void onFailure(@NonNull Exception e) {
- mActivityHelper.dismissDialog();
-
- // Show error message
- TextInputLayout passwordInput =
- (TextInputLayout) findViewById(R.id.password_layout);
- passwordInput.setError(getString(R.string.login_error));
- }
- });
- }
-
- @Override
- public void onClick(View view) {
- if (view.getId() == R.id.button_done) {
- boolean emailValid = mEmailValidator.validate(mEmailEditText.getText());
- boolean passwordValid = mPasswordValidator.validate(mPasswordEditText.getText());
- if (emailValid && passwordValid) {
- mActivityHelper.showLoadingDialog(R.string.progress_dialog_signing_in);
- signIn(mEmailEditText.getText().toString(), mPasswordEditText.getText().toString());
- }
- } else if (view.getId() == R.id.trouble_signing_in) {
- startActivity(RecoverPasswordActivity.createIntent(
- this,
- mActivityHelper.getFlowParams(),
- mEmailEditText.getText().toString()));
- }
- }
-
- public static Intent createIntent(
- Context context,
- FlowParameters flowParams,
- String email) {
- return BaseHelper.createBaseIntent(context, SignInActivity.class, flowParams)
- .putExtra(ExtraConstants.EXTRA_EMAIL, email);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/SignInNoPasswordActivity.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/SignInNoPasswordActivity.java
deleted file mode 100644
index 66f83c2089..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/SignInNoPasswordActivity.java
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
- * Copyright 2016 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.email;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.support.design.widget.TextInputLayout;
-import android.view.View;
-import android.view.WindowManager;
-import android.widget.Button;
-import android.widget.EditText;
-
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.ui.AppCompatBase;
-import com.firebase.ui.auth.ui.BaseHelper;
-import com.firebase.ui.auth.ui.ExtraConstants;
-import com.firebase.ui.auth.ui.FlowParameters;
-import com.firebase.ui.auth.ui.email.field_validators.EmailFieldValidator;
-
-public class SignInNoPasswordActivity extends AppCompatBase implements View.OnClickListener {
- private EditText mEmailEditText;
- private EmailFieldValidator mEmailFieldValidator;
- private AcquireEmailHelper mAcquireEmailHelper;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- mAcquireEmailHelper = new AcquireEmailHelper(mActivityHelper);
- setContentView(R.layout.signin_no_password_layout);
-
- String email = getIntent().getStringExtra(ExtraConstants.EXTRA_EMAIL);
- mEmailFieldValidator = new EmailFieldValidator(
- (TextInputLayout) findViewById(R.id.input_layout_email));
- mEmailEditText = (EditText) findViewById(R.id.email);
- if (email != null) {
- mEmailEditText.setText(email);
- }
-
- // show the keyboard
- getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);
-
- Button button = (Button) findViewById(R.id.button_ok);
- button.setOnClickListener(this);
- }
-
- @Override
- protected void onActivityResult(int requestCode, int resultCode, Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- mAcquireEmailHelper.onActivityResult(requestCode, resultCode, data);
- }
-
- @Override
- public void onClick(View view) {
- if (!mEmailFieldValidator.validate(mEmailEditText.getText())) {
- return;
- }
- mActivityHelper.showLoadingDialog(R.string.progress_dialog_loading);
- String email = mEmailEditText.getText().toString();
- mAcquireEmailHelper.checkAccountExists(email);
- }
-
- public static Intent createIntent(
- Context context,
- FlowParameters flowParams,
- String email) {
- return BaseHelper.createBaseIntent(context, SignInNoPasswordActivity.class, flowParams)
- .putExtra(ExtraConstants.EXTRA_EMAIL, email);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/field_validators/BaseValidator.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/field_validators/BaseValidator.java
deleted file mode 100644
index 2c2ff02709..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/field_validators/BaseValidator.java
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * Copyright 2016 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.email.field_validators;
-
-import android.support.design.widget.TextInputLayout;
-
-public class BaseValidator {
- protected TextInputLayout mErrorContainer;
- protected String mErrorMessage = "";
- protected String mEmptyMessage = null;
-
- public BaseValidator(TextInputLayout errorContainer) {
- mErrorContainer = errorContainer;
- }
-
- protected boolean isValid(CharSequence charSequence) {
- return true;
- }
-
- public boolean validate(CharSequence charSequence) {
- if (mEmptyMessage != null && (charSequence == null || charSequence.length() == 0)) {
- mErrorContainer.setError(mEmptyMessage);
- return false;
- } else if (isValid(charSequence)) {
- mErrorContainer.setError("");
- return true;
- } else {
- mErrorContainer.setError(mErrorMessage);
- return false;
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/field_validators/EmailFieldValidator.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/field_validators/EmailFieldValidator.java
deleted file mode 100644
index 73cbc46fe4..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/field_validators/EmailFieldValidator.java
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright 2016 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.email.field_validators;
-
-import android.support.design.widget.TextInputLayout;
-import android.util.Patterns;
-
-import com.firebase.ui.auth.R;
-
-public class EmailFieldValidator extends BaseValidator {
-
- public EmailFieldValidator(TextInputLayout errorContainer) {
- super(errorContainer);
- mErrorMessage = mErrorContainer.getContext().getResources().getString(
- R.string.invalid_email_address);
- mEmptyMessage = mErrorContainer.getResources().getString(R.string.missing_email_address);
- }
-
- @Override
- protected boolean isValid(CharSequence charSequence) {
- return Patterns.EMAIL_ADDRESS.matcher(charSequence).matches();
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/field_validators/PasswordFieldValidator.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/field_validators/PasswordFieldValidator.java
deleted file mode 100644
index a6566b84e7..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/field_validators/PasswordFieldValidator.java
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright 2016 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.email.field_validators;
-
-import android.support.design.widget.TextInputLayout;
-
-import com.firebase.ui.auth.R;
-
-public class PasswordFieldValidator extends BaseValidator {
- private int mMinLength;
-
- public PasswordFieldValidator(TextInputLayout errorContainer, int minLength) {
- super(errorContainer);
- mMinLength = minLength;
- String template = mErrorContainer.getResources().getString(R.string.password_length);
- mErrorMessage = String.format(template, mMinLength);
- }
-
- @Override
- protected boolean isValid(CharSequence charSequence) {
- return charSequence.length() >= mMinLength;
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/field_validators/RequiredFieldValidator.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/field_validators/RequiredFieldValidator.java
deleted file mode 100644
index 6d1e2fbaf6..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/field_validators/RequiredFieldValidator.java
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright 2016 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.email.field_validators;
-
-import android.support.design.widget.TextInputLayout;
-
-import com.firebase.ui.auth.R;
-
-public class RequiredFieldValidator extends BaseValidator {
- public RequiredFieldValidator(TextInputLayout errorContainer) {
- super(errorContainer);
- mErrorMessage = mErrorContainer.getContext().getResources().getString(R.string.required_field);
- }
-
- @Override
- protected boolean isValid(CharSequence charSequence) {
- return charSequence != null && charSequence.length() > 0;
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/idp/AuthMethodPickerActivity.java b/auth/src/main/java/com/firebase/ui/auth/ui/idp/AuthMethodPickerActivity.java
deleted file mode 100644
index 1d0db4cd8c..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/idp/AuthMethodPickerActivity.java
+++ /dev/null
@@ -1,215 +0,0 @@
-/*
- * Copyright 2016 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.idp;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.support.annotation.Nullable;
-import android.util.Log;
-import android.view.View;
-import android.widget.ImageView;
-import android.widget.LinearLayout;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.AuthUI.IdpConfig;
-import com.firebase.ui.auth.BuildConfig;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.provider.AuthCredentialHelper;
-import com.firebase.ui.auth.provider.FacebookProvider;
-import com.firebase.ui.auth.provider.GoogleProvider;
-import com.firebase.ui.auth.provider.IdpProvider;
-import com.firebase.ui.auth.provider.IdpProvider.IdpCallback;
-import com.firebase.ui.auth.provider.TwitterProvider;
-import com.firebase.ui.auth.ui.AppCompatBase;
-import com.firebase.ui.auth.ui.BaseHelper;
-import com.firebase.ui.auth.ui.FlowParameters;
-import com.firebase.ui.auth.ui.TaskFailureLogger;
-import com.firebase.ui.auth.ui.email.EmailHintContainerActivity;
-import com.firebase.ui.auth.util.EmailFlowUtil;
-import com.firebase.ui.auth.util.signincontainer.SaveSmartLock;
-import com.google.firebase.auth.AuthCredential;
-import com.google.firebase.auth.FacebookAuthProvider;
-import com.google.firebase.auth.FirebaseAuth;
-import com.google.firebase.auth.GoogleAuthProvider;
-import com.google.firebase.auth.TwitterAuthProvider;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Presents the list of authentication options for this app to the user. If an
- * identity provider option is selected, a {@link CredentialSignInHandler}
- * is launched to manage the IDP-specific sign-in flow. If email authentication is chosen,
- * the {@link EmailHintContainerActivity root email flow activity} is started.
- *
- *
- *
- */
-public class AuthMethodPickerActivity extends AppCompatBase
- implements IdpCallback, View.OnClickListener {
- private static final String TAG = "AuthMethodPicker";
- private static final int RC_EMAIL_FLOW = 2;
- private static final int RC_ACCOUNT_LINK = 3;
-
- private ArrayList mIdpProviders;
- @Nullable private SaveSmartLock mSaveSmartLock;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.auth_method_picker_layout);
- mSaveSmartLock = mActivityHelper.getSaveSmartLockInstance();
- findViewById(R.id.email_provider).setOnClickListener(this);
-
- populateIdpList(mActivityHelper.getFlowParams().providerInfo);
-
- int logoId = mActivityHelper.getFlowParams().logoId;
- ImageView logo = (ImageView) findViewById(R.id.logo);
- if (logoId == AuthUI.NO_LOGO) {
- logo.setVisibility(View.GONE);
- } else {
- logo.setImageResource(logoId);
- }
- }
-
- private void populateIdpList(List providers) {
- mIdpProviders = new ArrayList<>();
- for (IdpConfig idpConfig : providers) {
- switch (idpConfig.getProviderId()) {
- case AuthUI.GOOGLE_PROVIDER:
- mIdpProviders.add(new GoogleProvider(this, idpConfig));
- break;
- case AuthUI.FACEBOOK_PROVIDER:
- mIdpProviders.add(new FacebookProvider(
- this, idpConfig, mActivityHelper.getFlowParams().themeId));
- break;
- case AuthUI.TWITTER_PROVIDER:
- mIdpProviders.add(new TwitterProvider(this));
- break;
- case AuthUI.EMAIL_PROVIDER:
- findViewById(R.id.email_provider).setVisibility(View.VISIBLE);
- break;
- default:
- if (BuildConfig.DEBUG) {
- Log.d(TAG, "Encountered unknown IDPProvider parcel with type: "
- + idpConfig.getProviderId());
- }
- }
- }
-
- LinearLayout btnHolder = (LinearLayout) findViewById(R.id.btn_holder);
- for (final IdpProvider provider : mIdpProviders) {
- View loginButton = null;
- switch (provider.getProviderId()) {
- case GoogleAuthProvider.PROVIDER_ID:
- loginButton = getLayoutInflater()
- .inflate(R.layout.idp_button_google, btnHolder, false);
- break;
- case FacebookAuthProvider.PROVIDER_ID:
- loginButton = getLayoutInflater()
- .inflate(R.layout.idp_button_facebook, btnHolder, false);
- break;
- case TwitterAuthProvider.PROVIDER_ID:
- loginButton = getLayoutInflater()
- .inflate(R.layout.idp_button_twitter, btnHolder, false);
- break;
- default:
- Log.e(TAG, "No button for provider " + provider.getProviderId());
- }
-
- if (loginButton != null) {
- loginButton.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View view) {
- mActivityHelper.showLoadingDialog(R.string.progress_dialog_loading);
- provider.startLogin(AuthMethodPickerActivity.this);
- }
- });
- provider.setAuthenticationCallback(this);
- btnHolder.addView(loginButton, 0);
- }
- }
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- if (requestCode == RC_EMAIL_FLOW) {
- if (resultCode == RESULT_OK) {
- finish(RESULT_OK, data);
- }
- } else if (requestCode == RC_ACCOUNT_LINK) {
- finish(resultCode, data);
- } else {
- for (IdpProvider provider : mIdpProviders) {
- provider.onActivityResult(requestCode, resultCode, data);
- }
- }
- }
-
- @Override
- public void onSuccess(final IdpResponse response) {
- AuthCredential credential = AuthCredentialHelper.getAuthCredential(response);
- final FirebaseAuth firebaseAuth = mActivityHelper.getFirebaseAuth();
-
- firebaseAuth
- .signInWithCredential(credential)
- .addOnFailureListener(
- new TaskFailureLogger(TAG, "Firebase sign in with credential unsuccessful"))
- .addOnCompleteListener(new CredentialSignInHandler(
- AuthMethodPickerActivity.this,
- mActivityHelper,
- mSaveSmartLock,
- RC_ACCOUNT_LINK,
- response));
- }
-
- @Override
- public void onFailure(Bundle extra) {
- // stay on this screen
- mActivityHelper.dismissDialog();
- }
-
- @Override
- public void onClick(View view) {
- if (view.getId() == R.id.email_provider) {
- Intent intent = EmailFlowUtil.createIntent(
- this,
- mActivityHelper.getFlowParams());
- startActivityForResult(intent, RC_EMAIL_FLOW);
- }
- }
-
- @Override
- protected void onDestroy() {
- super.onDestroy();
- if (mIdpProviders != null) {
- for (final IdpProvider provider : mIdpProviders) {
- if (provider instanceof GoogleProvider) {
- ((GoogleProvider) provider).disconnect();
- }
- }
- }
- }
-
- public static Intent createIntent(
- Context context,
- FlowParameters flowParams) {
- return BaseHelper.createBaseIntent(context, AuthMethodPickerActivity.class, flowParams);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/idp/CredentialSignInHandler.java b/auth/src/main/java/com/firebase/ui/auth/ui/idp/CredentialSignInHandler.java
deleted file mode 100644
index e6404dcc77..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/idp/CredentialSignInHandler.java
+++ /dev/null
@@ -1,127 +0,0 @@
-/*
- * Copyright 2016 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.idp;
-
-import android.app.Activity;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
-import android.util.Log;
-
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.ui.BaseHelper;
-import com.firebase.ui.auth.ui.TaskFailureLogger;
-import com.firebase.ui.auth.ui.account_link.WelcomeBackIdpPrompt;
-import com.firebase.ui.auth.ui.account_link.WelcomeBackPasswordPrompt;
-import com.firebase.ui.auth.util.signincontainer.SaveSmartLock;
-import com.google.android.gms.tasks.OnCompleteListener;
-import com.google.android.gms.tasks.OnFailureListener;
-import com.google.android.gms.tasks.OnSuccessListener;
-import com.google.android.gms.tasks.Task;
-import com.google.firebase.auth.AuthResult;
-import com.google.firebase.auth.EmailAuthProvider;
-import com.google.firebase.auth.FirebaseAuth;
-import com.google.firebase.auth.FirebaseAuthUserCollisionException;
-import com.google.firebase.auth.FirebaseUser;
-import com.google.firebase.auth.ProviderQueryResult;
-
-public class CredentialSignInHandler implements OnCompleteListener {
- private final static String TAG = "CredentialSignInHandler";
-
- private Activity mActivity;
- private BaseHelper mHelper;
- @Nullable private SaveSmartLock mSmartLock;
- private IdpResponse mResponse;
- private int mAccountLinkResultCode;
-
- public CredentialSignInHandler(
- Activity activity,
- BaseHelper helper,
- @Nullable SaveSmartLock smartLock,
- int accountLinkResultCode,
- IdpResponse response) {
- mActivity = activity;
- mHelper = helper;
- mSmartLock = smartLock;
- mResponse = response;
- mAccountLinkResultCode = accountLinkResultCode;
- }
-
- @Override
- public void onComplete(@NonNull Task task) {
- if (task.isSuccessful()) {
- FirebaseUser firebaseUser = task.getResult().getUser();
- mHelper.saveCredentialsOrFinish(
- mSmartLock,
- mActivity,
- firebaseUser,
- mResponse);
- } else {
- if (task.getException() instanceof FirebaseAuthUserCollisionException) {
- final String email = mResponse.getEmail();
- FirebaseAuth firebaseAuth = mHelper.getFirebaseAuth();
- firebaseAuth.fetchProvidersForEmail(email)
- .addOnFailureListener(new TaskFailureLogger(
- TAG, "Error fetching providers for email"))
- .addOnSuccessListener(new StartWelcomeBackFlow(email))
- .addOnFailureListener(new OnFailureListener() {
- @Override
- public void onFailure(@NonNull Exception e) {
- // TODO: What to do when signing in with Credential fails
- // and we can't continue to Welcome back flow without
- // knowing providers?
- }
- });
- } else {
- mHelper.dismissDialog();
- Log.e(TAG, "Unexpected exception when signing in with credential",
- task.getException());
- }
- }
- }
-
- private class StartWelcomeBackFlow implements OnSuccessListener {
- private String mEmail;
-
- public StartWelcomeBackFlow(String email) {
- mEmail = email;
- }
-
- @Override
- public void onSuccess(@NonNull ProviderQueryResult result) {
- mHelper.dismissDialog();
-
- String provider = result.getProviders().get(0);
- if (provider.equals(EmailAuthProvider.PROVIDER_ID)) {
- // Start email welcome back flow
- mActivity.startActivityForResult(
- WelcomeBackPasswordPrompt.createIntent(
- mHelper.getApplicationContext(),
- mHelper.getFlowParams(),
- mResponse
- ), mAccountLinkResultCode);
- } else {
- // Start IDP welcome back flow
- mActivity.startActivityForResult(
- WelcomeBackIdpPrompt.createIntent(
- mHelper.getApplicationContext(),
- mHelper.getFlowParams(),
- result.getProviders().get(0),
- mResponse,
- mEmail
- ), mAccountLinkResultCode);
- }
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/idp/package-info.java b/auth/src/main/java/com/firebase/ui/auth/ui/idp/package-info.java
deleted file mode 100644
index 5297d9036e..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/idp/package-info.java
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * Copyright 2016 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.
- */
-
-/**
- * Activites related to identity provider authentication.
- */
-package com.firebase.ui.auth.ui.idp;
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AnnotatedStringResource.kt b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AnnotatedStringResource.kt
new file mode 100644
index 0000000000..7cf0c8ce5c
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AnnotatedStringResource.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.method_picker
+
+import android.content.Context
+import android.content.Intent
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.LinkAnnotation
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextLinkStyles
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withLink
+import androidx.core.net.toUri
+
+@Composable
+internal fun AnnotatedStringResource(
+ context: Context,
+ modifier: Modifier = Modifier,
+ text: String,
+ vararg links: Pair,
+ inPreview: Boolean = false,
+ previewText: String? = null,
+) {
+ val template = if (inPreview && previewText != null) {
+ previewText
+ } else {
+ text
+ }
+
+ val annotated = buildAnnotatedString {
+ var currentIndex = 0
+
+ links.forEach { (label, url) ->
+ val start = template.indexOf(label, currentIndex).takeIf { it >= 0 } ?: return@forEach
+
+ append(template.substring(currentIndex, start))
+
+ withLink(
+ LinkAnnotation.Url(
+ url,
+ styles = TextLinkStyles(
+ style = SpanStyle(
+ color = MaterialTheme.colorScheme.primary,
+ textDecoration = TextDecoration.Underline,
+ )
+ )
+ ) {
+ val intent = Intent(Intent.ACTION_VIEW, url.toUri())
+ context.startActivity(intent)
+ }
+ ) {
+ append(label)
+ }
+
+ currentIndex = start + label.length
+ }
+
+ if (currentIndex < template.length) {
+ append(template.substring(currentIndex))
+ }
+ }
+
+ Text(
+ modifier = modifier,
+ text = annotated,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center
+ )
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt
new file mode 100644
index 0000000000..e51c71c528
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt
@@ -0,0 +1,296 @@
+/*
+ * 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.method_picker
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.R
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.auth_provider.Provider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUIAsset
+import com.firebase.ui.auth.ui.components.AuthProviderButton
+import com.firebase.ui.auth.util.SignInPreferenceManager
+
+/**
+ * Configuration for a custom Terms of Service/Privacy Policy footer in [AuthMethodPicker].
+ *
+ * @param content A composable that replaces the default "By continuing..." footer. Use this to
+ * supply a checkbox or any custom consent UI.
+ * @param accepted The current acceptance state. Only used when [disableProvidersUntilAccepted]
+ * is true.
+ * @param disableProvidersUntilAccepted When true, provider buttons are disabled until [accepted]
+ * is true. Defaults to false — buttons remain enabled unless explicitly opted in.
+ *
+ * @since 10.0.0
+ */
+class MethodPickerTermsConfiguration(
+ val content: @Composable () -> Unit,
+ val accepted: Boolean = true,
+ val disableProvidersUntilAccepted: Boolean = false,
+)
+
+/**
+ * Renders the provider selection screen.
+ *
+ * **Example usage:**
+ * ```kotlin
+ * AuthMethodPicker(
+ * providers = listOf(
+ * AuthProvider.Google(),
+ * AuthProvider.Email(),
+ * ),
+ * onProviderSelected = { provider -> /* ... */ }
+ * )
+ * ```
+ *
+ * @param modifier A modifier for the screen layout.
+ * @param providers The list of providers to display.
+ * @param logo An optional logo to display.
+ * @param onProviderSelected A callback when a provider is selected.
+ * @param customLayout An optional custom layout composable for the provider buttons.
+ * @param termsOfServiceUrl The URL for the Terms of Service.
+ * @param privacyPolicyUrl The URL for the Privacy Policy.
+ * @param lastSignInPreference The last sign-in preference to show a "Continue as..." button.
+ * @param termsConfiguration Optional configuration for a custom ToS/Privacy Policy footer.
+ * When provided, replaces the default "By continuing..." text. See [MethodPickerTermsConfiguration].
+ *
+ * @since 10.0.0
+ */
+@Composable
+fun AuthMethodPicker(
+ modifier: Modifier = Modifier,
+ providers: List,
+ logo: AuthUIAsset? = null,
+ onProviderSelected: (AuthProvider) -> Unit,
+ termsOfServiceUrl: String? = null,
+ privacyPolicyUrl: String? = null,
+ lastSignInPreference: SignInPreferenceManager.SignInPreference? = null,
+ customLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null,
+ termsConfiguration: MethodPickerTermsConfiguration? = null,
+) {
+ val context = LocalContext.current
+ val inPreview = LocalInspectionMode.current
+ val stringProvider = LocalAuthUIStringProvider.current
+ val providerButtonsEnabled = termsConfiguration == null ||
+ !termsConfiguration.disableProvidersUntilAccepted ||
+ termsConfiguration.accepted
+
+ Column(
+ modifier = modifier
+ ) {
+ logo?.let {
+ Image(
+ modifier = Modifier
+ .weight(0.4f)
+ .align(Alignment.CenterHorizontally),
+ painter = it.painter,
+ contentDescription = if (inPreview) ""
+ else stringResource(R.string.fui_auth_method_picker_logo)
+ )
+ }
+ if (customLayout != null) {
+ Box(modifier = Modifier.weight(1f)) {
+ customLayout(providers, onProviderSelected)
+ }
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .widthIn(max = 400.dp)
+ .padding(horizontal = 24.dp)
+ .testTag("AuthMethodPicker LazyColumn"),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // Show "Continue as..." button if last sign-in preference exists
+ lastSignInPreference?.let { preference ->
+ val lastProvider = providers.find { it.providerId == preference.providerId }
+ if (lastProvider != null) {
+ item {
+ ContinueAsButton(
+ provider = lastProvider,
+ identifier = preference.identifier,
+ enabled = providerButtonsEnabled,
+ onClick = { onProviderSelected(lastProvider) }
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Divider with "or"
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ Text(
+ text = stringProvider.orContinueWith,
+ modifier = Modifier.padding(horizontal = 8.dp),
+ style = MaterialTheme.typography.bodySmall
+ )
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+ }
+
+ // Show all providers
+ itemsIndexed(providers) { index, provider ->
+ Box(
+ modifier = Modifier
+ .padding(bottom = if (index < providers.lastIndex) 16.dp else 0.dp)
+ ) {
+ AuthProviderButton(
+ modifier = Modifier
+ .fillMaxWidth(),
+ onClick = {
+ onProviderSelected(provider)
+ },
+ enabled = providerButtonsEnabled,
+ provider = provider,
+ stringProvider = LocalAuthUIStringProvider.current
+ )
+ }
+ }
+ }
+ }
+ }
+ if (termsConfiguration != null) {
+ termsConfiguration.content()
+ } else {
+ AnnotatedStringResource(
+ modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp),
+ context = context,
+ inPreview = inPreview,
+ previewText = "By continuing, you accept our Terms of Service and Privacy Policy.",
+ text = stringProvider.tosAndPrivacyPolicy(
+ termsOfServiceLabel = stringProvider.termsOfService,
+ privacyPolicyLabel = stringProvider.privacyPolicy
+ ),
+ links = arrayOf(
+ stringProvider.termsOfService to (termsOfServiceUrl ?: ""),
+ stringProvider.privacyPolicy to (privacyPolicyUrl ?: "")
+ )
+ )
+ }
+ }
+}
+
+/**
+ * A prominent "Continue as..." button that shows the last-used provider and identifier.
+ *
+ * @param provider The authentication provider
+ * @param identifier The user identifier (email, phone number, etc.)
+ * @param onClick Callback when the button is clicked
+ */
+@Composable
+private fun ContinueAsButton(
+ provider: AuthProvider,
+ identifier: String?,
+ enabled: Boolean = true,
+ onClick: () -> Unit
+) {
+ val stringProvider = LocalAuthUIStringProvider.current
+
+ AuthProviderButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("ContinueAsButton"),
+ onClick = onClick,
+ enabled = enabled,
+ provider = provider,
+ stringProvider = stringProvider,
+ subtitle = identifier,
+ showAsContinue = true
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewAuthMethodPicker() {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ AuthMethodPicker(
+ providers = listOf(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ ),
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null,
+ ),
+ AuthProvider.Google(
+ scopes = emptyList(),
+ serverClientId = null
+ ),
+ AuthProvider.Facebook(),
+ AuthProvider.Twitter(
+ customParameters = emptyMap()
+ ),
+ AuthProvider.Github(
+ customParameters = emptyMap()
+ ),
+ AuthProvider.Microsoft(
+ tenant = null,
+ customParameters = emptyMap()
+ ),
+ AuthProvider.Yahoo(
+ customParameters = emptyMap()
+ ),
+ AuthProvider.Apple(
+ locale = null,
+ customParameters = emptyMap()
+ ),
+ AuthProvider.Anonymous,
+ ),
+ logo = AuthUIAsset.Resource(R.drawable.fui_ic_check_circle_black_128dp),
+ onProviderSelected = { provider ->
+
+ },
+ termsOfServiceUrl = "https://example.com/terms",
+ privacyPolicyUrl = "https://example.com/privacy"
+ )
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/package-info.java b/auth/src/main/java/com/firebase/ui/auth/ui/package-info.java
deleted file mode 100644
index 2e025e4514..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/package-info.java
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * Copyright 2016 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.
- */
-
-/**
- * Activities which implement the AuthUI authentication flow.
- */
-package com.firebase.ui.auth.ui;
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt
new file mode 100644
index 0000000000..2d40e98e76
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt
@@ -0,0 +1,1036 @@
+/*
+ * 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
+
+import android.util.Log
+import androidx.activity.compose.LocalActivity
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipAnchorPosition
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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 androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.BuildConfig
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.MfaConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.auth_provider.filterToLinkedProviders
+import com.firebase.ui.auth.configuration.auth_provider.rememberAnonymousSignInHandler
+import com.firebase.ui.auth.configuration.auth_provider.rememberGoogleSignInHandler
+import com.firebase.ui.auth.configuration.auth_provider.rememberOAuthSignInHandler
+import com.firebase.ui.auth.configuration.auth_provider.rememberSignInWithFacebookLauncher
+import com.firebase.ui.auth.configuration.auth_provider.signInWithEmailLink
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.LocalAuthUITheme
+import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController
+import com.firebase.ui.auth.ui.components.rememberTopLevelDialogController
+import com.firebase.ui.auth.mfa.MfaChallengeContentState
+import com.firebase.ui.auth.mfa.MfaEnrollmentContentState
+import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker
+import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration
+import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState
+import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen
+import com.firebase.ui.auth.util.EmailLinkPersistenceManager
+import com.firebase.ui.auth.util.SignInPreferenceManager
+import com.firebase.ui.auth.util.displayIdentifier
+import com.firebase.ui.auth.util.getDisplayEmail
+import com.google.firebase.auth.AuthCredential
+import com.google.firebase.auth.EmailAuthProvider
+import com.google.firebase.auth.AuthResult
+import com.google.firebase.auth.MultiFactorResolver
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+/**
+ * High-level authentication screen that wires together provider selection, individual provider
+ * flows, error handling, and multi-factor enrollment/challenge flows. Back navigation is driven by
+ * the Jetpack Navigation stack so presses behave like native Android navigation.
+ *
+ * @param authenticatedContent Optional slot that allows callers to render the authenticated
+ * state themselves. When provided, it receives the current [AuthState] alongside an
+ * [AuthSuccessUiContext] containing common callbacks (sign out, manage MFA, reload user).
+ * @param customMethodPickerLayout Optional slot that fully replaces the method-picker screen.
+ * When provided, it renders as the *entire* screen content — edge-to-edge, with no logo, no
+ * Terms of Service/Privacy Policy footer, and no automatic system-inset handling. The caller is
+ * responsible for its own insets (e.g. `Modifier.safeDrawingPadding()`) and for displaying any
+ * required legal disclosures. [customMethodPickerTermsConfiguration] is ignored when this is set.
+ * @param customMethodPickerTermsConfiguration Optional custom Terms of Service/Privacy Policy
+ * footer for the *default* method-picker layout. Ignored when [customMethodPickerLayout] is
+ * provided, since that slot takes over the whole screen.
+ *
+ * @since 10.0.0
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FirebaseAuthScreen(
+ configuration: AuthUIConfiguration,
+ onSignInSuccess: (AuthResult) -> Unit,
+ onSignInFailure: (AuthException) -> Unit,
+ onSignInCancelled: () -> Unit,
+ modifier: Modifier = Modifier,
+ authUI: FirebaseAuthUI = FirebaseAuthUI.getInstance(),
+ emailLink: String? = null,
+ mfaConfiguration: MfaConfiguration = MfaConfiguration(),
+ customMethodPickerLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null,
+ customMethodPickerTermsConfiguration: MethodPickerTermsConfiguration? = null,
+ emailContent: (@Composable (EmailAuthContentState) -> Unit)? = null,
+ phoneContent: (@Composable (PhoneAuthContentState) -> Unit)? = null,
+ mfaEnrollmentContent: (@Composable (MfaEnrollmentContentState) -> Unit)? = null,
+ mfaChallengeContent: (@Composable (MfaChallengeContentState) -> Unit)? = null,
+ reauthContent: (@Composable (state: AuthState.ReauthenticationRequired, onDismiss: () -> Unit) -> Unit)? = null,
+ authenticatedContent: (@Composable (state: AuthState, uiContext: AuthSuccessUiContext) -> Unit)? = null,
+) {
+ // Set FirebaseUI version
+ LaunchedEffect(authUI.auth) {
+ authUI.auth.setFirebaseUIVersion(BuildConfig.VERSION_NAME)
+ }
+
+ val activity = LocalActivity.current
+ val context = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+ val stringProvider = DefaultAuthUIStringProvider(context)
+ val navController = rememberNavController()
+
+ val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle)
+ val dialogController = rememberTopLevelDialogController(stringProvider, authState)
+ val lastSuccessfulUserId = remember { mutableStateOf(null) }
+ val pendingLinkingCredential = remember { mutableStateOf(null) }
+ val pendingResolver = remember { mutableStateOf(null) }
+ val pendingReauthConfig = remember { mutableStateOf(null) }
+ val pendingReauthState = remember { mutableStateOf(null) }
+ val pendingReauthOperation = remember { mutableStateOf<(suspend (android.content.Context) -> Unit)?>(null) }
+ val emailLinkFromDifferentDevice = remember { mutableStateOf(null) }
+ val lastSignInPreference =
+ remember { mutableStateOf(null) }
+ val startRoute = remember(configuration.providers, configuration.isProviderChoiceAlwaysShown) {
+ getStartRoute(configuration)
+ }
+ val skipsMethodPicker = startRoute != AuthRoute.MethodPicker
+
+ // Load last sign-in preference on launch
+ LaunchedEffect(authState) {
+ lastSignInPreference.value = SignInPreferenceManager.getLastSignIn(context)
+ }
+
+ val emailProvider = configuration.providers.filterIsInstance().firstOrNull()
+ val logoAsset = configuration.logo
+ val onProviderSelected = authUI.rememberOnProviderSelected(
+ context = context,
+ activity = activity,
+ config = configuration,
+ onNavigate = { route -> navController.navigate(route.route) },
+ onUnknownProvider = { provider ->
+ onSignInFailure(
+ AuthException.UnknownException(
+ message = "Provider ${provider.providerId} is not supported in FirebaseAuthScreen",
+ cause = IllegalArgumentException(
+ "Provider ${provider.providerId} is not supported in FirebaseAuthScreen"
+ )
+ )
+ )
+ },
+ )
+ val continueWithProvider: (String) -> Unit = { providerId ->
+ configuration.providers.find { it.providerId == providerId }?.let { onProviderSelected(it) }
+ }
+
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides configuration.stringProvider,
+ LocalTopLevelDialogController provides dialogController,
+ LocalAuthUITheme provides (configuration.theme ?: LocalAuthUITheme.current)
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ NavHost(
+ navController = navController,
+ startDestination = startRoute.route,
+ enterTransition = configuration.transitions?.enterTransition ?: {
+ fadeIn(animationSpec = tween(700))
+ },
+ exitTransition = configuration.transitions?.exitTransition ?: {
+ fadeOut(animationSpec = tween(700))
+ },
+ popEnterTransition = configuration.transitions?.popEnterTransition ?: {
+ fadeIn(animationSpec = tween(700))
+ },
+ popExitTransition = configuration.transitions?.popExitTransition ?: {
+ fadeOut(animationSpec = tween(700))
+ }
+ ) {
+ composable(AuthRoute.MethodPicker.route) {
+ if (customMethodPickerLayout != null) {
+ Box(modifier = modifier.fillMaxSize()) {
+ customMethodPickerLayout(configuration.providers, onProviderSelected)
+ }
+ } else {
+ Scaffold { innerPadding ->
+ AuthMethodPicker(
+ modifier = modifier
+ .padding(innerPadding),
+ providers = configuration.providers,
+ logo = logoAsset,
+ termsOfServiceUrl = configuration.tosUrl,
+ privacyPolicyUrl = configuration.privacyPolicyUrl,
+ lastSignInPreference = lastSignInPreference.value,
+ termsConfiguration = customMethodPickerTermsConfiguration,
+ onProviderSelected = onProviderSelected,
+ )
+ }
+ }
+ }
+
+ composable(AuthRoute.Email.route) {
+ EmailAuthScreen(
+ context = context,
+ configuration = configuration,
+ authUI = authUI,
+ credentialForLinking = pendingLinkingCredential.value,
+ emailLinkFromDifferentDevice = emailLinkFromDifferentDevice.value,
+ onContinueWithProvider = continueWithProvider,
+ content = emailContent,
+ onSuccess = {
+ pendingLinkingCredential.value = null
+ },
+ onError = { exception ->
+ onSignInFailure(exception)
+ },
+ onCancel = {
+ pendingLinkingCredential.value = null
+ if (!skipsMethodPicker && !navController.popBackStack()) {
+ navController.navigate(AuthRoute.MethodPicker.route) {
+ popUpTo(AuthRoute.MethodPicker.route) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ }
+ )
+ }
+
+ composable(AuthRoute.Phone.route) {
+ PhoneAuthScreen(
+ context = context,
+ configuration = configuration,
+ authUI = authUI,
+ content = phoneContent,
+ onSuccess = {},
+ onError = { exception ->
+ onSignInFailure(exception)
+ },
+ onCancel = {
+ if (!skipsMethodPicker && !navController.popBackStack()) {
+ navController.navigate(AuthRoute.MethodPicker.route) {
+ popUpTo(AuthRoute.MethodPicker.route) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ }
+ )
+ }
+
+ composable(AuthRoute.Success.route) {
+ val uiContext = remember(authState, stringProvider) {
+ AuthSuccessUiContext(
+ authUI = authUI,
+ stringProvider = stringProvider,
+ configuration = configuration,
+ onSignOut = {
+ coroutineScope.launch {
+ try {
+ authUI.signOut(context)
+ // Keep sign-in preference for "Continue as..." on next launch
+ } catch (e: Exception) {
+ onSignInFailure(AuthException.from(e, stringProvider))
+ } finally {
+ pendingLinkingCredential.value = null
+ pendingResolver.value = null
+ }
+ }
+ },
+ onManageMfa = {
+ if (configuration.isMfaEnabled) {
+ navController.navigate(AuthRoute.MfaEnrollment.route)
+ } else {
+ val exception = AuthException.AuthCancelledException(
+ message = "Multi-factor authentication is disabled in the configuration. " +
+ "Enable MFA in AuthUIConfiguration to use this feature."
+ )
+ authUI.updateAuthState(AuthState.Error(exception))
+ }
+ },
+ onReloadUser = {
+ coroutineScope.launch {
+ try {
+ // Reload user to get fresh data from server
+ authUI.getCurrentUser()?.let {
+ it.reload().await()
+ it.getIdToken(true).await()
+ if (it.isEmailVerified) {
+ authUI.updateAuthState(
+ AuthState.Success(
+ result = null,
+ user = it,
+ isNewUser = false
+ )
+ )
+ } else {
+ authUI.updateAuthState(
+ AuthState.RequiresEmailVerification(
+ user = it,
+ email = it.email ?: ""
+ )
+ )
+ }
+ }
+ } catch (e: Exception) {
+ Log.e("FirebaseAuthScreen", "Failed to refresh user", e)
+ }
+ }
+ },
+ onNavigate = { route ->
+ navController.navigate(route.route)
+ }
+ )
+ }
+
+ if (authenticatedContent != null) {
+ authenticatedContent(authState, uiContext)
+ } else {
+ SuccessDestination(
+ authState = authState,
+ stringProvider = stringProvider,
+ configuration = configuration,
+ uiContext = uiContext
+ )
+ }
+ }
+
+ composable(AuthRoute.MfaEnrollment.route) {
+ val user = authUI.getCurrentUser()
+ if (user != null) {
+ MfaEnrollmentScreen(
+ user = user,
+ auth = authUI.auth,
+ configuration = mfaConfiguration,
+ authConfiguration = configuration,
+ content = mfaEnrollmentContent,
+ onComplete = { navController.popBackStack() },
+ onSkip = { navController.popBackStack() },
+ onError = { exception ->
+ onSignInFailure(AuthException.from(exception, stringProvider))
+ }
+ )
+ } else {
+ navController.popBackStack()
+ }
+ }
+
+ composable(AuthRoute.MfaChallenge.route) {
+ val resolver = pendingResolver.value
+ if (resolver != null) {
+ MfaChallengeScreen(
+ resolver = resolver,
+ auth = authUI.auth,
+ content = mfaChallengeContent,
+ onSuccess = {
+ pendingResolver.value = null
+ // Reset auth state to Idle so the firebaseAuthFlow Success state takes over
+ authUI.updateAuthState(AuthState.Idle)
+ },
+ onCancel = {
+ pendingResolver.value = null
+ authUI.updateAuthState(AuthState.Cancelled)
+ navController.popBackStack()
+ },
+ onError = { exception ->
+ onSignInFailure(AuthException.from(exception, stringProvider))
+ }
+ )
+ } else {
+ navController.popBackStack()
+ }
+ }
+ }
+
+ // Handle email link sign-in (deep links)
+ LaunchedEffect(emailLink) {
+ if (emailLink != null && emailProvider != null) {
+ try {
+ // Try to retrieve saved email from DataStore (same-device flow)
+ val savedEmail =
+ EmailLinkPersistenceManager.default.retrieveSessionRecord(context)?.email
+
+ if (savedEmail != null) {
+ // Same device - we have the email, sign in automatically
+ authUI.signInWithEmailLink(
+ context = context,
+ config = configuration,
+ provider = emailProvider,
+ email = savedEmail,
+ emailLink = emailLink
+ )
+ } else {
+ // Different device - no saved email
+ // Call signInWithEmailLink with empty email to trigger validation
+ // This will throw EmailLinkPromptForEmailException or EmailLinkWrongDeviceException
+ authUI.signInWithEmailLink(
+ context = context,
+ config = configuration,
+ provider = emailProvider,
+ email = "", // Empty email triggers cross-device detection
+ emailLink = emailLink
+ )
+ }
+ } catch (e: Exception) {
+ Log.e("FirebaseAuthScreen", "Failed to complete email link sign-in", e)
+ }
+ }
+ }
+
+ // Synchronise auth state changes with navigation stack.
+ LaunchedEffect(authState) {
+ val state = authState
+ val currentRoute = navController.currentBackStackEntry?.destination?.route
+ when (state) {
+ is AuthState.Success -> {
+ pendingResolver.value = null
+ pendingLinkingCredential.value = null
+
+ // If reauth just completed, execute the pending retry and skip normal success handling
+ pendingReauthOperation.value?.let { retry ->
+ pendingReauthOperation.value = null
+ pendingReauthConfig.value = null
+ pendingReauthState.value = null
+ // Lock the state to Loading before launching the retry so no
+ // intermediate Success emission can navigate to AuthRoute.Success.
+ authUI.updateAuthState(AuthState.Loading())
+ coroutineScope.launch {
+ try {
+ retry(context)
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ authUI.updateAuthState(AuthState.Error(e))
+ }
+ }
+ return@LaunchedEffect
+ }
+
+ state.result?.let { result ->
+ if (state.user.uid != lastSuccessfulUserId.value) {
+ onSignInSuccess(result)
+ lastSuccessfulUserId.value = state.user.uid
+
+ // Reload sign-in preference (may have been updated by provider)
+ coroutineScope.launch {
+ lastSignInPreference.value =
+ SignInPreferenceManager.getLastSignIn(context)
+ }
+ }
+ }
+
+ if (currentRoute != AuthRoute.Success.route) {
+ navController.navigate(AuthRoute.Success.route) {
+ popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ }
+
+ is AuthState.ReauthenticationRequired -> {
+ pendingReauthOperation.value = state.retryOperation
+ val linked = configuration.providers.filterToLinkedProviders(state.user)
+ if (linked.isEmpty()) {
+ authUI.updateAuthState(
+ AuthState.Error(
+ AuthException.UnknownException(
+ "No configured providers are linked to the current user"
+ )
+ )
+ )
+ return@LaunchedEffect
+ }
+ if (reauthContent != null) {
+ pendingReauthState.value = state
+ } else {
+ pendingReauthConfig.value = configuration.copy(
+ providers = linked,
+ isNewEmailAccountsAllowed = false,
+ isReauthenticationMode = true,
+ )
+ }
+ }
+
+ is AuthState.RequiresEmailVerification,
+ is AuthState.RequiresProfileCompletion,
+ -> {
+ pendingResolver.value = null
+ pendingLinkingCredential.value = null
+ if (currentRoute != AuthRoute.Success.route) {
+ navController.navigate(AuthRoute.Success.route) {
+ popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ }
+
+ is AuthState.RequiresMfa -> {
+ pendingResolver.value = state.resolver
+ if (currentRoute != AuthRoute.MfaChallenge.route) {
+ navController.navigate(AuthRoute.MfaChallenge.route) {
+ launchSingleTop = true
+ }
+ }
+ }
+
+ is AuthState.Cancelled -> {
+ pendingReauthOperation.value = null
+ pendingReauthConfig.value = null
+ pendingReauthState.value = null
+ pendingResolver.value = null
+ pendingLinkingCredential.value = null
+ lastSuccessfulUserId.value = null
+ if (currentRoute != startRoute.route) {
+ navController.navigate(startRoute.route) {
+ popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ // Keep external cancellation reporting centralized here so child screens
+ // can handle local navigation without triggering duplicate callbacks.
+ onSignInCancelled()
+ }
+
+ is AuthState.Idle -> {
+ pendingReauthOperation.value = null
+ pendingReauthConfig.value = null
+ pendingReauthState.value = null
+ pendingResolver.value = null
+ pendingLinkingCredential.value = null
+ lastSuccessfulUserId.value = null
+ if (currentRoute != startRoute.route) {
+ navController.navigate(startRoute.route) {
+ popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ }
+
+ else -> Unit
+ }
+ }
+
+ // Handle errors using top-level dialog controller
+ val errorState = authState as? AuthState.Error
+ if (errorState != null) {
+ LaunchedEffect(errorState) {
+ val exception = when (val throwable = errorState.exception) {
+ is AuthException -> throwable
+ else -> AuthException.from(throwable, stringProvider)
+ }
+
+ dialogController.showErrorDialog(
+ exception = exception,
+ onRetry = { _ ->
+ // Child screens handle their own retry logic
+ },
+ onRecover = when (exception) {
+ is AuthException.EmailAlreadyInUseException -> {
+ {
+ navController.navigate(AuthRoute.Email.route) {
+ launchSingleTop = true
+ }
+ }
+ }
+
+ is AuthException.AccountLinkingRequiredException -> {
+ {
+ pendingLinkingCredential.value = exception.credential
+ navController.navigate(AuthRoute.Email.route) {
+ launchSingleTop = true
+ }
+ }
+ }
+
+ is AuthException.EmailLinkPromptForEmailException -> {
+ {
+ emailLinkFromDifferentDevice.value = exception.emailLink
+ navController.navigate(AuthRoute.Email.route) {
+ launchSingleTop = true
+ }
+ }
+ }
+
+ is AuthException.EmailLinkCrossDeviceLinkingException -> {
+ {
+ emailLinkFromDifferentDevice.value = exception.emailLink
+ navController.navigate(AuthRoute.Email.route) {
+ launchSingleTop = true
+ }
+ }
+ }
+
+ is AuthException.DifferentSignInMethodRequiredException -> {
+ {
+ val providerId = exception.suggestedSignInMethod
+ if (providerId == EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD) {
+ navController.navigate(AuthRoute.Email.route) {
+ launchSingleTop = true
+ }
+ } else {
+ continueWithProvider(providerId)
+ }
+ }
+ }
+
+ else -> null
+ },
+ onDismiss = {
+ // Dialog dismissed
+ }
+ )
+ }
+ }
+
+ // Render the top-level dialog (only one instance)
+ dialogController.CurrentDialog()
+
+ val loadingState = authState as? AuthState.Loading
+ if (loadingState != null) {
+ LoadingDialog(loadingState.message ?: stringProvider.progressDialogLoading)
+ }
+
+ // Custom reauth UI — rendered when the caller provides reauthContent.
+ val pendingReauth = pendingReauthState.value
+ if (pendingReauth != null && reauthContent != null) {
+ reauthContent(pendingReauth) {
+ pendingReauthOperation.value = null
+ pendingReauthState.value = null
+ authUI.updateAuthState(AuthState.Idle)
+ }
+ }
+
+ // Default reauth bottom sheet — used when reauthContent is not provided.
+ val reauthConfig = pendingReauthConfig.value
+ if (reauthConfig != null) {
+ ModalBottomSheet(
+ onDismissRequest = {
+ pendingReauthOperation.value = null
+ pendingReauthConfig.value = null
+ authUI.updateAuthState(AuthState.Idle)
+ },
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ ) {
+ ReauthSheetContent(
+ authUI = authUI,
+ reauthConfig = reauthConfig,
+ activity = activity,
+ context = context,
+ emailContent = emailContent,
+ phoneContent = phoneContent,
+ customMethodPickerLayout = customMethodPickerLayout,
+ onDismiss = {
+ pendingReauthOperation.value = null
+ pendingReauthConfig.value = null
+ authUI.updateAuthState(AuthState.Idle)
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+sealed class AuthRoute(val route: String) {
+ object MethodPicker : AuthRoute("auth_method_picker")
+ object Email : AuthRoute("auth_email")
+ object Phone : AuthRoute("auth_phone")
+ object Success : AuthRoute("auth_success")
+ object MfaEnrollment : AuthRoute("auth_mfa_enrollment")
+ object MfaChallenge : AuthRoute("auth_mfa_challenge")
+}
+
+internal fun getStartRoute(configuration: AuthUIConfiguration): AuthRoute {
+ if (configuration.isProviderChoiceAlwaysShown || configuration.providers.size != 1) {
+ return AuthRoute.MethodPicker
+ }
+
+ return when (configuration.providers.single()) {
+ is AuthProvider.Email -> AuthRoute.Email
+ is AuthProvider.Phone -> AuthRoute.Phone
+ else -> AuthRoute.MethodPicker
+ }
+}
+
+data class AuthSuccessUiContext(
+ val authUI: FirebaseAuthUI,
+ val stringProvider: AuthUIStringProvider,
+ val configuration: AuthUIConfiguration,
+ val onSignOut: () -> Unit,
+ val onManageMfa: () -> Unit,
+ val onReloadUser: () -> Unit,
+ val onNavigate: (AuthRoute) -> Unit,
+)
+
+@Composable
+private fun SuccessDestination(
+ authState: AuthState,
+ stringProvider: AuthUIStringProvider,
+ configuration: AuthUIConfiguration,
+ uiContext: AuthSuccessUiContext,
+) {
+ when (authState) {
+ is AuthState.Success -> {
+ AuthSuccessContent(
+ authUI = uiContext.authUI,
+ stringProvider = stringProvider,
+ configuration = configuration,
+ onSignOut = uiContext.onSignOut,
+ onManageMfa = uiContext.onManageMfa
+ )
+ }
+
+ is AuthState.RequiresEmailVerification -> {
+ EmailVerificationContent(
+ authUI = uiContext.authUI,
+ stringProvider = stringProvider,
+ onCheckStatus = uiContext.onReloadUser,
+ onSignOut = uiContext.onSignOut
+ )
+ }
+
+ is AuthState.RequiresProfileCompletion -> {
+ ProfileCompletionContent(
+ missingFields = authState.missingFields,
+ stringProvider = stringProvider
+ )
+ }
+
+ else -> {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AuthSuccessContent(
+ authUI: FirebaseAuthUI,
+ stringProvider: AuthUIStringProvider,
+ configuration: AuthUIConfiguration,
+ onSignOut: () -> Unit,
+ onManageMfa: () -> Unit,
+) {
+ val user = authUI.getCurrentUser()
+ val userIdentifier = user.displayIdentifier()
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ if (userIdentifier.isNotBlank()) {
+ Text(
+ text = stringProvider.signedInAs(userIdentifier),
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ if (user != null && authUI.auth.app.options.projectId != null) {
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
+ TooltipAnchorPosition.Above
+ ),
+ tooltip = {
+ PlainTooltip {
+ Text(stringProvider.mfaDisabledTooltip)
+ }
+ },
+ state = rememberTooltipState(
+ initialIsVisible = false
+ )
+ ) {
+ Button(
+ onClick = onManageMfa,
+ enabled = configuration.isMfaEnabled
+ ) {
+ Text(stringProvider.manageMfaAction)
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ Button(onClick = onSignOut) {
+ Text(stringProvider.signOutAction)
+ }
+ }
+}
+
+@Composable
+private fun EmailVerificationContent(
+ authUI: FirebaseAuthUI,
+ stringProvider: AuthUIStringProvider,
+ onCheckStatus: () -> Unit,
+ onSignOut: () -> Unit,
+) {
+ val user = authUI.getCurrentUser()
+ val emailLabel = user.getDisplayEmail(stringProvider.emailProvider)
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = stringProvider.verifyEmailInstruction(emailLabel),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = { user?.sendEmailVerification() }) {
+ Text(stringProvider.resendVerificationEmailAction)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = onCheckStatus) {
+ Text(stringProvider.verifiedEmailAction)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = onSignOut) {
+ Text(stringProvider.signOutAction)
+ }
+ }
+}
+
+@Composable
+private fun ProfileCompletionContent(
+ missingFields: List,
+ stringProvider: AuthUIStringProvider,
+) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = stringProvider.profileCompletionMessage,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ if (missingFields.isNotEmpty()) {
+ Text(
+ text = stringProvider.profileMissingFieldsMessage(missingFields.joinToString()),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+}
+
+@Composable
+private fun LoadingDialog(message: String) {
+ AlertDialog(
+ onDismissRequest = {},
+ confirmButton = {},
+ containerColor = Color.Transparent,
+ text = {
+ Column(
+ modifier = Modifier
+ .padding(24.dp)
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator()
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = message,
+ textAlign = TextAlign.Center,
+ color = Color.White
+ )
+ }
+ }
+ )
+}
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ReauthSheetContent(
+ authUI: FirebaseAuthUI,
+ reauthConfig: AuthUIConfiguration,
+ activity: android.app.Activity?,
+ context: android.content.Context,
+ emailContent: (@Composable (EmailAuthContentState) -> Unit)?,
+ phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?,
+ customMethodPickerLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)?,
+ onDismiss: () -> Unit,
+) {
+ val sheetNavController = rememberNavController()
+ val startRoute = remember(reauthConfig) { getStartRoute(reauthConfig) }
+ val skipsMethodPicker = startRoute != AuthRoute.MethodPicker
+ val onProviderSelected = authUI.rememberOnProviderSelected(
+ context = context,
+ activity = activity,
+ config = reauthConfig,
+ onNavigate = { route -> sheetNavController.navigate(route.route) },
+ )
+
+ NavHost(
+ navController = sheetNavController,
+ startDestination = startRoute.route,
+ enterTransition = { fadeIn(animationSpec = tween(700)) },
+ exitTransition = { fadeOut(animationSpec = tween(700)) },
+ popEnterTransition = { fadeIn(animationSpec = tween(700)) },
+ popExitTransition = { fadeOut(animationSpec = tween(700)) },
+ ) {
+ composable(AuthRoute.MethodPicker.route) {
+ if (customMethodPickerLayout != null) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ customMethodPickerLayout(reauthConfig.providers, onProviderSelected)
+ }
+ } else {
+ Scaffold { innerPadding ->
+ AuthMethodPicker(
+ modifier = Modifier.padding(innerPadding),
+ providers = reauthConfig.providers,
+ onProviderSelected = onProviderSelected,
+ )
+ }
+ }
+ }
+
+ composable(AuthRoute.Email.route) {
+ com.firebase.ui.auth.ui.screens.email.EmailAuthScreen(
+ context = context,
+ configuration = reauthConfig,
+ authUI = authUI,
+ content = emailContent,
+ onSuccess = {},
+ onError = {},
+ onCancel = {
+ if (skipsMethodPicker || !sheetNavController.popBackStack()) onDismiss()
+ }
+ )
+ }
+
+ composable(AuthRoute.Phone.route) {
+ com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen(
+ context = context,
+ configuration = reauthConfig,
+ authUI = authUI,
+ content = phoneContent,
+ onSuccess = {},
+ onError = {},
+ onCancel = {
+ if (skipsMethodPicker || !sheetNavController.popBackStack()) onDismiss()
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun FirebaseAuthUI.rememberOnProviderSelected(
+ context: android.content.Context,
+ activity: android.app.Activity?,
+ config: AuthUIConfiguration,
+ onNavigate: (AuthRoute) -> Unit,
+ onUnknownProvider: ((AuthProvider) -> Unit)? = null,
+): (AuthProvider) -> Unit {
+ val anonymousProvider = config.providers.filterIsInstance().firstOrNull()
+ val googleProvider = config.providers.filterIsInstance().firstOrNull()
+ val facebookProvider = config.providers.filterIsInstance().firstOrNull()
+ val appleProvider = config.providers.filterIsInstance().firstOrNull()
+ val githubProvider = config.providers.filterIsInstance().firstOrNull()
+ val microsoftProvider = config.providers.filterIsInstance().firstOrNull()
+ val yahooProvider = config.providers.filterIsInstance().firstOrNull()
+ val twitterProvider = config.providers.filterIsInstance().firstOrNull()
+ val genericOAuthProviders = config.providers.filterIsInstance()
+
+ val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler(config) }
+ val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, config, it) }
+ val onSignInWithFacebook = facebookProvider?.let { rememberSignInWithFacebookLauncher(context, config, it) }
+ val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) }
+ val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) }
+ val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) }
+ val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) }
+ val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) }
+ val genericOAuthHandlers = genericOAuthProviders.associateWith {
+ rememberOAuthSignInHandler(context, activity, config, it)
+ }
+
+ return { provider ->
+ when (provider) {
+ is AuthProvider.Anonymous -> onSignInAnonymously?.invoke()
+ is AuthProvider.Email -> onNavigate(AuthRoute.Email)
+ is AuthProvider.Phone -> onNavigate(AuthRoute.Phone)
+ is AuthProvider.Google -> onSignInWithGoogle?.invoke()
+ is AuthProvider.Facebook -> onSignInWithFacebook?.invoke()
+ is AuthProvider.Apple -> onSignInWithApple?.invoke()
+ is AuthProvider.Github -> onSignInWithGithub?.invoke()
+ is AuthProvider.Microsoft -> onSignInWithMicrosoft?.invoke()
+ is AuthProvider.Yahoo -> onSignInWithYahoo?.invoke()
+ is AuthProvider.Twitter -> onSignInWithTwitter?.invoke()
+ is AuthProvider.GenericOAuth -> genericOAuthHandlers[provider]?.invoke()
+ else -> onUnknownProvider?.invoke(provider)
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt
new file mode 100644
index 0000000000..0780348ee3
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt
@@ -0,0 +1,190 @@
+/*
+ * 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
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.MfaFactor
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.configuration.validators.VerificationCodeValidator
+import com.firebase.ui.auth.mfa.MfaChallengeContentState
+import com.firebase.ui.auth.ui.components.VerificationCodeInputField
+
+@Composable
+internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) {
+ val isSms = state.factorType == MfaFactor.Sms
+ val stringProvider = LocalAuthUIStringProvider.current
+ val verificationCodeValidator = remember {
+ VerificationCodeValidator(stringProvider)
+ }
+
+ Scaffold { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(innerPadding)
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = if (isSms) {
+ val phoneLabel = state.maskedPhoneNumber ?: ""
+ stringProvider.enterVerificationCodeTitle(phoneLabel)
+ } else {
+ stringProvider.mfaStepVerifyFactorTitle
+ },
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center
+ )
+
+ if (isSms && state.maskedPhoneNumber != null) {
+ Text(
+ text = stringProvider.mfaStepVerifyFactorSmsHelper,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ if (state.error != null) {
+ Text(
+ text = state.error,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ VerificationCodeInputField(
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ codeLength = 6,
+ validator = verificationCodeValidator,
+ isError = state.error != null,
+ errorMessage = state.error,
+ onCodeChange = state.onVerificationCodeChange
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ if (isSms) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ TextButton(
+ onClick = { state.onResendCodeClick?.invoke() },
+ enabled = state.onResendCodeClick != null && !state.isLoading && state.resendTimer == 0
+ ) {
+ Text(
+ text = if (state.resendTimer > 0) {
+ val minutes = state.resendTimer / 60
+ val seconds = state.resendTimer % 60
+ val formatted = "$minutes:${String.format(java.util.Locale.ROOT, "%02d", seconds)}"
+ stringProvider.resendCodeTimer(formatted)
+ } else {
+ stringProvider.resendCode
+ }
+ )
+ }
+
+ TextButton(
+ onClick = state.onCancelClick,
+ enabled = !state.isLoading
+ ) {
+ Text(stringProvider.useDifferentMethodAction)
+ }
+ }
+ } else {
+ OutlinedButton(
+ onClick = state.onCancelClick,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(stringProvider.dismissAction)
+ }
+ }
+
+ Button(
+ onClick = state.onVerifyClick,
+ enabled = state.isValid && !state.isLoading,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.padding(end = 8.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ Text(stringProvider.verifyAction)
+ }
+ }
+ }
+}
+
+/**
+ * Renders with a simulated status/nav bar (see CP-240) so correct edge-to-edge inset handling
+ * can be verified in the IDE preview. A plain `@Preview` draws no system chrome at all, so
+ * inset issues would be invisible there.
+ */
+@Preview(showSystemUi = true)
+@Composable
+private fun PreviewDefaultMfaChallengeContentEdgeToEdge() {
+ val applicationContext = LocalContext.current
+ val stringProvider = DefaultAuthUIStringProvider(applicationContext)
+
+ AuthUITheme {
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides stringProvider
+ ) {
+ DefaultMfaChallengeContent(
+ state = MfaChallengeContentState(
+ factorType = MfaFactor.Sms,
+ maskedPhoneNumber = "+1••••••890"
+ )
+ )
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreen.kt
new file mode 100644
index 0000000000..2dab06adbf
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreen.kt
@@ -0,0 +1,267 @@
+/*
+ * 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
+
+import androidx.activity.compose.LocalActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import com.firebase.ui.auth.configuration.MfaFactor
+import com.firebase.ui.auth.mfa.MfaChallengeContentState
+import com.firebase.ui.auth.mfa.SmsEnrollmentHandler
+import com.firebase.ui.auth.mfa.maskPhoneNumber
+import com.google.firebase.auth.AuthResult
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.MultiFactorResolver
+import com.google.firebase.auth.PhoneAuthOptions
+import com.google.firebase.auth.PhoneAuthProvider
+import com.google.firebase.auth.PhoneMultiFactorGenerator
+import com.google.firebase.auth.PhoneMultiFactorInfo
+import com.google.firebase.auth.TotpMultiFactorGenerator
+import com.google.firebase.auth.TotpMultiFactorInfo
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+import java.util.concurrent.TimeUnit
+
+/**
+ * A stateful composable that manages the Multi-Factor Authentication (MFA) challenge flow
+ * when a user attempts to sign in with MFA enabled.
+ *
+ * This screen is displayed when an [AuthState.RequiresMfa] state is encountered during sign-in.
+ * It handles the verification of the user's second factor (SMS or TOTP) and completes the
+ * sign-in process upon successful verification.
+ *
+ * **Challenge Flow:**
+ * 1. Screen detects available MFA factors from the resolver
+ * 2. For SMS: automatically sends verification code and shows masked phone number
+ * 3. For TOTP: prompts user to enter code from authenticator app
+ * 4. User enters verification code
+ * 5. System verifies code and completes sign-in
+ *
+ * @param resolver The [MultiFactorResolver] containing MFA session and available factors
+ * @param auth The [FirebaseAuth] instance
+ * @param onSuccess Callback invoked when MFA challenge completes successfully
+ * @param onCancel Callback invoked when user cancels the MFA challenge
+ * @param onError Callback invoked when an error occurs during verification
+ * @param content A composable lambda that receives [MfaChallengeContentState] to render custom UI
+ *
+ * @since 10.0.0
+ */
+@Composable
+fun MfaChallengeScreen(
+ resolver: MultiFactorResolver,
+ auth: FirebaseAuth,
+ onSuccess: (AuthResult) -> Unit,
+ onCancel: () -> Unit,
+ onError: (Exception) -> Unit = {},
+ content: @Composable ((MfaChallengeContentState) -> Unit)? = null
+) {
+ val coroutineScope = rememberCoroutineScope()
+
+ val isLoading = remember { mutableStateOf(false) }
+ val error = remember { mutableStateOf(null) }
+ val verificationCode = rememberSaveable { mutableStateOf("") }
+ val verificationId = remember { mutableStateOf(null) }
+ val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) }
+
+ // Handle resend timer countdown
+ LaunchedEffect(resendTimerSeconds.intValue) {
+ if (resendTimerSeconds.intValue > 0) {
+ delay(1000)
+ resendTimerSeconds.intValue--
+ }
+ }
+
+ val hints = resolver.hints
+ val firstHint = hints.firstOrNull()
+
+ val factorType = remember {
+ when (firstHint?.factorId) {
+ PhoneMultiFactorGenerator.FACTOR_ID -> MfaFactor.Sms
+ TotpMultiFactorGenerator.FACTOR_ID -> MfaFactor.Totp
+ else -> MfaFactor.Sms
+ }
+ }
+
+ val maskedPhoneNumber = remember {
+ if (firstHint is PhoneMultiFactorInfo) {
+ maskPhoneNumber(firstHint.phoneNumber)
+ } else null
+ }
+
+ LaunchedEffect(firstHint) {
+ if (firstHint is PhoneMultiFactorInfo) {
+ isLoading.value = true
+ try {
+ val callbacks = object : PhoneAuthProvider.OnVerificationStateChangedCallbacks() {
+ override fun onVerificationCompleted(credential: com.google.firebase.auth.PhoneAuthCredential) {
+ coroutineScope.launch {
+ try {
+ val assertion = PhoneMultiFactorGenerator.getAssertion(credential)
+ val result = resolver.resolveSignIn(assertion).await()
+ onSuccess(result)
+ } catch (e: Exception) {
+ error.value = e.message
+ onError(e)
+ }
+ }
+ }
+
+ override fun onVerificationFailed(e: com.google.firebase.FirebaseException) {
+ error.value = e.message
+ onError(e)
+ isLoading.value = false
+ }
+
+ override fun onCodeSent(
+ verId: String,
+ token: PhoneAuthProvider.ForceResendingToken
+ ) {
+ verificationId.value = verId
+ resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS
+ isLoading.value = false
+ }
+ }
+
+ val options = PhoneAuthOptions.newBuilder()
+ .setMultiFactorHint(firstHint)
+ .setMultiFactorSession(resolver.session)
+ .setCallbacks(callbacks)
+ .setTimeout(SmsEnrollmentHandler.VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .build()
+
+ PhoneAuthProvider.verifyPhoneNumber(options)
+ } catch (e: Exception) {
+ error.value = e.message
+ onError(e)
+ isLoading.value = false
+ }
+ }
+ }
+
+ val state = MfaChallengeContentState(
+ factorType = factorType,
+ maskedPhoneNumber = maskedPhoneNumber,
+ isLoading = isLoading.value,
+ error = error.value,
+ verificationCode = verificationCode.value,
+ resendTimer = resendTimerSeconds.intValue,
+ onVerificationCodeChange = { code ->
+ verificationCode.value = code
+ error.value = null
+ },
+ onVerifyClick = {
+ coroutineScope.launch {
+ isLoading.value = true
+ try {
+ val assertion = when (factorType) {
+ MfaFactor.Sms -> {
+ val verId = verificationId.value
+ require(verId != null) { "No verification ID available" }
+ val credential = PhoneAuthProvider.getCredential(
+ verId,
+ verificationCode.value
+ )
+ PhoneMultiFactorGenerator.getAssertion(credential)
+ }
+ MfaFactor.Totp -> {
+ val totpInfo = firstHint as? TotpMultiFactorInfo
+ require(totpInfo != null) { "No TOTP info available" }
+ TotpMultiFactorGenerator.getAssertionForSignIn(
+ totpInfo.uid,
+ verificationCode.value
+ )
+ }
+ }
+
+ val result = resolver.resolveSignIn(assertion).await()
+ onSuccess(result)
+ error.value = null
+ } catch (e: Exception) {
+ error.value = e.message
+ onError(e)
+ } finally {
+ isLoading.value = false
+ }
+ }
+ },
+ onResendCodeClick = if (factorType == MfaFactor.Sms && firstHint is PhoneMultiFactorInfo) {
+ {
+ if (resendTimerSeconds.intValue == 0) {
+ coroutineScope.launch {
+ isLoading.value = true
+ try {
+ val callbacks = object : PhoneAuthProvider.OnVerificationStateChangedCallbacks() {
+ override fun onVerificationCompleted(credential: com.google.firebase.auth.PhoneAuthCredential) {
+ coroutineScope.launch {
+ try {
+ val assertion = PhoneMultiFactorGenerator.getAssertion(credential)
+ val result = resolver.resolveSignIn(assertion).await()
+ onSuccess(result)
+ } catch (e: Exception) {
+ error.value = e.message
+ onError(e)
+ }
+ }
+ }
+
+ override fun onVerificationFailed(e: com.google.firebase.FirebaseException) {
+ error.value = e.message
+ onError(e)
+ isLoading.value = false
+ }
+
+ override fun onCodeSent(
+ verId: String,
+ token: PhoneAuthProvider.ForceResendingToken
+ ) {
+ verificationId.value = verId
+ resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS
+ error.value = null
+ isLoading.value = false
+ }
+ }
+
+ val options = PhoneAuthOptions.newBuilder()
+ .setMultiFactorHint(firstHint)
+ .setMultiFactorSession(resolver.session)
+ .setCallbacks(callbacks)
+ .setTimeout(SmsEnrollmentHandler.VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .build()
+
+ PhoneAuthProvider.verifyPhoneNumber(options)
+ } catch (e: Exception) {
+ error.value = e.message
+ onError(e)
+ isLoading.value = false
+ }
+ }
+ }
+ }
+ } else null,
+ onCancelClick = onCancel
+ )
+
+ if (content != null) {
+ content(state)
+ } else {
+ DefaultMfaChallengeContent(state)
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt
new file mode 100644
index 0000000000..1cbb459495
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt
@@ -0,0 +1,640 @@
+/*
+ * 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
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.MfaFactor
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.mfa.MfaEnrollmentContentState
+import com.firebase.ui.auth.mfa.MfaEnrollmentStep
+import com.firebase.ui.auth.mfa.toMfaErrorMessage
+import com.firebase.ui.auth.ui.components.QrCodeImage
+import com.firebase.ui.auth.ui.components.ReauthenticationDialog
+import com.firebase.ui.auth.ui.screens.phone.EnterPhoneNumberUI
+import com.firebase.ui.auth.ui.screens.phone.EnterVerificationCodeUI
+import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.MultiFactorInfo
+import com.google.firebase.auth.PhoneMultiFactorInfo
+import com.google.firebase.auth.TotpMultiFactorInfo
+
+@Composable
+internal fun DefaultMfaEnrollmentContent(
+ state: MfaEnrollmentContentState,
+ authConfiguration: AuthUIConfiguration,
+ user: FirebaseUser
+) {
+ val stringProvider = LocalAuthUIStringProvider.current
+ val snackbarHostState = remember { SnackbarHostState() }
+ val showReauthDialog = remember { mutableStateOf(false) }
+ val reauthErrorMessage = remember { mutableStateOf(null) }
+ val successMessage = remember { mutableStateOf(null) }
+
+ LaunchedEffect(state.error, state.exception) {
+ val exception = state.exception
+ when {
+ exception is FirebaseAuthRecentLoginRequiredException -> {
+ showReauthDialog.value = true
+ }
+ exception != null -> {
+ snackbarHostState.showSnackbar(exception.toMfaErrorMessage(stringProvider))
+ }
+ !state.error.isNullOrBlank() -> {
+ snackbarHostState.showSnackbar(state.error!!)
+ }
+ }
+ }
+
+ LaunchedEffect(successMessage.value) {
+ successMessage.value?.let { message ->
+ snackbarHostState.showSnackbar(message)
+ successMessage.value = null
+ }
+ }
+
+ LaunchedEffect(reauthErrorMessage.value) {
+ reauthErrorMessage.value?.let { message ->
+ snackbarHostState.showSnackbar(message)
+ reauthErrorMessage.value = null
+ }
+ }
+
+ if (showReauthDialog.value) {
+ ReauthenticationDialog(
+ user = user,
+ onDismiss = {
+ showReauthDialog.value = false
+ },
+ onSuccess = {
+ showReauthDialog.value = false
+ successMessage.value = stringProvider.identityVerifiedMessage
+ },
+ onError = { exception ->
+ reauthErrorMessage.value = when {
+ exception.message?.contains("password", ignoreCase = true) == true ->
+ stringProvider.incorrectPasswordError
+ exception.message?.contains("network", ignoreCase = true) == true ->
+ stringProvider.noInternet
+ else -> stringProvider.reauthGenericError
+ }
+ }
+ )
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ when (state.step) {
+ MfaEnrollmentStep.SelectFactor -> {
+ SelectFactorUI(
+ availableFactors = state.availableFactors,
+ enrolledFactors = state.enrolledFactors,
+ onFactorSelected = state.onFactorSelected,
+ onUnenrollFactor = state.onUnenrollFactor,
+ onSkipClick = state.onSkipClick,
+ isLoading = state.isLoading,
+ error = state.error,
+ stringProvider = stringProvider
+ )
+ }
+
+ MfaEnrollmentStep.ConfigureSms -> {
+ state.selectedCountry?.let { country ->
+ EnterPhoneNumberUI(
+ configuration = authConfiguration,
+ isLoading = state.isLoading,
+ phoneNumber = state.phoneNumber,
+ selectedCountry = country,
+ onPhoneNumberChange = state.onPhoneNumberChange,
+ onCountrySelected = state.onCountrySelected,
+ onSendCodeClick = state.onSendSmsCodeClick,
+ title = stringProvider.mfaEnrollmentEnterPhoneNumber
+ )
+ }
+ }
+
+ MfaEnrollmentStep.ConfigureTotp -> {
+ ConfigureTotpUI(
+ totpSecret = state.totpSecret?.sharedSecretKey,
+ totpQrCodeUrl = state.totpQrCodeUrl,
+ onContinueClick = state.onContinueToVerifyClick,
+ onBackClick = state.onBackClick,
+ isLoading = state.isLoading,
+ isValid = state.isValid,
+ error = state.error,
+ stringProvider = stringProvider
+ )
+ }
+
+ MfaEnrollmentStep.VerifyFactor -> {
+ when (state.selectedFactor) {
+ MfaFactor.Sms -> {
+ val formattedPhone =
+ "${state.selectedCountry?.dialCode ?: ""}${state.phoneNumber}"
+ EnterVerificationCodeUI(
+ configuration = authConfiguration,
+ isLoading = state.isLoading,
+ verificationCode = state.verificationCode,
+ fullPhoneNumber = formattedPhone,
+ resendTimer = state.resendTimer,
+ onVerificationCodeChange = state.onVerificationCodeChange,
+ onVerifyCodeClick = state.onVerifyClick,
+ onResendCodeClick = state.onResendCodeClick ?: {},
+ onChangeNumberClick = state.onBackClick,
+ title = stringProvider.mfaEnrollmentVerifySmsCode
+ )
+ }
+
+ MfaFactor.Totp -> {
+ VerifyTotpUI(
+ verificationCode = state.verificationCode,
+ onVerificationCodeChange = state.onVerificationCodeChange,
+ onVerifyClick = state.onVerifyClick,
+ onBackClick = state.onBackClick,
+ isLoading = state.isLoading,
+ isValid = state.isValid,
+ error = state.error,
+ stringProvider = stringProvider
+ )
+ }
+
+ null -> Unit
+ }
+ }
+
+ MfaEnrollmentStep.ShowRecoveryCodes -> {
+ ShowRecoveryCodesUI(
+ recoveryCodes = state.recoveryCodes.orEmpty(),
+ onDoneClick = state.onCodesSavedClick,
+ isLoading = state.isLoading,
+ error = state.error,
+ stringProvider = stringProvider
+ )
+ }
+ }
+
+ SnackbarHost(
+ hostState = snackbarHostState,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(16.dp)
+ )
+ }
+}
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun SelectFactorUI(
+ availableFactors: List,
+ enrolledFactors: List,
+ onFactorSelected: (MfaFactor) -> Unit,
+ onUnenrollFactor: (MultiFactorInfo) -> Unit,
+ onSkipClick: (() -> Unit)?,
+ isLoading: Boolean,
+ error: String?,
+ stringProvider: AuthUIStringProvider
+) {
+ val enrolledFactorIds = enrolledFactors.mapNotNull {
+ when (it) {
+ is PhoneMultiFactorInfo -> MfaFactor.Sms
+ is TotpMultiFactorInfo -> MfaFactor.Totp
+ else -> null
+ }
+ }.toSet()
+
+ val factorsToEnroll = availableFactors.filter { it !in enrolledFactorIds }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringProvider.mfaManageFactorsTitle) },
+ colors = AuthUITheme.resolvedTopAppBarColors
+ )
+ }
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = stringProvider.mfaManageFactorsDescription,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ error?.let {
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ if (enrolledFactors.isNotEmpty()) {
+ Text(
+ text = stringProvider.mfaActiveMethodsTitle,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ enrolledFactors.forEach { factorInfo ->
+ EnrolledFactorItem(
+ factorInfo = factorInfo,
+ onRemove = { onUnenrollFactor(factorInfo) },
+ enabled = !isLoading,
+ stringProvider = stringProvider
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+ }
+
+ if (factorsToEnroll.isNotEmpty()) {
+ Text(
+ text = stringProvider.mfaAddNewMethodTitle,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ factorsToEnroll.forEach { factor ->
+ Button(
+ onClick = { onFactorSelected(factor) },
+ enabled = !isLoading,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ when (factor) {
+ MfaFactor.Sms -> Text(stringProvider.mfaStepConfigureSmsTitle)
+ MfaFactor.Totp -> Text(stringProvider.mfaStepConfigureTotpTitle)
+ }
+ }
+ }
+ } else if (enrolledFactors.isNotEmpty()) {
+ Text(
+ text = stringProvider.mfaAllMethodsEnrolledMessage,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(vertical = 8.dp)
+ )
+ }
+
+ onSkipClick?.let {
+ TextButton(
+ onClick = it,
+ enabled = !isLoading
+ ) {
+ Text(stringProvider.skipAction)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EnrolledFactorItem(
+ factorInfo: MultiFactorInfo,
+ onRemove: () -> Unit,
+ enabled: Boolean,
+ stringProvider: AuthUIStringProvider
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = when (factorInfo) {
+ is PhoneMultiFactorInfo -> stringProvider.smsAuthenticationLabel
+ is TotpMultiFactorInfo -> stringProvider.totpAuthenticationLabel
+ else -> stringProvider.unknownMethodLabel
+ },
+ style = MaterialTheme.typography.titleSmall
+ )
+ Text(
+ text = when (factorInfo) {
+ is PhoneMultiFactorInfo -> factorInfo.phoneNumber ?: stringProvider.smsAuthenticationLabel
+ is TotpMultiFactorInfo -> factorInfo.displayName ?: stringProvider.totpAuthenticationLabel
+ else -> ""
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = stringProvider.enrolledOnDateLabel(
+ java.text.SimpleDateFormat(
+ "MMM dd, yyyy",
+ java.util.Locale.getDefault()
+ ).format(java.util.Date(factorInfo.enrollmentTimestamp * 1000))
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ OutlinedButton(
+ onClick = onRemove,
+ enabled = enabled,
+ colors = ButtonDefaults.outlinedButtonColors(
+ contentColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Text(stringProvider.removeAction)
+ }
+ }
+ }
+}
+
+@Composable
+private fun ConfigureTotpUI(
+ totpSecret: String?,
+ totpQrCodeUrl: String?,
+ onContinueClick: () -> Unit,
+ onBackClick: () -> Unit,
+ isLoading: Boolean,
+ isValid: Boolean,
+ error: String?,
+ stringProvider: AuthUIStringProvider
+) {
+ Scaffold { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = stringProvider.mfaStepConfigureTotpTitle,
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center
+ )
+
+ Text(
+ text = stringProvider.setupAuthenticatorDescription,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ error?.let {
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center
+ )
+ }
+
+ totpQrCodeUrl?.let { url ->
+ QrCodeImage(
+ content = url,
+ size = 220.dp
+ )
+ }
+
+ totpSecret?.let { secret ->
+ Text(
+ text = stringProvider.secretKeyLabel,
+ style = MaterialTheme.typography.titleSmall
+ )
+ Text(
+ text = secret,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ TextButton(
+ onClick = onBackClick,
+ enabled = !isLoading,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(stringProvider.backAction)
+ }
+
+ Button(
+ onClick = onContinueClick,
+ enabled = !isLoading && isValid,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(stringProvider.continueText)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun VerifyTotpUI(
+ verificationCode: String,
+ onVerificationCodeChange: (String) -> Unit,
+ onVerifyClick: () -> Unit,
+ onBackClick: () -> Unit,
+ isLoading: Boolean,
+ isValid: Boolean,
+ error: String?,
+ stringProvider: AuthUIStringProvider
+) {
+ Scaffold { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = stringProvider.mfaStepVerifyFactorTitle,
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center
+ )
+
+ Text(
+ text = stringProvider.mfaStepVerifyFactorTotpHelper,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ error?.let {
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center
+ )
+ }
+
+ OutlinedTextField(
+ value = verificationCode,
+ onValueChange = onVerificationCodeChange,
+ label = { Text(stringProvider.verificationCodeLabel) },
+ enabled = !isLoading,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ OutlinedButton(
+ onClick = onBackClick,
+ enabled = !isLoading,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(stringProvider.backAction)
+ }
+
+ Button(
+ onClick = onVerifyClick,
+ enabled = !isLoading && isValid,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(stringProvider.verifyAction)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ShowRecoveryCodesUI(
+ recoveryCodes: List,
+ onDoneClick: () -> Unit,
+ isLoading: Boolean,
+ error: String?,
+ stringProvider: AuthUIStringProvider
+) {
+ Scaffold { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = stringProvider.mfaStepShowRecoveryCodesTitle,
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center
+ )
+
+ Text(
+ text = stringProvider.mfaStepShowRecoveryCodesHelper,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.error
+ )
+
+ error?.let {
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ recoveryCodes.forEach { code ->
+ Text(
+ text = code,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+
+ Button(
+ onClick = onDoneClick,
+ enabled = !isLoading,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(stringProvider.recoveryCodesSavedAction)
+ }
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreen.kt
new file mode 100644
index 0000000000..29f6827c9e
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreen.kt
@@ -0,0 +1,396 @@
+/*
+ * 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
+
+import androidx.activity.compose.LocalActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.platform.LocalContext
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.MfaConfiguration
+import com.firebase.ui.auth.configuration.MfaFactor
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.data.CountryData
+import com.firebase.ui.auth.util.CountryUtils
+import com.firebase.ui.auth.mfa.MfaEnrollmentContentState
+import com.firebase.ui.auth.mfa.MfaEnrollmentStep
+import com.firebase.ui.auth.mfa.SmsEnrollmentHandler
+import com.firebase.ui.auth.mfa.SmsEnrollmentSession
+import com.firebase.ui.auth.mfa.TotpEnrollmentHandler
+import com.firebase.ui.auth.mfa.TotpSecret
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.FirebaseUser
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/**
+ * A stateful composable that manages the Multi-Factor Authentication (MFA) enrollment flow.
+ *
+ * This screen handles all steps of MFA enrollment including factor selection, configuration,
+ * verification, and recovery code display. It uses the provided handlers to communicate with
+ * Firebase Authentication and exposes state through a content slot for custom UI rendering.
+ *
+ * **Enrollment Flow:**
+ * 1. **SelectFactor** - User chooses between SMS or TOTP
+ * 2. **ConfigureSms** or **ConfigureTotp** - User sets up their chosen factor
+ * 3. **VerifyFactor** - User verifies with a code
+ * 4. **ShowRecoveryCodes** - (Optional) User receives backup codes
+ *
+ * @param user The currently authenticated [FirebaseUser] to enroll in MFA
+ * @param auth The [FirebaseAuth] instance
+ * @param configuration MFA configuration controlling available factors and behavior
+ * @param onComplete Callback invoked when enrollment completes successfully
+ * @param onSkip Callback invoked when user skips enrollment (only if not required)
+ * @param onError Callback invoked when an error occurs during enrollment
+ * @param content A composable lambda that receives [MfaEnrollmentContentState] to render custom UI
+ *
+ * @since 10.0.0
+ */
+@Composable
+fun MfaEnrollmentScreen(
+ user: FirebaseUser,
+ auth: FirebaseAuth,
+ configuration: MfaConfiguration,
+ authConfiguration: AuthUIConfiguration? = null,
+ onComplete: () -> Unit,
+ onSkip: () -> Unit = {},
+ onError: (Exception) -> Unit = {},
+ content: @Composable ((MfaEnrollmentContentState) -> Unit)? = null
+) {
+ val activity = requireNotNull(LocalActivity.current) {
+ "MfaEnrollmentScreen must be used within an Activity context for SMS verification"
+ }
+ val coroutineScope = rememberCoroutineScope()
+ val applicationContext = LocalContext.current.applicationContext
+
+ val smsHandler = remember(activity, auth, user) { SmsEnrollmentHandler(activity, auth, user) }
+ val totpHandler = remember(auth, user) { TotpEnrollmentHandler(auth, user) }
+
+ val currentStep = rememberSaveable { mutableStateOf(MfaEnrollmentStep.SelectFactor) }
+ val selectedFactor = rememberSaveable { mutableStateOf(null) }
+ val isLoading = remember { mutableStateOf(false) }
+ val error = remember { mutableStateOf(null) }
+ val lastException = remember { mutableStateOf(null) }
+ val enrolledFactors = remember { mutableStateOf(user.multiFactor.enrolledFactors) }
+
+ val phoneNumber = rememberSaveable { mutableStateOf("") }
+ val selectedCountry = remember { mutableStateOf(CountryUtils.getDefaultCountry()) }
+ val smsSession = remember { mutableStateOf(null) }
+
+ val totpSecret = remember { mutableStateOf(null) }
+ val totpQrCodeUrl = remember { mutableStateOf(null) }
+
+ val verificationCode = rememberSaveable { mutableStateOf("") }
+
+ val recoveryCodes = remember { mutableStateOf?>(null) }
+
+ val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) }
+
+ val phoneAuthConfiguration = remember(authConfiguration, applicationContext) {
+ authConfiguration ?: authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null
+ )
+ )
+ }
+ }
+ }
+
+ // Handle resend timer countdown
+ LaunchedEffect(resendTimerSeconds.intValue) {
+ if (resendTimerSeconds.intValue > 0) {
+ delay(1000)
+ resendTimerSeconds.intValue--
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ if (configuration.allowedFactors.size == 1) {
+ selectedFactor.value = configuration.allowedFactors.first()
+ when (selectedFactor.value) {
+ MfaFactor.Sms -> currentStep.value = MfaEnrollmentStep.ConfigureSms
+ MfaFactor.Totp -> {
+ currentStep.value = MfaEnrollmentStep.ConfigureTotp
+ isLoading.value = true
+ try {
+ val secret = totpHandler.generateSecret()
+ totpSecret.value = secret
+ totpQrCodeUrl.value = secret.generateQrCodeUrl(
+ accountName = user.email ?: user.phoneNumber ?: "User",
+ issuer = auth.app.name
+ )
+ error.value = null
+ lastException.value = null
+ } catch (e: Exception) {
+ error.value = e.message
+ lastException.value = e
+ onError(e)
+ } finally {
+ isLoading.value = false
+ }
+ }
+ null -> {}
+ }
+ }
+ }
+
+ val state = MfaEnrollmentContentState(
+ step = currentStep.value,
+ isLoading = isLoading.value,
+ error = error.value,
+ exception = lastException.value,
+ onBackClick = {
+ when (currentStep.value) {
+ MfaEnrollmentStep.SelectFactor -> {}
+ MfaEnrollmentStep.ConfigureSms, MfaEnrollmentStep.ConfigureTotp -> {
+ currentStep.value = MfaEnrollmentStep.SelectFactor
+ selectedFactor.value = null
+ phoneNumber.value = ""
+ totpSecret.value = null
+ totpQrCodeUrl.value = null
+ }
+ MfaEnrollmentStep.VerifyFactor -> {
+ verificationCode.value = ""
+ when (selectedFactor.value) {
+ MfaFactor.Sms -> currentStep.value = MfaEnrollmentStep.ConfigureSms
+ MfaFactor.Totp -> currentStep.value = MfaEnrollmentStep.ConfigureTotp
+ null -> currentStep.value = MfaEnrollmentStep.SelectFactor
+ }
+ }
+ MfaEnrollmentStep.ShowRecoveryCodes -> {
+ currentStep.value = MfaEnrollmentStep.VerifyFactor
+ }
+ }
+ error.value = null
+ lastException.value = null
+ },
+ availableFactors = configuration.allowedFactors,
+ enrolledFactors = enrolledFactors.value,
+ onFactorSelected = { factor ->
+ selectedFactor.value = factor
+ when (factor) {
+ MfaFactor.Sms -> {
+ currentStep.value = MfaEnrollmentStep.ConfigureSms
+ }
+ MfaFactor.Totp -> {
+ currentStep.value = MfaEnrollmentStep.ConfigureTotp
+ coroutineScope.launch {
+ isLoading.value = true
+ try {
+ val secret = totpHandler.generateSecret()
+ totpSecret.value = secret
+ totpQrCodeUrl.value = secret.generateQrCodeUrl(
+ accountName = user.email ?: user.phoneNumber ?: "User",
+ issuer = auth.app.name
+ )
+ error.value = null
+ lastException.value = null
+ } catch (e: Exception) {
+ error.value = e.message
+ lastException.value = e
+ onError(e)
+ } finally {
+ isLoading.value = false
+ }
+ }
+ }
+ }
+ },
+ onUnenrollFactor = { factorInfo ->
+ coroutineScope.launch {
+ isLoading.value = true
+ try {
+ user.multiFactor.unenroll(factorInfo).addOnCompleteListener { task ->
+ if (task.isSuccessful) {
+ // Refresh the enrolled factors list
+ enrolledFactors.value = user.multiFactor.enrolledFactors
+ error.value = null
+ } else {
+ error.value = task.exception?.message
+ task.exception?.let {
+ lastException.value = it
+ onError(it)
+ }
+ }
+ isLoading.value = false
+ }
+ } catch (e: Exception) {
+ error.value = e.message
+ lastException.value = e
+ onError(e)
+ isLoading.value = false
+ }
+ }
+ },
+ onSkipClick = if (!configuration.requireEnrollment) {
+ { onSkip() }
+ } else null,
+ phoneNumber = phoneNumber.value,
+ onPhoneNumberChange = { phone ->
+ phoneNumber.value = phone
+ error.value = null
+ },
+ selectedCountry = selectedCountry.value,
+ onCountrySelected = { country ->
+ selectedCountry.value = country
+ },
+ onSendSmsCodeClick = {
+ coroutineScope.launch {
+ isLoading.value = true
+ try {
+ val fullPhoneNumber = "${selectedCountry.value.dialCode}${phoneNumber.value}"
+ val session = smsHandler.sendVerificationCode(fullPhoneNumber)
+ smsSession.value = session
+ currentStep.value = MfaEnrollmentStep.VerifyFactor
+ resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS
+ error.value = null
+ lastException.value = null
+ } catch (e: Exception) {
+ error.value = e.message
+ lastException.value = e
+ onError(e)
+ } finally {
+ isLoading.value = false
+ }
+ }
+ },
+ totpSecret = totpSecret.value,
+ totpQrCodeUrl = totpQrCodeUrl.value,
+ onContinueToVerifyClick = {
+ currentStep.value = MfaEnrollmentStep.VerifyFactor
+ },
+ verificationCode = verificationCode.value,
+ onVerificationCodeChange = { code ->
+ verificationCode.value = code
+ error.value = null
+ },
+ onVerifyClick = {
+ coroutineScope.launch {
+ isLoading.value = true
+ try {
+ when (selectedFactor.value) {
+ MfaFactor.Sms -> {
+ val session = smsSession.value
+ if (session != null) {
+ smsHandler.enrollWithVerificationCode(
+ session = session,
+ verificationCode = verificationCode.value,
+ displayName = "SMS"
+ )
+ } else {
+ throw IllegalStateException("No SMS session available")
+ }
+ }
+ MfaFactor.Totp -> {
+ val secret = totpSecret.value
+ if (secret != null) {
+ totpHandler.enrollWithVerificationCode(
+ totpSecret = secret,
+ verificationCode = verificationCode.value,
+ displayName = "Authenticator App"
+ )
+ } else {
+ throw IllegalStateException("No TOTP secret available")
+ }
+ }
+ null -> throw IllegalStateException("No factor selected")
+ }
+
+ // Refresh enrolled factors after successful enrollment
+ enrolledFactors.value = user.multiFactor.enrolledFactors
+
+ if (configuration.enableRecoveryCodes) {
+ recoveryCodes.value = generateRecoveryCodes()
+ currentStep.value = MfaEnrollmentStep.ShowRecoveryCodes
+ } else {
+ onComplete()
+ }
+ error.value = null
+ lastException.value = null
+ } catch (e: Exception) {
+ error.value = e.message
+ lastException.value = e
+ onError(e)
+ } finally {
+ isLoading.value = false
+ }
+ }
+ },
+ selectedFactor = selectedFactor.value,
+ resendTimer = resendTimerSeconds.intValue,
+ onResendCodeClick = if (selectedFactor.value == MfaFactor.Sms) {
+ {
+ if (resendTimerSeconds.intValue == 0) {
+ coroutineScope.launch {
+ val session = smsSession.value
+ if (session != null) {
+ isLoading.value = true
+ try {
+ val newSession = smsHandler.resendVerificationCode(session)
+ smsSession.value = newSession
+ resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS
+ error.value = null
+ lastException.value = null
+ } catch (e: Exception) {
+ error.value = e.message
+ lastException.value = e
+ onError(e)
+ } finally {
+ isLoading.value = false
+ }
+ }
+ }
+ }
+ }
+ } else null,
+ recoveryCodes = recoveryCodes.value,
+ onCodesSavedClick = {
+ onComplete()
+ }
+ )
+
+ if (content != null) {
+ content(state)
+ } else {
+ DefaultMfaEnrollmentContent(
+ state = state,
+ authConfiguration = phoneAuthConfiguration,
+ user = user
+ )
+ }
+}
+
+/**
+ * Generates placeholder recovery codes.
+ * In a production implementation, these would come from Firebase or a backend service.
+ */
+private fun generateRecoveryCodes(): List {
+ return List(10) { index ->
+ List(4) { (0..9).random() }
+ .joinToString("")
+ .let { if (index % 2 == 0) "$it-${(1000..9999).random()}" else it }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt
new file mode 100644
index 0000000000..667fc364b3
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt
@@ -0,0 +1,441 @@
+/*
+ * 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 android.util.Log
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.AuthState
+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.configuration.auth_provider.createOrLinkUserWithEmailAndPassword
+import com.firebase.ui.auth.configuration.auth_provider.sendPasswordResetEmail
+import com.firebase.ui.auth.configuration.auth_provider.sendSignInLinkToEmail
+import com.firebase.ui.auth.configuration.auth_provider.signInWithEmailAndPassword
+import com.firebase.ui.auth.configuration.auth_provider.signInWithEmailLink
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException
+import com.firebase.ui.auth.credentialmanager.PasswordCredentialException
+import com.firebase.ui.auth.credentialmanager.PasswordCredentialHandler
+import com.firebase.ui.auth.credentialmanager.PasswordCredentialNotFoundException
+import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController
+import com.google.firebase.auth.AuthCredential
+import com.google.firebase.auth.AuthResult
+import com.google.firebase.auth.EmailAuthProvider
+import kotlinx.coroutines.launch
+
+enum class EmailAuthMode {
+ SignIn,
+ EmailLinkSignIn,
+ SignUp,
+ ResetPassword,
+}
+
+/**
+ * A class passed to the content slot, containing all the necessary information to render custom
+ * UIs for sign-in, sign-up, and password reset flows.
+ *
+ * @param mode An enum representing the current UI mode. Use a when expression on this to render
+ * the correct screen.
+ * @param isLoading true when an asynchronous operation (like signing in or sending an email)
+ * is in progress.
+ * @param error An optional error message to display to the user.
+ * @param email The current value of the email input field.
+ * @param onEmailChange (Modes: [EmailAuthMode.SignIn], [EmailAuthMode.SignUp],
+ * [EmailAuthMode.ResetPassword]) A callback to be invoked when the email input changes.
+ * @param password An optional custom layout composable for the provider buttons.
+ * @param onPasswordChange (Modes: [EmailAuthMode.SignIn], [EmailAuthMode.SignUp]) The current
+ * value of the password input field.
+ * @param confirmPassword (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when the password
+ * input changes.
+ * @param onConfirmPasswordChange (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when
+ * the password confirmation input changes.
+ * @param displayName (Mode: [EmailAuthMode.SignUp]) The current value of the display name field.
+ * @param onDisplayNameChange (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when the
+ * display name input changes.
+ * @param onSignInClick (Mode: [EmailAuthMode.SignIn]) A callback to be invoked to attempt a
+ * sign-in with the provided credentials.
+ * @param onSignUpClick (Mode: [EmailAuthMode.SignUp]) A callback to be invoked to attempt to
+ * create a new account.
+ * @param onSendResetLinkClick (Mode: [EmailAuthMode.ResetPassword]) A callback to be invoked to
+ * send a password reset email.
+ * @param resetLinkSent (Mode: [EmailAuthMode.ResetPassword]) true after the password reset link
+ * has been successfully sent.
+ * @param emailSignInLinkSent (Mode: [EmailAuthMode.SignIn]) true after the email sign in link has
+ * been successfully sent.
+ * @param onGoToSignUp A callback to switch the UI to the SignUp mode.
+ * @param onGoToSignIn A callback to switch the UI to the SignIn mode.
+ * @param onGoToResetPassword A callback to switch the UI to the ResetPassword mode.
+ */
+class EmailAuthContentState(
+ val mode: EmailAuthMode,
+ val isLoading: Boolean = false,
+ val error: String? = null,
+ val email: String,
+ val onEmailChange: (String) -> Unit,
+ val password: String,
+ val onPasswordChange: (String) -> Unit,
+ val confirmPassword: String,
+ val onConfirmPasswordChange: (String) -> Unit,
+ val displayName: String,
+ val onDisplayNameChange: (String) -> Unit,
+ val onRetrievedCredential: (Pair) -> Unit,
+ val onSignInClick: () -> Unit,
+ val onSignInEmailLinkClick: () -> Unit,
+ val onSignUpClick: () -> Unit,
+ val onSendResetLinkClick: () -> Unit,
+ val resetLinkSent: Boolean = false,
+ val emailSignInLinkSent: Boolean = false,
+ val onGoToSignUp: () -> Unit,
+ val onGoToSignIn: () -> Unit,
+ val onGoToResetPassword: () -> Unit,
+ val onGoToEmailLinkSignIn: () -> Unit,
+)
+
+/**
+ * A stateful composable that manages the logic for all email-based authentication flows,
+ * including sign-in, sign-up, and password reset. It exposes the state for the current mode to
+ * a custom UI via a trailing lambda (slot), allowing for complete visual customization.
+ *
+ * @param configuration
+ * @param onSuccess
+ * @param onError
+ * @param onCancel
+ * @param content
+ */
+@Composable
+fun EmailAuthScreen(
+ context: Context,
+ configuration: AuthUIConfiguration,
+ authUI: FirebaseAuthUI,
+ credentialForLinking: AuthCredential? = null,
+ emailLinkFromDifferentDevice: String? = null,
+ onContinueWithProvider: (String) -> Unit = {},
+ onSuccess: (AuthResult) -> Unit,
+ onError: (AuthException) -> Unit,
+ onCancel: () -> Unit,
+ content: @Composable ((EmailAuthContentState) -> Unit)? = null,
+) {
+ val provider = configuration.providers.filterIsInstance().first()
+ val stringProvider = LocalAuthUIStringProvider.current
+ val dialogController = LocalTopLevelDialogController.current
+ val coroutineScope = rememberCoroutineScope()
+
+ // Start in EmailLinkSignIn mode if coming from cross-device flow
+ val initialMode = if (emailLinkFromDifferentDevice != null && provider.isEmailLinkSignInEnabled) {
+ EmailAuthMode.EmailLinkSignIn
+ } else {
+ EmailAuthMode.SignIn
+ }
+ val mode = rememberSaveable { mutableStateOf(initialMode) }
+ val displayNameValue = rememberSaveable { mutableStateOf("") }
+ val emailTextValue = rememberSaveable { mutableStateOf("") }
+ val passwordTextValue = rememberSaveable { mutableStateOf("") }
+ val confirmPasswordTextValue = rememberSaveable { mutableStateOf("") }
+
+ // Used for clearing text fields when switching EmailAuthMode changes
+ val textValues = listOf(
+ displayNameValue,
+ emailTextValue,
+ passwordTextValue,
+ confirmPasswordTextValue
+ )
+
+ val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle)
+ val isLoading = authState is AuthState.Loading
+ val authCredentialForLinking = remember { credentialForLinking }
+ val errorMessage =
+ if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null
+ val resetLinkSent = authState is AuthState.PasswordResetLinkSent
+ val emailSignInLinkSent = authState is AuthState.EmailSignInLinkSent
+
+ // Track if credentials were retrieved from Credential Manager
+ val retrievedCredential = remember { mutableStateOf?>(null) }
+
+ LaunchedEffect(authState) {
+ Log.d("EmailAuthScreen", "Current state: $authState")
+ when (val state = authState) {
+ is AuthState.Success -> {
+ state.result?.let { result ->
+ onSuccess(result)
+ }
+ }
+
+ is AuthState.Error -> {
+ val exception = AuthException.from(state.exception, stringProvider)
+ onError(exception)
+ dialogController?.showErrorDialog(
+ exception = exception,
+ onRetry = { ex ->
+ when (ex) {
+ is AuthException.UserNotFoundException -> {
+ val provider = configuration.providers
+ .filterIsInstance()
+ .first()
+ if (provider.isNewAccountsAllowed) {
+ // User not found, but new accounts are allowed, switch to sign-up
+ mode.value = EmailAuthMode.SignUp
+ }
+ }
+
+ is AuthException.InvalidCredentialsException -> {
+ // User can retry sign in with corrected credentials
+ }
+
+ is AuthException.EmailAlreadyInUseException -> {
+ // Switch to sign-in mode
+ mode.value = EmailAuthMode.SignIn
+ }
+
+ else -> Unit
+ }
+ },
+ onRecover = if (exception is AuthException.DifferentSignInMethodRequiredException) {
+ { ex ->
+ val differentProviderException =
+ ex as AuthException.DifferentSignInMethodRequiredException
+ if (differentProviderException.suggestedSignInMethod ==
+ EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD) {
+ mode.value = EmailAuthMode.EmailLinkSignIn
+ } else {
+ onContinueWithProvider(differentProviderException.suggestedSignInMethod)
+ }
+ }
+ } else {
+ null
+ },
+ onDismiss = {
+ // Dialog dismissed
+ }
+ )
+ }
+
+ is AuthState.Cancelled -> {
+ onCancel()
+ }
+
+ else -> Unit
+ }
+ }
+
+ val state = EmailAuthContentState(
+ mode = mode.value,
+ displayName = displayNameValue.value,
+ email = emailTextValue.value,
+ password = passwordTextValue.value,
+ confirmPassword = confirmPasswordTextValue.value,
+ isLoading = isLoading,
+ error = errorMessage,
+ resetLinkSent = resetLinkSent,
+ emailSignInLinkSent = emailSignInLinkSent,
+ onEmailChange = { email ->
+ emailTextValue.value = email
+ },
+ onPasswordChange = { password ->
+ passwordTextValue.value = password
+ },
+ onConfirmPasswordChange = { confirmPassword ->
+ confirmPasswordTextValue.value = confirmPassword
+ },
+ onDisplayNameChange = { displayName ->
+ displayNameValue.value = displayName
+ },
+ onRetrievedCredential = { credential ->
+ retrievedCredential.value = credential
+ },
+ onSignInClick = {
+ coroutineScope.launch {
+ try {
+ // Check if user is signing in with retrieved credentials
+ val isUsingRetrievedCredential = retrievedCredential.value?.let { (email, password) ->
+ email == emailTextValue.value && password == passwordTextValue.value
+ } ?: false
+
+ authUI.signInWithEmailAndPassword(
+ context = context,
+ config = configuration,
+ email = emailTextValue.value,
+ password = passwordTextValue.value,
+ credentialForLinking = authCredentialForLinking,
+ skipCredentialSave = isUsingRetrievedCredential
+ )
+ } catch (e: Exception) {
+ onError(AuthException.from(e, stringProvider))
+ }
+ }
+ },
+ onSignInEmailLinkClick = {
+ coroutineScope.launch {
+ try {
+ if (emailLinkFromDifferentDevice != null) {
+ authUI.signInWithEmailLink(
+ context = context,
+ config = configuration,
+ provider = provider,
+ email = emailTextValue.value,
+ emailLink = emailLinkFromDifferentDevice,
+ )
+ } else {
+ authUI.sendSignInLinkToEmail(
+ context = context,
+ config = configuration,
+ provider = provider,
+ email = emailTextValue.value,
+ credentialForLinking = authCredentialForLinking,
+ )
+ }
+ } catch (e: Exception) {
+ onError(AuthException.from(e, stringProvider))
+ }
+ }
+ },
+ onSignUpClick = {
+ coroutineScope.launch {
+ try {
+ authUI.createOrLinkUserWithEmailAndPassword(
+ context = context,
+ config = configuration,
+ provider = provider,
+ name = displayNameValue.value,
+ email = emailTextValue.value,
+ password = passwordTextValue.value,
+ )
+ } catch (e: Exception) {
+ onError(AuthException.from(e, stringProvider))
+ }
+ }
+ },
+ onSendResetLinkClick = {
+ coroutineScope.launch {
+ try {
+ authUI.sendPasswordResetEmail(
+ email = emailTextValue.value,
+ config = configuration,
+ actionCodeSettings = configuration.passwordResetActionCodeSettings,
+ )
+ } catch (e: Exception) {
+ onError(AuthException.from(e, stringProvider))
+ }
+ }
+ },
+ onGoToSignUp = {
+ textValues.forEach { it.value = "" }
+ mode.value = EmailAuthMode.SignUp
+ },
+ onGoToSignIn = {
+ textValues.forEach { it.value = "" }
+ mode.value = EmailAuthMode.SignIn
+ },
+ onGoToResetPassword = {
+ textValues.forEach { it.value = "" }
+ mode.value = EmailAuthMode.ResetPassword
+ },
+ onGoToEmailLinkSignIn = {
+ textValues.forEach { it.value = "" }
+ mode.value = EmailAuthMode.EmailLinkSignIn
+ },
+ )
+
+ if (content != null) {
+ content(state)
+ } else {
+ DefaultEmailAuthContent(
+ configuration = configuration,
+ state = state,
+ onCancel = onCancel
+ )
+ }
+}
+
+@Composable
+private fun DefaultEmailAuthContent(
+ configuration: AuthUIConfiguration,
+ state: EmailAuthContentState,
+ onCancel: () -> Unit,
+) {
+ when (state.mode) {
+ EmailAuthMode.SignIn -> {
+ SignInUI(
+ configuration = configuration,
+ email = state.email,
+ isLoading = state.isLoading,
+ emailSignInLinkSent = state.emailSignInLinkSent,
+ password = state.password,
+ onEmailChange = state.onEmailChange,
+ onPasswordChange = state.onPasswordChange,
+ onRetrievedCredential = state.onRetrievedCredential,
+ onSignInClick = state.onSignInClick,
+ onGoToSignUp = state.onGoToSignUp,
+ onGoToResetPassword = state.onGoToResetPassword,
+ onGoToEmailLinkSignIn = state.onGoToEmailLinkSignIn,
+ onNavigateBack = onCancel
+ )
+ }
+
+ EmailAuthMode.EmailLinkSignIn -> {
+ SignInEmailLinkUI(
+ configuration = configuration,
+ email = state.email,
+ isLoading = state.isLoading,
+ emailSignInLinkSent = state.emailSignInLinkSent,
+ onEmailChange = state.onEmailChange,
+ onSignInWithEmailLink = state.onSignInEmailLinkClick,
+ onGoToSignIn = state.onGoToSignIn,
+ onGoToResetPassword = state.onGoToResetPassword,
+ onNavigateBack = onCancel
+ )
+ }
+
+ EmailAuthMode.SignUp -> {
+ SignUpUI(
+ configuration = configuration,
+ isLoading = state.isLoading,
+ displayName = state.displayName,
+ email = state.email,
+ password = state.password,
+ confirmPassword = state.confirmPassword,
+ onDisplayNameChange = state.onDisplayNameChange,
+ onEmailChange = state.onEmailChange,
+ onPasswordChange = state.onPasswordChange,
+ onConfirmPasswordChange = state.onConfirmPasswordChange,
+ onSignUpClick = state.onSignUpClick,
+ onGoToSignIn = state.onGoToSignIn,
+ onNavigateBack = onCancel
+ )
+ }
+
+ EmailAuthMode.ResetPassword -> {
+ ResetPasswordUI(
+ configuration = configuration,
+ isLoading = state.isLoading,
+ email = state.email,
+ resetLinkSent = state.resetLinkSent,
+ onEmailChange = state.onEmailChange,
+ onSendResetLink = state.onSendResetLinkClick,
+ onGoToSignIn = state.onGoToSignIn,
+ onNavigateBack = onCancel
+ )
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt
new file mode 100644
index 0000000000..7d1de8a233
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt
@@ -0,0 +1,223 @@
+/*
+ * 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 androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.configuration.validators.EmailValidator
+import com.firebase.ui.auth.ui.components.AuthTextField
+import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ResetPasswordUI(
+ modifier: Modifier = Modifier,
+ configuration: AuthUIConfiguration,
+ isLoading: Boolean,
+ email: String,
+ resetLinkSent: Boolean,
+ onEmailChange: (String) -> Unit,
+ onSendResetLink: () -> Unit,
+ onGoToSignIn: () -> Unit,
+ onNavigateBack: (() -> Unit)? = null,
+) {
+
+ val context = LocalContext.current
+ val stringProvider = LocalAuthUIStringProvider.current
+ val emailValidator = remember {
+ EmailValidator(stringProvider)
+ }
+
+ val isFormValid = remember(email) {
+ derivedStateOf { emailValidator.validate(email) }
+ }
+
+ val isDialogVisible = remember(resetLinkSent) { mutableStateOf(resetLinkSent) }
+
+ if (isDialogVisible.value) {
+ AlertDialog(
+ title = {
+ Text(
+ text = stringProvider.recoverPasswordLinkSentDialogTitle,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ },
+ text = {
+ Text(
+ text = stringProvider.recoverPasswordLinkSentDialogBody(email),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Start
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onGoToSignIn()
+ isDialogVisible.value = false
+ }
+ ) {
+ Text(stringProvider.dismissAction)
+ }
+ },
+ onDismissRequest = {
+ isDialogVisible.value = false
+ },
+ )
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(stringProvider.recoverPasswordPageTitle)
+ },
+ navigationIcon = {
+ if (onNavigateBack != null) {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringProvider.backAction
+ )
+ }
+ }
+ },
+ colors = AuthUITheme.resolvedTopAppBarColors
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ AuthTextField(
+ value = email,
+ validator = emailValidator,
+ enabled = !isLoading,
+ label = {
+ Text(stringProvider.emailHint)
+ },
+ onValueChange = { text ->
+ onEmailChange(text)
+ }
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier
+ .align(Alignment.End),
+ ) {
+ Button(
+ onClick = {
+ onGoToSignIn()
+ },
+ enabled = !isLoading,
+ ) {
+ Text(stringProvider.signInDefault.uppercase())
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Button(
+ onClick = {
+ onSendResetLink()
+ },
+ enabled = !isLoading && isFormValid.value,
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(16.dp)
+ )
+ } else {
+ Text(stringProvider.sendButtonText.uppercase())
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ TermsAndPrivacyForm(
+ modifier = Modifier.align(Alignment.End),
+ tosUrl = configuration.tosUrl,
+ ppUrl = configuration.privacyPolicyUrl,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewResetPasswordUI() {
+ val applicationContext = LocalContext.current
+ val provider = AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isEmailLinkSignInEnabled = false,
+ isEmailLinkForceSameDeviceEnabled = true,
+ emailLinkActionCodeSettings = null,
+ isNewAccountsAllowed = true,
+ minimumPasswordLength = 8,
+ passwordValidationRules = listOf()
+ )
+
+ AuthUITheme {
+ ResetPasswordUI(
+ configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ tosUrl = ""
+ privacyPolicyUrl = ""
+ },
+ email = "",
+ isLoading = false,
+ resetLinkSent = true,
+ onEmailChange = { email -> },
+ onSendResetLink = {},
+ onGoToSignIn = {},
+ )
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt
new file mode 100644
index 0000000000..f2ec55fa36
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt
@@ -0,0 +1,273 @@
+/*
+ * 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 androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.configuration.validators.EmailValidator
+import com.firebase.ui.auth.ui.components.AuthTextField
+import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm
+import com.google.firebase.auth.actionCodeSettings
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SignInEmailLinkUI(
+ modifier: Modifier = Modifier,
+ configuration: AuthUIConfiguration,
+ isLoading: Boolean,
+ emailSignInLinkSent: Boolean,
+ email: String,
+ onEmailChange: (String) -> Unit,
+ onSignInWithEmailLink: () -> Unit,
+ onGoToSignIn: () -> Unit,
+ onGoToResetPassword: () -> Unit,
+ onNavigateBack: (() -> Unit)? = null,
+) {
+ val provider = configuration.providers.filterIsInstance().first()
+ val stringProvider = LocalAuthUIStringProvider.current
+ val emailValidator = remember { EmailValidator(stringProvider) }
+
+ val isFormValid = remember(email) {
+ derivedStateOf {
+ emailValidator.validate(email)
+ }
+ }
+
+ if (provider.isEmailLinkSignInEnabled) {
+ val isDialogVisible =
+ remember(emailSignInLinkSent) { mutableStateOf(emailSignInLinkSent) }
+
+ if (isDialogVisible.value) {
+ AlertDialog(
+ title = {
+ Text(
+ text = stringProvider.emailSignInLinkSentDialogTitle,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ },
+ text = {
+ Text(
+ text = stringProvider.emailSignInLinkSentDialogBody(email),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Start
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ isDialogVisible.value = false
+ }
+ ) {
+ Text(stringProvider.dismissAction)
+ }
+ },
+ onDismissRequest = {
+ isDialogVisible.value = false
+ },
+ )
+ }
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringProvider.signInDefault,
+ modifier = Modifier.semantics { heading() }
+ )
+ },
+ navigationIcon = {
+ if (onNavigateBack != null) {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringProvider.backAction
+ )
+ }
+ }
+ },
+ colors = AuthUITheme.resolvedTopAppBarColors
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ AuthTextField(
+ value = email,
+ validator = emailValidator,
+ enabled = !isLoading,
+ label = {
+ Text(stringProvider.emailHint)
+ },
+ onValueChange = { text ->
+ onEmailChange(text)
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ TextButton(
+ modifier = Modifier
+ .align(Alignment.Start),
+ onClick = {
+ onGoToResetPassword()
+ },
+ enabled = !isLoading,
+ contentPadding = PaddingValues.Zero
+ ) {
+ Text(
+ modifier = modifier,
+ text = stringProvider.troubleSigningIn,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ textDecoration = TextDecoration.Underline
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(
+ onClick = {
+ onSignInWithEmailLink()
+ },
+ modifier = Modifier.align(Alignment.End),
+ enabled = !isLoading && isFormValid.value,
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp)
+ )
+ } else {
+ Text(stringProvider.signInDefault.uppercase())
+ }
+ }
+
+ // Show toggle to go back to password mode
+ Spacer(modifier = Modifier.height(64.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ Text(
+ text = stringProvider.orContinueWith,
+ modifier = Modifier.padding(horizontal = 8.dp),
+ style = MaterialTheme.typography.bodySmall
+ )
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(
+ onClick = {
+ onGoToSignIn()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading
+ ) {
+ Text(stringProvider.signInWithPassword.uppercase())
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ TermsAndPrivacyForm(
+ modifier = Modifier.align(Alignment.End),
+ tosUrl = configuration.tosUrl,
+ ppUrl = configuration.privacyPolicyUrl,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewSignInEmailLinkUI() {
+ val applicationContext = LocalContext.current
+ val provider = AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isEmailLinkSignInEnabled = true,
+ isEmailLinkForceSameDeviceEnabled = true,
+ emailLinkActionCodeSettings = actionCodeSettings {
+ url = "https://fake-project-id.firebaseapp.com"
+ handleCodeInApp = true
+ setAndroidPackageName(
+ "fake.project.id",
+ true,
+ null
+ )
+ },
+ isNewAccountsAllowed = true,
+ minimumPasswordLength = 8,
+ passwordValidationRules = listOf()
+ )
+
+ AuthUITheme {
+ SignInEmailLinkUI(
+ configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ tosUrl = ""
+ privacyPolicyUrl = ""
+ },
+ email = "",
+ isLoading = false,
+ emailSignInLinkSent = false,
+ onEmailChange = { email -> },
+ onSignInWithEmailLink = {},
+ onGoToSignIn = {},
+ onGoToResetPassword = {},
+ )
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt
new file mode 100644
index 0000000000..4f290c699c
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt
@@ -0,0 +1,346 @@
+/*
+ * 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.util.Log
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TooltipAnchorPosition
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.configuration.validators.EmailValidator
+import com.firebase.ui.auth.configuration.validators.PasswordValidator
+import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException
+import com.firebase.ui.auth.credentialmanager.PasswordCredentialException
+import com.firebase.ui.auth.credentialmanager.PasswordCredentialHandler
+import com.firebase.ui.auth.credentialmanager.PasswordCredentialNotFoundException
+import com.firebase.ui.auth.ui.components.AuthTextField
+import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController
+import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SignInUI(
+ modifier: Modifier = Modifier,
+ configuration: AuthUIConfiguration,
+ isLoading: Boolean,
+ emailSignInLinkSent: Boolean,
+ email: String,
+ password: String,
+ onEmailChange: (String) -> Unit,
+ onPasswordChange: (String) -> Unit,
+ onRetrievedCredential: (Pair) -> Unit,
+ onSignInClick: () -> Unit,
+ onGoToSignUp: () -> Unit,
+ onGoToResetPassword: () -> Unit,
+ onGoToEmailLinkSignIn: () -> Unit,
+ onNavigateBack: (() -> Unit)? = null,
+) {
+ val context = LocalContext.current
+ val provider = configuration.providers.filterIsInstance().first()
+ val stringProvider = LocalAuthUIStringProvider.current
+ val emailValidator = remember { EmailValidator(stringProvider) }
+ val passwordValidator = remember {
+ PasswordValidator(stringProvider = stringProvider, rules = emptyList())
+ }
+
+ val isFormValid = remember(email, password) {
+ derivedStateOf {
+ listOf(
+ emailValidator.validate(email),
+ passwordValidator.validate(password)
+ ).all { it }
+ }
+ }
+
+ // Retrieve saved credentials when in SignIn mode
+ val credentialRetrievalAttempted = remember { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) {
+ if (configuration.isCredentialManagerEnabled &&
+ !credentialRetrievalAttempted.value &&
+ PasswordCredentialHandler.hasSavedCredentials(context)) {
+ credentialRetrievalAttempted.value = true
+
+ try {
+ val credentialHandler = PasswordCredentialHandler(context)
+ val credential = credentialHandler.getPassword()
+
+ Log.d("EmailAuthScreen", "Retrieved credential for: ${credential.username}")
+
+ // Auto-fill the email and password fields
+ onEmailChange(credential.username)
+ onPasswordChange(credential.password)
+
+ emailValidator.validate(credential.username)
+ passwordValidator.validate(credential.password)
+
+ // Store retrieved credential to compare later
+ onRetrievedCredential(Pair(credential.username, credential.password))
+
+ onSignInClick()
+ } catch (e: PasswordCredentialNotFoundException) {
+ Log.d("EmailAuthScreen", "No saved credentials found")
+ // No credentials saved - user will enter manually
+ } catch (e: PasswordCredentialCancelledException) {
+ Log.d("EmailAuthScreen", "User cancelled credential selection")
+ // User cancelled - let them enter manually
+ } catch (e: PasswordCredentialException) {
+ Log.w("EmailAuthScreen", "Failed to retrieve credentials", e)
+ // Failed to retrieve - let them enter manually
+ }
+ }
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringProvider.signInDefault,
+ modifier = Modifier.semantics { heading() }
+ )
+ },
+ navigationIcon = {
+ if (onNavigateBack != null) {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringProvider.backAction
+ )
+ }
+ }
+ },
+ colors = AuthUITheme.resolvedTopAppBarColors
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ AuthTextField(
+ value = email,
+ validator = emailValidator,
+ enabled = !isLoading,
+ label = {
+ Text(stringProvider.emailHint)
+ },
+ onValueChange = { text ->
+ onEmailChange(text)
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ AuthTextField(
+ value = password,
+ validator = passwordValidator,
+ enabled = !isLoading,
+ isSecureTextField = true,
+ label = {
+ Text(stringProvider.passwordHint)
+ },
+ onValueChange = { text ->
+ onPasswordChange(text)
+ }
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ TextButton(
+ modifier = Modifier
+ .align(Alignment.Start),
+ onClick = {
+ onGoToResetPassword()
+ },
+ enabled = !isLoading,
+ contentPadding = PaddingValues.Zero
+ ) {
+ Text(
+ modifier = modifier,
+ text = stringProvider.troubleSigningIn,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ textDecoration = TextDecoration.Underline
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier
+ .align(Alignment.End),
+ ) {
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
+ TooltipAnchorPosition.Above
+ ),
+ tooltip = {
+ PlainTooltip {
+ Text(stringProvider.newAccountsDisabledTooltip)
+ }
+ },
+ state = rememberTooltipState(
+ initialIsVisible = !provider.isNewAccountsAllowed
+ )
+ ) {
+ Button(
+ onClick = {
+ onGoToSignUp()
+ },
+ enabled = provider.isNewAccountsAllowed && !isLoading,
+ ) {
+ Text(stringProvider.signupPageTitle.uppercase())
+ }
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Button(
+ onClick = {
+ onSignInClick()
+ },
+ enabled = !isLoading && isFormValid.value,
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(16.dp)
+ )
+ } else {
+ Text(stringProvider.signInDefault.uppercase())
+ }
+ }
+ }
+
+ // Show toggle to email link sign-in
+ if (provider.isEmailLinkSignInEnabled) {
+ Spacer(modifier = Modifier.height(64.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ Text(
+ text = stringProvider.orContinueWith,
+ modifier = Modifier.padding(horizontal = 8.dp),
+ style = MaterialTheme.typography.bodySmall
+ )
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(
+ onClick = {
+ onGoToEmailLinkSignIn()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading
+ ) {
+ Text(stringProvider.signInWithEmailLink.uppercase())
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ TermsAndPrivacyForm(
+ modifier = Modifier.align(Alignment.End),
+ tosUrl = configuration.tosUrl,
+ ppUrl = configuration.privacyPolicyUrl,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewSignInUI() {
+ val applicationContext = LocalContext.current
+ val provider = AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isEmailLinkSignInEnabled = false,
+ isEmailLinkForceSameDeviceEnabled = true,
+ emailLinkActionCodeSettings = null,
+ isNewAccountsAllowed = false,
+ minimumPasswordLength = 8,
+ passwordValidationRules = listOf()
+ )
+ val stringProvider = DefaultAuthUIStringProvider(applicationContext)
+
+ AuthUITheme {
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides stringProvider
+ ) {
+ SignInUI(
+ configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ tosUrl = ""
+ privacyPolicyUrl = ""
+ },
+ email = "",
+ password = "",
+ isLoading = false,
+ emailSignInLinkSent = false,
+ onEmailChange = { email -> },
+ onPasswordChange = { password -> },
+ onRetrievedCredential = { credential -> },
+ onSignInClick = {},
+ onGoToSignUp = {},
+ onGoToResetPassword = {},
+ onGoToEmailLinkSignIn = {},
+ )
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt
new file mode 100644
index 0000000000..7b6ba03c5e
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt
@@ -0,0 +1,258 @@
+/*
+ * 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 androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.configuration.validators.EmailValidator
+import com.firebase.ui.auth.configuration.validators.GeneralFieldValidator
+import com.firebase.ui.auth.configuration.validators.PasswordValidator
+import com.firebase.ui.auth.ui.components.AuthTextField
+import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SignUpUI(
+ modifier: Modifier = Modifier,
+ configuration: AuthUIConfiguration,
+ isLoading: Boolean,
+ displayName: String,
+ email: String,
+ password: String,
+ confirmPassword: String,
+ onDisplayNameChange: (String) -> Unit,
+ onEmailChange: (String) -> Unit,
+ onPasswordChange: (String) -> Unit,
+ onConfirmPasswordChange: (String) -> Unit,
+ onGoToSignIn: () -> Unit,
+ onSignUpClick: () -> Unit,
+ onNavigateBack: (() -> Unit)? = null,
+) {
+ val provider = configuration.providers.filterIsInstance().first()
+ val context = LocalContext.current
+ val stringProvider = LocalAuthUIStringProvider.current
+ val displayNameValidator = remember { GeneralFieldValidator(stringProvider) }
+ val emailValidator = remember { EmailValidator(stringProvider) }
+ val passwordValidator = remember {
+ PasswordValidator(
+ stringProvider = stringProvider,
+ rules = provider.passwordValidationRules
+ )
+ }
+ val confirmPasswordValidator = remember(password) {
+ GeneralFieldValidator(
+ stringProvider = stringProvider,
+ isValid = { value ->
+ value == password
+ },
+ customMessage = stringProvider.passwordsDoNotMatch
+ )
+ }
+
+ val isFormValid = remember(displayName, email, password, confirmPassword) {
+ derivedStateOf {
+ listOf(
+ !provider.isDisplayNameRequired || displayNameValidator.validate(displayName),
+ emailValidator.validate(email),
+ passwordValidator.validate(password),
+ confirmPasswordValidator.validate(confirmPassword)
+ ).all { it }
+ }
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(stringProvider.signupPageTitle)
+ },
+ navigationIcon = {
+ if (onNavigateBack != null) {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringProvider.backAction
+ )
+ }
+ }
+ },
+ colors = AuthUITheme.resolvedTopAppBarColors
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ if (provider.isDisplayNameRequired) {
+ AuthTextField(
+ value = displayName,
+ validator = displayNameValidator,
+ enabled = !isLoading,
+ label = {
+ Text(stringProvider.nameHint)
+ },
+ onValueChange = { text ->
+ onDisplayNameChange(text)
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ AuthTextField(
+ value = email,
+ validator = emailValidator,
+ enabled = !isLoading,
+ label = {
+ Text(stringProvider.emailHint)
+ },
+ onValueChange = { text ->
+ onEmailChange(text)
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ AuthTextField(
+ value = password,
+ validator = passwordValidator,
+ enabled = !isLoading,
+ isSecureTextField = true,
+ label = {
+ Text(stringProvider.passwordHint)
+ },
+ onValueChange = { text ->
+ onPasswordChange(text)
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ AuthTextField(
+ value = confirmPassword,
+ validator = confirmPasswordValidator,
+ enabled = !isLoading,
+ isSecureTextField = true,
+ label = {
+ Text(stringProvider.confirmPasswordHint)
+ },
+ onValueChange = { text ->
+ onConfirmPasswordChange(text)
+ }
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier
+ .align(Alignment.End),
+ ) {
+ Button(
+ onClick = {
+ onGoToSignIn()
+ },
+ enabled = !isLoading,
+ ) {
+ Text(stringProvider.signInDefault.uppercase())
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Button(
+ onClick = {
+ onSignUpClick()
+ },
+ enabled = !isLoading && isFormValid.value,
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(16.dp)
+ )
+ } else {
+ Text(stringProvider.signupPageTitle.uppercase())
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ TermsAndPrivacyForm(
+ modifier = Modifier.align(Alignment.End),
+ tosUrl = configuration.tosUrl,
+ ppUrl = configuration.privacyPolicyUrl,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewSignUpUI() {
+ val applicationContext = LocalContext.current
+ val provider = AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isEmailLinkSignInEnabled = false,
+ isEmailLinkForceSameDeviceEnabled = true,
+ emailLinkActionCodeSettings = null,
+ isNewAccountsAllowed = true,
+ minimumPasswordLength = 8,
+ passwordValidationRules = listOf()
+ )
+
+ AuthUITheme {
+ SignUpUI(
+ configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ tosUrl = ""
+ privacyPolicyUrl = ""
+ },
+ isLoading = false,
+ displayName = "",
+ email = "",
+ password = "",
+ confirmPassword = "",
+ onDisplayNameChange = { name -> },
+ onEmailChange = { email -> },
+ onPasswordChange = { password -> },
+ onConfirmPasswordChange = { confirmPassword -> },
+ onSignUpClick = {},
+ onGoToSignIn = {}
+ )
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt
new file mode 100644
index 0000000000..2b9ffc13d1
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt
@@ -0,0 +1,193 @@
+/*
+ * 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.phone
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.configuration.validators.PhoneNumberValidator
+import com.firebase.ui.auth.data.CountryData
+import com.firebase.ui.auth.ui.components.AuthTextField
+import com.firebase.ui.auth.ui.components.CountrySelector
+import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm
+import com.firebase.ui.auth.util.CountryUtils
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EnterPhoneNumberUI(
+ modifier: Modifier = Modifier,
+ configuration: AuthUIConfiguration,
+ isLoading: Boolean,
+ phoneNumber: String,
+ selectedCountry: CountryData,
+ onPhoneNumberChange: (String) -> Unit,
+ onCountrySelected: (CountryData) -> Unit,
+ onSendCodeClick: () -> Unit,
+ title: String? = null,
+ onNavigateBack: (() -> Unit)? = null,
+) {
+ val context = LocalContext.current
+ val provider = configuration.providers.filterIsInstance().first()
+ val stringProvider = LocalAuthUIStringProvider.current
+ val phoneNumberValidator = remember(selectedCountry) {
+ PhoneNumberValidator(stringProvider, selectedCountry)
+ }
+
+ val isFormValid = remember(selectedCountry, phoneNumber) {
+ derivedStateOf {
+ phoneNumberValidator.validate(phoneNumber)
+ }
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(title ?: stringProvider.signInWithPhone)
+ },
+ navigationIcon = {
+ if (onNavigateBack != null) {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringProvider.backAction
+ )
+ }
+ }
+ },
+ colors = AuthUITheme.resolvedTopAppBarColors
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Text(stringProvider.enterPhoneNumberTitle)
+ Spacer(modifier = Modifier.height(16.dp))
+ AuthTextField(
+ value = phoneNumber,
+ validator = phoneNumberValidator,
+ enabled = !isLoading,
+ label = {
+ Text(stringProvider.phoneNumberHint)
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Phone
+ ),
+ leadingIcon = {
+ CountrySelector(
+ selectedCountry = selectedCountry,
+ onCountrySelected = onCountrySelected,
+ enabled = !isLoading,
+ allowedCountries = provider.allowedCountries?.toSet()
+ )
+ },
+ onValueChange = {
+ onPhoneNumberChange(it)
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier
+ .align(Alignment.End),
+ ) {
+ Button(
+ onClick = onSendCodeClick,
+ enabled = !isLoading && isFormValid.value,
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(16.dp)
+ )
+ } else {
+ Text(stringProvider.sendVerificationCode.uppercase())
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ TermsAndPrivacyForm(
+ modifier = Modifier.align(Alignment.End),
+ tosUrl = configuration.tosUrl,
+ ppUrl = configuration.privacyPolicyUrl,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewEnterPhoneNumberUI() {
+ val applicationContext = LocalContext.current
+ val provider = AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null,
+ timeout = 60L,
+ isInstantVerificationEnabled = true
+ )
+
+ AuthUITheme {
+ EnterPhoneNumberUI(
+ configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ tosUrl = ""
+ privacyPolicyUrl = ""
+ },
+ isLoading = false,
+ phoneNumber = "",
+ selectedCountry = CountryUtils.getDefaultCountry(),
+ onPhoneNumberChange = {},
+ onCountrySelected = {},
+ onSendCodeClick = {},
+ )
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt
new file mode 100644
index 0000000000..be90bbf0b2
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt
@@ -0,0 +1,224 @@
+/*
+ * 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.phone
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.configuration.validators.VerificationCodeValidator
+import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm
+import com.firebase.ui.auth.ui.components.VerificationCodeInputField
+import java.util.Locale
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EnterVerificationCodeUI(
+ modifier: Modifier = Modifier,
+ configuration: AuthUIConfiguration,
+ isLoading: Boolean,
+ verificationCode: String,
+ fullPhoneNumber: String,
+ resendTimer: Int,
+ onVerificationCodeChange: (String) -> Unit,
+ onVerifyCodeClick: () -> Unit,
+ onResendCodeClick: () -> Unit,
+ onChangeNumberClick: () -> Unit,
+ title: String? = null,
+ onNavigateBack: (() -> Unit)? = null,
+) {
+ val context = LocalContext.current
+ val stringProvider = LocalAuthUIStringProvider.current
+ val verificationCodeValidator = remember {
+ VerificationCodeValidator(stringProvider)
+ }
+
+ val isFormValid = remember(verificationCode) {
+ derivedStateOf {
+ verificationCodeValidator.validate(verificationCode)
+ }
+ }
+
+ val resendEnabled = resendTimer == 0 && !isLoading
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(title ?: stringProvider.verifyPhoneNumber)
+ },
+ navigationIcon = {
+ if (onNavigateBack != null) {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringProvider.backAction
+ )
+ }
+ }
+ },
+ colors = AuthUITheme.resolvedTopAppBarColors
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Text(
+ text = stringProvider.enterVerificationCodeTitle(fullPhoneNumber),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ modifier = Modifier.align(Alignment.Start),
+ onClick = onChangeNumberClick,
+ enabled = !isLoading,
+ contentPadding = PaddingValues.Zero
+ ) {
+ Text(
+ text = stringProvider.changePhoneNumber,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ textDecoration = TextDecoration.Underline
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+
+ VerificationCodeInputField(
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ validator = verificationCodeValidator,
+ onCodeChange = onVerificationCodeChange
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ modifier = Modifier.align(Alignment.Start),
+ onClick = onResendCodeClick,
+ enabled = resendEnabled,
+ contentPadding = PaddingValues.Zero
+ ) {
+ Text(
+ text = if (resendTimer > 0) {
+ val minutes = resendTimer / 60
+ val seconds = resendTimer % 60
+ val timeFormatted =
+ "$minutes:${String.format(Locale.ROOT, "%02d", seconds)}"
+ stringProvider.resendCodeTimer(timeFormatted)
+ } else {
+ stringProvider.resendCode
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ textDecoration = if (resendEnabled) TextDecoration.Underline else TextDecoration.None
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier
+ .align(Alignment.End),
+ ) {
+ Button(
+ onClick = onVerifyCodeClick,
+ enabled = !isLoading && isFormValid.value,
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(16.dp)
+ )
+ } else {
+ Text(stringProvider.verifyPhoneNumber.uppercase())
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ TermsAndPrivacyForm(
+ modifier = Modifier.align(Alignment.End),
+ tosUrl = configuration.tosUrl,
+ ppUrl = configuration.privacyPolicyUrl,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewEnterVerificationCodeUI() {
+ val applicationContext = LocalContext.current
+ val provider = AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null,
+ timeout = 60L,
+ isInstantVerificationEnabled = true
+ )
+
+ AuthUITheme {
+ EnterVerificationCodeUI(
+ configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ tosUrl = ""
+ privacyPolicyUrl = ""
+ },
+ isLoading = false,
+ verificationCode = "",
+ fullPhoneNumber = "+1234567890",
+ resendTimer = 30,
+ onVerificationCodeChange = {},
+ onVerifyCodeClick = {},
+ onResendCodeClick = {},
+ onChangeNumberClick = {},
+ )
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt
new file mode 100644
index 0000000000..e317c639d1
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt
@@ -0,0 +1,389 @@
+/*
+ * 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.phone
+
+import android.content.Context
+import android.util.Log
+import androidx.activity.compose.LocalActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Modifier
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.AuthState
+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.configuration.auth_provider.signInWithPhoneAuthCredential
+import com.firebase.ui.auth.configuration.auth_provider.submitVerificationCode
+import com.firebase.ui.auth.configuration.auth_provider.verifyPhoneNumber
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.data.CountryData
+import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController
+import com.firebase.ui.auth.util.CountryUtils
+import com.google.firebase.auth.AuthResult
+import com.google.firebase.auth.PhoneAuthProvider
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+enum class PhoneAuthStep {
+ /**
+ * An enum representing a view requiring a phone number which needs to be entered.
+ */
+ EnterPhoneNumber,
+
+ /**
+ * An enum representing a view requiring a phone number verification code which needs to
+ * be entered.
+ */
+ EnterVerificationCode
+}
+
+/**
+ * A class passed to the content slot, containing all the necessary information to render a custom
+ * UI for every step of the phone authentication process.
+ *
+ * @param step An enum representing the current step in the flow. Use a when expression on this
+ * to render the correct UI.
+ * @param isLoading true when an asynchronous operation (like sending or verifying a code) is in
+ * progress.
+ * @param error An optional error message to display to the user.
+ * @param phoneNumber (Step: [PhoneAuthStep.EnterPhoneNumber]) The current value of the phone
+ * number input field.
+ * @param onPhoneNumberChange (Step: [PhoneAuthStep.EnterPhoneNumber]) A callback to be invoked
+ * when the phone number input changes.
+ * @param selectedCountry (Step: [PhoneAuthStep.EnterPhoneNumber]) The currently selected country
+ * object, containing its name, dial code, and flag.
+ * @param onCountrySelected (Step: [PhoneAuthStep.EnterPhoneNumber]) A callback to be invoked when
+ * the user selects a new country.
+ * @param onSendCodeClick (Step: [PhoneAuthStep.EnterPhoneNumber]) A callback to be invoked to
+ * send the verification code to the entered number.
+ * @param verificationCode (Step: [PhoneAuthStep.EnterVerificationCode]) The current value of the
+ * 6-digit code input field.
+ * @param onVerificationCodeChange (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to be
+ * invoked when the verification code input changes.
+ * @param onVerifyCodeClick (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to be invoked
+ * to submit the verification code.
+ * @param fullPhoneNumber (Step: [PhoneAuthStep.EnterVerificationCode]) The formatted full phone
+ * number to display for user confirmation.
+ * @param onResendCodeClick (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to be invoked
+ * when the user clicks "Resend Code".
+ * @param resendTimer (Step: [PhoneAuthStep.EnterVerificationCode]) The number of seconds remaining
+ * before the "Resend" action is available.
+ * @param onChangeNumberClick (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to navigate
+ * back to the [PhoneAuthStep.EnterPhoneNumber] step.
+ */
+class PhoneAuthContentState(
+ val step: PhoneAuthStep,
+ val isLoading: Boolean = false,
+ val error: String? = null,
+ val phoneNumber: String,
+ val onPhoneNumberChange: (String) -> Unit,
+ val selectedCountry: CountryData,
+ val onCountrySelected: (CountryData) -> Unit,
+ val onSendCodeClick: () -> Unit,
+ val verificationCode: String,
+ val onVerificationCodeChange: (String) -> Unit,
+ val onVerifyCodeClick: () -> Unit,
+ val fullPhoneNumber: String,
+ val onResendCodeClick: () -> Unit,
+ val resendTimer: Int = 0,
+ val onChangeNumberClick: () -> Unit,
+)
+
+/**
+ * A stateful composable that manages the complete logic for phone number authentication. It handles
+ * the multi-step flow of sending and verifying an SMS code, exposing the state for each step to a
+ * custom UI via a trailing lambda (slot). This component renders no UI itself.
+ *
+ * @param context The Android context.
+ * @param configuration The authentication UI configuration containing the phone provider settings.
+ * @param authUI The FirebaseAuthUI instance used for authentication operations.
+ * @param onSuccess Callback invoked when authentication succeeds with the [AuthResult].
+ * @param onError Callback invoked when an authentication error occurs.
+ * @param onCancel Callback invoked when the user cancels the authentication flow.
+ * @param modifier Optional [Modifier] for the composable.
+ * @param content A composable lambda that receives [PhoneAuthContentState] to render the UI for
+ * each step. If null, no UI will be rendered.
+ */
+@Composable
+fun PhoneAuthScreen(
+ context: Context,
+ configuration: AuthUIConfiguration,
+ authUI: FirebaseAuthUI,
+ onSuccess: (AuthResult) -> Unit,
+ onError: (AuthException) -> Unit,
+ onCancel: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable ((PhoneAuthContentState) -> Unit)? = null,
+) {
+ val activity = LocalActivity.current
+ val provider = configuration.providers.filterIsInstance().first()
+ val stringProvider = LocalAuthUIStringProvider.current
+ val dialogController = LocalTopLevelDialogController.current
+ val coroutineScope = rememberCoroutineScope()
+
+ val step = rememberSaveable { mutableStateOf(PhoneAuthStep.EnterPhoneNumber) }
+ val phoneNumberValue = rememberSaveable { mutableStateOf(provider.defaultNumber ?: "") }
+ val verificationCodeValue = rememberSaveable { mutableStateOf("") }
+ val selectedCountry = remember {
+ mutableStateOf(
+ provider.defaultCountryCode?.let { code ->
+ CountryUtils.findByCountryCode(code)
+ } ?: CountryUtils.getDefaultCountry()
+ )
+ }
+ val fullPhoneNumber = remember(selectedCountry.value, phoneNumberValue.value) {
+ CountryUtils.formatPhoneNumber(selectedCountry.value.dialCode, phoneNumberValue.value)
+ }
+ val verificationId = rememberSaveable { mutableStateOf(null) }
+ val forceResendingToken =
+ rememberSaveable { mutableStateOf(null) }
+ val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) }
+ val pendingVerificationPhoneNumber = remember { mutableStateOf(null) }
+ val verificationStartTime = remember { mutableStateOf(null) }
+
+ val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle)
+ val isLoading = authState is AuthState.Loading
+ val errorMessage =
+ if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null
+
+ // Handle resend timer countdown
+ LaunchedEffect(resendTimerSeconds.intValue) {
+ if (resendTimerSeconds.intValue > 0) {
+ delay(1000)
+ resendTimerSeconds.intValue--
+ }
+ }
+
+ LaunchedEffect(authState) {
+ Log.d("PhoneAuthScreen", "Current state: $authState")
+ when (val state = authState) {
+ is AuthState.Success -> {
+ state.result?.let { result ->
+ onSuccess(result)
+ }
+ }
+
+ is AuthState.PhoneNumberVerificationRequired -> {
+ verificationId.value = state.verificationId
+ forceResendingToken.value = state.forceResendingToken
+ step.value = PhoneAuthStep.EnterVerificationCode
+ resendTimerSeconds.intValue = provider.timeout.toInt() // Start 60-second countdown
+ }
+
+ is AuthState.SMSAutoVerified -> {
+ // Auto-verification succeeded, sign in with the credential
+ // and clear pending verification tracking
+ pendingVerificationPhoneNumber.value = null
+ verificationStartTime.value = null
+
+ coroutineScope.launch {
+ try {
+ authUI.signInWithPhoneAuthCredential(
+ context = context,
+ config = configuration,
+ credential = state.credential
+ )
+ } catch (e: Exception) {
+ // Error will be handled by authState flow
+ }
+ }
+ }
+
+ is AuthState.Error -> {
+ val exception = AuthException.from(state.exception, stringProvider)
+ onError(exception)
+
+ // Show dialog for phone-specific errors using top-level controller
+ dialogController?.showErrorDialog(
+ exception = exception,
+ onRetry = { ex ->
+ when (ex) {
+ is AuthException.InvalidCredentialsException -> {
+ // User can retry with corrected code or phone number
+ }
+ else -> Unit
+ }
+ },
+ onDismiss = {
+ // Dialog dismissed
+ }
+ )
+ }
+
+ is AuthState.Cancelled -> {
+ onCancel()
+ }
+
+ else -> Unit
+ }
+ }
+
+ val state = PhoneAuthContentState(
+ step = step.value,
+ isLoading = isLoading,
+ error = errorMessage,
+ phoneNumber = phoneNumberValue.value,
+ onPhoneNumberChange = { number ->
+ phoneNumberValue.value = number
+ },
+ selectedCountry = selectedCountry.value,
+ onCountrySelected = { country ->
+ selectedCountry.value = country
+ },
+ onSendCodeClick = {
+ coroutineScope.launch {
+ try {
+ val currentTime = System.currentTimeMillis()
+ val timeoutMs = provider.timeout * 1000
+ val timeSinceLastVerification = verificationStartTime.value?.let {
+ currentTime - it
+ } ?: Long.MAX_VALUE
+
+ // Check if the same phone number is being verified again within the cooldown period
+ val storedNumber = pendingVerificationPhoneNumber.value
+ val isSameNumber = storedNumber != null && fullPhoneNumber == storedNumber
+
+ // Check cooldown: same number and still within timeout period
+ if (isSameNumber && timeSinceLastVerification < timeoutMs) {
+ // Calculate remaining cooldown time in seconds
+ val remainingCooldownSeconds = ((timeoutMs - timeSinceLastVerification) / 1000).coerceAtLeast(1)
+ val cooldownException = AuthException.PhoneVerificationCooldownException(
+ message = "Please wait ${remainingCooldownSeconds} second${if (remainingCooldownSeconds != 1L) "s" else ""} before verifying the same phone number again. The cooldown period is ${provider.timeout} seconds.",
+ cooldownSeconds = remainingCooldownSeconds
+ )
+ // Update auth state to show the error
+ authUI.updateAuthState(AuthState.Error(cooldownException))
+ throw cooldownException
+ }
+
+ // Track the phone number and start time for cooldown checking
+ pendingVerificationPhoneNumber.value = fullPhoneNumber
+ verificationStartTime.value = currentTime
+
+ authUI.verifyPhoneNumber(
+ provider = provider,
+ activity = activity,
+ phoneNumber = fullPhoneNumber,
+ config = configuration,
+ )
+ } catch (e: Exception) {
+ // Error will be handled by authState flow
+ }
+ }
+ },
+ verificationCode = verificationCodeValue.value,
+ onVerificationCodeChange = { code ->
+ verificationCodeValue.value = code
+ },
+ onVerifyCodeClick = {
+ coroutineScope.launch {
+ try {
+ verificationId.value?.let { id ->
+ authUI.submitVerificationCode(
+ context = context,
+ config = configuration,
+ verificationId = id,
+ code = verificationCodeValue.value
+ )
+ }
+ } catch (e: Exception) {
+ // Error will be handled by authState flow
+ }
+ }
+ },
+ fullPhoneNumber = fullPhoneNumber,
+ onResendCodeClick = {
+ if (resendTimerSeconds.intValue == 0) {
+ coroutineScope.launch {
+ try {
+ authUI.verifyPhoneNumber(
+ activity = activity,
+ provider = provider,
+ phoneNumber = fullPhoneNumber,
+ config = configuration,
+ forceResendingToken = forceResendingToken.value,
+ )
+ resendTimerSeconds.intValue = provider.timeout.toInt() // Restart timer
+ } catch (e: Exception) {
+ // Error will be handled by authState flow
+ }
+ }
+ }
+ },
+ resendTimer = resendTimerSeconds.intValue,
+ onChangeNumberClick = {
+ step.value = PhoneAuthStep.EnterPhoneNumber
+ verificationCodeValue.value = ""
+ verificationId.value = null
+ forceResendingToken.value = null
+ resendTimerSeconds.intValue = 0
+ }
+ )
+
+ if (content != null) {
+ content(state)
+ } else {
+ DefaultPhoneAuthContent(
+ configuration = configuration,
+ state = state,
+ onCancel = onCancel
+ )
+ }
+}
+
+@Composable
+private fun DefaultPhoneAuthContent(
+ configuration: AuthUIConfiguration,
+ state: PhoneAuthContentState,
+ onCancel: () -> Unit,
+) {
+ when (state.step) {
+ PhoneAuthStep.EnterPhoneNumber -> {
+ EnterPhoneNumberUI(
+ configuration = configuration,
+ isLoading = state.isLoading,
+ phoneNumber = state.phoneNumber,
+ selectedCountry = state.selectedCountry,
+ onPhoneNumberChange = state.onPhoneNumberChange,
+ onCountrySelected = state.onCountrySelected,
+ onSendCodeClick = state.onSendCodeClick,
+ onNavigateBack = onCancel
+ )
+ }
+
+ PhoneAuthStep.EnterVerificationCode -> {
+ EnterVerificationCodeUI(
+ configuration = configuration,
+ isLoading = state.isLoading,
+ verificationCode = state.verificationCode,
+ fullPhoneNumber = state.fullPhoneNumber,
+ resendTimer = state.resendTimer,
+ onVerificationCodeChange = state.onVerificationCodeChange,
+ onVerifyCodeClick = state.onVerifyCodeClick,
+ onResendCodeClick = state.onResendCodeClick,
+ onChangeNumberClick = state.onChangeNumberClick,
+ onNavigateBack = onCancel
+ )
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/util/AbstractActivityLifecycleCallbacks.java b/auth/src/main/java/com/firebase/ui/auth/util/AbstractActivityLifecycleCallbacks.java
deleted file mode 100644
index a4e1a45a8f..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/util/AbstractActivityLifecycleCallbacks.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright 2016 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.util;
-
-import android.app.Activity;
-import android.app.Application;
-import android.os.Bundle;
-
-/**
- * Implementation of {@link android.app.Application.ActivityLifecycleCallbacks} which
- * does nothing in response to each callback; a useful base class for implementors that only
- * care about a subset of the callbacks.
- */
-public abstract class AbstractActivityLifecycleCallbacks
- implements Application.ActivityLifecycleCallbacks {
-
- @Override
- public void onActivityCreated(Activity activity, Bundle bundle) {}
-
- @Override
- public void onActivityStarted(Activity activity) {}
-
- @Override
- public void onActivityResumed(Activity activity) {}
-
- @Override
- public void onActivityPaused(Activity activity) {}
-
- @Override
- public void onActivityStopped(Activity activity) {}
-
- @Override
- public void onActivitySaveInstanceState(Activity activity, Bundle bundle) {}
-
- @Override
- public void onActivityDestroyed(Activity activity) {}
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/util/ContinueUrlBuilder.kt b/auth/src/main/java/com/firebase/ui/auth/util/ContinueUrlBuilder.kt
new file mode 100644
index 0000000000..80efbd8bd4
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/util/ContinueUrlBuilder.kt
@@ -0,0 +1,72 @@
+/*
+ * 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.util
+
+import androidx.annotation.RestrictTo
+import com.firebase.ui.auth.util.EmailLinkParser.LinkParameters.ANONYMOUS_USER_ID_IDENTIFIER
+import com.firebase.ui.auth.util.EmailLinkParser.LinkParameters.FORCE_SAME_DEVICE_IDENTIFIER
+import com.firebase.ui.auth.util.EmailLinkParser.LinkParameters.PROVIDER_ID_IDENTIFIER
+import com.firebase.ui.auth.util.EmailLinkParser.LinkParameters.SESSION_IDENTIFIER
+
+/**
+ * Builder for constructing continue URLs with embedded session and authentication parameters.
+ * Used in email link sign-in flows to pass state between devices.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+class ContinueUrlBuilder(url: String) {
+
+ private val continueUrl: StringBuilder
+
+ init {
+ require(url.isNotBlank()) { "URL cannot be empty" }
+ continueUrl = StringBuilder(url).append("?")
+ }
+
+ fun appendSessionId(sessionId: String): ContinueUrlBuilder {
+ addQueryParam(SESSION_IDENTIFIER, sessionId)
+ return this
+ }
+
+ fun appendAnonymousUserId(anonymousUserId: String): ContinueUrlBuilder {
+ addQueryParam(ANONYMOUS_USER_ID_IDENTIFIER, anonymousUserId)
+ return this
+ }
+
+ fun appendProviderId(providerId: String): ContinueUrlBuilder {
+ addQueryParam(PROVIDER_ID_IDENTIFIER, providerId)
+ return this
+ }
+
+ fun appendForceSameDeviceBit(forceSameDevice: Boolean): ContinueUrlBuilder {
+ val bit = if (forceSameDevice) "1" else "0"
+ addQueryParam(FORCE_SAME_DEVICE_IDENTIFIER, bit)
+ return this
+ }
+
+ private fun addQueryParam(key: String, value: String) {
+ if (value.isBlank()) return
+
+ val isFirstParam = continueUrl.last() == '?'
+ val mark = if (isFirstParam) "" else "&"
+ continueUrl.append("$mark$key=$value")
+ }
+
+ fun build(): String {
+ if (continueUrl.last() == '?') {
+ // No params added so we remove the '?'
+ continueUrl.setLength(continueUrl.length - 1)
+ }
+ return continueUrl.toString()
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/util/CountryUtils.kt b/auth/src/main/java/com/firebase/ui/auth/util/CountryUtils.kt
new file mode 100644
index 0000000000..a4a4a3cbee
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/util/CountryUtils.kt
@@ -0,0 +1,143 @@
+package com.firebase.ui.auth.util
+
+import com.firebase.ui.auth.data.ALL_COUNTRIES
+import com.firebase.ui.auth.data.CountryData
+import java.text.Normalizer
+import java.util.Locale
+
+/**
+ * Utility functions for searching and filtering countries.
+ */
+object CountryUtils {
+
+ // Lazy-initialized maps for fast lookups
+ private val countryCodeMap: Map by lazy {
+ ALL_COUNTRIES.associateBy { it.countryCode.uppercase() }
+ }
+
+ private val dialCodeMap: Map> by lazy {
+ ALL_COUNTRIES.groupBy { it.dialCode }
+ }
+
+ /**
+ * Finds a country by its ISO 3166-1 alpha-2 country code.
+ *
+ * @param countryCode The two-letter country code (e.g., "US", "GB").
+ * @return The CountryData or null if not found.
+ */
+ fun findByCountryCode(countryCode: String): CountryData? {
+ return countryCodeMap[countryCode.uppercase()]
+ }
+
+ /**
+ * Finds all countries with the given dial code.
+ *
+ * @param dialCode The international dialing code (e.g., "+1", "+44").
+ * @return List of countries with that dial code, or empty list if none found.
+ */
+ fun findByDialCode(dialCode: String): List {
+ return dialCodeMap[dialCode] ?: emptyList()
+ }
+
+ /**
+ * Searches for countries by name. Supports partial matching and diacritic-insensitive search.
+ *
+ * @param query The search query.
+ * @return List of countries matching the query, or empty list if none found.
+ */
+ fun searchByName(query: String): List {
+ val trimmedQuery = query.trim()
+ if (trimmedQuery.isEmpty()) return emptyList()
+
+ val normalizedQuery = normalizeString(trimmedQuery)
+
+ return ALL_COUNTRIES.filter { country ->
+ normalizeString(country.name).contains(normalizedQuery, ignoreCase = true)
+ }
+ }
+
+ /**
+ * Searches for countries by name, country code, or dial code.
+ * Supports partial matching and diacritic-insensitive search.
+ *
+ * @param query The search query (country name, country code, or dial code).
+ * @return List of countries matching the query, sorted by relevance.
+ */
+ fun search(query: String): List {
+ val trimmedQuery = query.trim()
+ if (trimmedQuery.isEmpty()) return emptyList()
+
+ val normalizedQuery = normalizeString(trimmedQuery)
+ val uppercaseQuery = trimmedQuery.uppercase()
+
+ return ALL_COUNTRIES.filter { country ->
+ // Match by country name (partial, case-insensitive, diacritic-insensitive)
+ normalizeString(country.name).contains(normalizedQuery, ignoreCase = true) ||
+ // Match by country code (partial, case-insensitive)
+ country.countryCode.uppercase().contains(uppercaseQuery) ||
+ // Match by dial code (partial)
+ country.dialCode.contains(trimmedQuery)
+ }.sortedWith(
+ compareBy(
+ // Prioritize exact matches first
+ { country ->
+ when {
+ country.countryCode.uppercase() == uppercaseQuery -> 0
+ country.dialCode == trimmedQuery -> 1
+ normalizeString(country.name) == normalizedQuery -> 2
+ else -> 3
+ }
+ },
+ // Then sort alphabetically by name
+ { country -> country.name }
+ )
+ )
+ }
+
+ /**
+ * Filters countries by allowed country codes.
+ *
+ * @param allowedCountryCodes Set of allowed ISO 3166-1 alpha-2 country codes.
+ * @return List of countries that are in the allowed set.
+ */
+ fun filterByAllowedCountries(allowedCountryCodes: Set): List {
+ if (allowedCountryCodes.isEmpty()) return ALL_COUNTRIES
+
+ val uppercaseAllowed = allowedCountryCodes.map { it.uppercase() }.toSet()
+ return ALL_COUNTRIES.filter { it.countryCode.uppercase() in uppercaseAllowed }
+ }
+
+ /**
+ * Gets the default country based on the device's locale.
+ *
+ * @return The CountryData for the device's country, or United States as fallback.
+ */
+ fun getDefaultCountry(): CountryData {
+ val deviceCountryCode = Locale.getDefault().country
+ return findByCountryCode(deviceCountryCode) ?: findByCountryCode("US")!!
+ }
+
+ /**
+ * Formats a phone number with the country's dial code.
+ *
+ * @param dialCode The country dial code (e.g., "+1").
+ * @param phoneNumber The local phone number.
+ * @return The formatted international phone number.
+ */
+ fun formatPhoneNumber(dialCode: String, phoneNumber: String): String {
+ val cleanNumber = phoneNumber.replace(Regex("[^0-9]"), "")
+ return "$dialCode$cleanNumber"
+ }
+
+ /**
+ * Normalizes a string by removing diacritics and converting to lowercase.
+ *
+ * @param value The string to normalize.
+ * @return The normalized string.
+ */
+ private fun normalizeString(value: String): String {
+ return Normalizer.normalize(value, Normalizer.Form.NFD)
+ .replace(Regex("\\p{M}"), "")
+ .lowercase()
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/util/CredentialPersistenceManager.kt b/auth/src/main/java/com/firebase/ui/auth/util/CredentialPersistenceManager.kt
new file mode 100644
index 0000000000..e625344099
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/util/CredentialPersistenceManager.kt
@@ -0,0 +1,75 @@
+/*
+ * 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.util
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.first
+
+private val Context.credentialDataStore: DataStore by preferencesDataStore(
+ name = "com.firebase.ui.auth.util.CredentialPersistenceManager"
+)
+
+/**
+ * Manages persistence for credential manager state.
+ *
+ * This class tracks whether credentials have been saved to the Android Credential Manager
+ * to prevent unnecessary credential retrieval attempts when no credentials exist.
+ *
+ * @since 10.0.0
+ */
+object CredentialPersistenceManager {
+
+ private val KEY_HAS_SAVED_CREDENTIALS = booleanPreferencesKey("has_saved_credentials")
+
+ /**
+ * Marks that credentials have been successfully saved to the credential manager.
+ *
+ * @param context The Android context
+ */
+ suspend fun setCredentialsSaved(context: Context) {
+ context.credentialDataStore.edit { prefs ->
+ prefs[KEY_HAS_SAVED_CREDENTIALS] = true
+ }
+ }
+
+ /**
+ * Checks if credentials have been saved at least once.
+ * This prevents unnecessary credential retrieval attempts.
+ *
+ * @param context The Android context
+ * @return true if credentials have been saved, false otherwise
+ */
+ suspend fun hasSavedCredentials(context: Context): Boolean {
+ val prefs = context.credentialDataStore.data.first()
+ return prefs[KEY_HAS_SAVED_CREDENTIALS] ?: false
+ }
+
+ /**
+ * Clears the saved credentials flag.
+ * Useful for testing or when user signs out permanently.
+ *
+ * @param context The Android context
+ */
+ suspend fun clearSavedCredentialsFlag(context: Context) {
+ context.credentialDataStore.edit { prefs ->
+ prefs.remove(KEY_HAS_SAVED_CREDENTIALS)
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/util/CredentialsApiHelper.java b/auth/src/main/java/com/firebase/ui/auth/util/CredentialsApiHelper.java
deleted file mode 100644
index 03d6b4eca7..0000000000
--- a/auth/src/main/java/com/firebase/ui/auth/util/CredentialsApiHelper.java
+++ /dev/null
@@ -1,186 +0,0 @@
-/*
- * Copyright 2016 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.util;
-
-import android.app.Activity;
-import android.app.PendingIntent;
-import android.support.annotation.NonNull;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.AuthUI.IdpConfig;
-import com.google.android.gms.auth.api.Auth;
-import com.google.android.gms.auth.api.credentials.Credential;
-import com.google.android.gms.auth.api.credentials.CredentialRequest;
-import com.google.android.gms.auth.api.credentials.CredentialRequestResult;
-import com.google.android.gms.auth.api.credentials.HintRequest;
-import com.google.android.gms.auth.api.credentials.IdentityProviders;
-import com.google.android.gms.common.api.GoogleApiClient;
-import com.google.android.gms.common.api.Result;
-import com.google.android.gms.common.api.ResultCallback;
-import com.google.android.gms.common.api.Status;
-import com.google.android.gms.tasks.Continuation;
-import com.google.android.gms.tasks.Task;
-import com.google.android.gms.tasks.TaskCompletionSource;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * A {@link com.google.android.gms.tasks.Task Task} based wrapper for the Smart Lock for Passwords
- * API.
- */
-public class CredentialsApiHelper {
-
- @NonNull
- private final GoogleApiClientTaskHelper mClientHelper;
-
- private CredentialsApiHelper(GoogleApiClientTaskHelper gacHelper) {
- mClientHelper = gacHelper;
- }
-
- public CredentialRequest createCredentialRequest(List providers) {
- boolean emailSupported = false;
- ArrayList idps = new ArrayList<>();
- for (IdpConfig provider : providers) {
- String providerId = provider.getProviderId();
- if (AuthUI.EMAIL_PROVIDER.equals(providerId)) {
- emailSupported = true;
- } else if (AuthUI.GOOGLE_PROVIDER.equals(providerId)) {
- idps.add(IdentityProviders.GOOGLE);
- } else if (AuthUI.FACEBOOK_PROVIDER.equals(providerId)) {
- idps.add(IdentityProviders.FACEBOOK);
- } else if (AuthUI.TWITTER_PROVIDER.equals(providerId)) {
- idps.add(IdentityProviders.TWITTER);
- }
- }
-
- return new CredentialRequest.Builder()
- .setPasswordLoginSupported(emailSupported)
- .setAccountTypes(idps.toArray(new String[idps.size()]))
- .build();
- }
-
- public Task