diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000000..a6d5269d33 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,46 @@ +name: Android Lint + +# pull_request only, matching e2e_test.yml. android.yml's [pull_request, push] +# is why every commit there produces two identical `build` runs. +on: + - pull_request + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Cache Gradle packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + + - name: Set up JDK 21 + uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + with: + java-version: '21' + distribution: 'temurin' + + # lintAll gates :proguard-tests, which applies the google-services plugin and + # will not configure without this file. Mirrors what scripts/build.sh copies. + - name: Copy google-services.json + run: | + cp library/google-services.json app/google-services.json + cp library/google-services.json proguard-tests/google-services.json + + - name: Android Lint + run: ./gradlew --max-workers=2 lintAll + + - name: Print Logs + if: failure() + run: ./scripts/print_build_logs.sh diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bb74b0a759..c7870c5001 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,9 @@ plugins { id("org.jetbrains.kotlin.plugin.compose") id("com.google.gms.google-services") id("kotlin-kapt") + // The slot demos host the auth screens on their own Navigation 3 back stacks, and a + // rememberNavBackStack key has to be @Serializable to survive process death. + alias(libs.plugins.kotlin.serialization) } android { diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt index a418427daa..5839d46edc 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/AuthChooserActivity.kt @@ -78,7 +78,7 @@ fun AuthChooserScreen( verticalArrangement = Arrangement.spacedBy(24.dp) ) { Spacer(modifier = Modifier.height(16.dp)) - // Header + Text( text = "Firebase Auth UI Compose", style = MaterialTheme.typography.headlineLarge, @@ -92,7 +92,6 @@ fun AuthChooserScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) - // Emulator Mode Warning if (isEmulatorMode) { Card( modifier = Modifier.fillMaxWidth(), @@ -121,7 +120,6 @@ fun AuthChooserScreen( } } - // High-Level API Card Card( modifier = Modifier.fillMaxWidth(), onClick = onHighLevelApiClick @@ -157,7 +155,6 @@ fun AuthChooserScreen( } } - // Low-Level API Card Card( modifier = Modifier.fillMaxWidth(), onClick = onLowLevelApiClick @@ -193,7 +190,6 @@ fun AuthChooserScreen( } } - // Custom Slots & Theming Card Card( modifier = Modifier.fillMaxWidth(), onClick = onCustomSlotsClick @@ -229,7 +225,6 @@ fun AuthChooserScreen( } } - // Credential Linking Card Card( modifier = Modifier.fillMaxWidth(), onClick = onCredentialLinkingClick @@ -257,7 +252,6 @@ fun AuthChooserScreen( Spacer(modifier = Modifier.height(16.dp)) - // Info card Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/AuthFlowControllerDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/AuthFlowControllerDemoActivity.kt index ce24e612f4..5263fb607c 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/AuthFlowControllerDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/AuthFlowControllerDemoActivity.kt @@ -27,27 +27,23 @@ import com.google.firebase.auth.actionCodeSettings import kotlinx.coroutines.launch /** - * Demo activity showcasing the AuthFlowController API for managing - * Firebase authentication with lifecycle-safe control. + * Drives the auth flow from an Activity with [AuthFlowController], instead of composing + * `FirebaseAuthScreen` directly. * - * This demonstrates: - * - Creating an AuthFlowController with configuration - * - Starting the auth flow using ActivityResultLauncher - * - Observing auth state changes - * - Handling results (success, cancelled, error) - * - Proper lifecycle management with dispose() + * The flow runs in its own Activity, so its outcome arrives as an Activity result. That result + * only reports that the flow ended, which is why the demo also collects `authStateFlow` and + * registers an `AuthStateListener` beside it — those are what report progress and the signed-in + * user. Disposing the controller in `onDestroy` is the contract [AuthFlowController] documents. */ class AuthFlowControllerDemoActivity : ComponentActivity() { private lateinit var authController: AuthFlowController - // Modern ActivityResultLauncher for auth flow private val authLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> when (result.resultCode) { Activity.RESULT_OK -> { - // Get user data from result val userId = result.data?.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID) val isNewUser = result.data?.getBooleanExtra( FirebaseAuthActivity.EXTRA_IS_NEW_USER, @@ -71,10 +67,8 @@ class AuthFlowControllerDemoActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Initialize FirebaseAuthUI val authUI = FirebaseAuthUI.getInstance() - // Create auth configuration val configuration = AuthUIConfiguration( context = applicationContext, providers = listOf( @@ -103,7 +97,6 @@ class AuthFlowControllerDemoActivity : ComponentActivity() { defaultNumber = null, defaultCountryCode = null, allowedCountries = emptyList(), - smsCodeLength = 6, timeout = 120L, isInstantVerificationEnabled = true ), @@ -113,7 +106,6 @@ class AuthFlowControllerDemoActivity : ComponentActivity() { privacyPolicyUrl = "https://policies.google.com/privacy?hl=en-NG&fg=1" ) - // Create AuthFlowController authController = authUI.createAuthFlow(configuration) setContent { @@ -134,7 +126,6 @@ class AuthFlowControllerDemoActivity : ComponentActivity() { override fun onDestroy() { super.onDestroy() - // Clean up resources authController.dispose() } @@ -164,7 +155,6 @@ fun AuthFlowDemo( val authState by authController.authStateFlow.collectAsState(AuthState.Idle) var currentUser by remember { mutableStateOf(FirebaseAuth.getInstance().currentUser) } - // Observe Firebase auth state changes DisposableEffect(Unit) { val authStateListener = FirebaseAuth.AuthStateListener { auth -> currentUser = auth.currentUser @@ -205,7 +195,6 @@ fun AuthFlowDemo( Spacer(modifier = Modifier.height(16.dp)) - // Current Auth State Card Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( @@ -243,7 +232,6 @@ fun AuthFlowDemo( } } - // Current User Card currentUser?.let { user -> Card( modifier = Modifier.fillMaxWidth(), @@ -274,7 +262,6 @@ fun AuthFlowDemo( Spacer(modifier = Modifier.height(16.dp)) - // Action Buttons if (currentUser == null) { Button( onClick = onStartAuth, @@ -302,7 +289,6 @@ fun AuthFlowDemo( } } - // Info Card Card( modifier = Modifier.fillMaxWidth() ) { diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/EmailAuthSlotDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/EmailAuthSlotDemoActivity.kt index 5c5e6e13c0..3ddba56a77 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/EmailAuthSlotDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/EmailAuthSlotDemoActivity.kt @@ -39,6 +39,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.AuthUIConfiguration @@ -51,6 +56,33 @@ import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState import com.firebase.ui.auth.ui.screens.email.EmailAuthMode import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen import com.google.firebase.auth.AuthResult +import kotlinx.serialization.Serializable + +/** + * One email mode, as this demo's own back-stack key. + * + * The library's navigation keys are its own business — a caller hosting [EmailAuthScreen] outside + * `FirebaseAuthScreen` brings its own, built from the public [EmailAuthMode] and the address the + * screen hands back. Distinct keys are distinct entries, so every mode composes fresh and system + * back pops one mode at a time. + */ +@Serializable +private data class EmailModeKey(val mode: EmailAuthMode, val email: String = "") : NavKey + +/** + * Moves to [mode], carrying the address so a switch keeps what the user typed. + * + * Adds before trimming, so no single write empties the stack: a mode already on the stack is + * replaced by the fresh key rather than revisited with a stale address; one that is not is pushed, + * leaving the mode below reachable by back. + */ +private fun NavBackStack.goToEmailMode(mode: EmailAuthMode, email: String) { + val existing = indexOfFirst { it is EmailModeKey && it.mode == mode } + add(EmailModeKey(mode, email)) + if (existing >= 0) { + while (size > existing + 1) removeAt(existing) + } +} class EmailAuthSlotDemoActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -133,6 +165,12 @@ fun EmailAuthDemo( } } + // Every mode is a real destination on this demo's own stack, so system back steps between modes + // instead of leaving the flow. Allocated above the branch: a rememberSaveable must not sit + // behind one, or signing out and back in restores it against a composition that no longer + // matches. + val backStack = rememberNavBackStack(EmailModeKey(EmailAuthMode.SignIn)) + if (currentUser != null) { Column( modifier = Modifier @@ -160,22 +198,42 @@ fun EmailAuthDemo( } } else { CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) { - EmailAuthScreen( - context = context, - configuration = configuration, - authUI = authUI, - onSuccess = { result: AuthResult -> - Log.d("EmailAuthSlotDemo", "Auth success: ${result.user?.uid}") - }, - onError = { exception: AuthException -> - Log.e("EmailAuthSlotDemo", "Auth error", exception) + NavDisplay( + backStack = backStack, + // Guarded like the library's popOrNull: NavDisplay throws on an empty + // stack, and throws from recomposition, so the first mode must not pop. + onBack = { if (backStack.size > 1) backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { key -> + EmailAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + prefillEmail = key.email.ifEmpty { null }, + mode = key.mode, + onNavigateToMode = { mode, email -> + backStack.goToEmailMode(mode, email) + }, + onSuccess = { result: AuthResult -> + Log.d("EmailAuthSlotDemo", "Auth success: ${result.user?.uid}") + }, + onError = { exception: AuthException -> + Log.e("EmailAuthSlotDemo", "Auth error", exception) + }, + onCancel = { + // Below the start mode there is nothing left to pop back to. + if (backStack.size > 1) { + backStack.removeLastOrNull() + } else { + Log.d("EmailAuthSlotDemo", "Auth cancelled") + } + } + ) { state: EmailAuthContentState -> + CustomEmailAuthUI(state) + } + } }, - onCancel = { - Log.d("EmailAuthSlotDemo", "Auth cancelled") - } - ) { state: EmailAuthContentState -> - CustomEmailAuthUI(state) - } + ) } } } diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt index cfa10b93b2..aa8216c53f 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt @@ -7,12 +7,16 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith 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.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -32,7 +36,6 @@ 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.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.lifecycleScope @@ -42,6 +45,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import com.firebase.ui.auth.AuthException @@ -58,6 +62,7 @@ import com.firebase.ui.auth.configuration.theme.AuthUIAsset import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.ui.screens.AuthSuccessUiContext import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen +import com.firebase.ui.auth.ui.screens.reauth.ReauthContentState import com.firebase.ui.auth.util.EmailLinkConstants import com.firebase.ui.auth.util.displayIdentifier import com.firebase.ui.auth.util.getDisplayEmail @@ -102,10 +107,15 @@ class HighLevelApiDemoActivity : ComponentActivity() { isMfaEnabled = false stringProvider = customStringProvider transitions = AuthUITransitions( - enterTransition = { slideInHorizontally { it } }, - exitTransition = { slideOutHorizontally { -it } }, - popEnterTransition = { slideInHorizontally { -it } }, - popExitTransition = { slideOutHorizontally { it } } + transitionSpec = { + slideInHorizontally { it } togetherWith slideOutHorizontally { -it } + }, + popTransitionSpec = { + slideInHorizontally { -it } togetherWith slideOutHorizontally { it } + }, + predictivePopTransitionSpec = { _ -> + slideInHorizontally { -it } togetherWith slideOutHorizontally { it } + }, ) providers { provider(AuthProvider.Anonymous) @@ -143,7 +153,6 @@ class HighLevelApiDemoActivity : ComponentActivity() { defaultNumber = null, defaultCountryCode = null, allowedCountries = emptyList(), - smsCodeLength = 6, timeout = 120L, isInstantVerificationEnabled = true ) @@ -229,13 +238,7 @@ class HighLevelApiDemoActivity : ComponentActivity() { onSignInCancelled = { Log.d("HighLevelApiDemoActivity", "Authentication cancelled") }, - reauthContent = { state, onDismiss -> - ReauthDialog( - authUI = authUI, - state = state, - onDismiss = onDismiss, - ) - }, + reauthContent = { state -> ReauthDialog(state = state) }, authenticatedContent = { state, uiContext -> AppAuthenticatedContent(state, uiContext) } @@ -331,11 +334,13 @@ private fun AppAuthenticatedContent( lifecycleOwner.lifecycleScope.launch { isDeletingAccount = true try { + // Reauthentication, if it is needed, happens inside this call: + // the progress indicator below covers it, and the deletion is + // retried here rather than needing anything from this caller. uiContext.authUI.delete(context) - } catch (e: AuthException.InvalidCredentialsException) { - // ReauthenticationRequired state was emitted — - // FirebaseAuthScreen navigates to the reauth flow automatically. - Log.d("HighLevelApiDemoActivity", "Reauth required before delete") + } catch (e: AuthException.AuthCancelledException) { + // Declined at the identity check; the account is untouched. + Log.d("HighLevelApiDemoActivity", "Delete cancelled", e) } catch (e: AuthException) { Log.e("HighLevelApiDemoActivity", "Delete failed", e) } finally { @@ -414,20 +419,15 @@ private fun AppAuthenticatedContent( } } +/** + * Custom reauth UI. The slot only chooses a provider — the library owns every credential path, and + * for email/phone it presents its own sub-flow, which replaces this dialog while it is up. Keep the + * slot stateless for that reason. + */ @Composable -private fun ReauthDialog( - authUI: FirebaseAuthUI, - state: AuthState.ReauthenticationRequired, - onDismiss: () -> Unit, -) { - var password by remember { mutableStateOf("") } - var isVerifying by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } - val coroutineScope = rememberCoroutineScope() - val email = state.user.email.orEmpty() - +private fun ReauthDialog(state: ReauthContentState) { AlertDialog( - onDismissRequest = onDismiss, + onDismissRequest = state.onDismiss, containerColor = MaterialTheme.colorScheme.surfaceVariant, title = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { @@ -442,60 +442,43 @@ private fun ReauthDialog( } }, text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { Text( - "Signing in as $email", + "Signed in as ${state.user.displayIdentifier()}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) - com.firebase.ui.auth.ui.components.AuthTextField( - value = password, - onValueChange = { - password = it - errorMessage = null - }, - label = { Text("Password") }, - isSecureTextField = true, - isError = errorMessage != null, - errorMessage = errorMessage, - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - confirmButton = { - Button( - onClick = { - coroutineScope.launch { - isVerifying = true - errorMessage = null - try { - val result = authUI.auth - .signInWithEmailAndPassword(email, password) - .await() - result.user?.let { user -> - authUI.updateAuthState(AuthState.Success(result, user)) - } - } catch (e: Exception) { - errorMessage = "Incorrect password. Please try again." - } finally { - isVerifying = false - } - } - }, - enabled = password.isNotBlank() && !isVerifying, - ) { - if (isVerifying) { + state.error?.let { error -> + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + if (state.isLoading) { CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, ) - } else { - Text("Verify") + } + state.providers.forEach { provider -> + Button( + onClick = { state.onProviderSelected(provider) }, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Continue with ${provider.providerName}") + } } } }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = state.onDismiss) { Text("Cancel") } + }, ) } @@ -588,6 +571,15 @@ private fun ChangePasswordDialog( Log.d("HighLevelApiDemoActivity", "Password changed successfully") onDismiss() } + } catch (e: CancellationException) { + // withReauth suspends across the reauthentication sheet, so this + // scope really can be cancelled mid-call. Never report that as a + // failure the user can retry. + throw e + } catch (e: AuthException.AuthCancelledException) { + // The user backed out of confirming their identity. Nothing failed, + // and the password was not changed — so say neither. + Log.d("HighLevelApiDemoActivity", "Reauthentication declined", e) } catch (e: Exception) { updateError = "Failed to update password. Please try again." } finally { diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/PhoneAuthSlotDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/PhoneAuthSlotDemoActivity.kt index a36c567b5d..bdaab76eb8 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/PhoneAuthSlotDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/PhoneAuthSlotDemoActivity.kt @@ -38,6 +38,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.AuthUIConfiguration @@ -47,7 +52,32 @@ import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvi import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen import com.firebase.ui.auth.ui.screens.phone.PhoneAuthStep +import com.firebase.ui.auth.ui.screens.phone.rememberPhoneAuthFlowState import com.google.firebase.auth.AuthResult +import kotlinx.serialization.Serializable + +/** + * One phone step, as this demo's own back-stack key. + * + * A caller hosting [PhoneAuthScreen] outside `FirebaseAuthScreen` brings its own keys, built from + * the public [PhoneAuthStep]. The data a step switch must not dispose lives in the flow state, + * which is remembered above the display so it outlives the entries. + */ +@Serializable +private data class PhoneStepKey(val step: PhoneAuthStep) : NavKey + +/** + * Moves to [step], ignoring a move to the step already on top. + * + * The guard is the point: the screen asks to go to code entry for every verification id it has not + * navigated for, and a resend mints a new one — so without this, resending stacks a second code + * entry under the first and back returns to an identical screen instead of to number entry. + */ +private fun NavBackStack.goToPhoneStep(step: PhoneAuthStep) { + val key = PhoneStepKey(step) + if (lastOrNull() == key) return + add(key) +} class PhoneAuthSlotDemoActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -65,7 +95,6 @@ class PhoneAuthSlotDemoActivity : ComponentActivity() { defaultNumber = null, defaultCountryCode = "US", allowedCountries = emptyList(), - smsCodeLength = 6, timeout = 60L, isInstantVerificationEnabled = true ) @@ -110,6 +139,13 @@ fun PhoneAuthDemo( } } + // Each step is a real destination, so system back returns to the number entry rather than + // leaving the flow. The flow state sits above the display: a step switch must not dispose the + // verification it holds. Both are allocated above the branch, because a rememberSaveable must + // not sit behind one. + val backStack = rememberNavBackStack(PhoneStepKey(PhoneAuthStep.EnterPhoneNumber)) + val flowState = rememberPhoneAuthFlowState(configuration) + if (currentUser != null) { Column( modifier = Modifier @@ -137,22 +173,41 @@ fun PhoneAuthDemo( } } else { CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) { - PhoneAuthScreen( - context = context, - configuration = configuration, - authUI = authUI, - onSuccess = { result: AuthResult -> - Log.d("PhoneAuthSlotDemo", "Auth success: ${result.user?.uid}") - }, - onError = { exception: AuthException -> - Log.e("PhoneAuthSlotDemo", "Auth error", exception) + NavDisplay( + backStack = backStack, + // Guarded like the library's popOrNull: NavDisplay throws on an empty + // stack, and throws from recomposition, so the first step must not pop. + onBack = { if (backStack.size > 1) backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { key -> + PhoneAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + step = key.step, + onNavigateToStep = { backStack.goToPhoneStep(it) }, + onNavigateBack = { backStack.removeLastOrNull() }, + flowState = flowState, + onSuccess = { result: AuthResult -> + Log.d("PhoneAuthSlotDemo", "Auth success: ${result.user?.uid}") + }, + onError = { exception: AuthException -> + Log.e("PhoneAuthSlotDemo", "Auth error", exception) + }, + onCancel = { + // Below the first step there is nothing left to pop back to. + if (backStack.size > 1) { + backStack.removeLastOrNull() + } else { + Log.d("PhoneAuthSlotDemo", "Auth cancelled") + } + } + ) { state: PhoneAuthContentState -> + CustomPhoneAuthUI(state) + } + } }, - onCancel = { - Log.d("PhoneAuthSlotDemo", "Auth cancelled") - } - ) { state: PhoneAuthContentState -> - CustomPhoneAuthUI(state) - } + ) } } } diff --git a/auth/README.md b/auth/README.md index e2e1daab21..98107ee50f 100644 --- a/auth/README.md +++ b/auth/README.md @@ -6,7 +6,7 @@ Built entirely with **Jetpack Compose** and **Material Design 3**, FirebaseUI Au - **Simple API** - Choose between high-level screens or low-level controllers for maximum flexibility - **12+ Authentication Methods** - Email/Password, Phone, Google, Facebook, Twitter, GitHub, Microsoft, Yahoo, Apple, Anonymous, and custom OAuth providers -- **Multi-Factor Authentication** - SMS and TOTP (Time-based One-Time Password) with recovery codes +- **Multi-Factor Authentication** - SMS and TOTP (Time-based One-Time Password) - **Android Credential Manager** - Automatic credential saving and one-tap sign-in - **Material Design 3** - Beautiful, themeable UI components that integrate seamlessly with your app - **Localization Support** - Customizable strings for internationalization @@ -69,6 +69,7 @@ Equivalent FirebaseUI libraries are available for [iOS](https://github.com/fireb - [Email Link Sign-In](#email-link-sign-in) - [Password Validation Rules](#password-validation-rules) - [Credential Manager Integration](#credential-manager-integration) + - [Automated Testing (Firebase Test Lab & Robo)](#automated-testing-firebase-test-lab--robo) - [Sign Out & Account Deletion](#sign-out--account-deletion) 10. [Localization](#localization) 11. [Error Handling](#error-handling) @@ -85,10 +86,10 @@ Equivalent FirebaseUI libraries are available for [iOS](https://github.com/fireb Ensure your application is configured for use with Firebase. See the [Firebase documentation](https://firebase.google.com/docs/android/setup) for setup instructions. **Minimum Requirements:** -- Android SDK 21+ (Android 5.0 Lollipop) -- Kotlin 1.9+ -- Jetpack Compose (Compiler 1.5+) -- Firebase Auth 22.0.0+ +- Android SDK 23+ (Android 6.0 Marshmallow) +- Kotlin 2.0+ +- Jetpack Compose +- Firebase BoM 34.0.0+ ### Installation @@ -100,11 +101,11 @@ dependencies { implementation("com.firebaseui:firebase-ui-auth:10.0.0-beta04") // Required: Firebase Auth - implementation(platform("com.google.firebase:firebase-bom:32.7.0")) + implementation(platform("com.google.firebase:firebase-bom:34.17.0")) implementation("com.google.firebase:firebase-auth") // Required: Jetpack Compose - implementation(platform("androidx.compose:compose-bom:2024.01.00")) + implementation(platform("androidx.compose:compose-bom:2026.06.01")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.material3:material3") @@ -163,6 +164,7 @@ class MainActivity : ComponentActivity() { setContent { MyAppTheme { val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Email()) provider(AuthProvider.Google()) @@ -261,7 +263,7 @@ val authUI = FirebaseAuthUI.getInstance(customApp) // Or create with custom auth (for multi-tenancy) val customAuth = Firebase.auth(customApp) -val authUI = FirebaseAuthUI.create(auth = customAuth) +val authUI = FirebaseAuthUI.create(app = customApp, auth = customAuth) ``` **Key Methods:** @@ -280,7 +282,12 @@ val authUI = FirebaseAuthUI.create(auth = customAuth) `AuthUIConfiguration` defines all settings for your authentication flow. Use the DSL builder function for easy configuration: ```kotlin +val authTheme = AuthUITheme.fromMaterialTheme() // @Composable — resolve it here, not below + val configuration = authUIConfiguration { + // Required: an application Context. Omitting it throws when the block is evaluated. + context = applicationContext + // Required: Authentication providers providers { provider(AuthProvider.Email()) @@ -288,15 +295,16 @@ val configuration = authUIConfiguration { provider(AuthProvider.Phone()) } - // Optional: Theme configuration - theme = AuthUITheme.fromMaterialTheme() + // Optional: Theme. AuthUITheme.fromMaterialTheme() and AuthUITheme.Adaptive are + // @Composable, so resolve them above the builder and assign the result here. + theme = authTheme // Optional: Terms of Service and Privacy Policy URLs tosUrl = "https://example.com/terms" privacyPolicyUrl = "https://example.com/privacy" - // Optional: App logo - logo = Icons.Default.AccountCircle + // Optional: App logo. Wrap the source in an AuthUIAsset — a bare ImageVector is a type error. + logo = AuthUIAsset.Vector(Icons.Default.AccountCircle) // Optional: Enable MFA (default: true) isMfaEnabled = true @@ -321,6 +329,23 @@ val configuration = authUIConfiguration { // Optional: Locale override locale = Locale.FRENCH + + // Optional: when a non-anonymous user is already signed in, link the new credential + // onto that account instead of switching accounts (default: false) + isCredentialLinkingEnabled = false + + // Optional: send password-reset links to your own page rather than the Firebase-hosted + // one (default: null) + passwordResetActionCodeSettings = actionCodeSettings { + url = "https://example.com/reset" + handleCodeInApp = true + setAndroidPackageName(packageName, true, null) + } + + // Optional: resolve an email to its providers with the legacy fetchSignInMethodsForEmail + // call. Only useful if email enumeration protection is disabled on your project + // (default: false) + legacyFetchSignInWithEmail = false } ``` @@ -331,29 +356,37 @@ val configuration = authUIConfiguration { ```kotlin val controller = authUI.createAuthFlow(configuration) -lifecycleScope.launch { - // Start the flow - val state = controller.start() +// Register before the Activity reaches STARTED — as a property initializer or in onCreate. +// Registering later (in a click listener, say) throws. +val authLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() +) { /* the flow finished; inspect FirebaseAuth.currentUser or the result extras */ } - when (state) { - is AuthState.Success -> { - // Handle success - val user = state.result.user - } - is AuthState.Error -> { - // Handle error - Log.e(TAG, "Auth failed", state.exception) - } - is AuthState.Cancelled -> { - // User cancelled a single sign-in attempt (e.g. dismissed the - // Credential Manager sheet, backed out of MFA); the flow stays open - } - is AuthState.Aborted -> { - // Flow was ended via controller.cancel() - finish() - } - else -> { - // Handle other states (RequiresMfa, RequiresEmailVerification, etc.) +authLauncher.launch(controller.createIntent(this)) + +// Follow the flow in detail by collecting its state +lifecycleScope.launch { + controller.authStateFlow.collect { state -> + when (state) { + is AuthState.Success -> { + // Handle success + val user = state.user + } + is AuthState.Error -> { + // Handle error + Log.e(TAG, "Auth failed", state.exception) + } + is AuthState.Cancelled -> { + // User cancelled a single sign-in attempt (e.g. dismissed the + // Credential Manager sheet, backed out of MFA); the flow stays open + } + is AuthState.Aborted -> { + // Flow was ended via controller.cancel() + finish() + } + else -> { + // Handle other states (RequiresMfa, RequiresEmailVerification, etc.) + } } } } @@ -434,7 +467,8 @@ val emailProvider = AuthProvider.Email( ) val configuration = authUIConfiguration { - providers = listOf(emailProvider) + context = applicationContext + providers { provider(emailProvider) } } ``` @@ -453,9 +487,6 @@ val phoneProvider = AuthProvider.Phone( // Optional: Allowed countries allowedCountries = listOf("US", "CA", "GB"), - // Optional: SMS code length (default: 6) - smsCodeLength = 6, - // Optional: Timeout for SMS delivery in seconds (default: 60) timeout = 60L, @@ -464,6 +495,7 @@ val phoneProvider = AuthProvider.Phone( ) val configuration = authUIConfiguration { + context = applicationContext providers { provider(phoneProvider) } @@ -487,6 +519,7 @@ val googleProvider = AuthProvider.Google( ) val configuration = authUIConfiguration { + context = applicationContext providers { provider(googleProvider) } @@ -507,6 +540,7 @@ val facebookProvider = AuthProvider.Facebook( ) val configuration = authUIConfiguration { + context = applicationContext providers { provider(facebookProvider) } @@ -567,6 +601,7 @@ val appleProvider = AuthProvider.Apple( ) val configuration = authUIConfiguration { + context = applicationContext providers { provider(twitterProvider) provider(githubProvider) @@ -583,6 +618,7 @@ Enable anonymous authentication to let users use your app without signing in: ```kotlin val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Anonymous()) } @@ -624,6 +660,7 @@ val lineProvider = AuthProvider.GenericOAuth( ) val configuration = authUIConfiguration { + context = applicationContext providers { provider(lineProvider) } @@ -639,7 +676,9 @@ The high-level API provides a complete, opinionated authentication experience wi ```kotlin @Composable fun AuthenticationScreen() { + val localContext = LocalContext.current val configuration = authUIConfiguration { + context = localContext providers { provider(AuthProvider.Email()) provider(AuthProvider.Google()) @@ -648,7 +687,7 @@ fun AuthenticationScreen() { } tosUrl = "https://example.com/terms" privacyPolicyUrl = "https://example.com/privacy" - logo = Icons.Default.Lock + logo = AuthUIAsset.Vector(Icons.Default.Lock) } FirebaseAuthScreen( @@ -743,11 +782,16 @@ For maximum control, use the `AuthFlowController`: class AuthActivity : ComponentActivity() { private lateinit var controller: AuthFlowController + private val authLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { /* the flow finished */ } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val authUI = FirebaseAuthUI.getInstance() val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Email()) provider(AuthProvider.Google()) @@ -756,9 +800,13 @@ class AuthActivity : ComponentActivity() { controller = authUI.createAuthFlow(configuration) + // Only on a fresh start; unguarded, every recreation would launch a second flow. + if (savedInstanceState == null) { + authLauncher.launch(controller.createIntent(this)) + } + lifecycleScope.launch { - val state = controller.start() - handleAuthState(state) + controller.authStateFlow.collect { handleAuthState(it) } } } @@ -766,7 +814,7 @@ class AuthActivity : ComponentActivity() { when (state) { is AuthState.Success -> { // Successfully signed in - val user = state.result.user + val user = state.user startActivity(Intent(this, MainActivity::class.java)) finish() } @@ -827,7 +875,7 @@ FirebaseAuthScreen( phoneContent = { state -> /* ... */ }, mfaEnrollmentContent = { state -> /* ... */ }, mfaChallengeContent = { state -> /* ... */ }, - reauthContent = { state, onDismiss -> /* ... */ }, + reauthContent = { state -> /* ... */ }, ) { authState, uiContext -> // authenticated content } @@ -992,43 +1040,48 @@ mfaChallengeContent = { state -> #### Reauthentication (`reauthContent`) -Replaces the default reauthentication bottom sheet shown when a sensitive operation requires the user to re-verify their identity. Receives the `AuthState.ReauthenticationRequired` state (including an optional `reason` string and the signed-in `user`) and an `onDismiss` callback that resets auth state to `Idle`. +Replaces the default reauthentication bottom sheet shown when a sensitive operation requires the user to re-verify their identity. The `ReauthContentState` carries `user`, `reason`, the `providers` already filtered to those linked to that user, and callbacks to select a provider or dismiss. + +The library owns the credential exchange, so the slot only renders a provider chooser. Selecting a federated provider reauthenticates directly; selecting `AuthProvider.Email` or `AuthProvider.Phone` hands off to the library's own email/phone sub-flow, which honours your `emailContent` / `phoneContent` slots and replaces this slot while it is active. Password and OTP entry therefore never appear here. + +If the account has multi-factor authentication enrolled, Firebase needs the second factor to complete the reauthentication too. The library presents the MFA challenge as another sub-flow over this slot, honouring your `mfaChallengeContent` slot; resolving it completes the reauthentication and the pending operation resumes. Backing out of the challenge returns to this slot with the operation still pending, and a failed challenge latches into `state.error` like any other failed attempt. ```kotlin -reauthContent = { state, onDismiss -> +reauthContent = { state -> AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Verify your identity") }, + onDismissRequest = state.onDismiss, + title = { Text(state.reason ?: "Verify your identity") }, text = { - Column { - state.reason?.let { Text(it) } - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password") }, - visualTransformation = PasswordVisualTransformation(), - ) + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (state.isLoading) CircularProgressIndicator() + state.providers.forEach { provider -> + Button( + onClick = { state.onProviderSelected(provider) }, + enabled = !state.isLoading, + ) { Text("Continue with ${provider.providerName}") } + } } }, - confirmButton = { - Button(onClick = { - // Re-authenticate then update auth state on success - }) { Text("Confirm") } - }, + confirmButton = {}, dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } + TextButton(onClick = state.onDismiss) { Text("Cancel") } }, ) } ``` +While this slot is shown the library suppresses its own loading and error dialogs, so render `state.isLoading` and `state.error` yourself. `state.error` is the same message the library's own error dialog would have shown, and `state.exception` carries the exception behind it when you need to branch on the failure type. On success the library resumes the operation that required reauthentication — there is nothing to retry. `state.onDismiss` abandons reauthentication and calls `onSignInCancelled`, so any pending operation will never run; backing out of a single provider attempt returns to the slot with the operation still pending and does *not* call `onSignInCancelled`. Render the slot so it blocks interaction with the content behind it — that content stays composed, and the library only makes its own affordances inert. + +An armed reauthentication survives Activity recreation: rotating keeps the pending operation, the latched `state.error`, its `state.exception`, and any active email/phone sub-flow. The pending operation cannot survive process death, and if it is lost the flow emits an `AuthState.Error` explaining that identity confirmation was interrupted rather than dropping the operation silently. + For most cases, use [`withReauth`](#reauthentication) instead — it handles the full reauth cycle automatically and only shows the default bottom sheet. Use `reauthContent` when you need a custom design for the reauth UI. ### Reauthentication Firebase requires the user to have signed in recently before performing sensitive operations like deleting their account or changing their password. If the session is too old, Firebase throws `FirebaseAuthRecentLoginRequiredException`. -`withReauth` wraps any sensitive operation. If the exception is thrown, it automatically emits `AuthState.ReauthenticationRequired` and — once the user reauthenticates via the default bottom sheet or your `reauthContent` slot — retries the original operation. +`withReauth` wraps any sensitive operation. If the exception is thrown, it automatically emits `AuthState.Reauthentication.Required` and — once the user reauthenticates via the default bottom sheet or your `reauthContent` slot — retries the original operation. ```kotlin lifecycleScope.launch { @@ -1036,7 +1089,7 @@ lifecycleScope.launch { context = context, reason = "Verify your identity to delete your account", ) { - auth.currentUser?.delete()?.await() + authUI.auth.currentUser?.delete()?.await() } } ``` @@ -1044,17 +1097,26 @@ lifecycleScope.launch { `withReauth` handles the full cycle: 1. Runs the operation. -2. If `FirebaseAuthRecentLoginRequiredException` is thrown, emits `AuthState.ReauthenticationRequired` with the retry attached. -3. `FirebaseAuthScreen` shows the reauth UI scoped to the user's linked providers. +2. If `FirebaseAuthRecentLoginRequiredException` is thrown, emits `AuthState.Reauthentication.Required` with the retry attached. +3. `FirebaseAuthScreen` shows the reauth UI scoped to the user's linked providers, including the MFA challenge when the account has a second factor enrolled. 4. On successful reauthentication, retries the operation automatically and emits `AuthState.Success` or `AuthState.Error`. +The armed reauthentication lives on the process-cached `FirebaseAuthUI`, so it survives Activity recreation; it does not survive process death, and a lost operation is reported as an `AuthState.Error` rather than silently dropped. The operation runs at most once: if a recreation interrupts it mid-flight the flow reports the interruption instead of starting it again, because the first attempt may already have committed. + +**What `authStateFlow()` emits while this is running.** From the moment `FirebaseAuthScreen` picks the request up until it ends, every state is published as an `AuthState.Reauthentication` — the phases of that one request, each carrying its `requestId` and `userUid`. The ordinary `AuthState.Loading` / `AuthState.Error` / `AuthState.Cancelled` of the credential exchange are folded into those phases, so `is AuthState.Error` and `is AuthState.Loading` do **not** match for the duration and app-side error dialogs and spinners stay quiet: the library owns the UI for that window. Match `is AuthState.Reauthentication` if you need to know it is happening. The final outcome — `AuthState.Success`, `AuthState.Error` or `AuthState.Idle` — is published as an ordinary state once the request ends. Arming a request with no `FirebaseAuthScreen` composed (catching `withReauth`/`delete`'s exception and showing your own UI) folds nothing: states are published normally, and the next one simply replaces the arming. + **Activity-based alternative:** use `createReauthFlow` to start a standalone reauthentication activity scoped to the current user's linked providers, returning an `AuthFlowController`. ```kotlin val reauth = authUI.createReauthFlow( - context = context, configuration = authUIConfiguration { - // Providers are automatically filtered to those linked to the current user + context = applicationContext + // Required by the builder; createReauthFlow then filters this list down to the + // providers actually linked to the current user. + providers { + provider(AuthProvider.Email()) + provider(AuthProvider.Google()) + } }, ) val intent = reauth.createIntent(context) @@ -1075,11 +1137,14 @@ val mfaConfig = MfaConfiguration( // Optional: Require MFA enrollment (default: false) requireEnrollment = false, - // Optional: Enable recovery codes (default: true) - enableRecoveryCodes = true + // Optional: restrict the SMS enrollment step's country selector, as ISO 3166-1 alpha-2 + // codes (default: null, no restriction). Independent of the phone sign-in provider's own + // allowedCountries — an SMS second factor is configured separately from phone sign-in. + allowedCountries = listOf("US", "CA", "GB") ) val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Email()) } @@ -1091,39 +1156,89 @@ val configuration = authUIConfiguration { Prompt users to enroll in MFA after sign-in: +Every enrollment step is its own navigation destination, so the host owns the step and navigates +between them. Keep the flow state above the `NavDisplay` — a step switch must not dispose what a +previous step collected. + ```kotlin +@Serializable +data class MfaStepKey(val step: MfaEnrollmentStep) : NavKey + @Composable fun MfaEnrollmentFlow() { - val currentUser = FirebaseAuth.getInstance().currentUser + val auth = FirebaseAuth.getInstance() + val currentUser = auth.currentUser if (currentUser != null) { val mfaConfig = MfaConfiguration( - allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp) + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp), + allowedCountries = listOf("US", "CA", "GB") ) - - MfaEnrollmentScreen( - user = currentUser, - configuration = mfaConfig, - onEnrollmentComplete = { - Toast.makeText(context, "MFA enrolled successfully!", Toast.LENGTH_SHORT).show() - navigateToHome() + val backStack = rememberNavBackStack(MfaStepKey(MfaEnrollmentStep.SelectFactor)) + // Pass the restriction so the SMS step opens on a country the selector will offer. The + // screen also reconciles this itself, so a host that forgets cannot end up sending to an + // unpermitted dial code. + val flowState = rememberMfaEnrollmentFlowState(mfaConfig.allowedCountries) + // Read here, not inside onComplete: LocalContext.current is a @Composable read. + val context = LocalContext.current + + NavDisplay( + backStack = backStack, + // NavDisplay throws on an empty back stack, and throws from recomposition, so the + // first step must not pop. + onBack = { if (backStack.size > 1) backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { key -> + MfaEnrollmentScreen( + user = currentUser, + auth = auth, + configuration = mfaConfig, + onComplete = { + Toast.makeText(context, "MFA enrolled!", Toast.LENGTH_SHORT).show() + navigateToHome() + }, + onSkip = { navigateToHome() }, + step = key.step, + // A step already on top must not be pushed twice. + onNavigateToStep = { + val target = MfaStepKey(it) + if (backStack.lastOrNull() != target) backStack.add(target) + }, + onNavigateBack = { + if (backStack.size > 1) backStack.removeLastOrNull() + }, + flowState = flowState, + ) + } }, - onSkip = { - navigateToHome() - } ) } } ``` +A back-stack key must be `@Serializable` to survive process death, so add the +`org.jetbrains.kotlin.plugin.serialization` plugin to the module hosting this screen. If you would +rather not own any of the navigation, use `FirebaseAuthScreen` and its `mfaEnrollmentContent` slot, +which owns it for you. + Or with custom UI: ```kotlin MfaEnrollmentScreen( user = currentUser, + auth = auth, configuration = mfaConfig, - onEnrollmentComplete = { /* ... */ }, - onSkip = { /* ... */ } + onComplete = { /* ... */ }, + onSkip = { /* ... */ }, + // Hosted exactly as above — the step and its two navigation callbacks, plus the flow state + // remembered above the NavDisplay. + step = key.step, + onNavigateToStep = { + val target = MfaStepKey(it) + if (backStack.lastOrNull() != target) backStack.add(target) + }, + onNavigateBack = { if (backStack.size > 1) backStack.removeLastOrNull() }, + flowState = flowState, ) { state -> when (state.step) { MfaEnrollmentStep.SelectFactor -> { @@ -1138,9 +1253,6 @@ MfaEnrollmentScreen( MfaEnrollmentStep.VerifyFactor -> { CustomVerificationUI(state) } - MfaEnrollmentStep.ShowRecoveryCodes -> { - CustomRecoveryCodesUI(state) - } } } ``` @@ -1159,7 +1271,8 @@ FirebaseAuthScreen( // MFA challenges are handled automatically by FirebaseAuthScreen // But you can also handle them manually: if (exception is AuthException.MfaRequiredException) { - showMfaChallengeScreen(exception.resolver) + // The resolver arrives on AuthState.RequiresMfa, not on the exception. + showMfaChallengePrompt() } } ) @@ -1172,20 +1285,15 @@ Or handle manually: fun ManualMfaChallenge(resolver: MultiFactorResolver) { MfaChallengeScreen( resolver = resolver, - onChallengeComplete = { assertion -> - // Complete sign-in with the assertion - lifecycleScope.launch { - try { - val result = resolver.resolveSignIn(assertion) - navigateToHome() - } catch (e: Exception) { - showError(e) - } - } + auth = FirebaseAuth.getInstance(), + onSuccess = { result -> + // The library resolved the challenge; the user is signed in + navigateToHome() }, onCancel = { navigateBack() - } + }, + onError = { showError(it) } ) } ``` @@ -1205,6 +1313,7 @@ FirebaseUI provides pre-configured themes for light and dark modes: ```kotlin val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Email()) provider(AuthProvider.Google()) @@ -1220,12 +1329,15 @@ val configuration = authUIConfiguration { `AuthUITheme.Adaptive` automatically switches between light and dark themes based on the system setting: ```kotlin +val adaptiveTheme = AuthUITheme.Adaptive // @Composable getter — read it outside the builder + val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Email()) provider(AuthProvider.Google()) } - theme = AuthUITheme.Adaptive // Adapts to system dark mode + theme = adaptiveTheme } ``` @@ -1240,12 +1352,13 @@ Use `.copy()` to customize specific properties of the default theme: ```kotlin @Composable fun AuthScreen() { + val localContext = LocalContext.current val customTheme = AuthUITheme.Adaptive.copy( providerButtonShape = MaterialTheme.shapes.extraLarge // Pill-shaped buttons ) val configuration = authUIConfiguration { - context = applicationContext + context = localContext providers { provider(AuthProvider.Google()) provider(AuthProvider.Email()) @@ -1271,12 +1384,14 @@ FirebaseUI Auth supports two theming patterns with clear precedence rules: The simplest approach is to set the theme only in `authUIConfiguration`: ```kotlin +val adaptiveTheme = AuthUITheme.Adaptive + val configuration = authUIConfiguration { context = applicationContext providers { provider(AuthProvider.Email()) } - theme = AuthUITheme.Adaptive // Set theme here + theme = adaptiveTheme // Set theme here } FirebaseAuthScreen( @@ -1292,15 +1407,17 @@ FirebaseAuthScreen( You can also wrap `FirebaseAuthScreen` with `AuthUITheme`: ```kotlin +val adaptiveTheme = AuthUITheme.Adaptive + val configuration = authUIConfiguration { context = applicationContext providers { provider(AuthProvider.Email()) } - theme = AuthUITheme.Adaptive // Theme in configuration + theme = adaptiveTheme // Theme in configuration } -AuthUITheme(theme = AuthUITheme.Adaptive) { // Optional wrapper +AuthUITheme(theme = adaptiveTheme) { // Optional wrapper Surface(color = MaterialTheme.colorScheme.background) { FirebaseAuthScreen( configuration = configuration, @@ -1319,6 +1436,8 @@ Understanding which theme applies is important: 1. **Configuration theme takes precedence:** ```kotlin val configuration = authUIConfiguration { + context = applicationContext + providers { provider(AuthProvider.Email()) } theme = AuthUITheme.Default // LIGHT theme } @@ -1331,6 +1450,8 @@ Understanding which theme applies is important: 2. **Wrapper as fallback:** ```kotlin val configuration = authUIConfiguration { + context = applicationContext + providers { provider(AuthProvider.Email()) } // theme not specified (null) } @@ -1343,6 +1464,8 @@ Understanding which theme applies is important: 3. **Ultimate fallback:** ```kotlin val configuration = authUIConfiguration { + context = applicationContext + providers { provider(AuthProvider.Email()) } // theme not specified (null) } @@ -1360,11 +1483,16 @@ Use `fromMaterialTheme()` to automatically inherit your app's Material Design th @Composable fun App() { MyAppTheme { // Your existing Material3 theme - val configuration = authUIConfiguration { - providers { - provider(AuthProvider.Email()) + val localContext = LocalContext.current + val authTheme = AuthUITheme.fromMaterialTheme() // Inherits colors, typography, shapes + val configuration = remember(localContext, authTheme) { + authUIConfiguration { + context = localContext + providers { + provider(AuthProvider.Email()) + } + theme = authTheme } - theme = AuthUITheme.fromMaterialTheme() // Inherits colors, typography, shapes } FirebaseAuthScreen( @@ -1378,14 +1506,17 @@ fun App() { You can also customize while inheriting: ```kotlin +val authTheme = AuthUITheme.fromMaterialTheme( + providerButtonShape = RoundedCornerShape(16.dp) // Override button shape +) + val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Google()) provider(AuthProvider.Facebook()) } - theme = AuthUITheme.fromMaterialTheme( - providerButtonShape = RoundedCornerShape(16.dp) // Override button shape - ) + theme = authTheme } ``` @@ -1414,6 +1545,7 @@ val customTheme = AuthUITheme( ) val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Email()) } @@ -1433,7 +1565,12 @@ val customTheme = AuthUITheme.Default.copy( ) val configuration = authUIConfiguration { - providers = listOf(AuthProvider.Google(), AuthProvider.Facebook(), AuthProvider.Email()) + context = applicationContext + providers { + provider(AuthProvider.Google()) + provider(AuthProvider.Facebook()) + provider(AuthProvider.Email()) + } theme = customTheme } ``` @@ -1441,14 +1578,17 @@ val configuration = authUIConfiguration { **Option 2: Using `fromMaterialTheme()`:** ```kotlin +val authTheme = AuthUITheme.fromMaterialTheme( + providerButtonShape = RoundedCornerShape(16.dp) +) + val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Google()) provider(AuthProvider.Facebook()) } - theme = AuthUITheme.fromMaterialTheme( - providerButtonShape = RoundedCornerShape(16.dp) - ) + theme = authTheme } ``` @@ -1463,6 +1603,7 @@ val customTheme = AuthUITheme( ) val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Google()) provider(AuthProvider.Facebook()) @@ -1496,6 +1637,7 @@ val customTheme = AuthUITheme.Default.copy( ) val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Google()) provider(AuthProvider.Facebook()) @@ -1514,15 +1656,18 @@ val customProviderStyles = mapOf( ) ) +val authTheme = AuthUITheme.fromMaterialTheme( + providerButtonShape = RoundedCornerShape(12.dp), + providerStyles = customProviderStyles +) + val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Google()) provider(AuthProvider.Facebook()) } - theme = AuthUITheme.fromMaterialTheme( - providerButtonShape = RoundedCornerShape(12.dp), - providerStyles = customProviderStyles - ) + theme = authTheme } ``` @@ -1551,6 +1696,7 @@ val customTheme = AuthUITheme.Default.copy( ) val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Google()) // Uses custom shape (24.dp) provider(AuthProvider.Facebook()) // Uses custom shape (8.dp) @@ -1573,6 +1719,7 @@ val customTheme = AuthUITheme.Default.copy( ) val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Email()) } theme = customTheme } @@ -1582,25 +1729,33 @@ If left unset (`null`), the top app bar falls back to colors derived from `color ### Screen Transitions -Customize the animations when navigating between screens using the `AuthUITransitions` object: +Customize the animations when navigating between screens using the `AuthUITransitions` object. +Each spec is an `AnimatedContentTransitionScope>` receiver returning one +`ContentTransform`, so the enter and exit halves are paired with `togetherWith`: **Slide animations:** ```kotlin import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import com.firebase.ui.auth.configuration.AuthUITransitions val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Email()) provider(AuthProvider.Google()) } transitions = AuthUITransitions( - enterTransition = { slideInHorizontally { it } }, // Slide in from right - exitTransition = { slideOutHorizontally { -it } }, // Slide out to left - popEnterTransition = { slideInHorizontally { -it } }, // Slide in from left - popExitTransition = { slideOutHorizontally { it } } // Slide out to right + // Slide in from right, slide out to left + transitionSpec = { slideInHorizontally { it } togetherWith slideOutHorizontally { -it } }, + // Slide in from left, slide out to right + popTransitionSpec = { slideInHorizontally { -it } togetherWith slideOutHorizontally { it } }, + // Predictive back falls back to the default cross-fade if left unset, so mirror the pop + predictivePopTransitionSpec = { + slideInHorizontally { -it } togetherWith slideOutHorizontally { it } + } ) } ``` @@ -1610,17 +1765,18 @@ val configuration = authUIConfiguration { ```kotlin import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import com.firebase.ui.auth.configuration.AuthUITransitions val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Phone()) } transitions = AuthUITransitions( - enterTransition = { fadeIn() }, - exitTransition = { fadeOut() }, - popEnterTransition = { fadeIn() }, - popExitTransition = { fadeOut() } + transitionSpec = { fadeIn() togetherWith fadeOut() }, + popTransitionSpec = { fadeIn() togetherWith fadeOut() }, + predictivePopTransitionSpec = { fadeIn() togetherWith fadeOut() } ) } ``` @@ -1632,17 +1788,23 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import com.firebase.ui.auth.configuration.AuthUITransitions val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Facebook()) } transitions = AuthUITransitions( - enterTransition = { fadeIn() + scaleIn(initialScale = 0.9f) }, - exitTransition = { fadeOut() + scaleOut(targetScale = 0.9f) }, - popEnterTransition = { fadeIn() + scaleIn(initialScale = 0.9f) }, - popExitTransition = { fadeOut() + scaleOut(targetScale = 0.9f) } + transitionSpec = { + fadeIn() + scaleIn(initialScale = 0.9f) togetherWith + fadeOut() + scaleOut(targetScale = 0.9f) + }, + popTransitionSpec = { + fadeIn() + scaleIn(initialScale = 0.9f) togetherWith + fadeOut() + scaleOut(targetScale = 0.9f) + } ) } ``` @@ -1652,20 +1814,58 @@ val configuration = authUIConfiguration { ```kotlin import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import com.firebase.ui.auth.configuration.AuthUITransitions + +val configuration = authUIConfiguration { + context = applicationContext + providers { + provider(AuthProvider.Email()) + } + transitions = AuthUITransitions( + // A vertical push: the new step rises from the bottom as the old one leaves via the top + transitionSpec = { slideInVertically { it } togetherWith slideOutVertically { -it } } + ) +} +``` + +**Per-destination animations:** + +Read `authRoute()` off `initialState` / `targetState` to vary the animation by the screen being +navigated to or from: + +```kotlin +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import com.firebase.ui.auth.configuration.AuthUITransitions +import com.firebase.ui.auth.ui.screens.AuthRoute +import com.firebase.ui.auth.ui.screens.authRoute val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Email()) } transitions = AuthUITransitions( - enterTransition = { slideInVertically { it } }, // Slide up - exitTransition = { slideOutVertically { -it } } // Slide down + transitionSpec = { + if (targetState.authRoute() is AuthRoute.Success) { + fadeIn() togetherWith fadeOut() + } else { + slideInHorizontally { it } togetherWith slideOutHorizontally { -it } + } + } ) } ``` -> **Note:** If not specified, default fade in/out transitions with 700ms duration are used. +> **Note:** Each spec is independent. Any one left unset falls back to the library's default +> 700ms cross-fade — `predictivePopTransitionSpec` included, which does *not* fall back to +> `popTransitionSpec`. `predictivePopTransitionSpec` also receives the swipe edge +> (`NavigationEvent.EDGE_LEFT`, `EDGE_RIGHT` or `EDGE_NONE`) and runs when the gesture *starts*, +> so a side effect placed in it fires even for gestures the user goes on to cancel. ## Advanced Features @@ -1676,6 +1876,7 @@ Seamlessly upgrade anonymous users to permanent accounts: ```kotlin // 1. Configure anonymous authentication with upgrade enabled val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Anonymous()) provider(AuthProvider.Email()) @@ -1711,6 +1912,7 @@ val emailProvider = AuthProvider.Email( ) val configuration = authUIConfiguration { + context = applicationContext providers { provider(emailProvider) } @@ -1840,6 +2042,7 @@ Credential Manager is enabled by default. To disable: ```kotlin val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Email()) } @@ -1847,6 +2050,89 @@ val configuration = authUIConfiguration { } ``` +### Automated Testing (Firebase Test Lab & Robo) + +Every input and button on the auth screens carries a stable, public test tag, and FirebaseUI exposes those tags as Android resource ids automatically — no setup required in your app. This is what lets [Firebase Test Lab's Robo test](https://firebase.google.com/docs/test-lab/android/robo-ux-test) and the Google Play Console's pre-launch report drive a real sign-in during automated testing, instead of typing into the wrong field or getting stuck on a screen it can't navigate. + +**Why this matters:** a crawler that can't tell which field is the password will happily type a username into it, then hammer "sign in" and "forgot password" until your test account is buried in reset emails. Every field and button below resolves to one unambiguous resource id, so a crawler — or your own instrumented test — can target it directly. + +**Tag reference.** Tags are grouped by screen; import `com.firebase.ui.auth.ui.FirebaseAuthTestTags`. + +| Screen | Constant | Resource id | +|---|---|---| +| Sign in | `SignIn.EMAIL_FIELD` | `fui_sign_in_email_field` | +| | `SignIn.PASSWORD_FIELD` | `fui_sign_in_password_field` | +| | `SignIn.SIGN_IN_BUTTON` | `fui_sign_in_sign_in_button` | +| | `SignIn.SIGN_UP_BUTTON` | `fui_sign_in_sign_up_button` | +| | `SignIn.FORGOT_PASSWORD_BUTTON` | `fui_sign_in_forgot_password_button` | +| | `SignIn.EMAIL_LINK_BUTTON` | `fui_sign_in_email_link_button` | +| Sign up | `SignUp.NAME_FIELD` | `fui_sign_up_name_field` | +| | `SignUp.EMAIL_FIELD` | `fui_sign_up_email_field` | +| | `SignUp.PASSWORD_FIELD` | `fui_sign_up_password_field` | +| | `SignUp.CONFIRM_PASSWORD_FIELD` | `fui_sign_up_confirm_password_field` | +| | `SignUp.SIGN_UP_BUTTON` | `fui_sign_up_sign_up_button` | +| Password recovery | `ResetPassword.EMAIL_FIELD` | `fui_reset_password_email_field` | +| | `ResetPassword.SEND_BUTTON` | `fui_reset_password_send_button` | +| | `ResetPassword.DISMISS_BUTTON` | `fui_reset_password_dismiss_button` | +| Email link sign-in | `EmailLink.EMAIL_FIELD` | `fui_email_link_email_field` | +| | `EmailLink.SEND_LINK_BUTTON` | `fui_email_link_send_link_button` | +| | `EmailLink.DISMISS_BUTTON` | `fui_email_link_dismiss_button` | +| Phone number entry | `PhoneNumber.PHONE_NUMBER_FIELD` | `fui_phone_number_phone_number_field` | +| | `PhoneNumber.COUNTRY_SELECTOR_BUTTON` | `fui_phone_number_country_selector_button` | +| | `PhoneNumber.SEND_CODE_BUTTON` | `fui_phone_number_send_code_button` | +| SMS verification | `VerificationCode.CODE_FIELD` | `fui_verification_code_code_field` | +| | `VerificationCode.VERIFY_BUTTON` | `fui_verification_code_verify_button` | +| | `VerificationCode.RESEND_CODE_BUTTON` | `fui_verification_code_resend_code_button` | +| | `VerificationCode.CHANGE_PHONE_NUMBER_BUTTON` | `fui_verification_code_change_phone_number_button` | +| MFA sign-in challenge | `MfaChallenge.CODE_FIELD` | `fui_mfa_challenge_code_field` | +| | `MfaChallenge.VERIFY_BUTTON` | `fui_mfa_challenge_verify_button` | +| Re-authentication | `Reauth.PASSWORD_FIELD` | `fui_reauth_password_field` | +| | `Reauth.VERIFY_BUTTON` | `fui_reauth_verify_button` | +| | `Reauth.DISMISS_BUTTON` | `fui_reauth_dismiss_button` | +| Method picker | `MethodPicker.PROVIDER_LIST` | `fui_method_picker_provider_list` | +| | `MethodPicker.CONTINUE_AS_BUTTON` | `fui_method_picker_continue_as_button` | +| Country selector | `CountrySelector.COUNTRY_LIST` | `fui_country_selector_country_list` | + +`VerificationCode.CODE_FIELD` and `MfaChallenge.CODE_FIELD` each name the whole six-digit input rather than an individual digit box: the field accepts a complete code in a single `ACTION_SET_TEXT`/`performTextInput` call and distributes it across the digit boxes, so one Robo directive or one `performTextInput("123456")` types the entire code. + +**In your own instrumented tests**, target these the same way you'd target any other tag: + +```kotlin +composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD) + .performTextInput("test@example.com") + +composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.SignIn.PASSWORD_FIELD) + .performTextInput("correcthorsebatterystaple") + +composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_IN_BUTTON) + .performClick() +``` + +Or with UiAutomator, by resource name: + +```kotlin +device.findObject(By.res("fui_sign_in_email_field")).text = "test@example.com" +``` + +**With Firebase Test Lab.** Pass the resource ids as [Robo directives](https://firebase.google.com/docs/test-lab/android/command-line#robo-test-with-a-script) so the crawler fills real values instead of guessing: + +```bash +gcloud firebase test android run \ + --type=robo \ + --app=app-debug.apk \ + --robo-directives=fui_sign_in_email_field=test@example.com,fui_sign_in_password_field=correcthorsebatterystaple \ + --device model=MediumPhone.arm,version=34 +``` + +This is exactly the mechanism a **Play Console pre-launch report** uses, under **Test and release → Testing → Pre-launch report → Settings → Test account credentials**; the resource ids above are what you enter there for the username and password fields. + +Verified with a real Firebase Test Lab Robo run against the sign-in screen (August 2026): the crawler resolved `fui_sign_in_email_field` and `fui_sign_in_password_field` as `android.widget.EditText` nodes, typed the directive values into both, and submitted via `fui_sign_in_sign_in_button` — along the way also navigating by resource id through sign-up, password recovery, and phone entry, confirming the tagging works generally rather than only where a directive points. Robo's crawling behavior is Google's, not ours, and can change independently of this library; treat this as a snapshot of current behavior rather than a permanent guarantee. + +Renaming or removing a tag, or changing the resource id it resolves to, is a breaking change to FirebaseUI's public API — not an internal detail — so a value documented here will not change without a major version bump. + ### Sign Out & Account Deletion **Sign Out:** @@ -1854,12 +2140,13 @@ val configuration = authUIConfiguration { ```kotlin @Composable fun SettingsScreen() { + val scope = rememberCoroutineScope() val context = LocalContext.current val authUI = remember { FirebaseAuthUI.getInstance() } Button( onClick = { - lifecycleScope.launch { + scope.launch { authUI.signOut(context) // User is signed out, navigate to auth screen navigateToAuth() @@ -1904,16 +2191,18 @@ Button( FirebaseUI includes default English strings. To add custom localization: ```kotlin -class SpanishStringProvider(context: Context) : AuthUIStringProvider { - override fun signInWithEmail() = "Iniciar sesión con correo" - override fun signInWithGoogle() = "Iniciar sesión con Google" - override fun signInWithFacebook() = "Iniciar sesión con Facebook" - override fun invalidEmail() = "Correo inválido" - override fun weakPassword() = "Contraseña débil" - // ... implement all other required methods +// AuthUIStringProvider declares ~170 abstract `val`s, so override properties, not functions, +// and expect to supply every one — DefaultAuthUIStringProvider is final and cannot be subclassed. +// For most apps, translating the library's own string resources is the lighter option. +class SpanishStringProvider : AuthUIStringProvider { + override val signInWithEmail = "Iniciar sesión con correo" + override val signInWithGoogle = "Iniciar sesión con Google" + override val invalidEmailAddress = "Correo inválido" + // ... every other member of AuthUIStringProvider } val configuration = authUIConfiguration { + context = applicationContext providers { provider(AuthProvider.Email()) } @@ -1990,6 +2279,7 @@ var errorState by remember { mutableStateOf(null) } errorState?.let { error -> ErrorRecoveryDialog( error = error, + stringProvider = DefaultAuthUIStringProvider(LocalContext.current), onRetry = { // Retry the authentication errorState = null @@ -2012,67 +2302,9 @@ errorState?.let { error -> ## Migration Guide -### From FirebaseUI Auth 9.x (View-based) - -The new Compose library has a completely different architecture. Here's how to migrate: - -**Old (9.x - View/Activity based):** - -```java -// Old approach with startActivityForResult -Intent signInIntent = AuthUI.getInstance() - .createSignInIntentBuilder() - .setAvailableProviders(Arrays.asList( - new AuthUI.IdpConfig.EmailBuilder().build(), - new AuthUI.IdpConfig.GoogleBuilder().build() - )) - .setTheme(R.style.AppTheme) - .build(); - -signInLauncher.launch(signInIntent); -``` - -**New (10.x - Compose based):** - -```kotlin -// New approach with Composable -val configuration = authUIConfiguration { - providers { - provider(AuthProvider.Email()) - provider(AuthProvider.Google()) - } - theme = AuthUITheme.fromMaterialTheme() -} - -FirebaseAuthScreen( - configuration = configuration, - onSignInSuccess = { result -> /* ... */ }, - onSignInFailure = { exception -> /* ... */ }, - onSignInCancelled = { /* ... */ } -) -``` - -**Key Changes:** - -1. **Pure Compose** - No more Activities or Intents, everything is Composable -2. **Configuration DSL** - Use `authUIConfiguration {}` instead of `createSignInIntentBuilder()` -3. **Provider Builders** - `AuthProvider.Email()` instead of `IdpConfig.EmailBuilder().build()` -4. **Callbacks** - Direct callback parameters instead of `ActivityResultLauncher` -5. **Theming** - `AuthUITheme` instead of `R.style` theme resources -6. **State Management** - Reactive `Flow` instead of `AuthStateListener` - -**Migration Checklist:** - -- [ ] Update dependency to `firebase-ui-auth:10.0.0-beta02` -- [ ] Convert Activities to Composables -- [ ] Replace Intent-based flow with `FirebaseAuthScreen` -- [ ] Update configuration from builder pattern to DSL -- [ ] Replace theme resources with `AuthUITheme` -- [ ] Update error handling from result codes to `AuthException` -- [ ] Remove `ActivityResultLauncher` and use direct callbacks -- [ ] Update sign-out/delete to use suspend functions - -For a complete migration example, see the [migration guide](../docs/upgrade-to-10.0.md). +Migrating from 9.x? [docs/upgrade-to-10.0.md](../docs/upgrade-to-10.0.md) is the full guide — +dependencies, provider configuration, theming, sign-out and deletion, auth-state observation, and +the Activity-based route for apps that can't use Compose everywhere. --- diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index 8e2e188e2e..76d2d5ab92 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -5,6 +5,9 @@ plugins { id("com.vanniktech.maven.publish") id("org.jetbrains.kotlin.android") alias(libs.plugins.kotlin.compose) + // Navigation 3 back-stack keys are @Serializable — that is how rememberNavBackStack persists + // them across configuration change and process death. + alias(libs.plugins.kotlin.serialization) } android { @@ -50,14 +53,24 @@ android { "DuplicateStrings", "LocaleFolder", "IconLocation", - "VectorPath" + "VectorPath", + "RtlEnabled", // A library cannot decide this; the consuming app declares it + // Satisfied by any enclosing if(), so it flags 5 of this module's 23 Log.d calls + // and misses the rest. Guarding those 5 with Log.isLoggable does not protect them, + // it silences them: the default per-tag level is INFO. Two of the five are wanted + // in field reports (PhoneAuthScreen.kt "Logged, not silent") and carry no user + // data; the other three log an email, a display name and a verificationId, which + // needs redaction rather than a guard — CPRN-440, which also owns re-enabling this. + "LogConditional" ) checkAllWarnings = true warningsAsErrors = true abortOnError = true - baseline = file("$rootDir/library/quality/lint-baseline.xml") + // Pre-existing debt only: 168 localization findings (CPRN-432). Every entry is + // suppressed; new ones still fail. + baseline = file("lint-baseline.xml") } testOptions { @@ -102,7 +115,18 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) - api(libs.compose.navigation) + // Navigation 3. `api`, not `implementation`: AuthUITransitions' lambdas are typed over + // androidx.navigation3.scene.Scene and AuthRoute's destinations are NavKeys, so both types are + // part of this library's public surface. Deliberately no lifecycle-viewmodel-navigation3 — + // auth/src/main is ViewModel-free. + api(libs.androidx.navigation3.runtime) + api(libs.androidx.navigation3.ui) + // No kotlinx-serialization declaration here on purpose. auth/src/main uses exactly one symbol + // from it — `@Serializable` on the AuthRoute keys — which lives in kotlinx-serialization-core, + // and navigation3-runtime publishes core in its own `api` variant, so the `api` above already + // puts it on this module's *and* every consumer's compile classpath. Declaring it again would + // only pin a higher core than navigation3 resolves, for every consumer, to no benefit. The + // `-json` runtime is a test-only dependency (see testImplementation below). implementation(libs.zxing.core) annotationProcessor(libs.androidx.lifecycle.compiler) @@ -126,6 +150,15 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.androidx.credentials) testImplementation(libs.compose.ui.test.junit4) + // Only the route test needs a concrete format, to round-trip a key's address the way saved + // state does. Nothing in auth/src/main touches `Json`. + // + // Its version is pinned to navigation3-runtime's own kotlinx-serialization-core (see the + // catalog): `-json` pulls a matching `-core`, Gradle resolves the highest on a classpath, and + // this is the only test that exercises the @Serializable codegen at runtime — so a higher + // `-json` here would silently move that test onto a core version no consumer of this library + // ever runs. + testImplementation(libs.kotlinx.serialization.json) debugImplementation(project(":internal:lintchecks")) } @@ -146,4 +179,7 @@ dependencies { tasks.withType().configureEach { jvmArgs("-javaagent:${mockitoAgent.asPath}") + // The suite OOMs the test worker on Gradle's 512m default, killing the run part-way. + // Cumulative retention across the module's Robolectric suites, not any one test. + maxHeapSize = "2g" } diff --git a/auth/lint-baseline.xml b/auth/lint-baseline.xml new file mode 100644 index 0000000000..b9c0976fa1 --- /dev/null +++ b/auth/lint-baseline.xml @@ -0,0 +1,2132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml index 29f9d060e5..45a4e5a625 100644 --- a/auth/src/main/AndroidManifest.xml +++ b/auth/src/main/AndroidManifest.xml @@ -34,10 +34,15 @@ android:name="com.facebook.sdk.ClientToken" android:value="@string/facebook_client_token"/> + + android:exported="false" + tools:ignore="RedundantLabel" /> get() { @@ -226,17 +229,10 @@ class AuthFlowController internal constructor( } /** - * Disposes the controller and releases all resources. - * - * This method: - * - Cancels all coroutines in the controller scope - * - Stops listening to auth state changes - * - Marks the controller as disposed + * Cancels the controller's coroutines and state collection, and marks it disposed. * - * Call this method in your Activity's `onDestroy()` to prevent memory leaks. - * - * **Important:** Once disposed, this controller cannot be reused. Create a new - * controller if you need to start another auth flow. + * Call this from your Activity's `onDestroy()`. Disposing twice is harmless, but a + * disposed controller cannot be reused — create a new one to start another flow. * * **Example:** * ```kotlin @@ -245,13 +241,11 @@ class AuthFlowController internal constructor( * authController.dispose() * } * ``` - * - * @throws IllegalStateException if already disposed (when called multiple times) */ fun dispose() { if (isDisposed.compareAndSet(false, true)) { stateCollectionJob?.cancel() - coroutineScope.cancel() + coroutineJob.cancel() } } diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt b/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt new file mode 100644 index 0000000000..3a3564a605 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser + +/** Where one auth flow's states go. */ +internal fun interface AuthStateSink { + fun emit(state: AuthState) +} + +/** + * One auth flow's collaborators, and where its states go. Provider code is written against this, + * not [FirebaseAuthUI], so it reaches the public state channel only through [sink]. + * + * @since 10.0.0 + */ +internal class AuthFlowScope( + val auth: FirebaseAuth, + val config: AuthUIConfiguration, + val credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null, + val loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null, + /** + * What this flow is currently doing, for the screens rendering it. Under a reauthentication + * request's scope this is that request's phase rather than the host's state. + */ + val state: State, + private val sink: AuthStateSink, +) { + fun emit(state: AuthState) = sink.emit(state) + + /** + * Publishes what [result] means for this flow: a password user who still owes email + * verification is not signed in yet, however successful the credential exchange was. + */ + fun emitResult(result: AuthResult?, defaultIsNewUser: Boolean = false) { + val user = result?.user + if (user != null) { + val isNewUser = result.additionalUserInfo?.isNewUser ?: defaultIsNewUser + emit(authUserState(user, result, isNewUser)) + } else { + emit(AuthState.Idle) + } + } +} + +/** + * What a signed-in [user] means as an [AuthState]: the single source of truth for whether they + * still owe email verification. Callers must not re-derive it — only password users with an email + * can satisfy that screen. + */ +internal fun authUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState { + val email = user.email + return if (!user.isEmailVerified && + email != null && + user.providerData.any { it.providerId == "password" } + ) { + AuthState.RequiresEmailVerification(user = user, email = email) + } else { + AuthState.Success(result = result, user = user, isNewUser = isNewUser) + } +} + +/** The auth flow the current composition belongs to, or null outside one. */ +internal val LocalAuthFlowScope = staticCompositionLocalOf { null } + +/** + * The ambient flow when composed inside one, otherwise a fresh flow over [authUI]'s public state — + * which is what a consumer composing `EmailAuthScreen` or `PhoneAuthScreen` on its own gets. + */ +@Composable +internal fun rememberAuthFlowScope( + authUI: FirebaseAuthUI, + configuration: AuthUIConfiguration, +): AuthFlowScope { + val ambient = LocalAuthFlowScope.current + val hostState = remember(authUI) { authUI.authStateFlow() } + .collectAsState(AuthState.Idle) + return remember(ambient, authUI, configuration, hostState) { + ambient ?: hostAuthFlowScope(authUI, configuration, hostState) + } +} + +/** An [AuthFlowScope] over [authUI]'s public flow, in both directions. */ +internal fun hostAuthFlowScope( + authUI: FirebaseAuthUI, + configuration: AuthUIConfiguration, + state: State, +): AuthFlowScope = AuthFlowScope( + auth = authUI.auth, + config = configuration, + credentialManagerProvider = authUI.testCredentialManagerProvider, + loginManagerProvider = authUI.testLoginManagerProvider, + state = state, + sink = { authUI.updateAuthState(it) }, +) diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt index 410107cdda..2ec0ddfb83 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt @@ -21,6 +21,8 @@ import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactorResolver import com.google.firebase.auth.PhoneAuthCredential import com.google.firebase.auth.PhoneAuthProvider +import kotlinx.coroutines.CompletableDeferred +import java.util.UUID /** * Represents the authentication state in Firebase Auth UI. @@ -28,7 +30,8 @@ import com.google.firebase.auth.PhoneAuthProvider * This class encapsulates all possible authentication states that can occur during * the authentication flow, including success, error, and intermediate states. * - * Use the companion object factory methods or specific subclass constructors to create instances. + * Instances come from the companion object factory methods or a subclass constructor; states only + * the library may publish have an `internal` constructor. * * @since 10.0.0 */ @@ -76,11 +79,14 @@ abstract class AuthState private constructor() { * @property result The [AuthResult] containing the authenticated user, may be null if not available * @property user The authenticated [FirebaseUser] * @property isNewUser Whether this is a newly created user account + * @property reauthenticatedUid The uid this success re-proved, or `null` if it is not a + * reauthentication. Settable only from within the library. */ - class Success( + class Success internal constructor( val result: AuthResult?, val user: FirebaseUser, - val isNewUser: Boolean = false + val isNewUser: Boolean = false, + val reauthenticatedUid: String? = null ) : AuthState() { override val isNotification: Boolean = false override fun equals(other: Any?): Boolean { @@ -88,18 +94,21 @@ abstract class AuthState private constructor() { if (other !is Success) return false return result == other.result && user == other.user && - isNewUser == other.isNewUser + isNewUser == other.isNewUser && + reauthenticatedUid == other.reauthenticatedUid } override fun hashCode(): Int { var result1 = result?.hashCode() ?: 0 result1 = 31 * result1 + user.hashCode() result1 = 31 * result1 + isNewUser.hashCode() + result1 = 31 * result1 + (reauthenticatedUid?.hashCode() ?: 0) return result1 } override fun toString(): String = - "AuthState.Success(result=$result, user=$user, isNewUser=$isNewUser)" + "AuthState.Success(result=$result, user=$user, isNewUser=$isNewUser, " + + "reauthenticatedUid=$reauthenticatedUid)" } /** @@ -248,33 +257,208 @@ abstract class AuthState private constructor() { } /** - * Reauthentication is required before a sensitive operation (e.g. delete account, change email) - * can proceed. Use [FirebaseAuthUI.createReauthFlow] to launch the reauthentication flow. - * - * @property user The [FirebaseUser] that needs to reauthenticate - * @property reason Optional human-readable reason to show the user + * A state in the lifecycle of one reauthentication request. Every state carries a stable + * [requestId], so recreation can tell a continuation from a new operation for the same user. */ - class ReauthenticationRequired( - val user: FirebaseUser, - val reason: String? = null, - // Not included in equals/hashCode — lambdas have no meaningful equality. - val retryOperation: (suspend (android.content.Context) -> Unit)? = null, - ) : AuthState() { + sealed class Reauthentication : AuthState() { + abstract val requestId: String + abstract val userUid: String + internal abstract val request: Request? override val isNotification: Boolean = false - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ReauthenticationRequired) return false - return user == other.user && reason == other.reason + + /** Process-local data shared by every resumable state of one reauthentication request. */ + internal class Request( + val requestId: String, + val user: FirebaseUser, + val reason: String?, + /** + * Where the caller awaiting this request is parked, or null when nobody is — a + * standalone flow from [FirebaseAuthUI.createReauthFlow] has no operation behind it. + */ + val resolver: CompletableDeferred? = null, + ) { + /** Whether a caller is waiting on this request to decide a pending operation. */ + val hasPendingOperation: Boolean get() = resolver != null + + /** Whether the awaiting caller is still there to resume. */ + val isResumable: Boolean get() = resolver?.isActive != false + + /** Credentials were accepted: the caller resumes and retries. Idempotent. */ + fun resolve() { + resolver?.complete(true) + } + + /** + * The request ended without proof. Completed with a value, not an exception: failing a + * parented Deferred would cancel the caller's scope, so [FirebaseAuthUI.withReauth] + * throws in its own frame instead. + */ + fun decline() { + resolver?.complete(false) + } } - override fun hashCode(): Int { - var result = user.hashCode() - result = 31 * result + (reason?.hashCode() ?: 0) - return result + /** + * Reauthentication is required before a sensitive operation (e.g. delete account, change + * email) can proceed. Use [FirebaseAuthUI.createReauthFlow] to launch a standalone + * reauthentication flow. + * + * @property requestId Stable identifier for this sensitive operation + * @property user The [FirebaseUser] that needs to reauthenticate + * @property reason Optional human-readable reason to show the user + */ + class Required internal constructor( + override val request: Request, + ) : Reauthentication() { + /** A request with nobody waiting on it, as a standalone reauthentication flow has. */ + internal constructor( + user: FirebaseUser, + reason: String? = null, + ) : this( + Request( + requestId = UUID.randomUUID().toString(), + user = user, + reason = reason, + ) + ) + + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + val user: FirebaseUser get() = request.user + val reason: String? get() = request.reason + + /** + * Identity is the request. Snapshot state and [FirebaseAuthUI.pendingReauth] both + * conflate equal values, so a transition that must be observed changes the phase type. + */ + override fun equals(other: Any?): Boolean = + other is Required && requestId == other.requestId + + override fun hashCode(): Int = requestId.hashCode() + + override fun toString(): String = + "AuthState.Reauthentication.Required(requestId=$requestId, " + + "user=$user, reason=$reason)" } - override fun toString(): String = - "AuthState.ReauthenticationRequired(user=$user, reason=$reason)" + /** The user has selected a provider and the library is exchanging credentials. */ + internal class Authenticating( + override val request: Request, + val message: String? = null, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** The most recent credential attempt failed, but the request remains outstanding. */ + internal class AttemptFailed( + override val request: Request, + val exception: Exception, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** A credential attempt requires MFA, which reauthentication UI does not yet support. */ + internal class RequiresMfa( + override val request: Request, + val resolver: MultiFactorResolver, + val hint: String? = null, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** Phone verification sent a code and is waiting for the user to enter it. */ + internal class PhoneNumberVerificationRequired( + override val request: Request, + val verificationId: String, + val forceResendingToken: PhoneAuthProvider.ForceResendingToken, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** Phone verification obtained a credential automatically. */ + internal class SmsAutoVerified( + override val request: Request, + val credential: PhoneAuthCredential, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** A password-reset email was sent from the reauthentication email sub-flow. */ + internal class PasswordResetLinkSent( + override val request: Request, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** A sign-in link was sent from the reauthentication email sub-flow. */ + internal class EmailSignInLinkSent( + override val request: Request, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** Credentials were accepted for the request's user. Terminal for the exchange. */ + internal class Succeeded( + override val request: Request, + val success: Success, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** + * A provider attempt is about to run, clearing any previously surfaced failure. Null once + * credentials were accepted, so a late attempt cannot rewind a finished request. + */ + internal fun attemptStarted(): AuthState? = when (this) { + is Required, + is Authenticating, + is AttemptFailed, + is RequiresMfa, + is PhoneNumberVerificationRequired, + is SmsAutoVerified, + is PasswordResetLinkSent, + is EmailSignInLinkSent, + -> request?.let { Authenticating(it) } + + else -> null + } + + /** + * The active sub-flow was consumed, so the request returns to provider selection. Null from + * a surfaced failure: only [attemptStarted] clears one, when a real attempt replaces it. + */ + internal fun returnedToProviderSelection(): AuthState? = when (this) { + is Authenticating, + is PhoneNumberVerificationRequired, + is SmsAutoVerified, + is PasswordResetLinkSent, + is EmailSignInLinkSent, + -> request?.let { Required(it) } + + else -> null + } + + /** + * The user backed out of an in-flight provider sub-flow. Null in every other phase, so a + * surfaced failure or a finished request is never rewound to provider selection. + */ + internal fun attemptCancelled(): AuthState? = when (this) { + is Authenticating, + is RequiresMfa, + is PhoneNumberVerificationRequired, + is SmsAutoVerified, + -> request?.let { Required(it) } + + else -> null + } } /** @@ -329,20 +513,12 @@ abstract class AuthState private constructor() { /** * Phone number verification requires manual code entry. * - * This state is emitted when Firebase Phone Authentication cannot instantly verify - * the phone number and sends an SMS code that the user must manually enter. This is - * the normal flow when automatic SMS retrieval is not available or fails. - * - * **Resending codes:** - * To allow users to resend the verification code (if they didn't receive it), - * call [FirebaseAuthUI.verifyPhoneNumber] again with: - * - `isForceResendingTokenEnabled = true` - * - `forceResendingToken` from this state - * - * @property verificationId The verification ID to use when submitting the code. - * This must be passed to [FirebaseAuthUI.submitVerificationCode]. - * @property forceResendingToken Token that can be used to resend the SMS code if needed + * Emitted when instant verification is unavailable or fails, so the code has to be typed + * in. `PhoneAuthScreen` holds both properties in its flow state and hands them back when + * submitting a code or resending. * + * @property verificationId Identifies the verification the submitted code belongs to. + * @property forceResendingToken Resends the SMS without restarting the verification. */ class PhoneNumberVerificationRequired( val verificationId: String, diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt index 1c502d22cd..047e02f1ae 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt @@ -48,10 +48,14 @@ import java.util.concurrent.ConcurrentHashMap * ```kotlin * val authUI = FirebaseAuthUI.getInstance() * val configuration = authUIConfiguration { - * providers = listOf(AuthProvider.Email(), AuthProvider.Google(...)) + * context = applicationContext + * providers { + * provider(AuthProvider.Email(...)) + * provider(AuthProvider.Google(...)) + * } * } * val controller = authUI.createAuthFlow(configuration) - * val intent = controller.createIntent(context) + * val intent = controller.createIntent(this) * launcher.launch(intent) * ``` * diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt index 972b1786b8..1978eabde0 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -20,10 +20,10 @@ import androidx.annotation.MainThread 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.firebase.ui.auth.ui.screens.reauth.toReauthConfiguration import com.google.firebase.Firebase import com.google.firebase.FirebaseApp import com.google.firebase.auth.AuthResult @@ -33,15 +33,19 @@ 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.CompletableDeferred +import kotlinx.coroutines.Job 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.flow.getAndUpdate import kotlinx.coroutines.tasks.await +import java.util.UUID +import kotlin.coroutines.coroutineContext import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicLong /** * The central class that coordinates all authentication operations for Firebase Auth UI Compose. @@ -80,7 +84,14 @@ class FirebaseAuthUI private constructor( ) { private val _authStateFlow = MutableStateFlow(AuthState.Idle) - private val authStateRevision = AtomicLong(0) + + /** + * The reauthentication request waiting for a screen to take it on, or null. Process-scoped + * like the caller awaiting it, so a recreated screen picks up the same request. + */ + internal val pendingReauth = MutableStateFlow(null) + + /** How many composed [FirebaseAuthScreen]s can currently drive a reauthentication request. */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) var testCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null @@ -172,10 +183,11 @@ class FirebaseAuthUI private constructor( * * val authUI = FirebaseAuthUI.getInstance() * val configuration = authUIConfiguration { - * providers = listOf( - * AuthProvider.Email(), - * AuthProvider.Google(...) - * ) + * context = applicationContext + * providers { + * provider(AuthProvider.Email(...)) + * provider(AuthProvider.Google(...)) + * } * } * * authController = authUI.createAuthFlow(configuration) @@ -230,8 +242,6 @@ class FirebaseAuthUI private constructor( * * @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 @@ -243,15 +253,10 @@ class FirebaseAuthUI private constructor( ?: throw AuthException.UserNotFoundException( message = "No user is currently signed in" ) - val linked = configuration.providers.filterToLinkedProviders(currentUser) - check(linked.isNotEmpty()) { + val reauthConfig = configuration.toReauthConfiguration(currentUser) + checkNotNull(reauthConfig) { "No configured providers are linked to the current user" } - val reauthConfig = configuration.copy( - providers = linked, - isNewEmailAccountsAllowed = false, - isReauthenticationMode = true, - ) return AuthFlowController(this, reauthConfig) } @@ -267,6 +272,8 @@ class FirebaseAuthUI private constructor( * - [AuthState.Cancelled] when authentication is cancelled * - [AuthState.RequiresMfa] when multi-factor authentication is needed * - [AuthState.RequiresEmailVerification] when email verification is needed + * - [AuthState.Reauthentication] for the whole of a reauthentication [FirebaseAuthScreen] is + * driving: the states above are then reported as its library-owned phases instead * * The flow automatically emits [AuthState.Success] or [AuthState.Idle] based on * the current authentication state when collection starts. @@ -303,7 +310,7 @@ class FirebaseAuthUI private constructor( val firebaseAuthFlow = callbackFlow { fun buildState(currentUser: FirebaseUser?): AuthState { return if (currentUser != null) { - handleAuthUserState(currentUser, result = null, isNewUser = false) + authUserState(currentUser, result = null, isNewUser = false) } else { AuthState.Idle } @@ -320,12 +327,16 @@ class FirebaseAuthUI private constructor( // 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 + val isStale = when (current) { + is AuthState.Success, + is AuthState.RequiresEmailVerification, + is AuthState.RequiresProfileCompletion, + -> true + else -> false } + if (isStale) updateAuthState(AuthState.Idle) + // A signed-out user cannot reauthenticate; the caller is told, not dropped. + pendingReauth.getAndUpdate { null }?.request?.decline() } trySend(buildState(firebaseAuth.currentUser)) } @@ -365,53 +376,21 @@ class FirebaseAuthUI private constructor( */ @MainThread fun updateAuthState(state: AuthState) { - authStateRevision.incrementAndGet() _authStateFlow.value = state } /** - * Retracts a pending [AuthState.Loading] by resetting to [AuthState.Idle], but only while - * [revision] is still the most recent write. Any state emitted since is left untouched. - * - * The revision is what makes this precise: [AuthState.Loading] compares equal whenever the - * message matches, and [MutableStateFlow] drops a write equal to the current value without - * replacing the stored reference - so neither equality nor identity can tell a concurrent - * operation's Loading apart from the caller's. - * - * @param revision The value [currentAuthStateRevision] returned right after the caller emitted - * the [AuthState.Loading] it now wants to retract + * Re-reads the signed-in user from the server and republishes the resulting auth state. + * No-op when nobody is signed in. */ - internal fun clearLoadingState(revision: Long) { - if (authStateRevision.get() == revision) updateAuthState(AuthState.Idle) - } - - /** Identifies the most recent [updateAuthState] write. See [clearLoadingState]. */ - internal fun currentAuthStateRevision(): Long = authStateRevision.get() - - 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) - } + internal suspend fun reloadUser() { + val user = auth.currentUser ?: return + user.reload().await() + user.getIdToken(true).await() + // Signing out (or switching account) mid-reload must win: publishing here would pin the + // combine in authStateFlow() to a Success for a user who is already gone. + if (auth.currentUser?.uid != user.uid) return + updateAuthState(authUserState(user, result = null, isNewUser = false)) } /** @@ -455,8 +434,17 @@ class FirebaseAuthUI private constructor( // Sign out from Firebase Auth auth.signOut() .also { - signOutFromGoogle(context) - signOutFromFacebook() + signOutFromGoogle( + auth = auth, + context = context, + credentialManagerProvider = testCredentialManagerProvider + ?: AuthProvider.Google.DefaultCredentialManagerProvider(), + ) + signOutFromFacebook( + auth = auth, + loginManagerProvider = testLoginManagerProvider + ?: AuthProvider.Facebook.DefaultLoginManagerProvider(), + ) } // Update state to idle (user signed out) @@ -482,43 +470,16 @@ class FirebaseAuthUI private constructor( } } - /** - * 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. + * If the [operation] throws [FirebaseAuthRecentLoginRequiredException], this raises a + * reauthentication request and suspends. [FirebaseAuthScreen] presents a sheet for it; once + * credentials are accepted the [operation] runs again on this same coroutine. + * + * If the user backs out, this throws [AuthException.AuthCancelledException] and the operation + * is not retried. A caller that must survive Activity recreation should launch from a scope + * that does too. * * All other exceptions propagate normally. * @@ -534,6 +495,7 @@ class FirebaseAuthUI private constructor( * @param context Android [Context] * @param reason Optional message shown to the user explaining why reauthentication is needed * @param operation The sensitive operation to attempt + * @throws AuthException.AuthCancelledException if the user declines reauthentication * @since 10.0.0 */ suspend fun withReauth( @@ -546,60 +508,72 @@ class FirebaseAuthUI private constructor( } catch (e: FirebaseAuthRecentLoginRequiredException) { val user = auth.currentUser ?: throw AuthException.UserNotFoundException(message = "No user is currently signed in") - updateAuthState( - AuthState.ReauthenticationRequired( + // Parented to the caller's job, so a dying scope makes this unresumable. + val resolver = CompletableDeferred(parent = coroutineContext[Job]) + val required = AuthState.Reauthentication.Required( + AuthState.Reauthentication.Request( + requestId = UUID.randomUUID().toString(), 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) - } - }, + resolver = resolver, ) ) + // One at a time; the caller this displaces is told rather than left waiting. + pendingReauth.getAndUpdate { required }?.request?.decline() + val retry = try { + resolver.await() + } finally { + pendingReauth.compareAndSet(required, null) + } + // Not through the resolver: failing a parented Deferred cancels the caller's scope. + if (!retry) { + throw AuthException.AuthCancelledException( + message = "Reauthentication was cancelled" + ) + } + // The screen handed over on a loading state, so the retry owes an outcome either way. + try { + operation() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Exception) { + updateAuthState(AuthState.Error(AuthException.from(failure, context))) + throw failure + } + updateAuthState( + auth.currentUser?.let { authUserState(it, result = null, isNewUser = false) } + ?: AuthState.Idle + ) } } + /** + * Deletes the signed-in user's account, reauthenticating first if Firebase requires it. + * + * @param context The Android [Context] for any required UI operations + * @throws AuthException.UserNotFoundException if no user is currently signed in + * @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 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) }, + withReauth(context) { + val currentUser = auth.currentUser + ?: throw AuthException.UserNotFoundException( + message = "No user is currently signed in" ) + updateAuthState( + AuthState.Loading(context.getString(R.string.fui_loading_deleting_account)) ) + currentUser.delete().await() + // The user is deleted and therefore signed out. + updateAuthState(AuthState.Idle) } - throw AuthException.InvalidCredentialsException( - message = e.message ?: "Recent login required for this operation", - cause = e - ) + } catch (e: AuthException.AuthCancelledException) { + // Declined, not failed: the screen already published the terminal state. + throw e } catch (e: CancellationException) { // Handle coroutine cancellation val cancelledException = AuthException.AuthCancelledException( @@ -744,4 +718,4 @@ class FirebaseAuthUI private constructor( 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/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt index 7eb92114e6..88fb8a7c95 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt @@ -99,7 +99,7 @@ class AuthUIConfigurationBuilder { is AuthProvider.Google -> provider.validate(context) is AuthProvider.Facebook -> provider.validate(context) is AuthProvider.GenericOAuth -> provider.validate() - else -> null + else -> {} } } @@ -237,6 +237,8 @@ class AuthUIConfiguration( ) { internal fun copy( providers: List = this.providers, + isAnonymousUpgradeEnabled: Boolean = this.isAnonymousUpgradeEnabled, + isCredentialLinkingEnabled: Boolean = this.isCredentialLinkingEnabled, isNewEmailAccountsAllowed: Boolean = this.isNewEmailAccountsAllowed, isReauthenticationMode: Boolean = this.isReauthenticationMode, ): AuthUIConfiguration = AuthUIConfiguration( @@ -247,7 +249,8 @@ class AuthUIConfiguration( stringProvider = this.stringProvider, isCredentialManagerEnabled = this.isCredentialManagerEnabled, isMfaEnabled = this.isMfaEnabled, - isAnonymousUpgradeEnabled = this.isAnonymousUpgradeEnabled, + isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled, + isCredentialLinkingEnabled = isCredentialLinkingEnabled, tosUrl = this.tosUrl, privacyPolicyUrl = this.privacyPolicyUrl, logo = this.logo, @@ -255,6 +258,7 @@ class AuthUIConfiguration( isNewEmailAccountsAllowed = isNewEmailAccountsAllowed, isDisplayNameRequired = this.isDisplayNameRequired, isProviderChoiceAlwaysShown = this.isProviderChoiceAlwaysShown, + legacyFetchSignInWithEmail = this.legacyFetchSignInWithEmail, 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 index b37dc34e19..41307b3285 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUITransitions.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUITransitions.kt @@ -15,21 +15,54 @@ 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 +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.Scene /** * 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 + * + * Each spec is an [AnimatedContentTransitionScope] receiver on [Scene]`<`[NavKey]`>` returning a + * single [ContentTransform], pairing the enter and exit halves with `togetherWith`. To vary the + * animation per destination, read [com.firebase.ui.auth.ui.screens.authRoute] off `initialState` + * / `targetState`. + * + * @property transitionSpec Forward navigation. + * @property popTransitionSpec Back navigation. + * @property predictivePopTransitionSpec Predictive-back gesture. Its `Int` is the swipe edge: + * [androidx.navigationevent.NavigationEvent.EDGE_LEFT] (`0`), + * [androidx.navigationevent.NavigationEvent.EDGE_RIGHT] (`1`) or + * [androidx.navigationevent.NavigationEvent.EDGE_NONE] (`2`) for a back from no edge at all. Left + * null it falls back to the library's default cross-fade, not to [popTransitionSpec]. It runs when + * the gesture **starts**, not when a back navigation completes, so any side effect placed in it + * (analytics, logging a screen change) also fires for gestures the user goes on to cancel. + * + * @since 10.0.0 */ data class AuthUITransitions( - val enterTransition: (AnimatedContentTransitionScope.() -> EnterTransition)? = null, - val exitTransition: (AnimatedContentTransitionScope.() -> ExitTransition)? = null, - val popEnterTransition: (AnimatedContentTransitionScope.() -> EnterTransition)? = null, - val popExitTransition: (AnimatedContentTransitionScope.() -> ExitTransition)? = null, + val transitionSpec: + (AnimatedContentTransitionScope>.() -> ContentTransform)? = null, + val popTransitionSpec: + (AnimatedContentTransitionScope>.() -> ContentTransform)? = null, + val predictivePopTransitionSpec: + (AnimatedContentTransitionScope>.(Int) -> ContentTransform)? = null, ) + +private const val DEFAULT_TRANSITION_MILLIS = 700 + +/** The cross-fade every auth surface animates with when the host configured no spec of its own. */ +internal val DefaultAuthContentTransform: + AnimatedContentTransitionScope>.() -> ContentTransform = { + fadeIn(animationSpec = tween(DEFAULT_TRANSITION_MILLIS)) togetherWith + fadeOut(animationSpec = tween(DEFAULT_TRANSITION_MILLIS)) + } + +/** [DefaultAuthContentTransform] at the predictive-pop arity. */ +internal val DefaultAuthPredictivePopContentTransform: + AnimatedContentTransitionScope>.(Int) -> ContentTransform = { + DefaultAuthContentTransform() + } 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 index ed748bfe05..bea7207514 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/MfaConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/MfaConfiguration.kt @@ -14,29 +14,42 @@ package com.firebase.ui.auth.configuration +import com.firebase.ui.auth.util.CountryUtils + /** * 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. + * This class controls which MFA factors are available to users and whether enrollment is + * mandatory. * * @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. + * @property allowedCountries ISO 3166-1 alpha-2 country codes the [MfaFactor.Sms] enrollment step + * restricts its country selector to, or `null` for no restriction. Dial + * codes are rejected: the filter behind this matches alpha-2 only, so a + * dial code would silently restrict to nothing. Lives here rather + * than on the phone sign-in provider because a second factor is + * configured independently of the first: Firebase enables SMS second + * factors separately from phone sign-in, and phone sign-in cannot carry + * a second factor at all. Defaults to null. */ class MfaConfiguration( val allowedFactors: List = listOf(MfaFactor.Sms, MfaFactor.Totp), val requireEnrollment: Boolean = false, - val enableRecoveryCodes: Boolean = true + val allowedCountries: List? = null ) { init { require(allowedFactors.isNotEmpty()) { "At least one MFA factor must be allowed" } + allowedCountries?.forEach { code -> + require(CountryUtils.findByCountryCode(code) != null) { + "Invalid country code: $code. allowedCountries takes ISO 3166-1 alpha-2 codes " + + "(e.g. 'us', 'GB'). Dial codes are not accepted." + } + } } } 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 index 1027b9cab7..3c5f2df498 100644 --- 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 @@ -2,10 +2,9 @@ package com.firebase.ui.auth.configuration.auth_provider import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope +import com.firebase.ui.auth.AuthFlowScope 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 @@ -13,7 +12,6 @@ import kotlinx.coroutines.tasks.await /** * Creates a remembered launcher function for anonymous sign-in. * - * @param config Authentication UI configuration * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure * @return A launcher function that starts the anonymous sign-in flow when invoked * @@ -21,8 +19,7 @@ import kotlinx.coroutines.tasks.await * @see createOrLinkUserWithEmailAndPassword for upgrading anonymous accounts */ @Composable -internal fun FirebaseAuthUI.rememberAnonymousSignInHandler( - config: AuthUIConfiguration, +internal fun AuthFlowScope.rememberAnonymousSignInHandler( onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { val context = androidx.compose.ui.platform.LocalContext.current @@ -30,14 +27,14 @@ internal fun FirebaseAuthUI.rememberAnonymousSignInHandler( return { coroutineScope.launch { try { - signInAnonymously(config) + signInAnonymously() } catch (e: AuthException) { // Already an AuthException, don't re-wrap it - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) if (e !is AuthException.AuthCancelledException) onSignInFailure(e) } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } } @@ -45,91 +42,29 @@ internal fun FirebaseAuthUI.rememberAnonymousSignInHandler( } /** - * Signs in a user anonymously with Firebase Authentication. + * Signs the user in anonymously. * - * 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 + * The account is temporary: linking a credential to it later upgrades it in place, which is + * what anonymous upgrade does for every other provider. */ -internal suspend fun FirebaseAuthUI.signInAnonymously(config: AuthUIConfiguration) { +internal suspend fun AuthFlowScope.signInAnonymously() { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningInAnonymously)) + emit(AuthState.Loading(config.stringProvider.loadingSigningInAnonymously)) val result = auth.signInAnonymously().await() - updateAuthStateWithResult(result, defaultIsNewUser = true) + emitResult(result, defaultIsNewUser = true) } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in anonymously was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) + emit(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 index 53ca936608..e4c46dd4bd 100644 --- 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 @@ -273,11 +273,6 @@ abstract class AuthProvider(open val providerId: String, open val providerName: */ 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. */ @@ -998,6 +993,9 @@ abstract class AuthProvider(open val providerId: String, open val providerName: internal fun canUpgradeAnonymous(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean { val currentUser = auth.currentUser return config.isAnonymousUpgradeEnabled + // Same reason as canLinkCredential: an upgrade link is not a proof of + // identity, so it must never be stamped as a reauthentication. + && !config.isReauthenticationMode && currentUser != null && currentUser.isAnonymous } @@ -1005,6 +1003,9 @@ abstract class AuthProvider(open val providerId: String, open val providerName: internal fun canLinkCredential(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean { val currentUser = auth.currentUser return config.isCredentialLinkingEnabled + // Linking is not a proof of identity: diverting a reauthentication to + // linkWithCredential would yield an unstamped Success the guard must reject. + && !config.isReauthenticationMode && currentUser != null && !currentUser.isAnonymous } 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 index 1e480eda98..bb1d277926 100644 --- 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 @@ -18,9 +18,9 @@ import android.content.Context import android.net.Uri import android.util.Log import com.firebase.ui.auth.R +import com.firebase.ui.auth.AuthFlowScope 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 @@ -54,9 +54,8 @@ private const val TAG = "EmailAuthProvider" * - 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( +internal suspend fun AuthFlowScope.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") @@ -67,79 +66,18 @@ internal suspend fun FirebaseAuthUI.signInOrReauth( } /** - * 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 + * Creates an email/password account, or links the credential to the signed-in anonymous user. * - * @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 + * Validates the password against [AuthProvider.Email.minimumPasswordLength] and + * [AuthProvider.Email.passwordValidationRules], refuses a new account when the provider + * disallows one, then either links to the anonymous user or creates a fresh account and merges + * [name] into the profile. * - * **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 - * } - * ``` + * @throws AuthException.AccountLinkingRequiredException when an anonymous upgrade meets an + * account that already owns [email] — the host must sign that account in to link. */ -internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( +internal suspend fun AuthFlowScope.createOrLinkUserWithEmailAndPassword( context: Context, - config: AuthUIConfiguration, provider: AuthProvider.Email, name: String?, email: String, @@ -153,8 +91,14 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( 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) { + if (config.isReauthenticationMode) { + throw AuthException.UnknownException( + message = context.getString(R.string.fui_error_reauth_sign_up_not_allowed) + ) + } + if (!shouldLinkCredential && + (!provider.isNewAccountsAllowed || !config.isNewEmailAccountsAllowed) + ) { throw AuthException.UserNotFoundException( message = context.getString(R.string.fui_error_email_does_not_exist) ) @@ -178,7 +122,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( } } - updateAuthState(AuthState.Loading(config.stringProvider.loadingCreatingUser)) + emit(AuthState.Loading(config.stringProvider.loadingCreatingUser)) val result = if (shouldLinkCredential) { auth.currentUser?.linkWithCredential(requireNotNull(pendingCredential))?.await() } else { @@ -220,7 +164,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( } } - updateAuthStateWithResult(result, defaultIsNewUser = true) + emitResult(result, defaultIsNewUser = true) return result } catch (e: FirebaseAuthUserCollisionException) { // Account collision: email already exists @@ -235,121 +179,46 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( }, cause = e ) - updateAuthState(AuthState.Error(accountLinkingException)) + emit(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)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } } /** - * Signs in a user with email and password, optionally linking a social credential. + * Signs in with email and password, optionally linking a social credential afterwards. * - * 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 - * } - * ``` + * An anonymous upgrade never signs the anonymous user out: the credentials are validated in a + * scratch auth instance first, and only then is [AuthException.AccountLinkingRequiredException] + * thrown for the host to resolve. A normal sign-in links [credentialForLinking], when given, + * and merges its profile. */ -internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( +internal suspend fun AuthFlowScope.signInWithEmailAndPassword( context: Context, - config: AuthUIConfiguration, email: String, password: String, credentialForLinking: AuthCredential? = null, skipCredentialSave: Boolean = false, ): AuthResult? { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningIn)) + emit(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), ) } @@ -384,7 +253,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( credential = credentialToValidate, cause = null ) - updateAuthState(AuthState.Error(accountLinkingException)) + emit(AuthState.Error(accountLinkingException)) throw accountLinkingException } } else { @@ -402,7 +271,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( credential = credentialToValidate, cause = null ) - updateAuthState(AuthState.Error(accountLinkingException)) + emit(AuthState.Error(accountLinkingException)) throw accountLinkingException } } @@ -462,34 +331,33 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( } } - updateAuthStateWithResult(result) + emitResult(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)) + emit(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)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = recoverLegacyDifferentSignInMethod(config, email, e) + val authException = recoverLegacyDifferentSignInMethod(email, e) ?: AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } } -private suspend fun FirebaseAuthUI.recoverLegacyDifferentSignInMethod( - config: AuthUIConfiguration, +private suspend fun AuthFlowScope.recoverLegacyDifferentSignInMethod( email: String, cause: Exception, ): AuthException.DifferentSignInMethodRequiredException? { @@ -539,7 +407,7 @@ private fun selectSuggestedLegacySignInMethod( } } -private suspend fun FirebaseAuthUI.fetchLegacySignInMethods(email: String): List { +private suspend fun AuthFlowScope.fetchLegacySignInMethods(email: String): List { return try { @Suppress("DEPRECATION") auth.fetchSignInMethodsForEmail(email) @@ -555,118 +423,49 @@ 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 - * ``` + * Signs in with [credential], or links it to the signed-in anonymous user when upgrade is on. * - * **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) - * ``` + * Merges [displayName] and [photoUrl] into the Firebase profile once authenticated. A collision + * surfaces as [AuthException.AccountLinkingRequiredException] rather than the raw Firebase + * exception, so the host can drive the linking flow. */ -internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( - config: AuthUIConfiguration, +internal suspend fun AuthFlowScope.signInAndLinkWithCredential( credential: AuthCredential, provider: AuthProvider? = null, displayName: String? = null, photoUrl: Uri? = null, ): AuthResult? { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingLinkingCredential)) + emit(AuthState.Loading(config.stringProvider.loadingLinkingCredential)) val result = if (canUpgradeAnonymous(config, auth) || canLinkCredential(config, auth)) { auth.currentUser?.linkWithCredential(credential)?.await() } else { - signInOrReauth(credential, config) + signInOrReauth(credential) } // 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)) - } + val reauthenticatedUser = auth.currentUser + ?: throw AuthException.UserNotFoundException( + message = "No user is currently signed in for reauthentication" + ) + emit( + AuthState.Success( + result = null, + user = reauthenticatedUser, + reauthenticatedUid = reauthenticatedUser.uid, + ) + ) return null } result?.user?.let { mergeProfile(auth, displayName, photoUrl) } - updateAuthStateWithResult(result) + emitResult(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)) + emit(AuthState.RequiresMfa(resolver, hint)) return null } catch (e: FirebaseAuthUserCollisionException) { // Account collision: account already exists with different sign-in method @@ -688,149 +487,46 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( credential = credentialForException, cause = e ) - updateAuthState(AuthState.Error(accountLinkingException)) + emit(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)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) + emit(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. + * Sends a passwordless sign-in link to [email]. * - * **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 + * The link's continue URL carries a session id, the anonymous user's id when upgrading, and the + * force-same-device flag; the email and session are persisted so [signInWithEmailLink] can + * validate the link when it comes back. * - * **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 + * [credentialForLinking] only adds the provider id to that URL — it is **not** persisted here. A + * caller linking a collided social credential must save it itself, via + * `EmailLinkPersistenceManager.saveCredentialForLinking`, before calling this; that is what + * [signInWithEmailLink] later picks up. */ -internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( +internal suspend fun AuthFlowScope.sendSignInLinkToEmail( context: Context, - config: AuthUIConfiguration, provider: AuthProvider.Email, email: String, credentialForLinking: AuthCredential?, persistenceManager: PersistenceManager = EmailLinkPersistenceManager.default, ) { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSendingEmailLink)) + emit(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 @@ -857,138 +553,50 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( // Save Email to dataStore for use in signInWithEmailLink persistenceManager.saveEmail(context, email, sessionId, anonymousUserId) - updateAuthState(AuthState.EmailSignInLinkSent()) + emit(AuthState.EmailSignInLinkSent()) } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Send sign in link to email was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(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 + * Completes a passwordless sign-in from the link the user followed. + * + * On the same device the address and session id come from storage and the user is signed in + * without further input. When the session id does not match — a different device, or storage + * cleared — an empty [email] raises [AuthException.EmailLinkPromptForEmailException]; call again + * with the address the user supplies. On the same-device path an empty [email] instead means the + * stored address is gone, and raises [AuthException.EmailMismatchException]. + * + * @throws AuthException.EmailLinkWrongDeviceException if the link requires the originating device + * — force-same-device, or an anonymous upgrade — and was opened elsewhere. + * @throws AuthException.EmailLinkCrossDeviceLinkingException if a link carrying a social + * credential to link is opened on another device. + * @throws AuthException.EmailLinkDifferentAnonymousUserException if the anonymous uid in the link + * is not the uid signed in now. + * @throws AuthException.InvalidEmailLinkException if the link is not a sign-in link. */ -internal suspend fun FirebaseAuthUI.signInWithEmailLink( +internal suspend fun AuthFlowScope.signInWithEmailLink( context: Context, - config: AuthUIConfiguration, provider: AuthProvider.Email, email: String, emailLink: String, persistenceManager: PersistenceManager = EmailLinkPersistenceManager.default, ): AuthResult? { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningInWithEmailLink)) + emit(AuthState.Loading(config.stringProvider.loadingSigningInWithEmailLink)) // Validate link format if (!auth.isSignInWithEmailLink(emailLink)) { @@ -1018,14 +626,14 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( // Session ID must always be present in the link if (sessionIdFromLink.isNullOrEmpty()) { val exception = AuthException.InvalidEmailLinkException() - updateAuthState(AuthState.Error(exception)) + emit(AuthState.Error(exception)) throw exception } // These scenarios require same-device flow if (isEmailLinkForceSameDeviceEnabled || !anonymousUserIdFromLink.isNullOrEmpty()) { val exception = AuthException.EmailLinkWrongDeviceException() - updateAuthState(AuthState.Error(exception)) + emit(AuthState.Error(exception)) throw exception } @@ -1053,7 +661,7 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( || currentUser.uid != anonymousUserIdFromLink ) { val exception = AuthException.EmailLinkDifferentAnonymousUserException() - updateAuthState(AuthState.Error(exception)) + emit(AuthState.Error(exception)) throw exception } } @@ -1064,12 +672,11 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( val result = if (storedCredentialForLink == null) { // Normal Flow: Just sign in with email link - handleEmailLinkNormalFlow(config, emailLinkCredential) + handleEmailLinkNormalFlow(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, @@ -1077,26 +684,31 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( } // Clear DataStore after success persistenceManager.clear(context) - updateAuthStateWithResult(result) + // In reauth mode the stamped Success is already published and there is no AuthResult, so + // emitResult would overwrite the stamp with Idle and orphan the operation. + if (result == null && config.isReauthenticationMode) { + return null + } + emitResult(result) return result } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in with email link was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } } -private suspend fun FirebaseAuthUI.handleDifferentDeviceErrorFlow( +private suspend fun AuthFlowScope.handleDifferentDeviceErrorFlow( oobCode: String, providerIdFromLink: String?, emailLink: String @@ -1107,7 +719,7 @@ private suspend fun FirebaseAuthUI.handleDifferentDeviceErrorFlow( } catch (e: Exception) { // Invalid action code val exception = AuthException.InvalidEmailLinkException(cause = e) - updateAuthState(AuthState.Error(exception)) + emit(AuthState.Error(exception)) throw exception } @@ -1119,7 +731,7 @@ private suspend fun FirebaseAuthUI.handleDifferentDeviceErrorFlow( providerName = providerNameForMessage, emailLink = emailLink ) - updateAuthState(AuthState.Error(exception)) + emit(AuthState.Error(exception)) throw exception } @@ -1128,20 +740,18 @@ private suspend fun FirebaseAuthUI.handleDifferentDeviceErrorFlow( cause = null, emailLink = emailLink ) - updateAuthState(AuthState.Error(exception)) + emit(AuthState.Error(exception)) throw exception } -private suspend fun FirebaseAuthUI.handleEmailLinkNormalFlow( - config: AuthUIConfiguration, +private suspend fun AuthFlowScope.handleEmailLinkNormalFlow( emailLinkCredential: AuthCredential, ): AuthResult? { - return signInAndLinkWithCredential(config, emailLinkCredential) + return signInAndLinkWithCredential(emailLinkCredential) } -private suspend fun FirebaseAuthUI.handleEmailLinkCredentialLinkingFlow( +private suspend fun AuthFlowScope.handleEmailLinkCredentialLinkingFlow( context: Context, - config: AuthUIConfiguration, email: String, emailLinkCredential: AuthCredential, storedCredentialForLink: AuthCredential, @@ -1169,7 +779,7 @@ private suspend fun FirebaseAuthUI.handleEmailLinkCredentialLinkingFlow( credential = storedCredentialForLink, cause = null ) - updateAuthState(AuthState.Error(accountLinkingException)) + emit(AuthState.Error(accountLinkingException)) throw accountLinkingException } } else { @@ -1191,91 +801,31 @@ private suspend fun FirebaseAuthUI.handleEmailLinkCredentialLinkingFlow( } /** - * Sends a password reset email to the specified email address. + * Sends a password reset email to [email] and emits [AuthState.PasswordResetLinkSent]. * - * 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 + * [actionCodeSettings] points the link at your own page instead of the Firebase-hosted one. */ -internal suspend fun FirebaseAuthUI.sendPasswordResetEmail( +internal suspend fun AuthFlowScope.sendPasswordResetEmail( email: String, - config: AuthUIConfiguration, actionCodeSettings: ActionCodeSettings? = null, ) { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSendingPasswordResetEmail)) + emit(AuthState.Loading(config.stringProvider.loadingSendingPasswordResetEmail)) auth.sendPasswordResetEmail(email, actionCodeSettings).await() - updateAuthState(AuthState.PasswordResetLinkSent()) + emit(AuthState.PasswordResetLinkSent()) } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Send password reset email was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) + emit(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 index 3cd51c1d39..0a8a7212be 100644 --- 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 @@ -14,6 +14,7 @@ package com.firebase.ui.auth.configuration.auth_provider +import com.google.firebase.auth.FirebaseAuth import android.content.Context import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult @@ -29,10 +30,9 @@ import com.facebook.FacebookCallback import com.facebook.FacebookException import com.facebook.login.LoginManager import com.facebook.login.LoginResult +import com.firebase.ui.auth.AuthFlowScope 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 @@ -46,7 +46,6 @@ import kotlinx.coroutines.launch * 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 * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure @@ -56,9 +55,8 @@ import kotlinx.coroutines.launch * @see signInWithFacebook */ @Composable -internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( +internal fun AuthFlowScope.rememberSignInWithFacebookLauncher( context: Context, - config: AuthUIConfiguration, provider: AuthProvider.Facebook, loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(), onSignInFailure: (AuthException) -> Unit = {}, @@ -67,7 +65,8 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( val callbackManager = remember { CallbackManager.Factory.create() } val loginManager = LoginManager.getInstance() val currentContext by rememberUpdatedState(context) - val currentConfig by rememberUpdatedState(config) + // Registered once under DisposableEffect(Unit), so it must not close over a stale scope. + val currentScope by rememberUpdatedState(this) val currentProvider by rememberUpdatedState(provider) val currentOnSignInFailure by rememberUpdatedState(onSignInFailure) @@ -86,32 +85,31 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( override fun onSuccess(result: LoginResult) { coroutineScope.launch { try { - signInWithFacebook( + currentScope.signInWithFacebook( context = currentContext, - config = currentConfig, provider = currentProvider, accessToken = result.accessToken, ) } catch (e: AuthException) { // Already an AuthException, don't re-wrap it - updateAuthState(AuthState.Error(e)) + currentScope.emit(AuthState.Error(e)) if (e !is AuthException.AuthCancelledException) currentOnSignInFailure(e) } catch (e: Exception) { val authException = AuthException.from(e, currentContext) - updateAuthState(AuthState.Error(authException)) + currentScope.emit(AuthState.Error(authException)) if (authException !is AuthException.AuthCancelledException) currentOnSignInFailure(authException) } } } override fun onCancel() { - updateAuthState(AuthState.Idle) + currentScope.emit(AuthState.Idle) } override fun onError(error: FacebookException) { Log.e("FacebookAuthProvider", "Error during Facebook sign in", error) val authException = AuthException.from(error, currentContext) - updateAuthState( + currentScope.emit( AuthState.Error( authException ) @@ -124,11 +122,11 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( } return { - updateAuthState( + emit( AuthState.Loading(config.stringProvider.loadingSigningInWithFacebook) ) try { - (testLoginManagerProvider ?: loginManagerProvider).logOut() + (this.loginManagerProvider ?: loginManagerProvider).logOut() } catch (e: Exception) { Log.w("FacebookAuthProvider", "Failed to clear Facebook session before sign in", e) } @@ -144,7 +142,6 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( * 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 @@ -157,21 +154,19 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( * @see rememberSignInWithFacebookLauncher * @see signInAndLinkWithCredential */ -internal suspend fun FirebaseAuthUI.signInWithFacebook( +internal suspend fun AuthFlowScope.signInWithFacebook( context: Context, - config: AuthUIConfiguration, provider: AuthProvider.Facebook, accessToken: AccessToken, credentialProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(), ) { try { - updateAuthState( + emit( 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, @@ -205,25 +200,25 @@ internal suspend fun FirebaseAuthUI.signInWithFacebook( ) // Re-throw to let UI handle the account linking flow - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: FacebookException) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(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)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } } @@ -238,12 +233,13 @@ internal suspend fun FirebaseAuthUI.signInWithFacebook( * This is typically called as part of the overall sign-out flow when a user signs out * from Firebase Authentication. */ -internal fun FirebaseAuthUI.signOutFromFacebook( +internal fun signOutFromFacebook( + auth: FirebaseAuth, loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(), ) { try { - if (Provider.fromId(getCurrentUser()?.providerId) != Provider.FACEBOOK) return - (testLoginManagerProvider ?: loginManagerProvider).logOut() + if (Provider.fromId(auth.currentUser?.providerId) != Provider.FACEBOOK) return + 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 index 09736f1900..96ffbb79f3 100644 --- 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 @@ -1,62 +1,31 @@ package com.firebase.ui.auth.configuration.auth_provider +import com.google.firebase.auth.FirebaseAuth import android.content.Context import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.credentials.CredentialManager import androidx.credentials.exceptions.GetCredentialCancellationException -import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.NoCredentialException +import com.firebase.ui.auth.AuthFlowScope 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. + * Remembers a callback that runs [signInWithGoogle] in the composition's scope. * - * This Composable function returns a lambda that, when invoked, initiates the Google Sign-In - * flow using [signInWithGoogle]. The callback is rebuilt on every recomposition so it always - * captures the latest parameters, and 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 - * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure - * @return A callback function that initiates Google Sign-In when invoked - * - * @see signInWithGoogle - * @see AuthProvider.Google + * Rebuilt on every recomposition, so it always captures the latest parameters. */ @Composable -internal fun FirebaseAuthUI.rememberGoogleSignInHandler( +internal fun AuthFlowScope.rememberGoogleSignInHandler( context: Context, - config: AuthUIConfiguration, provider: AuthProvider.Google, onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { @@ -64,13 +33,13 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler( return { coroutineScope.launch { try { - signInWithGoogle(context, config, provider) + signInWithGoogle(context, provider) } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) if (e !is AuthException.AuthCancelledException) onSignInFailure(e) } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } } @@ -78,52 +47,24 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler( } /** - * 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. + * Signs in with Google through Credential Manager. * - * **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] + * Requests OAuth authorization first when [AuthProvider.Google.scopes] is non-empty, then hands + * the credential to [signInAndLinkWithCredential], which owns anonymous upgrade and collision + * handling. * - * **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 - * - [GetCredentialCancellationException]: User dismissed the Credential Manager sheet - - * updates [AuthState.Cancelled] and does not throw - * - [GetCredentialException]: 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 + * Dismissing the Credential Manager sheet is not an error: it emits [AuthState.Cancelled] and + * returns normally rather than throwing, so the flow stays open on the method picker. */ -internal suspend fun FirebaseAuthUI.signInWithGoogle( +internal suspend fun AuthFlowScope.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)) + emit(AuthState.Loading(config.stringProvider.loadingSigningInWithGoogle)) // Request OAuth scopes if specified (before sign-in) if (provider.scopes.isNotEmpty()) { @@ -133,7 +74,7 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( } catch (e: Exception) { // Continue with sign-in even if scope authorization fails val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) } } @@ -143,7 +84,7 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( val result = if (provider.filterByAuthorizedAccounts) { // Default behavior: Try authorized accounts first, fallback to all accounts try { - (testCredentialManagerProvider ?: credentialManagerProvider).getGoogleCredential( + (this.credentialManagerProvider ?: credentialManagerProvider).getGoogleCredential( context = context, credentialManager = CredentialManager.create(context), serverClientId = provider.serverClientId!!, @@ -154,7 +95,7 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( // 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( + (this.credentialManagerProvider ?: credentialManagerProvider).getGoogleCredential( context = context, credentialManager = CredentialManager.create(context), serverClientId = provider.serverClientId!!, @@ -162,6 +103,21 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( autoSelectEnabled = provider.autoSelectEnabled ) } catch (fallbackException: NoCredentialException) { + // Credential Manager doesn't distinguish "no account on device" from + // developer-side misconfiguration, so log the possible causes for + // debugging. Never surfaced to end users: the overwhelming majority + // hitting this genuinely have no account, and Firebase Console + // guidance would just confuse them. + Log.w( + "GoogleAuthProvider", + "No credential returned from Credential Manager after trying both " + + "authorized and all accounts. Possible causes: (1) no Google " + + "account on this device, (2) no Android OAuth client / SHA-1 " + + "registered for this app's package + signing certificate in the " + + "Firebase console, or (3) the Credential Manager Google ID " + + "provider is unavailable on this device.", + fallbackException + ) // 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.", @@ -171,7 +127,7 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( } } else { // Developer explicitly wants to show all accounts (no fallback needed) - (testCredentialManagerProvider ?: credentialManagerProvider).getGoogleCredential( + (this.credentialManagerProvider ?: credentialManagerProvider).getGoogleCredential( context = context, credentialManager = CredentialManager.create(context), serverClientId = provider.serverClientId!!, @@ -182,7 +138,6 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( idTokenFromResult = result.idToken signInAndLinkWithCredential( - config = config, credential = result.credential, provider = provider, displayName = result.displayName, @@ -216,30 +171,30 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( ) // Re-throw to let UI handle the account linking flow - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: GetCredentialCancellationException) { // User dismissed the Credential Manager sheet - this is a normal user action, // not an error, so it goes to AuthState.Cancelled instead of AuthState.Error. // Swallow (don't rethrow) so rememberGoogleSignInHandler's catch block doesn't // overwrite this state with AuthState.Error. - updateAuthState(AuthState.Cancelled) + emit(AuthState.Cancelled) } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in with google was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) throw authException } } @@ -256,18 +211,19 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( * - 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] + * **Note:** This does not sign out from Firebase Auth itself. Call [com.firebase.ui.auth.FirebaseAuthUI.signOut] * separately if you need to sign out from Firebase. * * @param context Android context for Credential Manager */ -internal suspend fun FirebaseAuthUI.signOutFromGoogle( +internal suspend fun signOutFromGoogle( + auth: FirebaseAuth, context: Context, credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider = AuthProvider.Google.DefaultCredentialManagerProvider(), ) { try { - if (Provider.fromId(getCurrentUser()?.providerId) != Provider.GOOGLE) return - (testCredentialManagerProvider ?: credentialManagerProvider).clearCredentialState( + if (Provider.fromId(auth.currentUser?.providerId) != Provider.GOOGLE) return + credentialManagerProvider.clearCredentialState( context = context, credentialManager = CredentialManager.create(context) ) 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 index e85c4fea48..1b5fd3db8d 100644 --- 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 @@ -4,10 +4,9 @@ import android.app.Activity import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope +import com.firebase.ui.auth.AuthFlowScope 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 @@ -18,42 +17,17 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await /** - * Creates a Composable handler for OAuth provider sign-in. + * Remembers a callback that runs [signInWithProvider] in the composition's scope. * - * This function creates a sign-in handler, rebuilt on every recomposition so it always - * captures the latest parameters, 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 + * Rebuilt on every recomposition so it always captures the latest parameters. * - * **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 - * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure - * - * @return Lambda that triggers OAuth sign-in when invoked - * - * @throws IllegalStateException if LocalActivity.current is null - * - * @see signInWithProvider + * @throws IllegalStateException if [activity] is null. This is raised while composing, not when + * the returned callback runs, so a host that cannot supply an Activity fails at first composition. */ @Composable -internal fun FirebaseAuthUI.rememberOAuthSignInHandler( +internal fun AuthFlowScope.rememberOAuthSignInHandler( context: Context, activity: Activity?, - config: AuthUIConfiguration, provider: AuthProvider.OAuth, onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { @@ -68,16 +42,15 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler( try { signInWithProvider( context = context, - config = config, activity = activity, provider = provider ) } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) if (e !is AuthException.AuthCancelledException) onSignInFailure(e) } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(AuthState.Error(authException)) if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } } @@ -85,53 +58,21 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler( } /** - * 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 + * Signs in with an OAuth provider — GitHub, Microsoft, Yahoo, Apple, Twitter, or a custom + * OIDC/SAML provider. * - * **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 + * Runs Firebase's native OAuth activity flow and handles upgrade and collision itself: an + * eligible anonymous user is linked via `startActivityForLinkWithProvider`, and a collision + * becomes [AuthException.AccountLinkingRequiredException]. [signInAndLinkWithCredential] is used + * only to finish a `pendingAuthResult` left behind when the process died mid-flow. */ -internal suspend fun FirebaseAuthUI.signInWithProvider( +internal suspend fun AuthFlowScope.signInWithProvider( context: Context, - config: AuthUIConfiguration, activity: Activity, provider: AuthProvider.OAuth, ) { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningInWithProvider(provider.providerName))) + emit(AuthState.Loading(config.stringProvider.loadingSigningInWithProvider(provider.providerName))) // Build OAuth provider with scopes and custom parameters val oauthProvider = OAuthProvider @@ -157,7 +98,6 @@ internal suspend fun FirebaseAuthUI.signInWithProvider( if (credential != null) { // Complete the pending sign-in/link flow signInAndLinkWithCredential( - config = config, credential = credential, provider = provider, displayName = authResult.user?.displayName, @@ -202,7 +142,21 @@ internal suspend fun FirebaseAuthUI.signInWithProvider( android.util.Log.w("OAuthProvider", "Failed to save sign-in preference", e) } - updateAuthStateWithResult(authResult) + if (config.isReauthenticationMode) { + val reauthenticatedUser = auth.currentUser + ?: throw AuthException.UserNotFoundException( + message = "No user is currently signed in for reauthentication" + ) + emit( + AuthState.Success( + result = authResult, + user = reauthenticatedUser, + reauthenticatedUid = reauthenticatedUser.uid, + ) + ) + } else { + emitResult(authResult) + } } else { throw AuthException.UnknownException( message = "OAuth sign-in did not return a valid credential" @@ -222,23 +176,23 @@ internal suspend fun FirebaseAuthUI.signInWithProvider( credential = credential, cause = e ) - updateAuthState(AuthState.Error(accountLinkingException)) + emit(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)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(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 index 1ee3dc72fd..d7c6757a2a 100644 --- 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 @@ -2,10 +2,9 @@ package com.firebase.ui.auth.configuration.auth_provider import android.app.Activity import android.content.Context +import com.firebase.ui.auth.AuthFlowScope 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 @@ -14,116 +13,30 @@ 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] - * - * **Lifecycle:** Firebase reports verification progress as a stream, so this call does not - * return once the code is sent - on the SMS path it keeps collecting until the auto-retrieval - * window expires, verification fails, or the caller is cancelled. A credential auto-retrieved - * after [AuthState.PhoneNumberVerificationRequired] is therefore still emitted, as - * [AuthState.SMSAutoVerified]. On the instant-verification path Firebase reports no terminal - * callback at all, so only cancellation ends the call. Callers should cancel a superseded - * attempt before starting a new one. - * - * **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.NetworkException if a network error occurs - * @throws kotlinx.coroutines.CancellationException if the caller's coroutine is cancelled + * Starts phone verification for [phoneNumber]. + * + * Firebase may verify instantly, emitting [AuthState.SMSAutoVerified] with a ready credential, + * or fall back to SMS and emit [AuthState.PhoneNumberVerificationRequired] for code entry. + * Passing [forceResendingToken] from that state resends the code. + * + * Firebase reports progress as a stream, so this call does not return when the code is sent. On + * the SMS path it keeps collecting until the auto-retrieval window expires, verification fails, + * or the caller is cancelled — so a credential auto-retrieved after + * [AuthState.PhoneNumberVerificationRequired] still arrives, and a host already on code entry + * must handle the late [AuthState.SMSAutoVerified]. On the instant path Firebase reports no + * terminal callback at all, so only cancellation ends the call: cancel a superseded attempt + * before starting a new one. */ -internal suspend fun FirebaseAuthUI.verifyPhoneNumber( +internal suspend fun AuthFlowScope.verifyPhoneNumber( provider: AuthProvider.Phone, activity: Activity?, phoneNumber: String, - config: AuthUIConfiguration, multiFactorSession: MultiFactorSession? = null, forceResendingToken: PhoneAuthProvider.ForceResendingToken? = null, verifier: AuthProvider.Phone.Verifier = AuthProvider.Phone.DefaultVerifier(), ) { - // -1 never matches a real revision, so a cancellation before the Loading lands clears nothing. - var loadingRevision = -1L try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingVerifyingPhoneNumber)) - loadingRevision = currentAuthStateRevision() + emit(AuthState.Loading(config.stringProvider.loadingVerifyingPhoneNumber)) provider.verifyPhoneNumberFlow( auth = auth, activity = activity, @@ -134,11 +47,11 @@ internal suspend fun FirebaseAuthUI.verifyPhoneNumber( ).collect { result -> when (result) { is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { - updateAuthState(AuthState.SMSAutoVerified(credential = result.credential)) + emit(AuthState.SMSAutoVerified(credential = result.credential)) } is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { - updateAuthState( + emit( AuthState.PhoneNumberVerificationRequired( verificationId = result.verificationId, forceResendingToken = result.token, @@ -148,80 +61,39 @@ internal suspend fun FirebaseAuthUI.verifyPhoneNumber( } } } catch (e: CancellationException) { - // Cancellation here is the screen's own bookkeeping, not a failure: retract only the - // Loading this call emitted, then rethrow so no spurious Error reaches authStateFlow. - clearLoadingState(loadingRevision) + // Writes nothing: the caller cancelling this attempt owns whatever state replaces it, and + // a retraction from here would race the replacement's own Loading. throw e } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) + emit(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 + * Builds a credential from [verificationId] and the [code] the user typed, then signs in. * - * This method handles both normal sign-in and anonymous account upgrade scenarios based - * on the [AuthUIConfiguration] settings. + * Follows [AuthState.PhoneNumberVerificationRequired], which carries the verification id. + * Signing in goes through [signInWithPhoneAuthCredential], so anonymous upgrade is handled + * there rather than here. * - * **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 + * @throws AuthException.InvalidCredentialsException when the code is wrong or has expired. */ -internal suspend fun FirebaseAuthUI.submitVerificationCode( +internal suspend fun AuthFlowScope.submitVerificationCode( context: Context, - config: AuthUIConfiguration, verificationId: String, code: String, credentialProvider: AuthProvider.Phone.CredentialProvider = AuthProvider.Phone.DefaultCredentialProvider(), ): AuthResult? { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSubmittingVerificationCode)) + emit(AuthState.Loading(config.stringProvider.loadingSubmittingVerificationCode)) val credential = credentialProvider.getCredential(verificationId, code) return signInWithPhoneAuthCredential( context = context, - config = config, credential = credential ) } catch (e: CancellationException) { @@ -229,88 +101,32 @@ internal suspend fun FirebaseAuthUI.submitVerificationCode( message = "Submit verification code was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(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 + * Signs in with a verified [PhoneAuthCredential], from either verification path. * - * @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 + * Delegates to [signInAndLinkWithCredential], so anonymous upgrade and the + * [AuthException.AccountLinkingRequiredException] raised when the number already belongs to + * another account behave as they do for every other provider. */ -internal suspend fun FirebaseAuthUI.signInWithPhoneAuthCredential( +internal suspend fun AuthFlowScope.signInWithPhoneAuthCredential( context: Context, - config: AuthUIConfiguration, credential: PhoneAuthCredential, ): AuthResult? { try { - updateAuthState(AuthState.Loading(config.stringProvider.loadingSigningInWithPhone)) + emit(AuthState.Loading(config.stringProvider.loadingSigningInWithPhone)) val result = signInAndLinkWithCredential( - config = config, credential = credential, ) @@ -339,14 +155,14 @@ internal suspend fun FirebaseAuthUI.signInWithPhoneAuthCredential( message = "Sign in with phone was cancelled", cause = e ) - updateAuthState(AuthState.Error(cancelledException)) + emit(AuthState.Error(cancelledException)) throw cancelledException } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) + emit(AuthState.Error(e)) throw e } catch (e: Exception) { val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) + emit(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 index 9af38935bf..7e1c3698d6 100644 --- 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 @@ -248,9 +248,6 @@ interface AuthUIStringProvider { /** 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 @@ -410,15 +407,19 @@ interface AuthUIStringProvider { /** 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 + /** + * Content description for a single verification code digit, announced positionally + * (e.g. "Verification code digit 3 of 6"). + */ + fun verificationCodeDigitDescription(position: Int, total: Int): String = + "Verification code digit $position of $total" + /** Generic identity verified confirmation message. */ val identityVerifiedMessage: String @@ -511,9 +512,6 @@ interface AuthUIStringProvider { /** 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 @@ -533,9 +531,6 @@ interface AuthUIStringProvider { /** 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 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 index aec4a83ccf..e25776a76b 100644 --- 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 @@ -210,9 +210,6 @@ class DefaultAuthUIStringProvider( 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) @@ -373,15 +370,19 @@ class DefaultAuthUIStringProvider( 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 fun verificationCodeDigitDescription(position: Int, total: Int): String = + localizedContext.getString( + R.string.fui_verification_code_digit_description, + position, + total + ) + override val identityVerifiedMessage: String get() = localizedContext.getString(R.string.fui_identity_verified_message) @@ -462,8 +463,6 @@ class DefaultAuthUIStringProvider( 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 @@ -480,8 +479,6 @@ class DefaultAuthUIStringProvider( 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 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 index e171f47a8c..67a6a4e40d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/data/CountryData.kt +++ b/auth/src/main/java/com/firebase/ui/auth/data/CountryData.kt @@ -14,6 +14,8 @@ package com.firebase.ui.auth.data +import androidx.compose.runtime.saveable.Saver + /** * Represents country information for phone number authentication. * @@ -39,6 +41,21 @@ data class CountryData( fun getDisplayNameWithDialCode(): String = "$flagEmoji $name ($dialCode)" } +/** + * Round-trips [CountryData] through `rememberSaveable` as a positional list of its four fields. + */ +internal val CountryDataSaver: Saver> = Saver( + save = { listOf(it.name, it.dialCode, it.countryCode, it.flagEmoji) }, + restore = { saved -> + CountryData( + name = saved[0], + dialCode = saved[1], + countryCode = saved[2], + flagEmoji = saved[3], + ) + }, +) + /** * Converts an ISO 3166-1 alpha-2 country code to its corresponding flag emoji. * @@ -49,7 +66,7 @@ fun countryCodeToFlagEmoji(countryCode: String): String { if (countryCode.length != 2) return "" val uppercaseCode = countryCode.uppercase() - val baseCodePoint = 0x1F1E6 // Regional Indicator Symbol Letter A + val baseCodePoint = 0x1F1E6 val charCodeOffset = 'A'.code val firstChar = uppercaseCode[0].code 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 index 674cb42e60..c29a54f3d8 100644 --- a/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentState.kt @@ -28,7 +28,20 @@ import com.google.firebase.auth.MultiFactorInfo * Use a `when` expression on [step] to determine which UI to render: * * ```kotlin - * MfaEnrollmentScreen(user, config, onComplete, onSkip) { state -> + * MfaEnrollmentScreen( + * user = user, + * auth = auth, + * configuration = config, + * onComplete = onComplete, + * onSkip = onSkip, + * // The host owns the step: every one of them is its own navigation destination. Both writes + * // are guarded — a step already on top must not be pushed twice, and the first step must not + * // be popped, because NavDisplay throws on an empty back stack from recomposition. + * step = step, + * onNavigateToStep = { if (backStack.lastOrNull() != it) backStack.add(it) }, + * onNavigateBack = { if (backStack.size > 1) backStack.removeLastOrNull() }, + * flowState = flowState, + * ) { state -> * when (state.step) { * MfaEnrollmentStep.SelectFactor -> { * // Render factor selection UI using state.availableFactors @@ -56,6 +69,7 @@ import com.google.firebase.auth.MultiFactorInfo * @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 allowedCountries (Step: [MfaEnrollmentStep.ConfigureSms]) Country codes the selector is restricted to, or `null` for no restriction. Determined by [com.firebase.ui.auth.configuration.MfaConfiguration.allowedCountries]. * @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. * @@ -70,9 +84,6 @@ import com.google.firebase.auth.MultiFactorInfo * @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( @@ -109,6 +120,8 @@ data class MfaEnrollmentContentState( val selectedCountry: CountryData? = null, + val allowedCountries: List? = null, + val onCountrySelected: (CountryData) -> Unit = {}, val onSendSmsCodeClick: () -> Unit = {}, @@ -131,12 +144,7 @@ data class MfaEnrollmentContentState( val resendTimer: Int = 0, - val onResendCodeClick: (() -> Unit)? = null, - - // ShowRecoveryCodes step - val recoveryCodes: List? = null, - - val onCodesSavedClick: () -> Unit = {} + val onResendCodeClick: (() -> Unit)? = null ) { /** * Returns true if the current state is valid for the current step. @@ -149,7 +157,6 @@ data class MfaEnrollmentContentState( MfaEnrollmentStep.ConfigureSms -> phoneNumber.isNotBlank() MfaEnrollmentStep.ConfigureTotp -> totpSecret != null && totpQrCodeUrl != null MfaEnrollmentStep.VerifyFactor -> verificationCode.length == 6 - MfaEnrollmentStep.ShowRecoveryCodes -> !recoveryCodes.isNullOrEmpty() } /** 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 index 8d64da6202..e76c1b40e4 100644 --- a/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentStep.kt +++ b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentStep.kt @@ -21,7 +21,7 @@ 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. + * in MFA, from selecting a factor to verifying it. * * @since 10.0.0 */ @@ -50,14 +50,7 @@ enum class MfaEnrollmentStep { * 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 + VerifyFactor } /** @@ -71,7 +64,6 @@ fun MfaEnrollmentStep.getTitle(stringProvider: AuthUIStringProvider): String = w MfaEnrollmentStep.ConfigureSms -> stringProvider.mfaStepConfigureSmsTitle MfaEnrollmentStep.ConfigureTotp -> stringProvider.mfaStepConfigureTotpTitle MfaEnrollmentStep.VerifyFactor -> stringProvider.mfaStepVerifyFactorTitle - MfaEnrollmentStep.ShowRecoveryCodes -> stringProvider.mfaStepShowRecoveryCodesTitle } /** @@ -94,5 +86,4 @@ fun MfaEnrollmentStep.getHelperText( MfaFactor.Totp -> stringProvider.mfaStepVerifyFactorTotpHelper null -> stringProvider.mfaStepVerifyFactorGenericHelper } - MfaEnrollmentStep.ShowRecoveryCodes -> stringProvider.mfaStepShowRecoveryCodesHelper } 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 index 4f70343643..4e6188bd2e 100644 --- a/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt +++ b/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt @@ -15,6 +15,7 @@ package com.firebase.ui.auth.mfa import android.app.Activity +import androidx.compose.runtime.saveable.Saver 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 @@ -71,7 +72,6 @@ class SmsEnrollmentHandler( defaultNumber = null, defaultCountryCode = null, allowedCountries = null, - smsCodeLength = SMS_CODE_LENGTH, timeout = VERIFICATION_TIMEOUT_SECONDS, isInstantVerificationEnabled = true ) @@ -336,6 +336,33 @@ data class SmsEnrollmentSession( } } +/** + * Round-trips [SmsEnrollmentSession] through `rememberSaveable` as a positional list; every field + * it carries is `Parcelable` or a primitive. + */ +internal val SmsEnrollmentSessionSaver: Saver> = Saver( + save = { session -> + session?.let { + listOf( + it.verificationId, + it.phoneNumber, + it.forceResendingToken, + it.sentAt, + it.autoVerifiedCredential, + ) + } + }, + restore = { saved -> + SmsEnrollmentSession( + verificationId = saved[0] as String, + phoneNumber = saved[1] as String, + forceResendingToken = saved[2] as PhoneAuthProvider.ForceResendingToken?, + sentAt = saved[3] as Long, + autoVerifiedCredential = saved[4] as PhoneAuthCredential?, + ) + }, +) + /** * Masks the middle digits of a phone number for privacy. * @@ -357,20 +384,19 @@ fun maskPhoneNumber(phoneNumber: String): String { return phoneNumber } - // Determine country code length (typically 1-3 digits after +) - val digitsOnly = phoneNumber.substring(1) // Remove + + // Country-code length is a heuristic: NANP (+1) is one digit, most others two. + val digitsOnly = phoneNumber.substring(1) val countryCodeLength = when { - digitsOnly.length > 10 -> 2 // Likely 2-digit country code - digitsOnly[0] == '1' -> 1 // North America - else -> 2 // Most other countries + digitsOnly.length > 10 -> 2 + digitsOnly[0] == '1' -> 1 + else -> 2 } - val countryCode = phoneNumber.substring(0, countryCodeLength + 1) // Include + - // Keep last 3-4 digits visible, with longer numbers showing more + val countryCode = phoneNumber.substring(0, countryCodeLength + 1) 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 + phoneNumber.length >= 14 -> 4 + phoneNumber.length >= 11 -> 3 + else -> 2 } val lastDigits = phoneNumber.takeLast(lastDigitsCount) val maskedLength = phoneNumber.length - countryCode.length - lastDigitsCount diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt b/auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt new file mode 100644 index 0000000000..7eee4cdda4 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.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.ui + +/** + * Stable Compose test tags applied by the FirebaseUI Auth screens. These are public API — renaming + * or removing a constant is a breaking change, not an internal refactor. + */ +object FirebaseAuthTestTags { + + /** Tags on the auth method picker screen. */ + object MethodPicker { + + /** The scrollable list of provider buttons. */ + const val PROVIDER_LIST = "fui_method_picker_provider_list" + + /** + * The "Continue as ..." button, shown when a previous sign-in preference is available. + */ + const val CONTINUE_AS_BUTTON = "fui_method_picker_continue_as_button" + } + + /** Tags on the phone number country selector bottom sheet. */ + object CountrySelector { + + /** The scrollable country list. */ + const val COUNTRY_LIST = "fui_country_selector_country_list" + } + + /** Tags on the email/password sign-in screen. */ + object SignIn { + + /** The email address input. */ + const val EMAIL_FIELD = "fui_sign_in_email_field" + + /** The password input. */ + const val PASSWORD_FIELD = "fui_sign_in_password_field" + + /** The button that submits the entered credentials. */ + const val SIGN_IN_BUTTON = "fui_sign_in_sign_in_button" + + /** The button that navigates to the sign-up screen. */ + const val SIGN_UP_BUTTON = "fui_sign_in_sign_up_button" + + /** The "trouble signing in" button that navigates to password recovery. */ + const val FORGOT_PASSWORD_BUTTON = "fui_sign_in_forgot_password_button" + + /** The button that switches to email link sign-in. */ + const val EMAIL_LINK_BUTTON = "fui_sign_in_email_link_button" + + /** The toggle that shows or hides the entered password. */ + const val PASSWORD_VISIBILITY_TOGGLE = "fui_sign_in_password_visibility_toggle" + + /** The top app bar's back navigation button. */ + const val BACK_BUTTON = "fui_sign_in_back_button" + + /** The notice explaining that reauthentication here needs the account's password. */ + const val REAUTH_PASSWORD_NOTICE = "fui_sign_in_reauth_password_notice" + } + + /** Tags on the email/password sign-up screen. */ + object SignUp { + + /** The display name input, shown when the provider requires a name. */ + const val NAME_FIELD = "fui_sign_up_name_field" + + /** The email address input. */ + const val EMAIL_FIELD = "fui_sign_up_email_field" + + /** The password input. */ + const val PASSWORD_FIELD = "fui_sign_up_password_field" + + /** The password confirmation input. */ + const val CONFIRM_PASSWORD_FIELD = "fui_sign_up_confirm_password_field" + + /** The button that submits the new account. */ + const val SIGN_UP_BUTTON = "fui_sign_up_sign_up_button" + + /** The toggle that shows or hides the entered password. */ + const val PASSWORD_VISIBILITY_TOGGLE = "fui_sign_up_password_visibility_toggle" + + /** The toggle that shows or hides the entered password confirmation. */ + const val CONFIRM_PASSWORD_VISIBILITY_TOGGLE = + "fui_sign_up_confirm_password_visibility_toggle" + + /** The top app bar's back navigation button. */ + const val BACK_BUTTON = "fui_sign_up_back_button" + } + + /** Tags on the password recovery screen. */ + object ResetPassword { + + /** The email address input. */ + const val EMAIL_FIELD = "fui_reset_password_email_field" + + /** The button that sends the password reset link. */ + const val SEND_BUTTON = "fui_reset_password_send_button" + + /** The dismiss button of the "reset link sent" dialog. */ + const val DISMISS_BUTTON = "fui_reset_password_dismiss_button" + + /** The top app bar's back navigation button. */ + const val BACK_BUTTON = "fui_reset_password_back_button" + } + + /** Tags on the email link ("magic link") sign-in screen. */ + object EmailLink { + + /** The email address input. */ + const val EMAIL_FIELD = "fui_email_link_email_field" + + /** The button that sends the sign-in link. */ + const val SEND_LINK_BUTTON = "fui_email_link_send_link_button" + + /** The dismiss button of the "sign-in link sent" dialog. */ + const val DISMISS_BUTTON = "fui_email_link_dismiss_button" + + /** The "trouble signing in" button that navigates to password recovery. */ + const val FORGOT_PASSWORD_BUTTON = "fui_email_link_forgot_password_button" + + /** The top app bar's back navigation button. */ + const val BACK_BUTTON = "fui_email_link_back_button" + } + + /** Tags on the phone number entry screen. */ + object PhoneNumber { + + /** The phone number input. */ + const val PHONE_NUMBER_FIELD = "fui_phone_number_phone_number_field" + + /** The control that opens the country selector bottom sheet. */ + const val COUNTRY_SELECTOR_BUTTON = "fui_phone_number_country_selector_button" + + /** The button that requests an SMS verification code. */ + const val SEND_CODE_BUTTON = "fui_phone_number_send_code_button" + + /** The top app bar's back navigation button. */ + const val BACK_BUTTON = "fui_phone_number_back_button" + } + + /** Tags on the SMS verification code screen. */ + object VerificationCode { + + /** + * The verification code input group (one box per digit). The group itself is the + * editable node, so a single action enters the whole code. + */ + const val CODE_FIELD = "fui_verification_code_code_field" + + /** The button that submits the entered code. */ + const val VERIFY_BUTTON = "fui_verification_code_verify_button" + + /** The button that requests a new code. */ + const val RESEND_CODE_BUTTON = "fui_verification_code_resend_code_button" + + /** The button that returns to phone number entry. */ + const val CHANGE_PHONE_NUMBER_BUTTON = "fui_verification_code_change_phone_number_button" + + /** The top app bar's back navigation button. */ + const val BACK_BUTTON = "fui_verification_code_back_button" + } + + /** + * Tags on the multi-factor sign-in challenge screen (the second factor requested during + * sign-in, distinct from MFA enrollment). + */ + object MfaChallenge { + + /** The verification code input, for both the SMS and TOTP factors. */ + const val CODE_FIELD = "fui_mfa_challenge_code_field" + + /** The button that submits the entered code. */ + const val VERIFY_BUTTON = "fui_mfa_challenge_verify_button" + + /** The button that requests a new code. SMS factor only. */ + const val RESEND_CODE_BUTTON = "fui_mfa_challenge_resend_code_button" + + /** + * The button that cancels the challenge and returns to sign-in. Shared by the SMS and + * TOTP variants, which never compose at the same time. + */ + const val CANCEL_BUTTON = "fui_mfa_challenge_cancel_button" + } + + /** + * Tags on the re-authentication dialog. Kept separate from [SignIn] because the dialog and + * the flow behind it can be composed at the same time. + */ + object Reauth { + + /** The password input. */ + const val PASSWORD_FIELD = "fui_reauth_password_field" + + /** The button that submits the password. */ + const val VERIFY_BUTTON = "fui_reauth_verify_button" + + /** The button that dismisses the dialog without re-authenticating. */ + const val DISMISS_BUTTON = "fui_reauth_dismiss_button" + } + + /** Tags on the error recovery dialog. Only one instance is ever composed at a time. */ + object ErrorRecovery { + + /** The recovery/retry action; its label varies with the error being recovered from. */ + const val RETRY_BUTTON = "fui_error_recovery_retry_button" + + /** The button that dismisses the dialog without recovering. */ + const val DISMISS_BUTTON = "fui_error_recovery_dismiss_button" + } + + /** + * Tags on the terms-of-service and privacy-policy links shown at the bottom of several + * screens. Component-scoped since call sites never compose more than one instance at once. + */ + object TermsAndPrivacy { + + /** The link that opens the terms-of-service URL. */ + const val TOS_LINK = "fui_terms_and_privacy_tos_link" + + /** The link that opens the privacy-policy URL. */ + const val PRIVACY_LINK = "fui_terms_and_privacy_privacy_link" + } + + /** + * Tags on the multi-factor enrollment flow. Enroll/remove buttons are keyed per-factor since + * more than one can be shown at once. + */ + object MfaEnrollment { + + /** The button that starts SMS enrollment, shown on the factor-selection step. */ + const val ENROLL_SMS_BUTTON = "fui_mfa_enrollment_enroll_sms_button" + + /** The button that starts TOTP enrollment, shown on the factor-selection step. */ + const val ENROLL_TOTP_BUTTON = "fui_mfa_enrollment_enroll_totp_button" + + /** The button that removes an already-enrolled SMS factor. */ + const val REMOVE_SMS_BUTTON = "fui_mfa_enrollment_remove_sms_button" + + /** The button that removes an already-enrolled TOTP factor. */ + const val REMOVE_TOTP_BUTTON = "fui_mfa_enrollment_remove_totp_button" + + /** The button that skips enrollment, shown on the factor-selection step when optional. */ + const val SKIP_BUTTON = "fui_mfa_enrollment_skip_button" + + /** The button that returns from the TOTP secret/QR step to factor selection. */ + const val CONFIGURE_TOTP_BACK_BUTTON = "fui_mfa_enrollment_configure_totp_back_button" + + /** The button that advances from the TOTP secret/QR step to code verification. */ + const val CONFIGURE_TOTP_CONTINUE_BUTTON = + "fui_mfa_enrollment_configure_totp_continue_button" + + /** + * The input for the code generated by the user's authenticator app, on the TOTP + * verification step. + */ + const val VERIFY_TOTP_CODE_FIELD = "fui_mfa_enrollment_verify_totp_code_field" + + /** The button that returns from TOTP code verification to the secret/QR step. */ + const val VERIFY_TOTP_BACK_BUTTON = "fui_mfa_enrollment_verify_totp_back_button" + + /** The button that submits the entered TOTP code to complete verification. */ + const val VERIFY_TOTP_BUTTON = "fui_mfa_enrollment_verify_totp_button" + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/TestTagsAsResourceIds.kt b/auth/src/main/java/com/firebase/ui/auth/ui/TestTagsAsResourceIds.kt new file mode 100644 index 0000000000..d4eb3fb6ff --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/TestTagsAsResourceIds.kt @@ -0,0 +1,26 @@ +/* + * 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 + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId + +/** + * Publishes [FirebaseAuthTestTags] as resource ids for Robo/`By.res()`. Apply per semantics + * owner (each dialog/sheet/Scaffold), even ones untagged today, to avoid silent regressions. + */ +internal fun Modifier.exposeTestTagsAsResourceIds(): Modifier = + semantics { testTagsAsResourceId = true } 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 index 9a3f5e1a27..c0209dbc53 100644 --- 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 @@ -22,6 +22,7 @@ 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.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape @@ -66,7 +67,8 @@ import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults * ) * ``` * - * @param modifier A modifier for the button + * @param modifier Applied to the button itself; the content row always fills available width, so + * constrain sizing from the parent layout instead. * @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. @@ -118,7 +120,7 @@ fun AuthProviderButton( enabled = enabled, ) { Row( - modifier = modifier, + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start ) { 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 index 253a6e260a..d5c12223b5 100644 --- 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 @@ -42,10 +42,14 @@ 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.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription 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.R import com.firebase.ui.auth.configuration.PasswordRule import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.configuration.validators.EmailValidator @@ -86,6 +90,9 @@ import com.firebase.ui.auth.configuration.validators.PasswordValidator * @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. + * @param readOnly If the value cannot be edited by the user. + * @param visibilityToggleModifier A modifier for the password visibility toggle button, separate + * from [modifier] which targets the field itself — e.g. to apply a test tag to the toggle. */ @Composable fun AuthTextField( @@ -103,8 +110,14 @@ fun AuthTextField( visualTransformation: VisualTransformation = VisualTransformation.None, leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, + readOnly: Boolean = false, + visibilityToggleModifier: Modifier = Modifier, ) { var passwordVisible by remember { mutableStateOf(false) } + // semantics {} is not a composable scope, so the description is resolved out here — and + // only when it is wanted, since this recomposes on every keystroke. + val readOnlyStateDescription = + if (readOnly) stringResource(R.string.fui_text_field_read_only) else "" // Automatically set the correct keyboard type based on validator or field type val resolvedKeyboardOptions = remember(validator, isSecureTextField, keyboardOptions) { @@ -124,7 +137,17 @@ fun AuthTextField( TextField( modifier = modifier - .fillMaxWidth(), + .fillMaxWidth() + // A read-only field looks identical to an editable one, so state it semantically. + .then( + if (readOnly) { + Modifier.semantics { + stateDescription = readOnlyStateDescription + } + } else { + Modifier + } + ), value = value, onValueChange = { newValue -> onValueChange(newValue) @@ -133,6 +156,7 @@ fun AuthTextField( label = label, singleLine = true, enabled = enabled, + readOnly = readOnly, isError = isError ?: validator?.hasError ?: false, supportingText = { if (validator?.hasError ?: false) { @@ -167,6 +191,7 @@ fun AuthTextField( trailingIcon = trailingIcon ?: { if (isSecureTextField) { IconButton( + modifier = visibilityToggleModifier, onClick = { passwordVisible = !passwordVisible } 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 index 425aa32bc6..1893e4ca9c 100644 --- 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 @@ -57,6 +57,8 @@ 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.ui.FirebaseAuthTestTags +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import com.firebase.ui.auth.util.CountryUtils import kotlinx.coroutines.launch @@ -64,6 +66,7 @@ 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 modifier A modifier for the clickable row that opens the country list. * @param selectedCountry The currently selected country. * @param onCountrySelected Callback when a country is selected. * @param enabled Whether the selector is enabled. @@ -72,6 +75,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun CountrySelector( + modifier: Modifier = Modifier, selectedCountry: CountryData, onCountrySelected: (CountryData) -> Unit, enabled: Boolean = true, @@ -104,7 +108,7 @@ fun CountrySelector( // Clickable row showing flag, dial code and dropdown icon Row( - modifier = Modifier + modifier = modifier .fillMaxHeight() .clickable(enabled = enabled) { showBottomSheet = true @@ -134,6 +138,7 @@ fun CountrySelector( if (showBottomSheet) { ModalBottomSheet( + modifier = Modifier.exposeTestTagsAsResourceIds(), onDismissRequest = { showBottomSheet = false searchQuery = "" @@ -165,7 +170,7 @@ fun CountrySelector( modifier = Modifier .fillMaxWidth() .height(500.dp) - .testTag("CountrySelector LazyColumn") + .testTag(FirebaseAuthTestTags.CountrySelector.COUNTRY_LIST) ) { items(filteredCountries) { country -> Button( 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 index a3e216ebee..ddb18c6426 100644 --- 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 @@ -22,9 +22,12 @@ 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.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.window.DialogProperties import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.ui.FirebaseAuthTestTags +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FacebookAuthProvider import com.google.firebase.auth.GithubAuthProvider @@ -61,7 +64,8 @@ import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider * * @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 onRetry Callback invoked when the user taps the retry action, or `null` when there is + * nothing to retry — the action button is then not rendered at all * @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 @@ -73,7 +77,7 @@ import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider fun ErrorRecoveryDialog( error: AuthException, stringProvider: AuthUIStringProvider, - onRetry: (AuthException) -> Unit, + onRetry: ((AuthException) -> Unit)?, onDismiss: () -> Unit, modifier: Modifier = Modifier, onRecover: ((AuthException) -> Unit)? = null, @@ -97,11 +101,12 @@ fun ErrorRecoveryDialog( ) }, confirmButton = { - if (isRecoverable(error)) { + // No callback means no action to take, so an action button would be a no-op. + val action = onRecover ?: onRetry + if (action != null && isRecoverable(error)) { TextButton( - onClick = { - onRecover?.invoke(error) ?: onRetry(error) - } + modifier = Modifier.testTag(FirebaseAuthTestTags.ErrorRecovery.RETRY_BUTTON), + onClick = { action(error) }, ) { Text( text = getRecoveryActionText(error, stringProvider), @@ -111,14 +116,17 @@ fun ErrorRecoveryDialog( } }, dismissButton = { - TextButton(onClick = onDismiss) { + TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.ErrorRecovery.DISMISS_BUTTON), + onClick = onDismiss + ) { Text( text = stringProvider.dismissAction, style = MaterialTheme.typography.labelLarge ) } }, - modifier = modifier, + modifier = modifier.exposeTestTagsAsResourceIds(), properties = properties ) } 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 index 754aa5cc78..22b358a3b2 100644 --- 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 @@ -27,6 +27,7 @@ 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 androidx.core.graphics.createBitmap import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType import com.google.zxing.WriterException @@ -97,7 +98,7 @@ private fun generateQrCodeBitmap( hints ) - val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(sizePx, sizePx) val foregroundArgb = android.graphics.Color.argb( (foregroundColor.alpha * 255).toInt(), @@ -113,15 +114,15 @@ private fun generateQrCodeBitmap( (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 - ) + // One bulk copy rather than sizePx^2 setPixel calls: at the default 250.dp rendered + // at 2x that is 250,000 JNI crossings on the composition thread. + val pixels = IntArray(sizePx * sizePx) + for (y in 0 until sizePx) { + for (x in 0 until sizePx) { + pixels[y * sizePx + x] = if (bitMatrix[x, y]) foregroundArgb else backgroundArgb } } + bitmap.setPixels(pixels, 0, sizePx, 0, 0, sizePx, sizePx) bitmap } catch (e: WriterException) { 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 index 4622de9578..9921c4347d 100644 --- 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 @@ -41,12 +41,15 @@ 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.platform.testTag 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.firebase.ui.auth.ui.FirebaseAuthTestTags +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FirebaseUser import kotlinx.coroutines.launch @@ -75,6 +78,7 @@ fun ReauthenticationDialog( } AlertDialog( + modifier = Modifier.exposeTestTagsAsResourceIds(), onDismissRequest = { if (!isLoading) onDismiss() }, title = { val view = LocalView.current @@ -139,6 +143,7 @@ fun ReauthenticationDialog( modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) + .testTag(FirebaseAuthTestTags.Reauth.PASSWORD_FIELD) ) if (isLoading) { @@ -166,7 +171,8 @@ fun ReauthenticationDialog( ) } }, - enabled = password.isNotBlank() && !isLoading + enabled = password.isNotBlank() && !isLoading, + modifier = Modifier.testTag(FirebaseAuthTestTags.Reauth.VERIFY_BUTTON) ) { Text(stringProvider.verifyAction) } @@ -174,7 +180,8 @@ fun ReauthenticationDialog( dismissButton = { TextButton( onClick = onDismiss, - enabled = !isLoading + enabled = !isLoading, + modifier = Modifier.testTag(FirebaseAuthTestTags.Reauth.DISMISS_BUTTON) ) { Text(stringProvider.dismissAction) } 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 index 5cf33cd5a9..4717416b1a 100644 --- 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 @@ -24,11 +24,14 @@ 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.platform.testTag 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 +import com.firebase.ui.auth.ui.FirebaseAuthTestTags +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds @Composable fun TermsAndPrivacyForm( @@ -38,9 +41,12 @@ fun TermsAndPrivacyForm( ) { val uriHandler = LocalUriHandler.current Row( - modifier = modifier, + // Flagged here too (a no-op if an ancestor already is) so tags stay exposed for any + // future caller without a flagged ancestor. + modifier = modifier.exposeTestTagsAsResourceIds(), ) { TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.TermsAndPrivacy.TOS_LINK), onClick = { tosUrl?.let { uriHandler.openUri(it) @@ -57,6 +63,7 @@ fun TermsAndPrivacyForm( } Spacer(modifier = Modifier.width(24.dp)) TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.TermsAndPrivacy.PRIVACY_LINK), onClick = { ppUrl?.let { uriHandler.openUri(it) 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 index e5d22c1a82..a5b73917e5 100644 --- 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 @@ -82,14 +82,15 @@ class TopLevelDialogController( * for de-duplication. Pass this explicitly when the caller might not be the only observer of * the same error: by the time this runs, another observer may have already reset the live * auth state to `Idle`, so falling back to [currentAuthState] alone would miss the dedup. - * @param onRetry Callback when user clicks retry button + * @param onRetry Callback when user clicks retry button, or `null` when there is nothing to + * retry — [ErrorRecoveryDialog] then renders no action button at all * @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, errorState: AuthState.Error? = null, - onRetry: (AuthException) -> Unit = {}, + onRetry: ((AuthException) -> Unit)? = null, onRecover: ((AuthException) -> Unit)? = null, onDismiss: () -> Unit = {} ) { @@ -135,9 +136,11 @@ class TopLevelDialogController( ErrorRecoveryDialog( error = state.exception, stringProvider = stringProvider, - onRetry = { exception -> - state.onRetry(exception) - state.onDismiss() + onRetry = state.onRetry?.let { onRetry -> + { exception: AuthException -> + onRetry(exception) + state.onDismiss() + } }, onRecover = state.onRecover?.let { onRecover -> { exception -> @@ -157,7 +160,7 @@ class TopLevelDialogController( private sealed class DialogState { data class ErrorDialog( val exception: AuthException, - val onRetry: (AuthException) -> Unit, + val onRetry: ((AuthException) -> Unit)?, val onRecover: ((AuthException) -> Unit)?, val onDismiss: () -> Unit ) : DialogState() 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 index 58137835ca..e0388bc15b 100644 --- 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 @@ -54,12 +54,34 @@ 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.editableText +import androidx.compose.ui.semantics.insertTextAtCursor +import androidx.compose.ui.semantics.isEditable +import androidx.compose.ui.semantics.maxTextLength +import androidx.compose.ui.semantics.requestFocus import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setText +import androidx.compose.ui.text.AnnotatedString import androidx.core.text.isDigitsOnly +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.configuration.validators.FieldValidator +/** + * A row of [codeLength] single-character boxes that together hold one verification code. The + * group itself declares the text-input semantics so Robo/UiAutomator can `ACTION_SET_TEXT` the + * whole code in one action, since the individual boxes can't be addressed that way (issue #2050). + * + * @param modifier Applied to the group; a testTag here is the handle for resource-id lookups. + * @param codeLength How many digits the code has, and therefore how many boxes are drawn. + * @param validator Optional validator run against the code on every change; when supplied it drives + * the error state instead of [isError]. + * @param isError Whether to draw the boxes in their error state. Ignored when [validator] is set. + * @param errorMessage Message shown beneath the boxes. Ignored when [validator] is set. + * @param onCodeComplete Called with the code once every box is filled. + * @param onCodeChange Called with the code so far on every change. + */ @Composable fun VerificationCodeInputField( modifier: Modifier = Modifier, @@ -74,6 +96,7 @@ fun VerificationCodeInputField( val focusedIndex = remember { mutableStateOf(null) } val focusRequesters = remember { (1..codeLength).map { FocusRequester() } } val keyboardManager = LocalSoftwareKeyboardController.current + val stringProvider = LocalAuthUIStringProvider.current // Derive validation state val currentCodeString = remember { mutableStateOf("") } @@ -127,8 +150,69 @@ fun VerificationCodeInputField( errorMessage } + // The digits of [text], or null when this field cannot hold them. "Digit" follows + // Character.isDigit, so Arabic-Indic/fullwidth digits are accepted and normalised to ASCII. + fun digitsOf(text: String, availableSlots: Int): List? = when { + text.isEmpty() -> emptyList() + text.length > availableSlots -> null + !text.isDigitsOnly() -> null + else -> text.map { it.digitToInt() } + } + + // Index the visible cursor should sit at once [filled] boxes are occupied from the start. + fun cursorAfter(filled: Int): Int = filled.coerceIn(0, codeLength - 1) + Column( - modifier = modifier, + modifier = modifier + // Applied after [modifier] so a caller-supplied testTag lands on the same node + // these text-input actions are declared on. + .semantics { + // TODO: give this group a proper label via AuthUIStringProvider (tracked + // separately). Must be contentDescription, not text — text breaks Robo detection. + isEditable = true + maxTextLength = codeLength + // Digits before the first empty box, not every digit entered — boxes fill + // non-contiguously, so compacting would misreport a gap-filled state. + editableText = AnnotatedString( + code.value.takeWhile { it != null }.joinToString("") + ) + + setText { newCode -> + val digits = digitsOf(newCode.text, codeLength) ?: return@setText false + code.value = List(codeLength) { index -> digits.getOrNull(index) } + focusedIndex.value = cursorAfter(digits.size) + true + } + + insertTextAtCursor { inserted -> + // Inserting nothing succeeds before the full-code guard is reached, because + // inserting nothing into a full code is a no-op and a no-op is not a failure. + if (inserted.text.isEmpty()) return@insertTextAtCursor true + + val firstEmpty = code.value.indexOfFirst { it == null } + if (firstEmpty < 0) return@insertTextAtCursor false + + val digits = digitsOf(inserted.text, codeLength - firstEmpty) + ?: return@insertTextAtCursor false + + code.value = code.value.toMutableList().also { updated -> + digits.forEachIndexed { offset, digit -> + updated[firstEmpty + offset] = digit + } + } + focusedIndex.value = cursorAfter(firstEmpty + digits.size) + true + } + + // Moves focus via the index the widget watches, not a FocusRequester, so this + // can't throw if invoked before the boxes are attached. + requestFocus { + focusedIndex.value = + code.value.indexOfFirst { it == null }.takeIf { it >= 0 } + ?: (codeLength - 1) + true + } + }, horizontalAlignment = Alignment.CenterHorizontally ) { Row( @@ -142,6 +226,10 @@ fun VerificationCodeInputField( .aspectRatio(1f), number = number, isError = showError, + digitContentDescription = stringProvider.verificationCodeDigitDescription( + position = index + 1, + total = codeLength + ), focusRequester = focusRequesters[index], onFocusChanged = { isFocused -> if (isFocused) { @@ -191,6 +279,7 @@ private fun SingleDigitField( modifier: Modifier = Modifier, number: Int?, isError: Boolean = false, + digitContentDescription: String, focusRequester: FocusRequester, onFocusChanged: (Boolean) -> Unit, onNumberChanged: (Int?) -> Unit, @@ -253,7 +342,7 @@ private fun SingleDigitField( .fillMaxSize() .wrapContentSize() .semantics { - contentDescription = "Verification code digit" + contentDescription = digitContentDescription } .focusRequester(focusRequester) .onFocusChanged { 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 index 26c2feaed9..e5a17170b8 100644 --- 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 @@ -43,7 +43,9 @@ 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.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.AuthProviderButton +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import com.firebase.ui.auth.util.SignInPreferenceManager /** @@ -82,7 +84,6 @@ class MethodPickerTermsConfiguration( * @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. @@ -91,6 +92,7 @@ class MethodPickerTermsConfiguration( * @param onContinueAsSelected A callback when the "Continue as..." button is selected, with the * provider and saved identifier (email address). Falls back to [onProviderSelected] * if not provided. + * @param customLayout An optional custom layout composable for the provider buttons. * * @since 10.0.0 */ @@ -103,9 +105,9 @@ fun AuthMethodPicker( termsOfServiceUrl: String? = null, privacyPolicyUrl: String? = null, lastSignInPreference: SignInPreferenceManager.SignInPreference? = null, - customLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null, termsConfiguration: MethodPickerTermsConfiguration? = null, onContinueAsSelected: ((AuthProvider, String?) -> Unit)? = null, + customLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null, ) { val continueAsHandler: (AuthProvider, String?) -> Unit = onContinueAsSelected ?: { provider, _ -> onProviderSelected(provider) } @@ -117,7 +119,7 @@ fun AuthMethodPicker( termsConfiguration.accepted Column( - modifier = modifier + modifier = modifier.exposeTestTagsAsResourceIds() ) { logo?.let { Image( @@ -144,7 +146,7 @@ fun AuthMethodPicker( modifier = Modifier .widthIn(max = 400.dp) .padding(horizontal = 24.dp) - .testTag("AuthMethodPicker LazyColumn"), + .testTag(FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST), horizontalAlignment = Alignment.CenterHorizontally, ) { // Show "Continue as..." button if last sign-in preference exists @@ -239,7 +241,7 @@ private fun ContinueAsButton( AuthProviderButton( modifier = Modifier .fillMaxWidth() - .testTag("ContinueAsButton"), + .testTag(FirebaseAuthTestTags.MethodPicker.CONTINUE_AS_BUTTON), onClick = onClick, enabled = enabled, provider = provider, diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/AuthRoute.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/AuthRoute.kt new file mode 100644 index 0000000000..aa9b311953 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/AuthRoute.kt @@ -0,0 +1,341 @@ +/* + * 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.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.NavMetadataKey +import androidx.navigation3.runtime.get +import androidx.navigation3.runtime.metadata +import androidx.navigation3.scene.Scene +import com.firebase.ui.auth.mfa.MfaEnrollmentStep +import com.firebase.ui.auth.ui.screens.email.EmailAuthMode +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthStep +import kotlinx.serialization.Serializable + +/** + * A destination the library's navigation back stack can be told to go to. + * + * * **[Destination]** — a real back-stack entry, and therefore a Navigation 3 [NavKey]. Every one + * is registered on both hosts' entry providers, so navigating to it always resolves. All are + * `@Serializable`, which is what lets [androidx.navigation3.runtime.rememberNavBackStack] + * persist the stack — and any field a step carries, today only [Email.Step.email] — across + * configuration change *and* process death. + * * **[FlowEntry]** — [Email], [Phone] and [MfaEnrollment]. Naming one means "enter this flow"; + * [FlowEntry.startKey] resolves it to the step the flow opens on, so callers never have to name + * a step. Deliberately **not** a [NavKey]: it can never be put on a back stack, and [toKey] is + * the one way to turn it into something that can be. + * + * @since 10.0.0 + */ +@Serializable +sealed interface AuthRoute { + + /** An [AuthRoute] that is a real destination, and therefore a Navigation 3 back-stack key. */ + @Serializable + sealed interface Destination : AuthRoute, NavKey + + /** + * An [AuthRoute] that names a *flow* rather than a destination. [startKey] converts it to the + * destination entering the flow lands on; [toKey] applies that to any [AuthRoute]. + */ + sealed interface FlowEntry : AuthRoute { + /** The destination entering this flow lands on. */ + fun startKey(): Destination + } + + @Serializable + data object MethodPicker : Destination + + @Serializable + data object Success : Destination + + @Serializable + data object MfaChallenge : Destination + + /** + * Reauthentication, as an entry on the host's own back stack rather than a separate surface. + * + * Wraps the [step] it presents instead of duplicating the destination hierarchy: the same key + * type in the same stack cannot mean two configurations, and a wrapper is the cheapest way to + * say "this step, but in reauthentication mode". + * + * [requestId] and [userUid] make the entry the presentation marker itself, which is why nothing else + * has to be saved alongside the stack. + */ + @Serializable + data class Reauth( + val requestId: String, + val userUid: String, + val step: Destination, + ) : Destination + + /** Email and password, password recovery, and email-link sign-in. Starts at [SignIn]. */ + object Email : FlowEntry { + override fun startKey(): Destination = SignIn() + + /** + * One step per [EmailAuthMode], carrying the address typed so far as a field on the key, + * which is what preserves it across a switch: the step being left is disposed along with + * everything it held in composition state. + * + * Two instances of the same step with different addresses are **different keys**, so + * [com.firebase.ui.auth.ui.screens.email.navigateToEmailStep] asks "already on the stack?" + * of the step's *type*, not of the key. + */ + @Serializable + sealed interface Step : Destination { + /** The address this step was entered with, or null. */ + val email: String? + + /** This step carrying [email] instead. */ + fun withEmail(email: String?): Step + } + + @Serializable + data class SignIn(override val email: String? = null) : Step { + override fun withEmail(email: String?): Step = copy(email = email) + } + + @Serializable + data class SignUp(override val email: String? = null) : Step { + override fun withEmail(email: String?): Step = copy(email = email) + } + + @Serializable + data class ResetPassword(override val email: String? = null) : Step { + override fun withEmail(email: String?): Step = copy(email = email) + } + + @Serializable + data class EmailLinkSignIn(override val email: String? = null) : Step { + override fun withEmail(email: String?): Step = copy(email = email) + } + + /** The flow's start step carrying [email]. */ + fun startKey(email: String?): Step = SignIn(email) + + internal val steps: List + get() = listOf(SignIn(), SignUp(), ResetPassword(), EmailLinkSignIn()) + + internal fun stepFor(mode: EmailAuthMode, email: String? = null): Step = when (mode) { + EmailAuthMode.SignIn -> SignIn(email) + EmailAuthMode.SignUp -> SignUp(email) + EmailAuthMode.ResetPassword -> ResetPassword(email) + EmailAuthMode.EmailLinkSignIn -> EmailLinkSignIn(email) + } + + /** Whether [key] — a live back-stack key — belongs to this flow. */ + internal fun isStep(key: NavKey?): Boolean = key is Step + } + + /** Phone number verification. Starts at [EnterPhoneNumber]. */ + object Phone : FlowEntry { + override fun startKey(): Destination = EnterPhoneNumber + + /** + * One step per screen the phone flow walks through. The internal + * `AuthRoute.Phone.Step.phoneStep` extension maps a live key to the [PhoneAuthStep] + * [com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen] renders. + */ + @Serializable + sealed interface Step : Destination + + @Serializable + data object EnterPhoneNumber : Step + + @Serializable + data object EnterVerificationCode : Step + + internal val steps: List + get() = listOf(EnterPhoneNumber, EnterVerificationCode) + + internal fun stepFor(phoneStep: PhoneAuthStep): Step = + steps.first { it.phoneStep == phoneStep } + } + + /** + * Second-factor enrolment. Starts at [SelectFactor], or straight at the only allowed factor's + * step — resolved at flow entry by + * [com.firebase.ui.auth.ui.screens.mfa.mfaEnrollmentStartStep]. + */ + object MfaEnrollment : FlowEntry { + override fun startKey(): Destination = SelectFactor + + /** + * One step per screen the enrolment flow walks through. The internal + * `AuthRoute.MfaEnrollment.Step.enrollmentStep` extension maps a live key to the + * [MfaEnrollmentStep] [com.firebase.ui.auth.ui.screens.mfa.MfaEnrollmentScreen] renders. + */ + @Serializable + sealed interface Step : Destination + + @Serializable + data object SelectFactor : Step + + @Serializable + data object ConfigureSms : Step + + @Serializable + data object ConfigureTotp : Step + + @Serializable + data object VerifyFactor : Step + + internal val steps: List + get() = listOf(SelectFactor, ConfigureSms, ConfigureTotp, VerifyFactor) + + internal fun stepFor(enrollmentStep: MfaEnrollmentStep): Step = + steps.first { it.enrollmentStep == enrollmentStep } + } +} + +/** Which [EmailAuthMode] the hosted screen renders for this step. */ +internal val AuthRoute.Email.Step.mode: EmailAuthMode + get() = when (this) { + is AuthRoute.Email.SignIn -> EmailAuthMode.SignIn + is AuthRoute.Email.SignUp -> EmailAuthMode.SignUp + is AuthRoute.Email.ResetPassword -> EmailAuthMode.ResetPassword + is AuthRoute.Email.EmailLinkSignIn -> EmailAuthMode.EmailLinkSignIn + } + +/** Which [PhoneAuthStep] the hosted screen renders for this step. */ +internal val AuthRoute.Phone.Step.phoneStep: PhoneAuthStep + get() = when (this) { + AuthRoute.Phone.EnterPhoneNumber -> PhoneAuthStep.EnterPhoneNumber + AuthRoute.Phone.EnterVerificationCode -> PhoneAuthStep.EnterVerificationCode + } + +/** Which [MfaEnrollmentStep] the hosted screen renders for this step. */ +internal val AuthRoute.MfaEnrollment.Step.enrollmentStep: MfaEnrollmentStep + get() = when (this) { + AuthRoute.MfaEnrollment.SelectFactor -> MfaEnrollmentStep.SelectFactor + AuthRoute.MfaEnrollment.ConfigureSms -> MfaEnrollmentStep.ConfigureSms + AuthRoute.MfaEnrollment.ConfigureTotp -> MfaEnrollmentStep.ConfigureTotp + AuthRoute.MfaEnrollment.VerifyFactor -> MfaEnrollmentStep.VerifyFactor + } + +/** + * Every value a caller can hand the back stack, flow entry points included. Built from the same + * per-flow `steps` lists the hosts register from. + */ +internal val allAuthRoutes: List + get() = listOf(AuthRoute.MethodPicker, AuthRoute.Success, AuthRoute.MfaChallenge) + + listOf(AuthRoute.Email) + AuthRoute.Email.steps + + listOf(AuthRoute.Phone) + AuthRoute.Phone.steps + + listOf(AuthRoute.MfaEnrollment) + AuthRoute.MfaEnrollment.steps + +/** + * Resolves [this] to the key to actually push: a [AuthRoute.FlowEntry]'s start step, or the + * destination itself. Every push goes through this, or it would push a key nothing registered. + */ +internal fun AuthRoute.toKey(): AuthRoute.Destination = when (this) { + is AuthRoute.FlowEntry -> startKey() + is AuthRoute.Destination -> this +} + +/** + * Whether [this] — a live back-stack key, or null for an empty stack — is *at* [route], ignoring + * any argument the key carries. + * + * Compares runtime classes, not keys: `SignIn("bob@x.com") != SignIn(null)` as keys, but both are + * the same destination, which is what makes "is the flow already on its start step?" independent + * of what has been typed into it. + */ +internal fun NavKey?.isAt(route: AuthRoute): Boolean = + this != null && this::class == route.toKey()::class + +/** + * Sends the flow back to [route] as the only thing on the back stack: exactly one entry, and it is + * [route]'s resolved key, whatever the stack held before. + * + * Adds before trimming, so no single write leaves the stack empty. The pushed key is newly + * constructed, but a key `==` to one already on the stack keeps its saved composition state — do + * not rely on a reset to blank a form. + */ +internal fun NavBackStack.resetBackStackTo(route: AuthRoute) { + add(route.toKey()) + while (size > 1) removeAt(0) +} + +/** + * Pushes [route]'s key, guaranteeing it ends up on top **exactly once** — two keys that are `==` + * are one entry to Navigation 3, and a duplicate either crashes inside `runtime-saveable` or + * silently shares one instance of the screen. + * + * A push whose key is already on the stack therefore **moves** it, dropping everything that was + * above it: `[A,B]` push `B` → `[A,B]`, and `[A,B,C]` push `B` → `[A,B]`. Every call site relies on + * the target never being buried, so that trim never happens today; a new one must uphold that or + * accept the trim. + */ +internal fun NavBackStack.pushUnique(route: AuthRoute) { + val existing = indexOf(route.toKey()) + add(route.toKey()) + if (existing >= 0) { + while (size > existing + 1) removeAt(existing) + } +} + +/** + * Pops one entry, unless that would empty the stack. Returns whether anything was popped. The + * guard is needed because `NavDisplay` throws on an empty back stack, and throws from + * recomposition rather than from the call that emptied it, so it cannot be caught at the call site. + */ +internal fun NavBackStack.popOrNull(): Boolean = + if (size > 1) { + removeAt(size - 1) + true + } else { + false + } + +/** + * Metadata slot the library stamps its own key into on every entry it registers, so that a + * [Scene] can be asked which [AuthRoute] it is showing — see [authRoute]. + * + * @since 10.0.0 + */ +object AuthRouteMetadataKey : NavMetadataKey + +/** Per-key metadata stamping [route] into [AuthRouteMetadataKey]. */ +internal fun authRouteMetadata(route: AuthRoute): Map = + metadata { put(AuthRouteMetadataKey, route) } + +/** + * The [AuthRoute] this [Scene] is showing, or `null` for a scene the library did not register. + * + * This is what a [com.firebase.ui.auth.configuration.AuthUITransitions] lambda reads off + * `initialState` / `targetState` to vary the animation per destination: + * + * ```kotlin + * AuthUITransitions( + * transitionSpec = { + * if (targetState.authRoute() is AuthRoute.Success) { + * fadeIn() togetherWith fadeOut() + * } else { + * slideInHorizontally { it } togetherWith slideOutHorizontally { -it } + * } + * }, + * ) + * ``` + * + * Read from the entry metadata the library stamps itself (see [AuthRouteMetadataKey]); prefer it + * over [Scene.key], which mid-transition still reports the outgoing destination. A scene showing + * several entries reports the topmost. + * + * @since 10.0.0 + */ +fun Scene.authRoute(): AuthRoute? = + entries.lastOrNull()?.metadata?.get(AuthRouteMetadataKey) 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 index 14e0965f71..c986fcd4c5 100644 --- 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 @@ -14,11 +14,11 @@ package com.firebase.ui.auth.ui.screens +import com.firebase.ui.auth.AuthFlowScope +import com.firebase.ui.auth.LocalAuthFlowScope +import com.firebase.ui.auth.hostAuthFlowScope 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 @@ -31,7 +31,6 @@ 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 @@ -39,35 +38,40 @@ 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.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable 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.res.stringResource 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 androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.BuildConfig import com.firebase.ui.auth.FirebaseAuthActivity import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.R import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.DefaultAuthContentTransform +import com.firebase.ui.auth.configuration.DefaultAuthPredictivePopContentTransform 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 @@ -78,15 +82,41 @@ import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringPro 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.getRecoveryMessage 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.mfa.MfaEnrollmentStep +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds 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.email.EmailAuthMode +import com.firebase.ui.auth.ui.screens.email.emailAuthDestinations +import com.firebase.ui.auth.ui.screens.email.isEmailLinkSignInOffered +import com.firebase.ui.auth.ui.screens.email.isEmailSignUpOffered +import com.firebase.ui.auth.ui.screens.email.navigateToEmailStep +import com.firebase.ui.auth.ui.screens.mfa.MfaChallengeScreen +import com.firebase.ui.auth.ui.screens.mfa.enterMfaEnrollment +import com.firebase.ui.auth.ui.screens.mfa.entersMfaEnrollment +import com.firebase.ui.auth.ui.screens.mfa.exitMfaEnrollment +import com.firebase.ui.auth.ui.screens.mfa.mfaEnrollmentDestinations +import com.firebase.ui.auth.ui.screens.mfa.rememberMfaEnrollmentFlowState import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState -import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen +import com.firebase.ui.auth.ui.screens.phone.abandonVerification +import com.firebase.ui.auth.ui.screens.phone.exitPhoneAuth +import com.firebase.ui.auth.ui.screens.phone.phoneAuthDestinations +import com.firebase.ui.auth.ui.screens.phone.rememberPhoneAuthFlowState +import com.firebase.ui.auth.ui.screens.reauth.ReauthContentState +import com.firebase.ui.auth.ui.screens.reauth.ReauthSceneStrategy +import com.firebase.ui.auth.ui.screens.reauth.presentedReauth +import com.firebase.ui.auth.ui.screens.reauth.rememberReauthFlowState +import com.firebase.ui.auth.ui.screens.reauth.clearReauth +import com.firebase.ui.auth.ui.screens.reauth.navigateReauth +import com.firebase.ui.auth.ui.screens.reauth.returnToReauthStart +import com.firebase.ui.auth.ui.screens.reauth.reauthDestinations +import com.firebase.ui.auth.ui.screens.reauth.toReauthSurface +import com.firebase.ui.auth.ui.screens.reauth.toReauthConfiguration import com.firebase.ui.auth.util.EmailLinkPersistenceManager import com.firebase.ui.auth.util.SignInPreferenceManager import com.firebase.ui.auth.util.displayIdentifier @@ -95,14 +125,16 @@ 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.CancellationException 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 modifier Applied once to the root [Surface]; it does not reach dialogs/sheets, which are + * separate semantics owners the library flags for test-tag exposure on its own. * @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). @@ -114,6 +146,11 @@ import kotlinx.coroutines.tasks.await * @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. + * @param reauthContent Optional slot that replaces the default reauthentication bottom sheet, + * receiving a [ReauthContentState]. The library owns the credential exchange. An outstanding + * reauthentication survives Activity recreation (rotation) but not process death; if it is lost + * the flow surfaces an error rather than dropping the pending operation silently. An enrolled + * second factor is challenged over the slot, honouring [mfaChallengeContent]. * * @since 10.0.0 */ @@ -134,10 +171,9 @@ fun FirebaseAuthScreen( phoneContent: (@Composable (PhoneAuthContentState) -> Unit)? = null, mfaEnrollmentContent: (@Composable (MfaEnrollmentContentState) -> Unit)? = null, mfaChallengeContent: (@Composable (MfaChallengeContentState) -> Unit)? = null, - reauthContent: (@Composable (state: AuthState.ReauthenticationRequired, onDismiss: () -> Unit) -> Unit)? = null, + reauthContent: (@Composable (ReauthContentState) -> Unit)? = null, authenticatedContent: (@Composable (state: AuthState, uiContext: AuthSuccessUiContext) -> Unit)? = null, ) { - // Set FirebaseUI version LaunchedEffect(authUI.auth) { authUI.auth.setFirebaseUIVersion(BuildConfig.VERSION_NAME) } @@ -146,40 +182,163 @@ fun FirebaseAuthScreen( val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val stringProvider = remember(context) { DefaultAuthUIStringProvider(context) } - val navController = rememberNavController() - val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle) + // The reauth effects below run outside composition, so they cannot call stringResource + // themselves. + val reauthInterruptedMessage = stringResource(R.string.fui_error_reauth_interrupted) + val reauthNoLinkedProvidersMessage = + stringResource(R.string.fui_error_reauth_no_linked_providers) + val reauthIncompleteMessage = stringResource(R.string.fui_error_reauth_incomplete) + val reauthRetryingMessage = stringResource(R.string.fui_loading_reauth_retrying) + + val observedAuthState by remember(authUI) { authUI.authStateFlow() } + .collectAsState(initial = null as AuthState?) + val rawAuthState = observedAuthState ?: AuthState.Idle + val reauthFlowState = rememberReauthFlowState() + val reauthState = reauthFlowState.phase + val pendingReauth by authUI.pendingReauth.collectAsState() + val hostStateHolder = rememberUpdatedState(rawAuthState) + val hostScope = remember(authUI, configuration, hostStateHolder) { + hostAuthFlowScope(authUI, configuration, hostStateHolder) + } + val authState = rawAuthState 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 mfaEnrollmentFlowState = rememberMfaEnrollmentFlowState(mfaConfiguration.allowedCountries) + val phoneAuthFlowState = rememberPhoneAuthFlowState(configuration) + val reauthRequest = reauthState?.request + val reauthConfig = reauthRequest?.let { configuration.toReauthConfiguration(it.user) } + // Keyed to the request, never the host flow's: another operation, maybe another user. + val reauthPhoneFlowState = key(reauthRequest?.requestId) { + rememberPhoneAuthFlowState(reauthConfig ?: configuration) + } + /** + * The reauthentication surface, or null when there is none. One signal: [ReauthSceneStrategy] + * decides whether the sheet exists on it and the entry renders what it resolves to. + * + * Held as `State` because the entry reads it, and a `NavEntry`'s content lambda is built once + * per key — anything passed by value there never updates. + */ + val reauthSurface = remember(reauthState, configuration) { + reauthState.toReauthSurface(configuration) + } + val reauthSurfaceHolder = rememberUpdatedState(reauthSurface) + val reauthException = (reauthState as? AuthState.Reauthentication.AttemptFailed) + ?.exception + ?.let { throwable -> + when (throwable) { + is AuthException -> throwable + else -> AuthException.from(throwable, stringProvider) + } + } val emailLinkFromDifferentDevice = remember { mutableStateOf(null) } - val prefillEmail = remember { mutableStateOf(null) } + val typedEmail = rememberSaveable { mutableStateOf(null) } + val reauthPrefillEmail = remember(authUI, configuration.isReauthenticationMode) { + if (configuration.isReauthenticationMode) authUI.auth.currentUser?.email else null + } val lastSignInPreference = remember { mutableStateOf(null) } - // Last-processed AuthState, so the Idle branch below can tell a genuine reset apart from - // Idle-as-a-side-effect of consuming a notification (see AuthState.isNotification). - val previousAuthState = remember { mutableStateOf(AuthState.Idle) } + val previousAuthState = remember { mutableStateOf(null) } val startRoute = remember(configuration.providers, configuration.isProviderChoiceAlwaysShown) { getStartRoute(configuration) } val skipsMethodPicker = startRoute != AuthRoute.MethodPicker + val backStack = rememberNavBackStack(startRoute.toKey()) + // The stack is the presentation marker: a Reauth entry persists with it, across recreation and death. + val presentedReauth = backStack.presentedReauth() + val clearReauthPresentation: () -> Unit = remember(backStack) { { backStack.clearReauth() } } + /** + * Ends the request: clears presentation, clears the phase, publishes [terminal], then resolves + * the caller. The order matters — the caller resolves last so a fast retry's outcome stands. + */ + val finishReauth: (AuthState, Boolean) -> Unit = + remember(authUI, clearReauthPresentation, reauthFlowState) { + { terminal, retryOperation -> + clearReauthPresentation() + reauthFlowState.finish(retryOperation) + authUI.updateAuthState(terminal) + } + } + // A request that is never presented has no phase to end, so its caller is resolved directly. + val refuseReauth: (AuthState.Reauthentication.Required, AuthState) -> Unit = + remember(authUI, clearReauthPresentation) { + { required, terminal -> + clearReauthPresentation() + required.request.decline() + authUI.updateAuthState(terminal) + } + } + val currentOnSignInCancelled = rememberUpdatedState(onSignInCancelled) + val onReauthDismiss: () -> Unit = remember(finishReauth) { + { + // The user backed out, so the pending operation is not retried. + finishReauth(AuthState.Idle, false) + currentOnSignInCancelled.value() + } + } + /** + * Leaving one reauthentication step. The stack decides which of the two it is: another + * reauthentication entry underneath means step back and cancel the attempt; nothing underneath + * means the surface itself is being left. + */ + val onLeaveReauthStep: (AuthRoute.Reauth) -> Unit = + remember(reauthFlowState, backStack, onReauthDismiss) { + { marker -> + val below = backStack.getOrNull(backStack.lastIndex - 1) + if (below is AuthRoute.Reauth) { + backStack.popOrNull() + reauthFlowState.update(marker.requestId) { it.attemptCancelled() } + } else { + onReauthDismiss() + } + } + } + // The slot *is* the provider chooser, even for one provider, so it always starts at the picker + // step. The default sheet skips straight into a lone provider's flow, as it always did. + val reauthStartStepFor: (AuthUIConfiguration?) -> AuthRoute.Destination = + remember(reauthContent) { + { config -> + when { + // The slot is the provider chooser, so it starts at the picker even for one. + reauthContent != null -> AuthRoute.MethodPicker + config != null -> getStartRoute(config).toKey() + else -> AuthRoute.MethodPicker + } + } + } + val stepTransitionSpec = configuration.transitions?.transitionSpec + ?: DefaultAuthContentTransform + val stepPopTransitionSpec = configuration.transitions?.popTransitionSpec + ?: DefaultAuthContentTransform + val reauthSceneStrategy = + remember(onReauthDismiss, stepTransitionSpec, stepPopTransitionSpec) { + ReauthSceneStrategy( + surface = reauthSurfaceHolder, + onDismissRequest = onReauthDismiss, + transitionSpec = stepTransitionSpec, + popTransitionSpec = stepPopTransitionSpec, + ) + } - // 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( + val onOuterProviderSelected = hostScope.rememberOnProviderSelected( context = context, activity = activity, - config = configuration, - onNavigate = { route -> navController.navigate(route.route) }, + onNavigate = { route -> + if (route == AuthRoute.Email) { + backStack.navigateToEmailStep(AuthRoute.Email.SignIn(typedEmail.value)) + } else { + // pushUnique invariant: the picker is the only entry, so nothing can be buried. + backStack.pushUnique(route) + } + }, onUnknownProvider = { provider -> onSignInFailure( AuthException.UnknownException( @@ -192,6 +351,15 @@ fun FirebaseAuthScreen( }, onSignInFailure = onSignInFailure, ) + val currentOuterProviderSelected = rememberUpdatedState(onOuterProviderSelected) + val currentReauthState = rememberUpdatedState(reauthState) + val onProviderSelected: (AuthProvider) -> Unit = remember { + { provider -> + if (currentReauthState.value == null) { + currentOuterProviderSelected.value(provider) + } + } + } val continueWithProvider: (String) -> Unit = { providerId -> configuration.providers.find { it.providerId == providerId }?.let { onProviderSelected(it) } } @@ -199,37 +367,58 @@ fun FirebaseAuthScreen( CompositionLocalProvider( LocalAuthUIStringProvider provides configuration.stringProvider, LocalTopLevelDialogController provides dialogController, - LocalAuthUITheme provides (configuration.theme ?: LocalAuthUITheme.current) + LocalAuthUITheme provides (configuration.theme ?: LocalAuthUITheme.current), + // reauthDestinations overrides this with the outstanding request's own flow. + LocalAuthFlowScope provides hostScope, ) { Surface( - modifier = Modifier + modifier = modifier .fillMaxSize() + .exposeTestTagsAsResourceIds() ) { - 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)) + NavDisplay( + backStack = backStack, + sceneStrategies = listOf(reauthSceneStrategy), + // Back off a step that abandons work owes the same teardown the step's own control + // does; anything else is a plain pop. + onBack = { + when (val top = backStack.lastOrNull()) { + // Unreachable for a sheet-presented step, which swallows the gesture, but + // a bare reauth entry still routes here; the phase move is onLeaveStep's. + is AuthRoute.Reauth -> onLeaveReauthStep(top) + + is AuthRoute.Phone.EnterVerificationCode -> { + phoneAuthFlowState.abandonVerification("system back from code entry") + // Number entry is exempt from Idle's reset, so this lands there rather + // than unwinding the flow. + authUI.updateAuthState(AuthState.Idle) + backStack.popOrNull() + } + + else -> backStack.popOrNull() + } }, - popExitTransition = configuration.transitions?.popExitTransition ?: { - fadeOut(animationSpec = tween(700)) - } - ) { - composable(AuthRoute.MethodPicker.route) { + transitionSpec = stepTransitionSpec, + popTransitionSpec = stepPopTransitionSpec, + predictivePopTransitionSpec = + configuration.transitions?.predictivePopTransitionSpec + ?: DefaultAuthPredictivePopContentTransform, + // Every entry's content lambda below is built once per key: a value passed into + // one is captured at that first composition and never updates again. Anything + // that changes while an entry is on screen has to arrive as a `State` or a + // getter lambda, never by value. + entryProvider = entryProvider { + entry( + metadata = authRouteMetadata(AuthRoute.MethodPicker) + ) { if (customMethodPickerLayout != null) { - Box(modifier = modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { customMethodPickerLayout(configuration.providers, onProviderSelected) } } else { - Scaffold { innerPadding -> + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> AuthMethodPicker( - modifier = modifier + modifier = Modifier .padding(innerPadding), providers = configuration.providers, logo = logoAsset, @@ -238,11 +427,12 @@ fun FirebaseAuthScreen( lastSignInPreference = lastSignInPreference.value, termsConfiguration = customMethodPickerTermsConfiguration, onProviderSelected = { provider -> - prefillEmail.value = null + typedEmail.value = null onProviderSelected(provider) }, onContinueAsSelected = { provider, identifier -> - prefillEmail.value = if (provider is AuthProvider.Email) identifier else null + typedEmail.value = + if (provider is AuthProvider.Email) identifier else null onProviderSelected(provider) }, ) @@ -250,56 +440,42 @@ fun FirebaseAuthScreen( } } - composable(AuthRoute.Email.route) { - EmailAuthScreen( - context = context, - configuration = configuration, - authUI = authUI, - prefillEmail = prefillEmail.value, - 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 - } - } + emailAuthDestinations( + backStack = backStack, + context = context, + configuration = configuration, + authUI = authUI, + content = emailContent, + prefillEmail = { reauthPrefillEmail }, + credentialForLinking = { pendingLinkingCredential.value }, + emailLinkFromDifferentDevice = { emailLinkFromDifferentDevice.value }, + onEmailTyped = { typedEmail.value = it }, + onSuccess = { pendingLinkingCredential.value = null }, + onError = { exception -> onSignInFailure(exception) }, + onCancel = { + pendingLinkingCredential.value = null + if (!skipsMethodPicker && !backStack.popOrNull()) { + backStack.resetBackStackTo(AuthRoute.MethodPicker) } - ) - } + }, + ) - 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 - } - } + phoneAuthDestinations( + backStack = backStack, + context = context, + configuration = configuration, + authUI = authUI, + flowState = phoneAuthFlowState, + content = phoneContent, + onError = { exception -> onSignInFailure(exception) }, + onCancel = { + if (!skipsMethodPicker && !backStack.exitPhoneAuth()) { + backStack.resetBackStackTo(AuthRoute.MethodPicker) } - ) - } + }, + ) - composable(AuthRoute.Success.route) { + entry(metadata = authRouteMetadata(AuthRoute.Success)) { val uiContext = remember(authState, stringProvider) { AuthSuccessUiContext( authUI = authUI, @@ -309,7 +485,6 @@ fun FirebaseAuthScreen( 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 { @@ -319,47 +494,60 @@ fun FirebaseAuthScreen( } }, 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)) + if (reauthState == null) { + if (configuration.isMfaEnabled) { + // A second tap while the flow is already entered is a + // no-op: enterMfaEnrollment guards its own reset. + backStack.enterMfaEnrollment( + route = AuthRoute.MfaEnrollment, + configuration = mfaConfiguration, + flowState = mfaEnrollmentFlowState, + ) + } 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 ?: "" - ) - ) - } - } + authUI.reloadUser() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - Log.e("FirebaseAuthScreen", "Failed to refresh user", e) + // Signing out mid-reload fails the token refresh. That is + // the sign-out landing, not a refresh the user can retry. + if (authUI.getCurrentUser() == null) { + Log.d( + "FirebaseAuthScreen", + "Reload abandoned, user signed out", + e + ) + } else { + Log.e("FirebaseAuthScreen", "Failed to refresh user", e) + } } } }, onNavigate = { route -> - navController.navigate(route.route) + if (reauthState == null) { + // Naming a step of the enrolment flow enters it just as + // naming the flow does, so both clear the previous attempt. + if (route.entersMfaEnrollment) { + backStack.enterMfaEnrollment( + route = route, + configuration = mfaConfiguration, + flowState = mfaEnrollmentFlowState, + ) + } else { + // pushUnique invariant: `route` is consumer-supplied; safe only as Success is one entry. + backStack.pushUnique(route) + } + } } ) } @@ -376,79 +564,87 @@ fun FirebaseAuthScreen( } } - 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() + mfaEnrollmentDestinations( + backStack = backStack, + configuration = mfaConfiguration, + authConfiguration = configuration, + authUI = authUI, + flowState = mfaEnrollmentFlowState, + content = mfaEnrollmentContent, + onComplete = { backStack.exitMfaEnrollment() }, + onSkip = { backStack.exitMfaEnrollment() }, + onError = { exception -> + onSignInFailure(AuthException.from(exception, stringProvider)) } - } + ) - composable(AuthRoute.MfaChallenge.route) { - val resolver = pendingResolver.value + reauthDestinations( + backStack = backStack, + authUI = authUI, + activity = activity, + context = context, + configuration = configuration, + stringProvider = stringProvider, + reauthFlowState = reauthFlowState, + surface = reauthSurfaceHolder, + phoneFlowState = reauthPhoneFlowState, + emailContent = emailContent, + phoneContent = phoneContent, + mfaChallengeContent = mfaChallengeContent, + reauthContent = reauthContent, + customMethodPickerLayout = customMethodPickerLayout, + onDismiss = onReauthDismiss, + onLeaveStep = onLeaveReauthStep, + ) + + entry( + metadata = authRouteMetadata(AuthRoute.MfaChallenge) + ) { + val resolver = remember { pendingResolver.value } if (resolver != null) { MfaChallengeScreen( resolver = resolver, auth = authUI.auth, content = mfaChallengeContent, - onSuccess = { + onSuccess = { result -> pendingResolver.value = null - // Reset auth state to Idle so the firebaseAuthFlow Success state takes over - authUI.updateAuthState(AuthState.Idle) + hostScope.emitResult(result) }, + // Load-bearing pop: Cancelled below then sees the start step, so it skips a reset that blanks the address. onCancel = { pendingResolver.value = null authUI.updateAuthState(AuthState.Cancelled) - navController.popBackStack() + backStack.popOrNull() }, onError = { exception -> onSignInFailure(AuthException.from(exception, stringProvider)) } ) } else { - navController.popBackStack() + LaunchedEffect(Unit) { backStack.popOrNull() } } } - } + }, + ) - // Handle email link sign-in (deep links) LaunchedEffect(emailLink) { - if (emailLink != null && emailProvider != null) { + if (emailLink != null && emailProvider != null && reauthState == 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( + hostScope.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( + hostScope.signInWithEmailLink( context = context, - config = configuration, provider = emailProvider, - email = "", // Empty email triggers cross-device detection + email = "", emailLink = emailLink ) } @@ -458,47 +654,31 @@ fun FirebaseAuthScreen( } } - // Synchronise auth state changes with navigation stack. - LaunchedEffect(authState) { - val state = authState + LaunchedEffect(observedAuthState) { + val state = observedAuthState ?: return@LaunchedEffect val previous = previousAuthState.value previousAuthState.value = state - val currentRoute = navController.currentBackStackEntry?.destination?.route + // Guards below use `isAt` (runtime class), not `==`: keys carry arguments, so `==` blanks a live form. + val currentKey = backStack.lastOrNull() + // These steps show a "link sent" confirmation latched in their own composition, + // so resetting off one loses it. Same reasoning as Phone.EnterPhoneNumber below. + val ownsConfirmation = currentKey is AuthRoute.Email.EmailLinkSignIn || + currentKey is AuthRoute.Email.ResetPassword + // A modal reauthentication owns the screen; Aborted is how the host is dismissed. + if (reauthFlowState.phase != null && state !is AuthState.Aborted) { + return@LaunchedEffect + } + when (state) { is AuthState.Success -> { pendingResolver.value = null pendingLinkingCredential.value = null - // If reauth just completed, execute the pending retry and skip normal success handling. - // Guarded on !previous.isNotification: a wrong-password Error masks back into - // Success while signed in, and that must not be mistaken for a completed reauth. - if (!previous.isNotification) { - 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) @@ -506,35 +686,13 @@ fun FirebaseAuthScreen( } } - 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, - ) + // Only a real credential exchange carries a result — a re-asserted + // session does not (see CPRN-425), so it must not reset off a + // confirmation the user has not read yet. + if ((state.result != null || !ownsConfirmation) && + currentKey != AuthRoute.Success + ) { + backStack.resetBackStackTo(AuthRoute.Success) } } @@ -543,69 +701,62 @@ fun FirebaseAuthScreen( -> { 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 - } + // authUserState drops the result on these, so an exchange cannot be told + // from a re-asserted session here — the confirmation wins either way. + if (!ownsConfirmation && currentKey != AuthRoute.Success) { + backStack.resetBackStackTo(AuthRoute.Success) } } is AuthState.RequiresMfa -> { pendingResolver.value = state.resolver - if (currentRoute != AuthRoute.MfaChallenge.route) { - navController.navigate(AuthRoute.MfaChallenge.route) { - launchSingleTop = true - } + // pushUnique invariant: nothing is pushed on top of the challenge, so this covers the buried case. + if (currentKey != AuthRoute.MfaChallenge) { + backStack.pushUnique(AuthRoute.MfaChallenge) } } is AuthState.Cancelled -> { - pendingReauthOperation.value = null - pendingReauthConfig.value = null - pendingReauthState.value = null + clearReauthPresentation() 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 - } + typedEmail.value = null + if (!currentKey.isAt(startRoute)) { + backStack.resetBackStackTo(startRoute) } onSignInCancelled() authUI.updateAuthState(AuthState.Idle) } is AuthState.Aborted -> { - // Hosted by FirebaseAuthActivity: its own authStateFlow collector - // independently finishes the activity and resets state on Aborted. + // Outside the guard below: the activity host ends nothing itself. + clearReauthPresentation() + reauthFlowState.finish(false) + // A request raised before this composition accepted it has no phase to end. + pendingReauth?.request?.decline() if (activity !is FirebaseAuthActivity) { - pendingReauthOperation.value = null - pendingReauthConfig.value = null - pendingReauthState.value = null pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null + typedEmail.value = null authUI.updateAuthState(AuthState.Idle) } } is AuthState.Idle -> { - // A notification resets to Idle purely to avoid leaking to a freshly - // created screen — that's not a request to leave the current one. - if (!previous.isNotification) { - pendingReauthOperation.value = null - pendingReauthConfig.value = null - pendingReauthState.value = null + if (previous != null && !previous.isNotification) { + clearReauthPresentation() 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 - } + typedEmail.value = null + // Number entry is a real position in the flow, so a retraction + // reached there stays put rather than resetting out of it. + if (!currentKey.isAt(startRoute) && + currentKey !is AuthRoute.Phone.EnterPhoneNumber + ) { + backStack.resetBackStackTo(startRoute) } } } @@ -614,7 +765,139 @@ fun FirebaseAuthScreen( } } - // Handle errors using top-level dialog controller + // A marker with no request behind it: the process died and took the request with it. + LaunchedEffect(pendingReauth, backStack.presentedReauth()) { + if (backStack.presentedReauth() == null) return@LaunchedEffect + if (pendingReauth != null || reauthFlowState.phase != null) return@LaunchedEffect + clearReauthPresentation() + authUI.updateAuthState( + AuthState.Error( + AuthException.UnknownException( + reauthInterruptedMessage + ) + ) + ) + } + + // Takes on the request waiting on pendingReauth; a recreated screen re-runs it. + LaunchedEffect(pendingReauth) { + val required = pendingReauth ?: return@LaunchedEffect + if (reauthFlowState.phase?.requestId == required.requestId) return@LaunchedEffect + + val reauthConfiguration = configuration.toReauthConfiguration(required.user) + if (reauthConfiguration == null) { + refuseReauth( + required, + AuthState.Error( + AuthException.UnknownException( + reauthNoLinkedProvidersMessage + ) + ), + ) + return@LaunchedEffect + } + // A request whose caller is gone can never complete, so it is reported. + if (!required.request.isResumable) { + refuseReauth( + required, + AuthState.Error( + AuthException.UnknownException( + reauthInterruptedMessage + ) + ), + ) + return@LaunchedEffect + } + reauthFlowState.accept(required) + if (backStack.presentedReauth()?.requestId != required.requestId) { + backStack.clearReauth() + backStack.add( + AuthRoute.Reauth( + requestId = required.requestId, + userUid = required.userUid, + step = reauthStartStepFor(reauthConfiguration), + ) + ) + } + } + + // Keyed on the phase, so it also sees transitions the destinations make directly. + LaunchedEffect(reauthFlowState.phase) { + val phase = reauthFlowState.phase ?: return@LaunchedEffect + + // The challenge entry is on the stack exactly while the phase is RequiresMfa. + val marker = backStack.presentedReauth()?.takeIf { it.requestId == phase.requestId } + if (marker != null) { + if (phase is AuthState.Reauthentication.RequiresMfa) { + if (marker.step !is AuthRoute.MfaChallenge) { + backStack.navigateReauth(marker, AuthRoute.MfaChallenge) + } + } else if (marker.step is AuthRoute.MfaChallenge) { + backStack.returnToReauthStart() + } + } + + if (phase is AuthState.Reauthentication.Succeeded) { + val request = phase.request + val success = phase.success + if (success.reauthenticatedUid != phase.userUid || + success.user.uid != phase.userUid + ) { + // Wrong user is a failed attempt, not a dead request. + reauthFlowState.update(phase.requestId) { + AuthState.Reauthentication.AttemptFailed( + request, + AuthException.UnknownException( + reauthIncompleteMessage + ), + ) + } + return@LaunchedEffect + } + // A Success here would claim the pending operation had already succeeded. + val terminal = if (request.hasPendingOperation) { + AuthState.Loading( + reauthRetryingMessage + ) + } else { + AuthState.Success(result = null, user = request.user) + } + finishReauth(terminal, true) + } + } + + // The slot owns the error and loading presentation while it is what is on screen. + val reauthSlotActive = reauthContent != null && + reauthSurface != null && + presentedReauth?.step is AuthRoute.MethodPicker + + val reauthAttemptFailure = + reauthState as? AuthState.Reauthentication.AttemptFailed + if (reauthAttemptFailure != null && !reauthSlotActive) { + LaunchedEffect(reauthAttemptFailure) { + val exception = reauthException ?: return@LaunchedEffect + dialogController.showErrorDialog( + exception = exception, + errorState = AuthState.Error(reauthAttemptFailure.exception), + onRetry = null, + onRecover = null, + ) + } + } + + fun navigateToEmailStep(target: AuthRoute.Email.Step, address: String? = null) { + if (backStack.lastOrNull().isAt(target)) return + val carriedEmail = address?.takeIf { it.isNotEmpty() } ?: typedEmail.value + backStack.navigateToEmailStep(target, carriedEmail) + } + + val emailLinkRecoveryStep: AuthRoute.Email.Step = + if (configuration.isEmailLinkSignInOffered()) { + AuthRoute.Email.EmailLinkSignIn() + } else { + AuthRoute.Email.SignIn() + } + val errorState = authState as? AuthState.Error if (errorState != null) { LaunchedEffect(errorState) { @@ -626,42 +909,40 @@ fun FirebaseAuthScreen( dialogController.showErrorDialog( exception = exception, errorState = errorState, - onRetry = { _ -> - // Child screens handle their own retry logic - }, - onRecover = when (exception) { - is AuthException.EmailAlreadyInUseException -> { - { - navController.navigate(AuthRoute.Email.route) { - launchSingleTop = true - } + onRetry = null, + onRecover = if (configuration.isReauthenticationMode) { + null + } else when (exception) { + is AuthException.UserNotFoundException -> { + if (configuration.isEmailSignUpOffered()) { + { navigateToEmailStep(AuthRoute.Email.SignUp()) } + } else { + null } } + is AuthException.EmailAlreadyInUseException -> { + { navigateToEmailStep(AuthRoute.Email.SignIn(), exception.email) } + } + is AuthException.AccountLinkingRequiredException -> { { pendingLinkingCredential.value = exception.credential - navController.navigate(AuthRoute.Email.route) { - launchSingleTop = true - } + navigateToEmailStep(AuthRoute.Email.SignIn(), exception.email) } } is AuthException.EmailLinkPromptForEmailException -> { { emailLinkFromDifferentDevice.value = exception.emailLink - navController.navigate(AuthRoute.Email.route) { - launchSingleTop = true - } + navigateToEmailStep(emailLinkRecoveryStep) } } is AuthException.EmailLinkCrossDeviceLinkingException -> { { emailLinkFromDifferentDevice.value = exception.emailLink - navController.navigate(AuthRoute.Email.route) { - launchSingleTop = true - } + navigateToEmailStep(emailLinkRecoveryStep) } } @@ -669,9 +950,10 @@ fun FirebaseAuthScreen( { val providerId = exception.suggestedSignInMethod if (providerId == EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD) { - navController.navigate(AuthRoute.Email.route) { - launchSingleTop = true - } + navigateToEmailStep( + emailLinkRecoveryStep, + exception.email, + ) } else { continueWithProvider(providerId) } @@ -681,72 +963,36 @@ fun FirebaseAuthScreen( else -> null }, onDismiss = { - // Dialog dismissed } ) - // Consumed immediately so this doesn't leak to a freshly created screen. authUI.updateAuthState(AuthState.Idle) } } - // Render the top-level dialog (only one instance) dialogController.CurrentDialog() - val loadingState = authState as? AuthState.Loading - if (loadingState != null) { - LoadingDialog(loadingState.message ?: stringProvider.progressDialogLoading) + val loadingMessage = when (val state = authState) { + is AuthState.Loading -> state.message + is AuthState.Reauthentication.Authenticating -> state.message + else -> null } - - // 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) - } + val isLoading = authState is AuthState.Loading || + authState is AuthState.Reauthentication.Authenticating + if (isLoading && !reauthSlotActive) { + LoadingDialog(loadingMessage ?: stringProvider.progressDialogLoading) } - // 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") -} - +/** + * Where the flow starts, from the configuration alone: a single email or phone provider opens + * that flow directly, anything else opens the method picker. + * + * Returns an [AuthRoute] rather than an [AuthRoute.Destination], because a single-provider + * configuration names a *flow*; [toKey] is what resolves it to the step to actually push. + */ internal fun getStartRoute(configuration: AuthUIConfiguration): AuthRoute { if (configuration.isProviderChoiceAlwaysShown || configuration.providers.size != 1) { return AuthRoute.MethodPicker @@ -844,7 +1090,7 @@ private fun AuthSuccessContent( TooltipAnchorPosition.Above ), tooltip = { - PlainTooltip { + PlainTooltip(modifier = Modifier.exposeTestTagsAsResourceIds()) { Text(stringProvider.mfaDisabledTooltip) } }, @@ -929,6 +1175,7 @@ private fun ProfileCompletionContent( @Composable private fun LoadingDialog(message: String) { AlertDialog( + modifier = Modifier.exposeTestTagsAsResourceIds(), onDismissRequest = {}, confirmButton = {}, containerColor = Color.Transparent, @@ -951,87 +1198,11 @@ private fun LoadingDialog(message: String) { } ) } -@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( +internal fun AuthFlowScope.rememberOnProviderSelected( context: android.content.Context, activity: android.app.Activity?, - config: AuthUIConfiguration, onNavigate: (AuthRoute) -> Unit, onUnknownProvider: ((AuthProvider) -> Unit)? = null, onSignInFailure: (AuthException) -> Unit = {}, @@ -1046,18 +1217,18 @@ private fun FirebaseAuthUI.rememberOnProviderSelected( val twitterProvider = config.providers.filterIsInstance().firstOrNull() val genericOAuthProviders = config.providers.filterIsInstance() - val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler(config, onSignInFailure) } - val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, config, it, onSignInFailure) } + val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler(onSignInFailure) } + val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, it, onSignInFailure) } val onSignInWithFacebook = facebookProvider?.let { - rememberSignInWithFacebookLauncher(context, config, it, onSignInFailure = onSignInFailure) + rememberSignInWithFacebookLauncher(context, it, onSignInFailure = onSignInFailure) } - val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } - val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } - val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } - val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } - val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } + val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, it, onSignInFailure) } + val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, it, onSignInFailure) } + val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, it, onSignInFailure) } + val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, it, onSignInFailure) } + val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, it, onSignInFailure) } val genericOAuthHandlers = genericOAuthProviders.associateWith { - rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) + rememberOAuthSignInHandler(context, activity, it, onSignInFailure) } return { provider -> diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthDestinations.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthDestinations.kt new file mode 100644 index 0000000000..ebe5560258 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthDestinations.kt @@ -0,0 +1,252 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens.email + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.ui.screens.AuthRoute +import com.firebase.ui.auth.ui.screens.authRouteMetadata +import com.firebase.ui.auth.ui.screens.mode +import com.firebase.ui.auth.ui.screens.popOrNull +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult + +/** + * Registers the email flow's steps on [this] entry provider. Every host that offers email sign-in + * installs this same extension, so the destinations and the rules for moving between them are + * identical in the main [com.firebase.ui.auth.ui.screens.FirebaseAuthScreen] display and in the + * reauthentication sheet. + * + * Each step hosts an [EmailAuthScreen] pinned to its own [EmailAuthMode], turning mode switches + * into navigation through [navigateToEmailStep]; the address travels as a field on the key + * ([AuthRoute.Email.Step.email]) so it survives a switch. Errors are not acted on here — moving the + * flow on an [com.firebase.ui.auth.AuthState.Error] is the host's job alone. + * + * [authRouteMetadata] is stamped per key rather than per type, because an email step's key carries + * the address. + * + * @param backStack The host's back stack, which is what a mode switch mutates. + * @param onCancel Invoked when the flow is *left*, not when stepping between email steps. + * @param prefillEmail The address already fixed for this flow (e.g. a reauthenticating user's own), + * seeding a step entered with no address on its key. A getter, not a value, because an entry's + * content lambda is built once per key — the entry rule at `FirebaseAuthScreen`'s entry provider. + * @param onEmailTyped Reports the address as the user edits it, so a host-driven recovery that does + * not know the address (e.g. [com.firebase.ui.auth.AuthException.UserNotFoundException]) can carry + * the live value. + */ +internal fun EntryProviderScope.emailAuthDestinations( + backStack: NavBackStack, + context: Context, + configuration: AuthUIConfiguration, + authUI: FirebaseAuthUI, + content: (@Composable (EmailAuthContentState) -> Unit)?, + onCancel: () -> Unit, + prefillEmail: () -> String? = { null }, + credentialForLinking: () -> AuthCredential? = { null }, + emailLinkFromDifferentDevice: () -> String? = { null }, + onEmailTyped: (String) -> Unit = {}, + onSuccess: (AuthResult) -> Unit = {}, + onError: (AuthException) -> Unit = {}, + /** Passed through to [EmailAuthScreen]: where a consumed notification leaves the flow. */ + onNotificationConsumed: (() -> Unit)? = null, +) { + val body: @Composable (AuthRoute.Email.Step) -> Unit = { step -> + EmailAuthStep( + step = step, + entryKey = step, + backStack = backStack, + context = context, + configuration = configuration, + authUI = authUI, + content = content, + navigateToStep = { backStack.navigateToEmailStep(it) }, + isStepBelow = { AuthRoute.Email.isStep(it) }, + onCancel = onCancel, + prefillEmail = prefillEmail, + credentialForLinking = credentialForLinking, + emailLinkFromDifferentDevice = emailLinkFromDifferentDevice, + onEmailTyped = onEmailTyped, + onNotificationConsumed = onNotificationConsumed, + onSuccess = onSuccess, + onError = onError, + ) + } + + entry(metadata = { authRouteMetadata(it) }) { body(it) } + entry(metadata = { authRouteMetadata(it) }) { body(it) } + entry(metadata = { authRouteMetadata(it) }) { body(it) } + entry(metadata = { authRouteMetadata(it) }) { body(it) } +} + +/** + * One email step, as every host renders it: the reachability bounce, then an [EmailAuthScreen] + * pinned to [step]'s mode. + * + * @param entryKey The key [step] is registered under — [step] itself, or the wrapper carrying it + * ([AuthRoute.Reauth]). What the bounce removes, so a wrapped step drops its wrapper. + * @param navigateToStep Moves this host to another email step, wrapping it as that host needs. The + * bounce goes through it too, so a redirect stays inside the host's own family of keys. + * @param isStepBelow Whether a key below the top is an email step of this host's, which is what + * back stepping through the flow rather than leaving it turns on. + */ +@Composable +internal fun EmailAuthStep( + step: AuthRoute.Email.Step, + entryKey: NavKey, + backStack: NavBackStack, + context: Context, + configuration: AuthUIConfiguration, + authUI: FirebaseAuthUI, + navigateToStep: (AuthRoute.Email.Step) -> Unit, + isStepBelow: (NavKey?) -> Boolean, + onCancel: () -> Unit, + prefillEmail: () -> String? = { null }, + credentialForLinking: () -> AuthCredential? = { null }, + emailLinkFromDifferentDevice: () -> String? = { null }, + onEmailTyped: (String) -> Unit = {}, + onSuccess: (AuthResult) -> Unit = {}, + onError: (AuthException) -> Unit = {}, + /** Passed through to [EmailAuthScreen]: where a consumed notification leaves the flow. */ + onNotificationConsumed: (() -> Unit)? = null, + content: (@Composable (EmailAuthContentState) -> Unit)? = null, +) { + if (!configuration.isEmailStepOffered(step)) { + LaunchedEffect(entryKey) { + // Push before dropping: no point in this pair leaves the stack empty for a later edit to stop at. + navigateToStep(AuthRoute.Email.SignIn(step.email)) + backStack.remove(entryKey) + } + RedirectingStep() + } else { + EmailAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + prefillEmail = step.email?.ifEmpty { null } ?: prefillEmail(), + credentialForLinking = credentialForLinking(), + emailLinkFromDifferentDevice = emailLinkFromDifferentDevice(), + content = content, + mode = step.mode, + onNavigateToMode = { targetMode, email -> + navigateToStep(AuthRoute.Email.stepFor(targetMode, email)) + }, + onEmailTyped = onEmailTyped, + onNotificationConsumed = onNotificationConsumed, + onSuccess = onSuccess, + onError = onError, + onCancel = { + if (isStepBelow(backStack.getOrNull(backStack.lastIndex - 1))) { + backStack.popOrNull() + } else { + onCancel() + } + }, + ) + } +} + +/** + * Moves the flow to [step], carrying the address on the key so the typed value survives the step + * being left disposing whatever it held. + * + * Drops any existing entry *of the same step type* — and everything above it — then leaves a fresh + * one on top. So a step already on the stack is replaced, and one that is not is pushed, leaving + * the origin reachable. The fresh entry means the address just typed wins over the stale one the + * old entry held. + * + * Adds before removing, so no single write leaves the stack empty. Compares the step's *type*, not + * the key: a key carries the address, so `SignIn("a@b")` and `SignIn("c@d")` are different keys. + */ +internal fun NavBackStack.navigateToEmailStep(step: AuthRoute.Email.Step) { + val existing = indexOfFirst { it::class == step::class } + add(step) + if (existing >= 0) { + // Drops the old entry and all above it, stopping below the one just pushed, so a buried step is reached. + while (size > existing + 1) removeAt(existing) + } +} + +/** Overload for callers that name the step and the address separately. */ +internal fun NavBackStack.navigateToEmailStep( + step: AuthRoute.Email.Step, + email: String?, +) = navigateToEmailStep(step.withEmail(email)) + +/** + * Shown for as long as a redirect off a step that cannot render itself takes. Rendering nothing + * leaves a hole on screen for the whole of the configured cross-fade, not for a single frame. + */ +@Composable +internal fun RedirectingStep() { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +/** + * Whether [step] is reachable at all in this configuration. Asked of every step rather than only + * sign-up, because `AuthSuccessUiContext.onNavigate` accepts any + * [com.firebase.ui.auth.ui.screens.AuthRoute]. + */ +internal fun AuthUIConfiguration.isEmailStepOffered(step: AuthRoute.Email.Step): Boolean = + when (step) { + is AuthRoute.Email.SignUp -> isEmailSignUpOffered() + is AuthRoute.Email.EmailLinkSignIn -> isEmailLinkSignInOffered() + is AuthRoute.Email.SignIn, is AuthRoute.Email.ResetPassword -> true + } + +/** + * Whether the email flow may offer account creation. False while reauthenticating — proving an + * existing identity can never end in a new account — and whenever the configuration or the email + * provider itself disables new accounts. + */ +internal fun AuthUIConfiguration.isEmailSignUpOffered(): Boolean { + if (isReauthenticationMode || !isNewEmailAccountsAllowed) return false + return providers.filterIsInstance() + .firstOrNull() + ?.isNewAccountsAllowed == true +} + +/** + * Whether the email flow may offer email-link sign-in. False while reauthenticating: a link + * reopens the app with no request outstanding, so completing one there reports an interruption instead of + * finishing the pending operation. + */ +internal fun AuthUIConfiguration.isEmailLinkSignInOffered(): Boolean { + if (isReauthenticationMode) return false + return providers.filterIsInstance() + .firstOrNull() + ?.isEmailLinkSignInEnabled == true +} 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 index adf17afc50..680e622f1a 100644 --- 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 @@ -14,6 +14,7 @@ package com.firebase.ui.auth.ui.screens.email +import com.firebase.ui.auth.rememberAuthFlowScope import android.content.Context import android.util.Log import androidx.compose.runtime.Composable @@ -40,10 +41,8 @@ import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledExcepti 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 { @@ -57,8 +56,11 @@ enum class EmailAuthMode { * 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. * + * Switching modes navigates, so each mode composes fresh; only the address the user typed travels + * with the switch. + * * @param mode An enum representing the current UI mode. Use a when expression on this to render - * the correct screen. + * the correct screen. Every mode is its own navigation destination and this mirrors the active one. * @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. @@ -85,9 +87,15 @@ enum class EmailAuthMode { * 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 onGoToSignUp A callback to switch the UI to the SignUp mode. Inert when account creation + * is not on offer: reauthentication, or new accounts disabled in the configuration or on the + * provider. * @param onGoToSignIn A callback to switch the UI to the SignIn mode. * @param onGoToResetPassword A callback to switch the UI to the ResetPassword mode. + * @param onGoToEmailLinkSignIn A callback to switch the UI to the EmailLinkSignIn mode. Inert when + * email-link sign-in is not on offer: reauthentication, or a provider that does not enable it. + * @param isEmailLocked true when the library fixed [email] and it must not be edited. Render the + * email field read-only while it is true. */ class EmailAuthContentState( val mode: EmailAuthMode, @@ -112,6 +120,7 @@ class EmailAuthContentState( val onGoToSignIn: () -> Unit, val onGoToResetPassword: () -> Unit, val onGoToEmailLinkSignIn: () -> Unit, + val isEmailLocked: Boolean = false, ) /** @@ -119,11 +128,64 @@ class EmailAuthContentState( * 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 + * The mode is always driven from the outside: a host gives every mode its own navigation + * destination and passes [mode] and [onNavigateToMode]. Callers that do not want to own that are + * served by [com.firebase.ui.auth.ui.screens.FirebaseAuthScreen], which owns it for them. + * + * Hosting it yourself means a real back stack, not a variable holding the current mode. The screen + * offers no in-form control for stepping back to sign-in, so system back is the way back, and only + * a stack gives it something to pop — which also means the flow has to **start** at + * [EmailAuthMode.SignIn]. Every other mode is reached from it, and back is inert at the bottom of a + * stack, so a flow opened straight onto sign-up, password recovery or email-link sign-in leaves the + * user with no way to reach the password form. Bring your own key, built from the public + * [EmailAuthMode] and the address the switch hands over: + * + * ```kotlin + * @Serializable + * data class EmailModeKey(val mode: EmailAuthMode, val email: String = "") : NavKey + * + * val backStack = rememberNavBackStack(EmailModeKey(EmailAuthMode.SignIn)) + * NavDisplay( + * backStack = backStack, + * onBack = { backStack.removeLastOrNull() }, + * entryProvider = entryProvider { + * entry { key -> + * EmailAuthScreen( + * context = context, + * configuration = configuration, + * authUI = authUI, + * prefillEmail = key.email.ifEmpty { null }, + * mode = key.mode, + * // Replace a mode already on the stack rather than stacking a second copy; + * // add before trimming, so no single write empties it. + * onNavigateToMode = { mode, email -> + * val existing = backStack.indexOfFirst { it is EmailModeKey && it.mode == mode } + * backStack.add(EmailModeKey(mode, email)) + * if (existing >= 0) { while (backStack.size > existing + 1) backStack.removeAt(existing) } + * }, + * onSuccess = { /* … */ }, + * onError = { /* … */ }, + * onCancel = { /* … */ }, + * ) + * } + * }, + * ) + * ``` + * + * This composable never changes mode on its own in response to an error. Signing in with an + * address that has no account leaves the user on the sign-in form rather than moving them to + * sign-up; acting on an error is the host's job alone. + * + * @param mode The mode to render. A flow entered for a cross-device email link starts at + * [EmailAuthMode.EmailLinkSignIn]; every other entry starts at [EmailAuthMode.SignIn]. Give each + * mode its own navigation destination: a host that instead re-renders this screen in place carries + * the password, display name and "link sent" latches into the mode it switches to, and leaves + * system back with nothing to pop. + * @param onNavigateToMode Invoked when the user switches mode, with the address currently typed so + * the host can carry it over. Never called for a mode the configuration does not offer. + * @param onEmailTyped Invoked with the address as the user edits it, so a host driving [mode] has + * the live value once this step is disposed. Fires per keystroke, so a host must keep what it + * hears out of anything read during composition. */ @Composable fun EmailAuthScreen( @@ -132,52 +194,67 @@ fun EmailAuthScreen( authUI: FirebaseAuthUI, credentialForLinking: AuthCredential? = null, emailLinkFromDifferentDevice: String? = null, - onContinueWithProvider: (String) -> Unit = {}, onSuccess: (AuthResult) -> Unit, onError: (AuthException) -> Unit, onCancel: () -> Unit, prefillEmail: String? = null, + mode: EmailAuthMode, + onNavigateToMode: (mode: EmailAuthMode, email: String) -> Unit, + onEmailTyped: (String) -> Unit = {}, + /** + * Where a consumed one-off notification leaves the flow. Null retracts to [AuthState.Idle]; + * reauthentication passes its own, returning the request to provider selection. Explicit + * because this screen no longer decides which flow it is in by reading a relabelled state. + */ + onNotificationConsumed: (() -> Unit)? = null, 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(prefillEmail ?: "") } 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 isEmailLocked = remember(prefillEmail, configuration.isReauthenticationMode) { + configuration.isReauthenticationMode && !prefillEmail.isNullOrEmpty() + } - val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle) - val isLoading = authState is AuthState.Loading + val isSignUpOffered = configuration.isEmailSignUpOffered() + val isEmailLinkSignInOffered = configuration.isEmailLinkSignInOffered() + + val authFlowScope = rememberAuthFlowScope(authUI, configuration) + // Under a reauthentication request this is that request's phase, not the host's state. + val authState by authFlowScope.state + val isLoading = authState is AuthState.Loading || + authState is AuthState.Reauthentication.Authenticating val authCredentialForLinking = remember { credentialForLinking } - val errorMessage = - if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null + val errorMessage = when (val state = authState) { + is AuthState.Error -> state.exception.message + is AuthState.Reauthentication.AttemptFailed -> state.exception.message + else -> null + } - // Latched locally since these get consumed (reset to Idle) below — deriving directly from - // authState would close ResetPasswordUI/SignInEmailLinkUI's dialogs as soon as it resets. + // Latched: these states are consumed to Idle below, so deriving from authState closes dialogs. var resetLinkSentLocal by rememberSaveable { mutableStateOf(false) } var emailSignInLinkSentLocal by rememberSaveable { mutableStateOf(false) } - // Track if credentials were retrieved from Credential Manager val retrievedCredential = remember { mutableStateOf?>(null) } + /** + * The single way this screen changes mode, so every guard lives in one place: it asks the host + * to navigate and hands over the typed address. + * + * Only ever called for a switch the user asked for; error recovery is the host's. + */ + fun goToMode(target: EmailAuthMode) { + if (target == EmailAuthMode.SignUp && !isSignUpOffered) return + if (target == EmailAuthMode.EmailLinkSignIn && !isEmailLinkSignInOffered) return + onNavigateToMode(target, emailTextValue.value) + } + LaunchedEffect(authState) { Log.d("EmailAuthScreen", "Current state: $authState") when (val state = authState) { @@ -189,70 +266,25 @@ fun EmailAuthScreen( is AuthState.Error -> { val exception = AuthException.from(state.exception, stringProvider) + // The host shows this with its own recovery actions; this screen only reports it. onError(exception) - dialogController?.showErrorDialog( - exception = exception, - errorState = state, - 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 - } - ) - // Consumed immediately so this doesn't leak to a freshly created screen. - authUI.updateAuthState(AuthState.Idle) + // Consumed so the error doesn't leak into a freshly created screen. + authFlowScope.emit(AuthState.Idle) } is AuthState.Cancelled -> { onCancel() - // Consumed so this doesn't leak to a freshly created screen. - authUI.updateAuthState(AuthState.Idle) + authFlowScope.emit(AuthState.Idle) } is AuthState.PasswordResetLinkSent -> { resetLinkSentLocal = true - authUI.updateAuthState(AuthState.Idle) + onNotificationConsumed?.invoke() ?: authFlowScope.emit(AuthState.Idle) } is AuthState.EmailSignInLinkSent -> { emailSignInLinkSentLocal = true - authUI.updateAuthState(AuthState.Idle) + onNotificationConsumed?.invoke() ?: authFlowScope.emit(AuthState.Idle) } else -> Unit @@ -260,9 +292,10 @@ fun EmailAuthScreen( } val state = EmailAuthContentState( - mode = mode.value, + mode = mode, displayName = displayNameValue.value, email = emailTextValue.value, + isEmailLocked = isEmailLocked, password = passwordTextValue.value, confirmPassword = confirmPasswordTextValue.value, isLoading = isLoading, @@ -270,7 +303,10 @@ fun EmailAuthScreen( resetLinkSent = resetLinkSentLocal, emailSignInLinkSent = emailSignInLinkSentLocal, onEmailChange = { email -> - emailTextValue.value = email + if (!isEmailLocked) { + emailTextValue.value = email + onEmailTyped(email) + } }, onPasswordChange = { password -> passwordTextValue.value = password @@ -287,14 +323,12 @@ fun EmailAuthScreen( 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( + authFlowScope.signInWithEmailAndPassword( context = context, - config = configuration, email = emailTextValue.value, password = passwordTextValue.value, credentialForLinking = authCredentialForLinking, @@ -310,17 +344,15 @@ fun EmailAuthScreen( coroutineScope.launch { try { if (emailLinkFromDifferentDevice != null) { - authUI.signInWithEmailLink( + authFlowScope.signInWithEmailLink( context = context, - config = configuration, provider = provider, email = emailTextValue.value, emailLink = emailLinkFromDifferentDevice, ) } else { - authUI.sendSignInLinkToEmail( + authFlowScope.sendSignInLinkToEmail( context = context, - config = configuration, provider = provider, email = emailTextValue.value, credentialForLinking = authCredentialForLinking, @@ -334,9 +366,8 @@ fun EmailAuthScreen( onSignUpClick = { coroutineScope.launch { try { - authUI.createOrLinkUserWithEmailAndPassword( + authFlowScope.createOrLinkUserWithEmailAndPassword( context = context, - config = configuration, provider = provider, name = displayNameValue.value, email = emailTextValue.value, @@ -351,9 +382,8 @@ fun EmailAuthScreen( resetLinkSentLocal = false coroutineScope.launch { try { - authUI.sendPasswordResetEmail( + authFlowScope.sendPasswordResetEmail( email = emailTextValue.value, - config = configuration, actionCodeSettings = configuration.passwordResetActionCodeSettings, ) } catch (e: Exception) { @@ -361,25 +391,11 @@ fun EmailAuthScreen( } } }, - onGoToSignUp = { - textValues.forEach { it.value = "" } - mode.value = EmailAuthMode.SignUp - }, - onGoToSignIn = { - textValues.forEach { it.value = "" } - mode.value = EmailAuthMode.SignIn - emailSignInLinkSentLocal = false - }, - onGoToResetPassword = { - textValues.forEach { it.value = "" } - mode.value = EmailAuthMode.ResetPassword - resetLinkSentLocal = false - }, - onGoToEmailLinkSignIn = { - textValues.forEach { it.value = "" } - mode.value = EmailAuthMode.EmailLinkSignIn - emailSignInLinkSentLocal = false - }, + onGoToSignUp = { goToMode(EmailAuthMode.SignUp) }, + onGoToSignIn = { goToMode(EmailAuthMode.SignIn) }, + // Offered during reauthentication too; blocking it strands a user who forgot their password. + onGoToResetPassword = { goToMode(EmailAuthMode.ResetPassword) }, + onGoToEmailLinkSignIn = { goToMode(EmailAuthMode.EmailLinkSignIn) }, ) if (content != null) { @@ -414,7 +430,8 @@ private fun DefaultEmailAuthContent( onGoToSignUp = state.onGoToSignUp, onGoToResetPassword = state.onGoToResetPassword, onGoToEmailLinkSignIn = state.onGoToEmailLinkSignIn, - onNavigateBack = onCancel + onNavigateBack = onCancel, + isEmailLocked = state.isEmailLocked, ) } @@ -422,11 +439,11 @@ private fun DefaultEmailAuthContent( SignInEmailLinkUI( configuration = configuration, email = state.email, + isEmailLocked = state.isEmailLocked, isLoading = state.isLoading, emailSignInLinkSent = state.emailSignInLinkSent, onEmailChange = state.onEmailChange, onSignInWithEmailLink = state.onSignInEmailLinkClick, - onGoToSignIn = state.onGoToSignIn, onGoToResetPassword = state.onGoToResetPassword, onNavigateBack = onCancel ) @@ -445,8 +462,8 @@ private fun DefaultEmailAuthContent( onPasswordChange = state.onPasswordChange, onConfirmPasswordChange = state.onConfirmPasswordChange, onSignUpClick = state.onSignUpClick, - onGoToSignIn = state.onGoToSignIn, - onNavigateBack = onCancel + onNavigateBack = onCancel, + isEmailLocked = state.isEmailLocked, ) } @@ -455,6 +472,7 @@ private fun DefaultEmailAuthContent( configuration = configuration, isLoading = state.isLoading, email = state.email, + isEmailLocked = state.isEmailLocked, resetLinkSent = state.resetLinkSent, onEmailChange = state.onEmailChange, onSendResetLink = state.onSendResetLinkClick, 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 index 7d1de8a233..940990afc4 100644 --- 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 @@ -15,14 +15,12 @@ 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 @@ -43,6 +41,7 @@ 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.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -52,8 +51,10 @@ 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.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.AuthTextField import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -67,6 +68,7 @@ fun ResetPasswordUI( onSendResetLink: () -> Unit, onGoToSignIn: () -> Unit, onNavigateBack: (() -> Unit)? = null, + isEmailLocked: Boolean = false, ) { val context = LocalContext.current @@ -83,6 +85,7 @@ fun ResetPasswordUI( if (isDialogVisible.value) { AlertDialog( + modifier = Modifier.exposeTestTagsAsResourceIds(), title = { Text( text = stringProvider.recoverPasswordLinkSentDialogTitle, @@ -98,6 +101,8 @@ fun ResetPasswordUI( }, confirmButton = { TextButton( + modifier = Modifier + .testTag(FirebaseAuthTestTags.ResetPassword.DISMISS_BUTTON), onClick = { onGoToSignIn() isDialogVisible.value = false @@ -113,7 +118,7 @@ fun ResetPasswordUI( } Scaffold( - modifier = modifier, + modifier = modifier.exposeTestTagsAsResourceIds(), topBar = { TopAppBar( title = { @@ -121,7 +126,12 @@ fun ResetPasswordUI( }, navigationIcon = { if (onNavigateBack != null) { - IconButton(onClick = onNavigateBack) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testTag( + FirebaseAuthTestTags.ResetPassword.BACK_BUTTON + ) + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringProvider.backAction @@ -140,9 +150,11 @@ fun ResetPasswordUI( .verticalScroll(rememberScrollState()), ) { AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.ResetPassword.EMAIL_FIELD), value = email, validator = emailValidator, enabled = !isLoading, + readOnly = isEmailLocked, label = { Text(stringProvider.emailHint) }, @@ -151,33 +163,22 @@ fun ResetPasswordUI( } ) Spacer(modifier = Modifier.height(8.dp)) - Row( + Button( modifier = Modifier - .align(Alignment.End), + .align(Alignment.End) + .testTag(FirebaseAuthTestTags.ResetPassword.SEND_BUTTON), + onClick = { + onSendResetLink() + }, + enabled = !isLoading && isFormValid.value, ) { - 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()) - } + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text(stringProvider.sendButtonText.uppercase()) } } Spacer(modifier = Modifier.height(16.dp)) 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 index f2ec55fa36..c8482e58b3 100644 --- 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 @@ -16,9 +16,7 @@ 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 @@ -30,7 +28,6 @@ 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 @@ -45,6 +42,7 @@ 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.platform.testTag import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign @@ -57,8 +55,10 @@ 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.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.AuthTextField import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import com.google.firebase.auth.actionCodeSettings @OptIn(ExperimentalMaterial3Api::class) @@ -71,9 +71,9 @@ fun SignInEmailLinkUI( email: String, onEmailChange: (String) -> Unit, onSignInWithEmailLink: () -> Unit, - onGoToSignIn: () -> Unit, onGoToResetPassword: () -> Unit, onNavigateBack: (() -> Unit)? = null, + isEmailLocked: Boolean = false, ) { val provider = configuration.providers.filterIsInstance().first() val stringProvider = LocalAuthUIStringProvider.current @@ -91,6 +91,7 @@ fun SignInEmailLinkUI( if (isDialogVisible.value) { AlertDialog( + modifier = Modifier.exposeTestTagsAsResourceIds(), title = { Text( text = stringProvider.emailSignInLinkSentDialogTitle, @@ -106,6 +107,8 @@ fun SignInEmailLinkUI( }, confirmButton = { TextButton( + modifier = Modifier + .testTag(FirebaseAuthTestTags.EmailLink.DISMISS_BUTTON), onClick = { isDialogVisible.value = false } @@ -121,7 +124,7 @@ fun SignInEmailLinkUI( } Scaffold( - modifier = modifier, + modifier = modifier.exposeTestTagsAsResourceIds(), topBar = { TopAppBar( title = { @@ -132,7 +135,10 @@ fun SignInEmailLinkUI( }, navigationIcon = { if (onNavigateBack != null) { - IconButton(onClick = onNavigateBack) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testTag(FirebaseAuthTestTags.EmailLink.BACK_BUTTON) + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringProvider.backAction @@ -151,9 +157,11 @@ fun SignInEmailLinkUI( .verticalScroll(rememberScrollState()), ) { AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD), value = email, validator = emailValidator, enabled = !isLoading, + readOnly = isEmailLocked, label = { Text(stringProvider.emailHint) }, @@ -164,7 +172,8 @@ fun SignInEmailLinkUI( Spacer(modifier = Modifier.height(16.dp)) TextButton( modifier = Modifier - .align(Alignment.Start), + .align(Alignment.Start) + .testTag(FirebaseAuthTestTags.EmailLink.FORGOT_PASSWORD_BUTTON), onClick = { onGoToResetPassword() }, @@ -172,7 +181,6 @@ fun SignInEmailLinkUI( contentPadding = PaddingValues.Zero ) { Text( - modifier = modifier, text = stringProvider.troubleSigningIn, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, @@ -184,7 +192,9 @@ fun SignInEmailLinkUI( onClick = { onSignInWithEmailLink() }, - modifier = Modifier.align(Alignment.End), + modifier = Modifier + .align(Alignment.End) + .testTag(FirebaseAuthTestTags.EmailLink.SEND_LINK_BUTTON), enabled = !isLoading && isFormValid.value, ) { if (isLoading) { @@ -196,31 +206,6 @@ fun SignInEmailLinkUI( } } - // 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), @@ -266,7 +251,6 @@ fun PreviewSignInEmailLinkUI() { 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 index eb8b501597..2f51eaf351 100644 --- 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 @@ -48,12 +48,15 @@ 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.platform.testTag +import androidx.compose.ui.res.stringResource 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.R import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.authUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider @@ -66,9 +69,11 @@ import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledExcepti 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.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.AuthTextField import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -87,9 +92,9 @@ fun SignInUI( onGoToResetPassword: () -> Unit, onGoToEmailLinkSignIn: () -> Unit, onNavigateBack: (() -> Unit)? = null, + isEmailLocked: Boolean = false, ) { val context = LocalContext.current - val provider = configuration.providers.filterIsInstance().first() val stringProvider = LocalAuthUIStringProvider.current val emailValidator = remember { EmailValidator(stringProvider) } val passwordValidator = remember { @@ -105,11 +110,16 @@ fun SignInUI( } } + val isSignUpOffered = configuration.isEmailSignUpOffered() + + val isEmailLinkSignInOffered = configuration.isEmailLinkSignInOffered() + // Retrieve saved credentials when in SignIn mode val credentialRetrievalAttempted = remember { mutableStateOf(false) } LaunchedEffect(Unit) { if (configuration.isCredentialManagerEnabled && + !configuration.isReauthenticationMode && !credentialRetrievalAttempted.value && PasswordCredentialHandler.hasSavedCredentials(context)) { credentialRetrievalAttempted.value = true @@ -145,7 +155,7 @@ fun SignInUI( } Scaffold( - modifier = modifier, + modifier = modifier.exposeTestTagsAsResourceIds(), topBar = { TopAppBar( title = { @@ -156,7 +166,10 @@ fun SignInUI( }, navigationIcon = { if (onNavigateBack != null) { - IconButton(onClick = onNavigateBack) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testTag(FirebaseAuthTestTags.SignIn.BACK_BUTTON), + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringProvider.backAction @@ -175,9 +188,11 @@ fun SignInUI( .verticalScroll(rememberScrollState()), ) { AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD), value = email, validator = emailValidator, enabled = !isLoading, + readOnly = isEmailLocked, label = { Text(stringProvider.emailHint) }, @@ -187,6 +202,7 @@ fun SignInUI( ) Spacer(modifier = Modifier.height(16.dp)) AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignIn.PASSWORD_FIELD), value = password, validator = passwordValidator, enabled = !isLoading, @@ -196,12 +212,16 @@ fun SignInUI( }, onValueChange = { text -> onPasswordChange(text) - } + }, + visibilityToggleModifier = Modifier.testTag( + FirebaseAuthTestTags.SignIn.PASSWORD_VISIBILITY_TOGGLE + ) ) Spacer(modifier = Modifier.height(8.dp)) TextButton( modifier = Modifier - .align(Alignment.Start), + .align(Alignment.Start) + .testTag(FirebaseAuthTestTags.SignIn.FORGOT_PASSWORD_BUTTON), onClick = { onGoToResetPassword() }, @@ -209,7 +229,6 @@ fun SignInUI( contentPadding = PaddingValues.Zero ) { Text( - modifier = modifier, text = stringProvider.troubleSigningIn, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, @@ -217,12 +236,26 @@ fun SignInUI( ) } Spacer(modifier = Modifier.height(8.dp)) + if (configuration.isReauthenticationMode) { + // Firebase reports "password" for passwordless email-link accounts too, so such a + // user is offered a password field they can never fill. Say so instead of stalling. + Text( + modifier = Modifier + .align(Alignment.Start) + .testTag(FirebaseAuthTestTags.SignIn.REAUTH_PASSWORD_NOTICE), + text = stringResource(R.string.fui_reauth_password_required_notice), + style = MaterialTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + } Row( modifier = Modifier .align(Alignment.End), ) { - if (provider.isNewAccountsAllowed) { + if (isSignUpOffered) { Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.SignIn.SIGN_UP_BUTTON), onClick = { onGoToSignUp() }, @@ -233,6 +266,8 @@ fun SignInUI( Spacer(modifier = Modifier.width(16.dp)) } Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.SignIn.SIGN_IN_BUTTON), onClick = { onSignInClick() }, @@ -250,7 +285,7 @@ fun SignInUI( } // Show toggle to email link sign-in - if (provider.isEmailLinkSignInEnabled) { + if (isEmailLinkSignInOffered) { Spacer(modifier = Modifier.height(64.dp)) Row( modifier = Modifier.fillMaxWidth(), @@ -269,7 +304,9 @@ fun SignInUI( onClick = { onGoToEmailLinkSignIn() }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag(FirebaseAuthTestTags.SignIn.EMAIL_LINK_BUTTON), enabled = !isLoading ) { Text(stringProvider.signInWithEmailLink.uppercase()) 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 index 7b6ba03c5e..0c08be1c61 100644 --- 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 @@ -15,12 +15,10 @@ 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 @@ -39,6 +37,7 @@ 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.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.firebase.ui.auth.configuration.AuthUIConfiguration @@ -49,8 +48,10 @@ 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.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.AuthTextField import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -66,9 +67,9 @@ fun SignUpUI( onEmailChange: (String) -> Unit, onPasswordChange: (String) -> Unit, onConfirmPasswordChange: (String) -> Unit, - onGoToSignIn: () -> Unit, onSignUpClick: () -> Unit, onNavigateBack: (() -> Unit)? = null, + isEmailLocked: Boolean = false, ) { val provider = configuration.providers.filterIsInstance().first() val context = LocalContext.current @@ -103,7 +104,7 @@ fun SignUpUI( } Scaffold( - modifier = modifier, + modifier = modifier.exposeTestTagsAsResourceIds(), topBar = { TopAppBar( title = { @@ -111,7 +112,10 @@ fun SignUpUI( }, navigationIcon = { if (onNavigateBack != null) { - IconButton(onClick = onNavigateBack) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.BACK_BUTTON) + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringProvider.backAction @@ -131,6 +135,7 @@ fun SignUpUI( ) { if (provider.isDisplayNameRequired) { AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.NAME_FIELD), value = displayName, validator = displayNameValidator, enabled = !isLoading, @@ -144,9 +149,11 @@ fun SignUpUI( Spacer(modifier = Modifier.height(16.dp)) } AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.EMAIL_FIELD), value = email, validator = emailValidator, enabled = !isLoading, + readOnly = isEmailLocked, label = { Text(stringProvider.emailHint) }, @@ -156,6 +163,7 @@ fun SignUpUI( ) Spacer(modifier = Modifier.height(16.dp)) AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.PASSWORD_FIELD), value = password, validator = passwordValidator, enabled = !isLoading, @@ -165,10 +173,14 @@ fun SignUpUI( }, onValueChange = { text -> onPasswordChange(text) - } + }, + visibilityToggleModifier = Modifier.testTag( + FirebaseAuthTestTags.SignUp.PASSWORD_VISIBILITY_TOGGLE + ) ) Spacer(modifier = Modifier.height(16.dp)) AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.CONFIRM_PASSWORD_FIELD), value = confirmPassword, validator = confirmPasswordValidator, enabled = !isLoading, @@ -178,36 +190,28 @@ fun SignUpUI( }, onValueChange = { text -> onConfirmPasswordChange(text) - } + }, + visibilityToggleModifier = Modifier.testTag( + FirebaseAuthTestTags.SignUp.CONFIRM_PASSWORD_VISIBILITY_TOGGLE + ) ) Spacer(modifier = Modifier.height(8.dp)) - Row( + Button( modifier = Modifier - .align(Alignment.End), + .align(Alignment.End) + .testTag(FirebaseAuthTestTags.SignUp.SIGN_UP_BUTTON), + onClick = { + onSignUpClick() + }, + enabled = !isLoading && isFormValid.value, ) { - 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()) - } + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text(stringProvider.signupPageTitle.uppercase()) } } Spacer(modifier = Modifier.height(16.dp)) @@ -252,7 +256,6 @@ fun PreviewSignUpUI() { 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/MfaChallengeDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeDefaults.kt similarity index 87% rename from auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt rename to auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeDefaults.kt index 0780348ee3..49042ef355 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeDefaults.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.ui.screens +package com.firebase.ui.auth.ui.screens.mfa import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -36,6 +36,7 @@ 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.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -45,7 +46,9 @@ import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvi 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.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.VerificationCodeInputField +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds @Composable internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { @@ -55,7 +58,7 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { VerificationCodeValidator(stringProvider) } - Scaffold { innerPadding -> + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> Column( modifier = Modifier .fillMaxWidth() @@ -97,7 +100,9 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { Spacer(modifier = Modifier.height(8.dp)) VerificationCodeInputField( - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag(FirebaseAuthTestTags.MfaChallenge.CODE_FIELD), codeLength = 6, validator = verificationCodeValidator, isError = state.error != null, @@ -114,6 +119,9 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { verticalAlignment = Alignment.CenterVertically ) { TextButton( + modifier = Modifier.testTag( + FirebaseAuthTestTags.MfaChallenge.RESEND_CODE_BUTTON + ), onClick = { state.onResendCodeClick?.invoke() }, enabled = state.onResendCodeClick != null && !state.isLoading && state.resendTimer == 0 ) { @@ -130,6 +138,7 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { } TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.MfaChallenge.CANCEL_BUTTON), onClick = state.onCancelClick, enabled = !state.isLoading ) { @@ -140,7 +149,9 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { OutlinedButton( onClick = state.onCancelClick, enabled = !state.isLoading, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .testTag(FirebaseAuthTestTags.MfaChallenge.CANCEL_BUTTON) ) { Text(stringProvider.dismissAction) } @@ -149,7 +160,9 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { Button( onClick = state.onVerifyClick, enabled = state.isValid && !state.isLoading, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .testTag(FirebaseAuthTestTags.MfaChallenge.VERIFY_BUTTON) ) { if (state.isLoading) { CircularProgressIndicator( 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/mfa/MfaChallengeScreen.kt similarity index 99% rename from auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreen.kt rename to auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeScreen.kt index 2dab06adbf..7cc20534c0 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeScreen.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.ui.screens +package com.firebase.ui.auth.ui.screens.mfa import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable 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/mfa/MfaEnrollmentDefaults.kt similarity index 85% rename from auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt rename to auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt index 1cbb459495..a892fbcd12 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.ui.screens +package com.firebase.ui.auth.ui.screens.mfa import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -46,6 +46,8 @@ 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.LocalLocale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -57,8 +59,10 @@ 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.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.QrCodeImage import com.firebase.ui.auth.ui.components.ReauthenticationDialog +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import com.firebase.ui.auth.ui.screens.phone.EnterPhoneNumberUI import com.firebase.ui.auth.ui.screens.phone.EnterVerificationCodeUI import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException @@ -130,6 +134,8 @@ internal fun DefaultMfaEnrollmentContent( ) } + // Each step flags itself with exposeTestTagsAsResourceIds() because this screen is public and + // can be called standalone, with no flagged ancestor of ours above it. Box(modifier = Modifier.fillMaxSize()) { when (state.step) { MfaEnrollmentStep.SelectFactor -> { @@ -155,6 +161,9 @@ internal fun DefaultMfaEnrollmentContent( onPhoneNumberChange = state.onPhoneNumberChange, onCountrySelected = state.onCountrySelected, onSendCodeClick = state.onSendSmsCodeClick, + allowedCountries = remember(state.allowedCountries) { + state.allowedCountries?.toSet() + }, title = stringProvider.mfaEnrollmentEnterPhoneNumber ) } @@ -208,16 +217,6 @@ internal fun DefaultMfaEnrollmentContent( null -> Unit } } - - MfaEnrollmentStep.ShowRecoveryCodes -> { - ShowRecoveryCodesUI( - recoveryCodes = state.recoveryCodes.orEmpty(), - onDoneClick = state.onCodesSavedClick, - isLoading = state.isLoading, - error = state.error, - stringProvider = stringProvider - ) - } } SnackbarHost( @@ -252,6 +251,7 @@ private fun SelectFactorUI( val factorsToEnroll = availableFactors.filter { it !in enrolledFactorIds } Scaffold( + modifier = Modifier.exposeTestTagsAsResourceIds(), topBar = { TopAppBar( title = { Text(stringProvider.mfaManageFactorsTitle) }, @@ -311,11 +311,20 @@ private fun SelectFactorUI( modifier = Modifier.fillMaxWidth() ) + // Keyed per factor because a user enrolled in neither factor sees both buttons + // at once, and a shared tag would collide. factorsToEnroll.forEach { factor -> Button( onClick = { onFactorSelected(factor) }, enabled = !isLoading, - modifier = Modifier.fillMaxWidth() + modifier = when (factor) { + MfaFactor.Sms -> Modifier + .fillMaxWidth() + .testTag(FirebaseAuthTestTags.MfaEnrollment.ENROLL_SMS_BUTTON) + MfaFactor.Totp -> Modifier + .fillMaxWidth() + .testTag(FirebaseAuthTestTags.MfaEnrollment.ENROLL_TOTP_BUTTON) + } ) { when (factor) { MfaFactor.Sms -> Text(stringProvider.mfaStepConfigureSmsTitle) @@ -335,6 +344,7 @@ private fun SelectFactorUI( onSkipClick?.let { TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.MfaEnrollment.SKIP_BUTTON), onClick = it, enabled = !isLoading ) { @@ -352,6 +362,17 @@ private fun EnrolledFactorItem( enabled: Boolean, stringProvider: AuthUIStringProvider ) { + // LocalLocale is the Activity's configured locale. Locale.getDefault() and + // intl.Locale.current both read the process global, which disagrees with it when the host + // installs a per-context locale override — rendering the date in a different language from + // every stringResource around it. + val locale = LocalLocale.current + val enrollmentDateFormat = remember(locale) { + // getDateInstance, not a fixed pattern: "MMM dd, yyyy" puts the month first in every + // language, which is wrong in most of them. + java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM, locale.platformLocale) + } + Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( @@ -385,18 +406,26 @@ private fun EnrolledFactorItem( ) Text( text = stringProvider.enrolledOnDateLabel( - java.text.SimpleDateFormat( - "MMM dd, yyyy", - java.util.Locale.getDefault() - ).format(java.util.Date(factorInfo.enrollmentTimestamp * 1000)) + enrollmentDateFormat.format( + java.util.Date(factorInfo.enrollmentTimestamp * 1000) + ) ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } + // Same collision risk as the enroll buttons above (one item per enrolled factor), + // resolved the same way: key the tag off the factor type. OutlinedButton( onClick = onRemove, enabled = enabled, + modifier = when (factorInfo) { + is PhoneMultiFactorInfo -> + Modifier.testTag(FirebaseAuthTestTags.MfaEnrollment.REMOVE_SMS_BUTTON) + is TotpMultiFactorInfo -> + Modifier.testTag(FirebaseAuthTestTags.MfaEnrollment.REMOVE_TOTP_BUTTON) + else -> Modifier + }, colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.error ) @@ -418,7 +447,7 @@ private fun ConfigureTotpUI( error: String?, stringProvider: AuthUIStringProvider ) { - Scaffold { innerPadding -> + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> Column( modifier = Modifier .fillMaxSize() @@ -479,7 +508,9 @@ private fun ConfigureTotpUI( TextButton( onClick = onBackClick, enabled = !isLoading, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .testTag(FirebaseAuthTestTags.MfaEnrollment.CONFIGURE_TOTP_BACK_BUTTON) ) { Text(stringProvider.backAction) } @@ -487,7 +518,9 @@ private fun ConfigureTotpUI( Button( onClick = onContinueClick, enabled = !isLoading && isValid, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .testTag(FirebaseAuthTestTags.MfaEnrollment.CONFIGURE_TOTP_CONTINUE_BUTTON) ) { Text(stringProvider.continueText) } @@ -507,7 +540,7 @@ private fun VerifyTotpUI( error: String?, stringProvider: AuthUIStringProvider ) { - Scaffold { innerPadding -> + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> Column( modifier = Modifier .fillMaxSize() @@ -545,7 +578,9 @@ private fun VerifyTotpUI( label = { Text(stringProvider.verificationCodeLabel) }, enabled = !isLoading, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .testTag(FirebaseAuthTestTags.MfaEnrollment.VERIFY_TOTP_CODE_FIELD) ) Row( @@ -555,7 +590,9 @@ private fun VerifyTotpUI( OutlinedButton( onClick = onBackClick, enabled = !isLoading, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .testTag(FirebaseAuthTestTags.MfaEnrollment.VERIFY_TOTP_BACK_BUTTON) ) { Text(stringProvider.backAction) } @@ -563,7 +600,9 @@ private fun VerifyTotpUI( Button( onClick = onVerifyClick, enabled = !isLoading && isValid, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .testTag(FirebaseAuthTestTags.MfaEnrollment.VERIFY_TOTP_BUTTON) ) { Text(stringProvider.verifyAction) } @@ -571,70 +610,3 @@ private fun VerifyTotpUI( } } } - -@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/mfa/MfaEnrollmentDestinations.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDestinations.kt new file mode 100644 index 0000000000..44a04eea1d --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDestinations.kt @@ -0,0 +1,321 @@ +/* + * 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.mfa + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +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.MfaFactor +import com.firebase.ui.auth.data.CountryData +import com.firebase.ui.auth.data.CountryDataSaver +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.mfa.MfaEnrollmentStep +import com.firebase.ui.auth.mfa.SmsEnrollmentSession +import com.firebase.ui.auth.mfa.SmsEnrollmentSessionSaver +import com.firebase.ui.auth.mfa.TotpSecret +import com.firebase.ui.auth.ui.screens.AuthRoute +import com.firebase.ui.auth.ui.screens.authRouteMetadata +import com.firebase.ui.auth.ui.screens.email.RedirectingStep +import com.firebase.ui.auth.ui.screens.enrollmentStep +import com.firebase.ui.auth.ui.screens.popOrNull +import com.firebase.ui.auth.ui.screens.pushUnique +import com.firebase.ui.auth.ui.screens.resetBackStackTo +import com.firebase.ui.auth.ui.screens.toKey +import com.firebase.ui.auth.util.CountryUtils + +/** + * Everything an [MfaEnrollmentScreen] step needs that must outlive the step being left. + * + * Moving between steps disposes whatever the step being left held in composition, which would + * otherwise take the typed phone number and the fetched TOTP secret with it. Remembered by the host + * *above* the [androidx.navigation3.ui.NavDisplay] and handed to every step through + * [mfaEnrollmentDestinations], this is what a step reads and writes instead of its own local state. + * + * [selectedFactor], [phoneNumber], [verificationCode], [resendTimerSeconds], [smsSession] and + * [selectedCountry] are backed by [rememberSaveable] in [rememberMfaEnrollmentFlowState] and + * survive Activity recreation. [totpSecret] and [totpQrCodeUrl] do not — they are backed by plain + * [remember], since `com.google.firebase.auth.TotpSecret` cannot be reconstructed. That loss is + * recovered: [MfaEnrollmentScreen] detects a null [totpSecret] on [MfaEnrollmentStep.ConfigureTotp] + * or [MfaEnrollmentStep.VerifyFactor] and fetches a fresh one, bouncing back to `ConfigureTotp` + * with [totpSecretExpiredMessage] set when the user was already past it, since a new secret means a + * new QR code to scan. + * + * @since 10.0.0 + */ +class MfaEnrollmentFlowState internal constructor( + val selectedFactor: MutableState, + val phoneNumber: MutableState, + val verificationCode: MutableState, + val resendTimerSeconds: MutableIntState, + val smsSession: MutableState, + val totpSecret: MutableState, + val totpQrCodeUrl: MutableState, + val selectedCountry: MutableState, + val totpSecretExpiredMessage: MutableState, + private val initialCountry: CountryData, +) { + + /** + * Puts every field back to the value [rememberMfaEnrollmentFlowState] creates it with. + * + * Nothing else clears this state: the host remembers one instance above the + * [androidx.navigation3.ui.NavDisplay] and hands it to every step, and six of the nine fields + * are [rememberSaveable], so a value typed during one enrolment outlives both the step that + * took it and the flow it belonged to. + * + * Called on **entry** to the flow rather than on completion, because completion is not the only + * way out — a skip, a back press off the lowest step, a signed-out step redirecting, or a + * process death mid-flow all leave state behind, and only entry is reached by every one of + * them. Entry is also the point where a stale value is actually harmful: across a sign-out, the + * phone number and verification code the next enrolment would open pre-filled with belong to + * the previous user. + * + * Public because an external host driving [MfaEnrollmentScreen] with its own `step` and + * `flowState` has the same state to clear and no access to the internal + * `NavBackStack.enterMfaEnrollment` helper [com.firebase.ui.auth.ui.screens.FirebaseAuthScreen] + * funnels its own entries through. + * + * @since 10.0.0 + */ + fun reset() { + selectedFactor.value = null + phoneNumber.value = "" + verificationCode.value = "" + resendTimerSeconds.intValue = 0 + smsSession.value = null + totpSecret.value = null + totpQrCodeUrl.value = null + selectedCountry.value = initialCountry + totpSecretExpiredMessage.value = null + } +} + +/** + * The country the SMS step opens on: the device's own when [allowedCountries] permits it, else the + * first permitted one — so a restricted configuration cannot open pre-set to a country its own + * selector will not offer. + */ +internal fun initialEnrollmentCountry(allowedCountries: List?): CountryData { + val deviceCountry = CountryUtils.getDefaultCountry() + if (allowedCountries.isNullOrEmpty()) return deviceCountry + val permitted = CountryUtils.filterByAllowedCountries(allowedCountries.toSet()) + return permitted.firstOrNull { it.countryCode == deviceCountry.countryCode } + ?: permitted.firstOrNull() + ?: deviceCountry +} + +/** + * Creates and remembers the [MfaEnrollmentFlowState] a host installs [mfaEnrollmentDestinations] + * with. Called once, above the `NavDisplay`, so the same instance is handed to every step — see + * [MfaEnrollmentFlowState] for which of its fields actually survive Activity recreation. + */ +@Composable +fun rememberMfaEnrollmentFlowState( + allowedCountries: List? = null, +): MfaEnrollmentFlowState { + val initialCountry = remember(allowedCountries) { initialEnrollmentCountry(allowedCountries) } + val selectedFactor = rememberSaveable { mutableStateOf(null) } + val phoneNumber = rememberSaveable { mutableStateOf("") } + val verificationCode = rememberSaveable { mutableStateOf("") } + val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) } + val smsSession = rememberSaveable(stateSaver = SmsEnrollmentSessionSaver) { + mutableStateOf(null) + } + val totpSecret = remember { mutableStateOf(null) } + val totpQrCodeUrl = remember { mutableStateOf(null) } + val selectedCountry = rememberSaveable(stateSaver = CountryDataSaver) { + mutableStateOf(initialCountry) + } + val totpSecretExpiredMessage = remember { mutableStateOf(null) } + return remember(initialCountry) { + MfaEnrollmentFlowState( + selectedFactor = selectedFactor, + phoneNumber = phoneNumber, + verificationCode = verificationCode, + resendTimerSeconds = resendTimerSeconds, + smsSession = smsSession, + totpSecret = totpSecret, + totpQrCodeUrl = totpQrCodeUrl, + selectedCountry = selectedCountry, + totpSecretExpiredMessage = totpSecretExpiredMessage, + initialCountry = initialCountry, + ) + } +} + +/** + * Registers the MFA enrolment flow's steps on [this] graph. + * + * Every move between steps **pushes**; there is no destination this flow ever *replaces* another + * with. No enrolment or verification failure here moves the user off the step they are on — every + * one sets [MfaEnrollmentContentState.error] and stays put. + * + * @param flowState The state that must outlive a step switch — see [MfaEnrollmentFlowState]. + * Shared by every step this registers, and expected to be `remember`-ed by the host once, above + * the `NavDisplay`. + */ +internal fun EntryProviderScope.mfaEnrollmentDestinations( + backStack: NavBackStack, + configuration: MfaConfiguration, + authConfiguration: AuthUIConfiguration?, + authUI: FirebaseAuthUI, + flowState: MfaEnrollmentFlowState, + content: (@Composable (MfaEnrollmentContentState) -> Unit)?, + onComplete: () -> Unit, + onSkip: () -> Unit = {}, + onError: (Exception) -> Unit = {}, +) { + val body: @Composable (AuthRoute.MfaEnrollment.Step) -> Unit = { step -> + val user = authUI.getCurrentUser() + if (user != null) { + MfaEnrollmentScreen( + user = user, + auth = authUI.auth, + configuration = configuration, + authConfiguration = authConfiguration, + content = content, + step = step.enrollmentStep, + onNavigateToStep = { target -> + backStack.navigateToMfaStep(AuthRoute.MfaEnrollment.stepFor(target)) + }, + onNavigateBack = { backStack.popOrNull() }, + flowState = flowState, + onComplete = onComplete, + onSkip = onSkip, + onError = onError, + ) + } else { + // A single pop here cascades: the step below composes with a null user and pops again. + LaunchedEffect(Unit) { backStack.exitMfaEnrollment() } + RedirectingStep() + } + } + + entry( + metadata = authRouteMetadata(AuthRoute.MfaEnrollment.SelectFactor) + ) { body(it) } + entry( + metadata = authRouteMetadata(AuthRoute.MfaEnrollment.ConfigureSms) + ) { body(it) } + entry( + metadata = authRouteMetadata(AuthRoute.MfaEnrollment.ConfigureTotp) + ) { body(it) } + entry( + metadata = authRouteMetadata(AuthRoute.MfaEnrollment.VerifyFactor) + ) { body(it) } +} + +/** + * Pushes [step] onto the back stack. Always a push, never a pop-then-push. + * + * Upholds [pushUnique]'s precondition: this flow only ever moves *forward* through here — + * `SelectFactor` → a `Configure…` step → `VerifyFactor` — and every backward move goes through + * [popOrNull] instead, so a step this pushes is never already buried. A step that could be + * re-entered from above would take [pushUnique]'s buried-case trim. + */ +internal fun NavBackStack.navigateToMfaStep(step: AuthRoute.MfaEnrollment.Step) { + pushUnique(step) +} + +/** + * Whether [this] names the MFA enrolment flow — the [AuthRoute.MfaEnrollment] flow entry, or one of + * its [AuthRoute.MfaEnrollment.Step]s named directly. + * + * One clause covers both spellings because [AuthRoute.toKey] already collapses the + * [AuthRoute.FlowEntry] / [AuthRoute.Destination] split: a flow entry resolves to its own start + * step and a destination resolves to itself, so `MfaEnrollment` and its steps are exactly the + * routes whose key is a step of this flow. + */ +internal val AuthRoute.entersMfaEnrollment: Boolean + get() = toKey() is AuthRoute.MfaEnrollment.Step + +/** + * Enters the MFA enrolment flow at the step [route] asks for, clearing [flowState] first. + * + * The reset and the push are one call so that a host cannot enter the flow without clearing it — + * the defect this exists for was two entry call sites in + * [com.firebase.ui.auth.ui.screens.FirebaseAuthScreen] that both pushed a step directly. Call it + * for any [route] [entersMfaEnrollment] accepts. + * + * Honours the step [route] names instead of always resolving through [mfaEnrollmentStartStep]: a + * host naming [AuthRoute.MfaEnrollment.SelectFactor] under a single-factor configuration is asking + * for the picker, and redirecting it to that factor's configuration step would be a behavior + * change. Only [AuthRoute.MfaEnrollment], which names the flow rather than a step, is resolved. + * + * A no-op when a step of this flow is already on the stack. Entry is reachable from a destination + * that stays composed while the push runs, so a second tap can arrive mid-flow — and the reset, + * which exists to clear the *previous* enrolment, would there clear the one in progress. Nothing + * is left to do in that case: the flow is already entered. + * + * `pushUnique` invariant: that guard is also what keeps the buried-case trim from firing, since + * the only key this pushes is a step of a flow the stack has just been shown not to hold. + */ +internal fun NavBackStack.enterMfaEnrollment( + route: AuthRoute, + configuration: MfaConfiguration, + flowState: MfaEnrollmentFlowState, +) { + if (any { it is AuthRoute.MfaEnrollment.Step }) return + flowState.reset() + pushUnique( + when (route) { + is AuthRoute.FlowEntry -> mfaEnrollmentStartStep(configuration) + is AuthRoute.Destination -> route + } + ) +} + +/** + * Leaves the MFA enrolment flow from whatever depth it reached, in one write: truncates to the + * lowest step on the stack rather than popping repeatedly, so it does not matter how deep the flow + * went, which step it started on, or whether it was entered more than once. + * + * A no-op when no step is on the stack, so leaving twice cannot disturb what is underneath. Resets + * to [AuthRoute.Success] when the flow is the whole stack — `NavDisplay` throws on an empty one. + */ +internal fun NavBackStack.exitMfaEnrollment() { + val lowestStep = indexOfFirst { it is AuthRoute.MfaEnrollment.Step } + when { + lowestStep < 0 -> return + lowestStep == 0 -> resetBackStackTo(AuthRoute.Success) + else -> while (size > lowestStep) removeAt(size - 1) + } +} + +/** + * Where entering the MFA enrolment flow should land, resolved once at flow entry. + * + * A configuration offering exactly one factor has nothing to choose, so the flow starts on that + * factor's own configuration step directly. [MfaEnrollmentScreen] still fetches the TOTP secret the + * first time [AuthRoute.MfaEnrollment.ConfigureTotp] is entered, whichever way it was reached. + */ +internal fun mfaEnrollmentStartStep(configuration: MfaConfiguration): AuthRoute.MfaEnrollment.Step { + return when (configuration.allowedFactors.singleOrNull()) { + MfaFactor.Sms -> AuthRoute.MfaEnrollment.ConfigureSms + MfaFactor.Totp -> AuthRoute.MfaEnrollment.ConfigureTotp + null -> AuthRoute.MfaEnrollment.SelectFactor + } +} 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/mfa/MfaEnrollmentScreen.kt similarity index 59% rename from auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreen.kt rename to auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt index 29f6827c9e..f3322f9cea 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt @@ -12,32 +12,28 @@ * limitations under the License. */ -package com.firebase.ui.auth.ui.screens +package com.firebase.ui.auth.ui.screens.mfa 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.firebase.ui.auth.util.CountryUtils import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorInfo import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -45,14 +41,18 @@ 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. + * and verification. 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 + * + * The step is always driven from the outside: a host gives every step its own navigation + * destination and passes [step], [onNavigateToStep] and [onNavigateBack], so each step gets a real + * back-stack entry and the configured screen transitions, and a step switch does not dispose what a + * previous step held, because that lives in [flowState]. * * @param user The currently authenticated [FirebaseUser] to enroll in MFA * @param auth The [FirebaseAuth] instance @@ -60,6 +60,18 @@ import kotlinx.coroutines.launch * @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 step The step to render. A flow starts at [MfaEnrollmentStep.SelectFactor], or straight at + * the single allowed factor's configuration step when [MfaConfiguration.allowedFactors] holds only + * one. Give each step its own navigation destination: a host that instead re-renders this screen + * in place leaves system back with nothing to pop. + * @param onNavigateToStep Invoked when the flow moves forward — selecting a factor, or continuing + * from a configured one to verification. Always a push. + * @param onNavigateBack Invoked when the user backs out of a step — a pop, which returns to + * whichever of [MfaEnrollmentStep.ConfigureSms] or [MfaEnrollmentStep.ConfigureTotp] was actually + * pushed before [MfaEnrollmentStep.VerifyFactor]. + * @param flowState The data a step switch must not dispose — see + * [com.firebase.ui.auth.ui.screens.mfa.MfaEnrollmentFlowState]. Build one with + * [com.firebase.ui.auth.ui.screens.mfa.rememberMfaEnrollmentFlowState]. * @param content A composable lambda that receives [MfaEnrollmentContentState] to render custom UI * * @since 10.0.0 @@ -73,38 +85,88 @@ fun MfaEnrollmentScreen( onComplete: () -> Unit, onSkip: () -> Unit = {}, onError: (Exception) -> Unit = {}, - content: @Composable ((MfaEnrollmentContentState) -> Unit)? = null + step: MfaEnrollmentStep, + onNavigateToStep: (MfaEnrollmentStep) -> Unit, + onNavigateBack: () -> Unit, + flowState: MfaEnrollmentFlowState, + 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) } + MfaEnrollmentScreenInternal( + user = user, + auth = auth, + configuration = configuration, + smsHandler = smsHandler, + totpHandler = totpHandler, + authConfiguration = authConfiguration, + onComplete = onComplete, + onSkip = onSkip, + onError = onError, + step = step, + onNavigateToStep = onNavigateToStep, + onNavigateBack = onNavigateBack, + flowState = flowState, + content = content + ) +} - val verificationCode = rememberSaveable { mutableStateOf("") } +/** + * Handler-injection seam behind [MfaEnrollmentScreen]. + * + * Holds the entire enrollment state machine while taking [smsHandler] and [totpHandler] as + * parameters, so unit tests can substitute stubbed handlers instead of hitting real Firebase + * statics. Not part of the public API. + */ +@Composable +internal fun MfaEnrollmentScreenInternal( + user: FirebaseUser, + auth: FirebaseAuth, + configuration: MfaConfiguration, + smsHandler: SmsEnrollmentHandler, + totpHandler: TotpEnrollmentHandler, + authConfiguration: AuthUIConfiguration? = null, + onComplete: () -> Unit, + onSkip: () -> Unit = {}, + onError: (Exception) -> Unit = {}, + step: MfaEnrollmentStep, + onNavigateToStep: (MfaEnrollmentStep) -> Unit, + onNavigateBack: () -> Unit, + flowState: MfaEnrollmentFlowState, + content: @Composable ((MfaEnrollmentContentState) -> Unit)? = null +) { + val coroutineScope = rememberCoroutineScope() + val applicationContext = LocalContext.current.applicationContext - val recoveryCodes = remember { mutableStateOf?>(null) } + val selectedFactor = flowState.selectedFactor + val phoneNumber = flowState.phoneNumber + val verificationCode = flowState.verificationCode + val resendTimerSeconds = flowState.resendTimerSeconds + val smsSession = flowState.smsSession + val totpSecret = flowState.totpSecret + val totpQrCodeUrl = flowState.totpQrCodeUrl + val selectedCountry = flowState.selectedCountry + val totpSecretExpiredMessage = flowState.totpSecretExpiredMessage - val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) } + // Transient per-step UI state: never part of flowState, so a step switch resets it. + val isLoading = remember { mutableStateOf(false) } + val error = remember { mutableStateOf(null) } + val lastException = remember { mutableStateOf(null) } + // Snapshot the SDK's list: it hands back a mutable one, and holding that instance would + // both trip MutableCollectionMutableState and skip recomposition if it mutated in place. + val enrolledFactors = remember { + mutableStateOf>(user.multiFactor.enrolledFactors.toList()) + } - val phoneAuthConfiguration = remember(authConfiguration, applicationContext) { + // The SMS steps read only the terms and privacy URLs off this, so a host that supplied no + // configuration gets a stand-in. Its provider is arbitrary — a configuration must declare at + // least one — and nothing reads it: the country restriction comes from [MfaConfiguration]. + val stepConfiguration = remember(authConfiguration, applicationContext) { authConfiguration ?: authUIConfiguration { context = applicationContext providers { @@ -119,7 +181,6 @@ fun MfaEnrollmentScreen( } } - // Handle resend timer countdown LaunchedEffect(resendTimerSeconds.intValue) { if (resendTimerSeconds.intValue > 0) { delay(1000) @@ -127,13 +188,13 @@ fun MfaEnrollmentScreen( } } - 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 + // The null-secret guard fetches once per entry, so a back-and-forward must not re-fetch. + LaunchedEffect(step) { + when (step) { + MfaEnrollmentStep.ConfigureSms -> selectedFactor.value = MfaFactor.Sms + MfaEnrollmentStep.ConfigureTotp -> { + selectedFactor.value = MfaFactor.Totp + if (totpSecret.value == null) { isLoading.value = true try { val secret = totpHandler.generateSecret() @@ -142,7 +203,9 @@ fun MfaEnrollmentScreen( accountName = user.email ?: user.phoneNumber ?: "User", issuer = auth.app.name ) - error.value = null + // Non-null only via the VerifyFactor redirect below; a first fetch clears. + error.value = totpSecretExpiredMessage.value + totpSecretExpiredMessage.value = null lastException.value = null } catch (e: Exception) { error.value = e.message @@ -152,72 +215,58 @@ fun MfaEnrollmentScreen( isLoading.value = false } } - null -> {} } + MfaEnrollmentStep.VerifyFactor -> { + // A null secret here means Activity recreation dropped it: recover on ConfigureTotp. + if (selectedFactor.value == MfaFactor.Totp && totpSecret.value == null) { + totpSecretExpiredMessage.value = TOTP_SECRET_EXPIRED_MESSAGE + onNavigateBack() + } + } + MfaEnrollmentStep.SelectFactor -> Unit } } + // Snapped here rather than only where the flow state is created, because the selected country + // has two other ways of holding a value this configuration does not permit: a host driving + // this screen supplies `flowState` itself and may never have passed the list to + // `rememberMfaEnrollmentFlowState`, and a `rememberSaveable` restore can bring back a country + // allowed by an earlier configuration. Without this, the selector filters the list while the + // send still uses the unpermitted dial code. + LaunchedEffect(configuration.allowedCountries) { + val permitted = configuration.allowedCountries + if (!permitted.isNullOrEmpty() && + CountryUtils.filterByAllowedCountries(permitted.toSet()) + .none { it.countryCode == selectedCountry.value.countryCode } + ) { + selectedCountry.value = initialEnrollmentCountry(permitted) + } + } + + /** + * Moves the flow forward one step, by asking the host to navigate. Forward moves only — a + * backward move goes through `onBackClick`. + */ + fun goToStep(target: MfaEnrollmentStep) { + onNavigateToStep(target) + } + val state = MfaEnrollmentContentState( - step = currentStep.value, + step = step, 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 - }, + // flowState is deliberately not cleared: a step still on the stack keeps it. + onBackClick = onNavigateBack, 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 - } - } + goToStep( + when (factor) { + MfaFactor.Sms -> MfaEnrollmentStep.ConfigureSms + MfaFactor.Totp -> MfaEnrollmentStep.ConfigureTotp } - } + ) }, onUnenrollFactor = { factorInfo -> coroutineScope.launch { @@ -225,8 +274,7 @@ fun MfaEnrollmentScreen( try { user.multiFactor.unenroll(factorInfo).addOnCompleteListener { task -> if (task.isSuccessful) { - // Refresh the enrolled factors list - enrolledFactors.value = user.multiFactor.enrolledFactors + enrolledFactors.value = user.multiFactor.enrolledFactors.toList() error.value = null } else { error.value = task.exception?.message @@ -254,6 +302,7 @@ fun MfaEnrollmentScreen( error.value = null }, selectedCountry = selectedCountry.value, + allowedCountries = configuration.allowedCountries, onCountrySelected = { country -> selectedCountry.value = country }, @@ -264,7 +313,7 @@ fun MfaEnrollmentScreen( val fullPhoneNumber = "${selectedCountry.value.dialCode}${phoneNumber.value}" val session = smsHandler.sendVerificationCode(fullPhoneNumber) smsSession.value = session - currentStep.value = MfaEnrollmentStep.VerifyFactor + goToStep(MfaEnrollmentStep.VerifyFactor) resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS error.value = null lastException.value = null @@ -279,9 +328,7 @@ fun MfaEnrollmentScreen( }, totpSecret = totpSecret.value, totpQrCodeUrl = totpQrCodeUrl.value, - onContinueToVerifyClick = { - currentStep.value = MfaEnrollmentStep.VerifyFactor - }, + onContinueToVerifyClick = { goToStep(MfaEnrollmentStep.VerifyFactor) }, verificationCode = verificationCode.value, onVerificationCodeChange = { code -> verificationCode.value = code @@ -319,15 +366,9 @@ fun MfaEnrollmentScreen( null -> throw IllegalStateException("No factor selected") } - // Refresh enrolled factors after successful enrollment - enrolledFactors.value = user.multiFactor.enrolledFactors + enrolledFactors.value = user.multiFactor.enrolledFactors.toList() - if (configuration.enableRecoveryCodes) { - recoveryCodes.value = generateRecoveryCodes() - currentStep.value = MfaEnrollmentStep.ShowRecoveryCodes - } else { - onComplete() - } + onComplete() error.value = null lastException.value = null } catch (e: Exception) { @@ -365,11 +406,7 @@ fun MfaEnrollmentScreen( } } } - } else null, - recoveryCodes = recoveryCodes.value, - onCodesSavedClick = { - onComplete() - } + } else null ) if (content != null) { @@ -377,20 +414,17 @@ fun MfaEnrollmentScreen( } else { DefaultMfaEnrollmentContent( state = state, - authConfiguration = phoneAuthConfiguration, + authConfiguration = stepConfiguration, user = user ) } } /** - * Generates placeholder recovery codes. - * In a production implementation, these would come from Firebase or a backend service. + * Surfaced via [MfaEnrollmentContentState.error] on [MfaEnrollmentStep.ConfigureTotp] after the + * user is bounced back from [MfaEnrollmentStep.VerifyFactor] because Activity recreation dropped + * the TOTP secret. The regenerated secret has a new `sharedSecretKey`, so the QR code on screen is + * a different one the user has to re-scan. */ -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 } - } -} +internal const val TOTP_SECRET_EXPIRED_MESSAGE = + "Your authenticator setup session expired. Scan the new QR code to continue." 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 index 2b9ffc13d1..8f7f87a424 100644 --- 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 @@ -39,6 +39,7 @@ 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.platform.testTag import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -49,11 +50,24 @@ import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvi 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.FirebaseAuthTestTags 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.ui.exposeTestTagsAsResourceIds import com.firebase.ui.auth.util.CountryUtils +/** + * The phone number entry step, shared by phone sign-in and SMS multi-factor enrollment. + * + * @param allowedCountries Country codes the selector is restricted to, or `null` for no + * restriction. Supplied by the caller rather than read off [configuration]'s phone provider, + * because MFA enrollment reaches this step on configurations that declare no phone provider — + * it restricts countries through + * [com.firebase.ui.auth.configuration.MfaConfiguration.allowedCountries] instead. + * Deliberately has no default: a host that upgrades has to decide, rather than silently losing + * the restriction it used to get from [configuration]. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun EnterPhoneNumberUI( @@ -62,6 +76,7 @@ fun EnterPhoneNumberUI( isLoading: Boolean, phoneNumber: String, selectedCountry: CountryData, + allowedCountries: Set?, onPhoneNumberChange: (String) -> Unit, onCountrySelected: (CountryData) -> Unit, onSendCodeClick: () -> Unit, @@ -69,7 +84,6 @@ fun EnterPhoneNumberUI( onNavigateBack: (() -> Unit)? = null, ) { val context = LocalContext.current - val provider = configuration.providers.filterIsInstance().first() val stringProvider = LocalAuthUIStringProvider.current val phoneNumberValidator = remember(selectedCountry) { PhoneNumberValidator(stringProvider, selectedCountry) @@ -82,7 +96,7 @@ fun EnterPhoneNumberUI( } Scaffold( - modifier = modifier, + modifier = modifier.exposeTestTagsAsResourceIds(), topBar = { TopAppBar( title = { @@ -90,7 +104,10 @@ fun EnterPhoneNumberUI( }, navigationIcon = { if (onNavigateBack != null) { - IconButton(onClick = onNavigateBack) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testTag(FirebaseAuthTestTags.PhoneNumber.BACK_BUTTON) + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringProvider.backAction @@ -111,6 +128,7 @@ fun EnterPhoneNumberUI( Text(stringProvider.enterPhoneNumberTitle) Spacer(modifier = Modifier.height(16.dp)) AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD), value = phoneNumber, validator = phoneNumberValidator, enabled = !isLoading, @@ -122,10 +140,12 @@ fun EnterPhoneNumberUI( ), leadingIcon = { CountrySelector( + modifier = Modifier + .testTag(FirebaseAuthTestTags.PhoneNumber.COUNTRY_SELECTOR_BUTTON), selectedCountry = selectedCountry, onCountrySelected = onCountrySelected, enabled = !isLoading, - allowedCountries = provider.allowedCountries?.toSet() + allowedCountries = allowedCountries ) }, onValueChange = { @@ -139,6 +159,8 @@ fun EnterPhoneNumberUI( .align(Alignment.End), ) { Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.PhoneNumber.SEND_CODE_BUTTON), onClick = onSendCodeClick, enabled = !isLoading && isFormValid.value, ) { @@ -185,6 +207,7 @@ fun PreviewEnterPhoneNumberUI() { isLoading = false, phoneNumber = "", selectedCountry = CountryUtils.getDefaultCountry(), + allowedCountries = null, 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 index be90bbf0b2..a5b4b1bd0d 100644 --- 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 @@ -41,6 +41,7 @@ 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.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview @@ -51,8 +52,10 @@ 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.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm import com.firebase.ui.auth.ui.components.VerificationCodeInputField +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @@ -86,7 +89,7 @@ fun EnterVerificationCodeUI( val resendEnabled = resendTimer == 0 && !isLoading Scaffold( - modifier = modifier, + modifier = modifier.exposeTestTagsAsResourceIds(), topBar = { TopAppBar( title = { @@ -94,7 +97,12 @@ fun EnterVerificationCodeUI( }, navigationIcon = { if (onNavigateBack != null) { - IconButton(onClick = onNavigateBack) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testTag( + FirebaseAuthTestTags.VerificationCode.BACK_BUTTON + ) + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringProvider.backAction @@ -119,7 +127,9 @@ fun EnterVerificationCodeUI( Spacer(modifier = Modifier.height(8.dp)) TextButton( - modifier = Modifier.align(Alignment.Start), + modifier = Modifier + .align(Alignment.Start) + .testTag(FirebaseAuthTestTags.VerificationCode.CHANGE_PHONE_NUMBER_BUTTON), onClick = onChangeNumberClick, enabled = !isLoading, contentPadding = PaddingValues.Zero @@ -134,14 +144,18 @@ fun EnterVerificationCodeUI( Spacer(modifier = Modifier.height(16.dp)) VerificationCodeInputField( - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag(FirebaseAuthTestTags.VerificationCode.CODE_FIELD), validator = verificationCodeValidator, onCodeChange = onVerificationCodeChange ) Spacer(modifier = Modifier.height(8.dp)) TextButton( - modifier = Modifier.align(Alignment.Start), + modifier = Modifier + .align(Alignment.Start) + .testTag(FirebaseAuthTestTags.VerificationCode.RESEND_CODE_BUTTON), onClick = onResendCodeClick, enabled = resendEnabled, contentPadding = PaddingValues.Zero @@ -168,6 +182,8 @@ fun EnterVerificationCodeUI( .align(Alignment.End), ) { Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.VerificationCode.VERIFY_BUTTON), onClick = onVerifyCodeClick, enabled = !isLoading && isFormValid.value, ) { diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthDestinations.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthDestinations.kt new file mode 100644 index 0000000000..63f680db7f --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthDestinations.kt @@ -0,0 +1,236 @@ +/* + * 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.compose.runtime.Composable +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.MutableState +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.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.data.CountryData +import com.firebase.ui.auth.data.CountryDataSaver +import com.firebase.ui.auth.ui.screens.AuthRoute +import com.firebase.ui.auth.ui.screens.authRouteMetadata +import com.firebase.ui.auth.ui.screens.phoneStep +import com.firebase.ui.auth.ui.screens.popOrNull +import com.firebase.ui.auth.ui.screens.pushUnique +import com.firebase.ui.auth.util.CountryUtils +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job + +/** + * Everything a [PhoneAuthScreen] step needs that must outlive the step being left. + * + * Moving between steps disposes whatever the step being left held in composition, which would + * otherwise take the typed number, the verification id and the live verification attempt with it. + * Remembered by the host *above* the [androidx.navigation3.ui.NavDisplay] and handed to every step + * through [phoneAuthDestinations], this is what a step reads and writes instead of its own local + * state. + * + * [phoneNumber], [verificationCode], [selectedCountry], [verificationId], [forceResendingToken] + * and [resendTimerSeconds] are backed by [rememberSaveable] and survive Activity recreation. + * [selectedCountry] has to: the number it prefixes is restored, so a country re-resolved from the + * configuration instead would submit the typed number under a dial code the user never chose. + * + * @since 10.0.0 + */ +class PhoneAuthFlowState internal constructor( + val phoneNumber: MutableState, + val verificationCode: MutableState, + val selectedCountry: MutableState, + val verificationId: MutableState, + val forceResendingToken: MutableState, + val resendTimerSeconds: MutableIntState, + /** The number and start time of the attempt the cooldown check rejects a duplicate of. */ + internal val pendingVerificationPhoneNumber: MutableState, + internal val verificationStartTime: MutableState, + /** + * The live verification attempt, and a scope outliving the step that started it: the attempt + * stays open until Firebase's auto-retrieval timeout, so a step-scoped scope would cancel it + * on the way to code entry. + */ + internal val verificationJob: MutableState, + internal val verificationScope: CoroutineScope, + /** + * The verification id already navigated on, and the auto-verified credential already signed in + * with. Both steps observe the same auth state and are composed together for the length of a + * transition, so both would otherwise act on the same emission twice. + */ + internal val navigatedVerificationId: MutableState, + internal val consumedAutoCredential: MutableState, +) + +/** + * Cancels the verification in flight and clears everything code entry was working with, so the + * flow is back to what number entry started from. + * + * Shared by every way of abandoning an attempt — the "change number" control and a system back + * press off code entry both land on number entry, so both owe the same teardown. Leaves + * [PhoneAuthFlowState.pendingVerificationPhoneNumber] and + * [PhoneAuthFlowState.verificationStartTime] alone: the cooldown is about the number Firebase was + * last asked about, which abandoning the attempt does not change. + * + * Retracts nothing. The auth state a cancelled attempt leaves behind is the caller's, because who + * owns it differs: an ordinary flow retracts to [com.firebase.ui.auth.AuthState.Idle] via its + * [com.firebase.ui.auth.AuthFlowScope], while a reauthentication request moves its own phase + * instead. + */ +internal fun PhoneAuthFlowState.abandonVerification(reason: String) { + verificationJob.value?.let { job -> + Log.d("PhoneAuthScreen", "Cancelling verification attempt ($reason)") + job.cancel() + } + verificationJob.value = null + verificationCode.value = "" + verificationId.value = null + forceResendingToken.value = null + resendTimerSeconds.intValue = 0 +} + +/** + * Creates and remembers the [PhoneAuthFlowState] a host installs [phoneAuthDestinations] with. + * Called once, above the `NavDisplay`, so the same instance is handed to every step. + * + * Seeds [PhoneAuthFlowState.phoneNumber] and [PhoneAuthFlowState.selectedCountry] from + * [configuration]'s phone provider, and from the platform default when it offers none. + */ +@Composable +fun rememberPhoneAuthFlowState(configuration: AuthUIConfiguration): PhoneAuthFlowState { + val provider = configuration.providers.filterIsInstance().firstOrNull() + val phoneNumber = rememberSaveable { mutableStateOf(provider?.defaultNumber ?: "") } + val verificationCode = rememberSaveable { mutableStateOf("") } + val selectedCountry = rememberSaveable(stateSaver = CountryDataSaver) { + mutableStateOf( + provider?.defaultCountryCode?.let { code -> CountryUtils.findByCountryCode(code) } + ?: CountryUtils.getDefaultCountry() + ) + } + 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 verificationJob = remember { mutableStateOf(null) } + val verificationScope = rememberCoroutineScope() + val navigatedVerificationId = remember { mutableStateOf(null) } + val consumedAutoCredential = remember { mutableStateOf(null) } + return remember { + PhoneAuthFlowState( + phoneNumber = phoneNumber, + verificationCode = verificationCode, + selectedCountry = selectedCountry, + verificationId = verificationId, + forceResendingToken = forceResendingToken, + resendTimerSeconds = resendTimerSeconds, + pendingVerificationPhoneNumber = pendingVerificationPhoneNumber, + verificationStartTime = verificationStartTime, + verificationJob = verificationJob, + verificationScope = verificationScope, + navigatedVerificationId = navigatedVerificationId, + consumedAutoCredential = consumedAutoCredential, + ) + } +} + +/** + * Registers the phone flow's two steps on [this] entry provider, each rendering its own step of a + * [PhoneAuthScreen] driven from the outside. + * + * Reaching code entry is a push, so back returns to number entry. Leaving goes through + * [exitPhoneAuth], which drops both entries at once. + * + * @param flowState The state that must outlive a step switch — see [PhoneAuthFlowState]. Shared by + * every step this registers, and expected to be `remember`-ed by the host once, above the + * `NavDisplay`. + * @param onCancel Invoked when the flow is *left*, not when stepping back to number entry. + */ +internal fun EntryProviderScope.phoneAuthDestinations( + backStack: NavBackStack, + context: Context, + configuration: AuthUIConfiguration, + authUI: FirebaseAuthUI, + flowState: PhoneAuthFlowState, + content: (@Composable (PhoneAuthContentState) -> Unit)?, + onCancel: () -> Unit, + onError: (AuthException) -> Unit = {}, +) { + val body: @Composable (AuthRoute.Phone.Step) -> Unit = { key -> + PhoneAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + // The host's own auth-state observer owns where a completed sign-in navigates. + onSuccess = {}, + onError = onError, + onCancel = onCancel, + step = key.phoneStep, + onNavigateToStep = { target -> + backStack.navigateToPhoneStep(AuthRoute.Phone.stepFor(target)) + }, + onNavigateBack = { backStack.popOrNull() }, + flowState = flowState, + content = content, + ) + } + + entry( + metadata = authRouteMetadata(AuthRoute.Phone.EnterPhoneNumber) + ) { body(it) } + entry( + metadata = authRouteMetadata(AuthRoute.Phone.EnterVerificationCode) + ) { body(it) } +} + +/** + * Pushes [step], leaving the step below reachable, and does nothing when it is already on top. + * + * Upholds [pushUnique]'s precondition: the flow only moves forward through here — number entry to + * code entry — and every backward move goes through [popOrNull] instead. + */ +internal fun NavBackStack.navigateToPhoneStep(step: AuthRoute.Phone.Step) { + if (lastOrNull() == step) return + pushUnique(step) +} + +/** + * Leaves the phone flow from whatever depth it reached, in one write: truncates to the lowest phone + * step on the stack rather than popping repeatedly, so it does not matter how deep the flow went or + * whether it was entered more than once. + * + * Returns whether anything was removed. Changes nothing, and returns false, when no step is on the + * stack or the flow is the whole of it — `NavDisplay` throws on an empty back stack, so the caller + * decides what replaces it. + */ +internal fun NavBackStack.exitPhoneAuth(): Boolean { + val lowestStep = indexOfFirst { it is AuthRoute.Phone.Step } + if (lowestStep <= 0) return false + while (size > lowestStep) removeAt(size - 1) + return true +} 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 index 2406da7791..2bace2c3ff 100644 --- 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 @@ -14,18 +14,19 @@ package com.firebase.ui.auth.ui.screens.phone +import com.firebase.ui.auth.rememberAuthFlowScope import android.content.Context import android.util.Log import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect 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 @@ -40,8 +41,6 @@ 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.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -111,9 +110,8 @@ class PhoneAuthContentState( ) /** - * 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. + * A stateful composable that manages the complete logic for phone number authentication, exposing + * state for each step to a custom UI slot. Contributes no UI beyond its hosting layout node. * * @param context The Android context. * @param configuration The authentication UI configuration containing the phone provider settings. @@ -121,9 +119,19 @@ class PhoneAuthContentState( * @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 modifier Applied once to the [Box] hosting the rendered content; it propagates minimum + * constraints so it doesn't change how content is measured. + * @param step The step to render. A flow starts at [PhoneAuthStep.EnterPhoneNumber]. Give each + * step its own navigation destination: a host that instead re-renders this screen in place + * leaves system back with nothing to pop. + * @param onNavigateToStep Invoked when a sent code moves the flow on to + * [PhoneAuthStep.EnterVerificationCode]. Always a push. + * @param onNavigateBack Invoked when the user asks to change the number they entered — a pop back + * to [PhoneAuthStep.EnterPhoneNumber]. + * @param flowState The data a step switch must not dispose — see [PhoneAuthFlowState]. Build one + * with [rememberPhoneAuthFlowState]. * @param content A composable lambda that receives [PhoneAuthContentState] to render the UI for - * each step. If null, no UI will be rendered. + * each step. If null, the default UI for the current step is rendered. */ @Composable fun PhoneAuthScreen( @@ -134,6 +142,20 @@ fun PhoneAuthScreen( onError: (AuthException) -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier, + step: PhoneAuthStep, + onNavigateToStep: (PhoneAuthStep) -> Unit, + onNavigateBack: () -> Unit, + flowState: PhoneAuthFlowState, + /** + * Where a consumed one-off notification leaves the flow. Null retracts to [AuthState.Idle]; + * reauthentication passes its own, returning the request to provider selection. + */ + onNotificationConsumed: (() -> Unit)? = null, + /** + * A credential attempt is starting. Null retracts to [AuthState.Idle]; reauthentication passes + * its own, moving the request to its authenticating phase. + */ + onAttemptStarted: (() -> Unit)? = null, content: @Composable ((PhoneAuthContentState) -> Unit)? = null, ) { val activity = LocalActivity.current @@ -142,31 +164,36 @@ fun PhoneAuthScreen( 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 phoneNumberValue = flowState.phoneNumber + val verificationCodeValue = flowState.verificationCode + val selectedCountry = flowState.selectedCountry + val verificationId = flowState.verificationId + val forceResendingToken = flowState.forceResendingToken + val resendTimerSeconds = flowState.resendTimerSeconds + val pendingVerificationPhoneNumber = flowState.pendingVerificationPhoneNumber + val verificationStartTime = flowState.verificationStartTime + val verificationJob = flowState.verificationJob + val verificationScope = flowState.verificationScope + val navigatedVerificationId = flowState.navigatedVerificationId + val consumedAutoCredential = flowState.consumedAutoCredential + + /** + * Leaving the flow, rather than stepping inside it. Abandons the attempt on the way out: the + * verification outlives this step, so a live one would go on to sign in on a number the user + * has walked away from. + */ + val leaveFlow: () -> Unit = { + flowState.abandonVerification("leaving the phone flow") + onCancel() } + 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) } - - // Verification is a long-lived collection: it stays open until Firebase's auto-retrieval - // timeout, so a superseded attempt must be cancelled or it keeps writing auth state. - val verificationJob = remember { mutableStateOf(null) } - // Not rememberSaveable: the coroutine that clears this dies with the composition, so a value - // restored after rotation would latch forever and permanently disable auto sign-in. + + // Transient to code entry, so a step switch resets it. Not rememberSaveable either: the + // coroutine that clears this dies with the composition, so a value restored after rotation + // would latch forever and permanently disable auto sign-in. val isSubmittingCode = remember { mutableStateOf(false) } // Logged, not silent: which attempt was torn down and why is the first thing needed from a @@ -178,10 +205,32 @@ fun PhoneAuthScreen( } } - 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 + val authFlowScope = rememberAuthFlowScope(authUI, configuration) + // Under a reauthentication request this is that request's phase, not the host's state. + val currentAuthState = authFlowScope.state + val authState by currentAuthState + val isLoading = authState is AuthState.Loading || + authState is AuthState.Reauthentication.Authenticating + + // A cancelled Loading outlives this composition on the process-scoped FirebaseAuthUI, and + // currentAuthState is re-remembered per authUI, so onDispose reads the right instance. + // + // Only an ordinary Loading is retracted here. Under a reauthentication request the pending + // Loading is published as Reauthentication.Authenticating, which the reauth flow's own teardown + // owns; and were this to write anyway, updateAuthState folds Idle back into the outstanding request + // rather than dropping it. + DisposableEffect(authUI) { + onDispose { + if (currentAuthState.value is AuthState.Loading) { + authFlowScope.emit(AuthState.Idle) + } + } + } + val errorMessage = when (val state = authState) { + is AuthState.Error -> state.exception.message + is AuthState.Reauthentication.AttemptFailed -> state.exception.message + else -> null + } // Handle resend timer countdown LaunchedEffect(resendTimerSeconds.intValue) { @@ -203,38 +252,69 @@ fun PhoneAuthScreen( } } - is AuthState.PhoneNumberVerificationRequired -> { - verificationId.value = state.verificationId - forceResendingToken.value = state.forceResendingToken - step.value = PhoneAuthStep.EnterVerificationCode + is AuthState.PhoneNumberVerificationRequired, + is AuthState.Reauthentication.PhoneNumberVerificationRequired -> { + val id = when (state) { + is AuthState.PhoneNumberVerificationRequired -> state.verificationId + is AuthState.Reauthentication.PhoneNumberVerificationRequired -> { + state.verificationId + } + else -> error("Unreachable phone verification state") + } + verificationId.value = id + forceResendingToken.value = when (state) { + is AuthState.PhoneNumberVerificationRequired -> state.forceResendingToken + is AuthState.Reauthentication.PhoneNumberVerificationRequired -> { + state.forceResendingToken + } + else -> error("Unreachable phone verification state") + } + // A step re-entered by backing out re-runs this effect on the state it left with, + // so the move it already made must not repeat. + if (navigatedVerificationId.value != id) { + navigatedVerificationId.value = id + onNavigateToStep(PhoneAuthStep.EnterVerificationCode) + } resendTimerSeconds.intValue = provider.timeout.toInt() // Start 60-second countdown } - is AuthState.SMSAutoVerified -> { + is AuthState.SMSAutoVerified, + is AuthState.Reauthentication.SmsAutoVerified -> { + val credential = when (state) { + is AuthState.SMSAutoVerified -> state.credential + is AuthState.Reauthentication.SmsAutoVerified -> state.credential + else -> error("Unreachable SMS verification state") + } // Auto-verification succeeded, sign in with the credential // and clear pending verification tracking pendingVerificationPhoneNumber.value = null verificationStartTime.value = null - // A manually submitted code is already signing in: auto-verifying now would run a - // second concurrent sign-in with the same phone number. - if (isSubmittingCode.value) { + // Both steps observe this emission while a step transition has them composed + // together, and one credential can only be signed in with once. + if (consumedAutoCredential.value === credential) { + Log.d("PhoneAuthScreen", "Suppressed auto sign-in: credential already consumed") + } else if (isSubmittingCode.value) { + // A manually submitted code is already signing in: auto-verifying now would + // run a second concurrent sign-in with the same phone number. Log.d("PhoneAuthScreen", "Suppressed auto sign-in: manual submit in flight") // Restoring the submit's Loading both consumes the credential (so it can't // leak to a freshly composed screen) and keeps Verify/Resend disabled. - authUI.updateAuthState( + authFlowScope.emit( AuthState.Loading(configuration.stringProvider.loadingSigningInWithPhone) ) } else { + consumedAutoCredential.value = credential // Consumed before the async sign-in call so it can't be clobbered by that // call's own state. - authUI.updateAuthState(AuthState.Idle) - coroutineScope.launch { + onAttemptStarted?.invoke() ?: authFlowScope.emit(AuthState.Idle) + // The flow's scope, not this step's: a transition can dispose the step this + // ran from before the sign-in it started has landed. + verificationScope.launch { try { - authUI.signInWithPhoneAuthCredential( + authFlowScope.signInWithPhoneAuthCredential( context = context, - config = configuration, - credential = state.credential + credential = credential ) } catch (e: Exception) { // Error will be handled by authState flow @@ -273,13 +353,23 @@ fun PhoneAuthScreen( ) } // Consumed immediately so this doesn't leak to a freshly created screen. - authUI.updateAuthState(AuthState.Idle) + authFlowScope.emit(AuthState.Idle) } is AuthState.Cancelled -> { - onCancel() + leaveFlow() // Consumed so this doesn't leak to a freshly created screen. - authUI.updateAuthState(AuthState.Idle) + authFlowScope.emit(AuthState.Idle) + } + + is AuthState.Reauthentication.AttemptFailed -> { + // Same teardown as the ordinary Error branch above: the attempt is over, so stop + // holding Firebase's callbacks. The phase itself is left latched for the reauth UI + // to render, so nothing is consumed here. + val exception = AuthException.from(state.exception, stringProvider) + if (exception !is AuthException.PhoneVerificationCooldownException) { + cancelVerification("reauthentication attempt failed") + } } else -> Unit @@ -287,7 +377,7 @@ fun PhoneAuthScreen( } val state = PhoneAuthContentState( - step = step.value, + step = step, isLoading = isLoading, error = errorMessage, phoneNumber = phoneNumberValue.value, @@ -317,7 +407,7 @@ fun PhoneAuthScreen( val plural = if (remainingCooldownSeconds != 1L) "s" else "" // Rejected before anything is cancelled: a duplicate tap must not tear down the // healthy in-flight verification it was rejected in favour of. - authUI.updateAuthState( + authFlowScope.emit( AuthState.Error( AuthException.PhoneVerificationCooldownException( message = "Please wait $remainingCooldownSeconds second$plural " + @@ -334,13 +424,14 @@ fun PhoneAuthScreen( pendingVerificationPhoneNumber.value = fullPhoneNumber verificationStartTime.value = currentTime - verificationJob.value = coroutineScope.launch { + // The flow's scope, not this step's: this collection stays open past the move + // to code entry, and cancelVerification is what ends it. + verificationJob.value = verificationScope.launch { try { - authUI.verifyPhoneNumber( + authFlowScope.verifyPhoneNumber( provider = provider, activity = activity, phoneNumber = fullPhoneNumber, - config = configuration, ) } catch (e: Exception) { // Error will be handled by authState flow @@ -359,9 +450,8 @@ fun PhoneAuthScreen( coroutineScope.launch { try { verificationId.value?.let { id -> - authUI.submitVerificationCode( + authFlowScope.submitVerificationCode( context = context, - config = configuration, verificationId = id, code = verificationCodeValue.value ) @@ -379,15 +469,14 @@ fun PhoneAuthScreen( onResendCodeClick = { if (resendTimerSeconds.intValue == 0) { cancelVerification("code resent") - verificationJob.value = coroutineScope.launch { + verificationJob.value = verificationScope.launch { try { // The timer is restarted by the PhoneNumberVerificationRequired branch // above: this call only returns once the verification window closes. - authUI.verifyPhoneNumber( + authFlowScope.verifyPhoneNumber( activity = activity, provider = provider, phoneNumber = fullPhoneNumber, - config = configuration, forceResendingToken = forceResendingToken.value, ) } catch (e: Exception) { @@ -398,25 +487,27 @@ fun PhoneAuthScreen( }, resendTimer = resendTimerSeconds.intValue, onChangeNumberClick = { - cancelVerification("changing phone number") - verificationJob.value = null + flowState.abandonVerification("changing phone number") + // Nothing replaces the cancelled attempt here, so this handler retracts its Loading - + // as the outstanding request's provider-selection phase when one is running, Idle otherwise. + onNotificationConsumed?.invoke() ?: authFlowScope.emit(AuthState.Idle) isSubmittingCode.value = false - step.value = PhoneAuthStep.EnterPhoneNumber - verificationCodeValue.value = "" - verificationId.value = null - forceResendingToken.value = null - resendTimerSeconds.intValue = 0 + onNavigateBack() } ) - if (content != null) { - content(state) - } else { - DefaultPhoneAuthContent( - configuration = configuration, - state = state, - onCancel = onCancel - ) + // propagateMinConstraints keeps this box layout-neutral: content is measured with the same + // constraints it would receive without the box. + Box(modifier = modifier, propagateMinConstraints = true) { + if (content != null) { + content(state) + } else { + DefaultPhoneAuthContent( + configuration = configuration, + state = state, + onCancel = leaveFlow + ) + } } } @@ -426,6 +517,14 @@ private fun DefaultPhoneAuthContent( state: PhoneAuthContentState, onCancel: () -> Unit, ) { + // Keyed on the extracted list rather than on `configuration`, which is a plain class with no + // equals and is commonly rebuilt inside composition — keying on it would re-run every pass. + val allowedCountries = configuration.providers + .filterIsInstance() + .firstOrNull() + ?.allowedCountries + val allowedCountrySet = remember(allowedCountries) { allowedCountries?.toSet() } + when (state.step) { PhoneAuthStep.EnterPhoneNumber -> { EnterPhoneNumberUI( @@ -436,6 +535,7 @@ private fun DefaultPhoneAuthContent( onPhoneNumberChange = state.onPhoneNumberChange, onCountrySelected = state.onCountrySelected, onSendCodeClick = state.onSendCodeClick, + allowedCountries = allowedCountrySet, onNavigateBack = onCancel ) } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthContentState.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthContentState.kt new file mode 100644 index 0000000000..40c7a0b357 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthContentState.kt @@ -0,0 +1,100 @@ +/* + * 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.reauth + +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen +import com.google.firebase.auth.FirebaseUser + +/** + * State class containing all the necessary information to render a custom UI for the + * reauthentication flow triggered by a sensitive operation (account deletion, password change, + * email change). + * + * This class is passed to the `reauthContent` slot of [FirebaseAuthScreen]. The caller renders a + * provider chooser; the library owns the credential exchange. [AuthProvider.Email] and + * [AuthProvider.Phone] hand off to the library's own sub-flow, which replaces this slot while + * active, so keep the slot stateless. On success the library resumes the pending operation. + * + * Render the slot so it blocks interaction with the content behind it (a dialog or modal sheet): + * that content stays composed, and the library only makes its own affordances inert. + * + * ```kotlin + * FirebaseAuthScreen( + * configuration = configuration, + * onSignInSuccess = { }, + * onSignInFailure = { }, + * onSignInCancelled = { }, + * reauthContent = { state -> + * AlertDialog( + * onDismissRequest = state.onDismiss, + * title = { Text(state.reason ?: "Verify your identity") }, + * text = { + * Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + * state.error?.let { Text(it) } + * if (state.isLoading) CircularProgressIndicator() + * state.providers.forEach { provider -> + * Button( + * onClick = { state.onProviderSelected(provider) }, + * enabled = !state.isLoading, + * ) { Text("Continue with ${provider.providerName}") } + * } + * } + * }, + * confirmButton = {}, + * dismissButton = { TextButton(onClick = state.onDismiss) { Text("Cancel") } }, + * ) + * }, + * ) + * ``` + * + * @property user The [FirebaseUser] that needs to reauthenticate. + * @property reason An optional human-readable reason to show the user, as supplied by the caller of the sensitive operation. Will be `null` when no reason was given. + * @property providers The providers the user may reauthenticate with, already filtered by the library to those both configured and linked to [user]. + * @property onProviderSelected Callback invoked with the provider the user chose. Receives the selected [AuthProvider]; the library owns what happens next. + * @property isLoading `true` while a credential attempt, or the sensitive operation it unblocked, is in progress. Use this to show loading indicators and disable the provider buttons. The library's own loading dialog is suppressed while this slot is shown. + * @property error A localized error message for the last failed attempt, or `null` if it did not fail. Persists until the next credential attempt starts, so it can be rendered inline. Backing out of an attempt is not a failure and leaves this unchanged. Survives Activity recreation. + * @property onDismiss Callback to abandon reauthentication and drop the pending operation. This is the only way to abandon it — backing out of a single provider attempt returns to this slot with the operation still pending. + * @property exception The exception behind [error], or `null` if the last attempt did not fail. + * Branch on its type when a message alone is not enough. Survives Activity recreation with the + * active reauthentication request. + * + * @since 10.0.0 + */ +data class ReauthContentState( + /** The [FirebaseUser] that needs to reauthenticate. */ + val user: FirebaseUser, + + /** Optional human-readable reason to show the user. `null` when none was given. */ + val reason: String? = null, + + /** Configured providers linked to [user]. Already filtered by the library. */ + val providers: List = emptyList(), + + /** Callback invoked with the provider the user chose. The library owns the credential path. */ + val onProviderSelected: (AuthProvider) -> Unit = {}, + + /** `true` while a credential attempt, or the operation it unblocked, is in progress. */ + val isLoading: Boolean = false, + + /** Localized error message for the last failed attempt. `null` if it did not fail. */ + val error: String? = null, + + /** Callback to abandon reauthentication and drop the pending operation. */ + val onDismiss: () -> Unit = {}, + + /** The exception behind [error], if the last attempt failed. */ + val exception: Exception? = null, +) diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthDestinations.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthDestinations.kt new file mode 100644 index 0000000000..c481a98b3d --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthDestinations.kt @@ -0,0 +1,362 @@ +/* + * 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.reauth + +import com.firebase.ui.auth.LocalAuthFlowScope +import com.firebase.ui.auth.AuthFlowScope +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +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.filterToLinkedProviders +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.mfa.MfaChallengeContentState +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.ui.components.getRecoveryMessage +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds +import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker +import com.firebase.ui.auth.ui.screens.AuthRoute +import com.firebase.ui.auth.ui.screens.authRouteMetadata +import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState +import com.firebase.ui.auth.ui.screens.email.EmailAuthStep +import com.firebase.ui.auth.ui.screens.mfa.MfaChallengeScreen +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthFlowState +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen +import com.firebase.ui.auth.ui.screens.phoneStep +import com.firebase.ui.auth.ui.screens.rememberOnProviderSelected +import com.firebase.ui.auth.ui.screens.toKey +import com.google.firebase.auth.FirebaseUser + +/** + * What the reauthentication surface shows: the phase, its request, and the providers the user may + * reauthenticate with. + * + * Null means there is nothing to show and therefore no surface — [ReauthSceneStrategy] composes + * the sheet only while this resolves, so existence and content answer to one condition and a + * content-less sheet cannot be built. + */ +internal class ReauthSurface( + val state: AuthState.Reauthentication, + val request: AuthState.Reauthentication.Request, + val configuration: AuthUIConfiguration, +) + +/** + * [this] resolved against [configuration], or null when there is no surface: this phase has none, + * its request is gone, or nothing configured is linked to the user. + */ +internal fun AuthState.Reauthentication?.toReauthSurface( + configuration: AuthUIConfiguration, +): ReauthSurface? { + val state = this ?: return null + val request = when (state) { + is AuthState.Reauthentication.Required, + is AuthState.Reauthentication.Authenticating, + is AuthState.Reauthentication.AttemptFailed, + is AuthState.Reauthentication.RequiresMfa, + is AuthState.Reauthentication.PhoneNumberVerificationRequired, + is AuthState.Reauthentication.SmsAutoVerified, + is AuthState.Reauthentication.PasswordResetLinkSent, + is AuthState.Reauthentication.EmailSignInLinkSent, + // Momentary, but the surface stays up rather than flashing the flow underneath. + is AuthState.Reauthentication.Succeeded, + -> state.request + } ?: return null + val reauthConfiguration = configuration.toReauthConfiguration(request.user) ?: return null + return ReauthSurface(state, request, reauthConfiguration) +} + +/** The reauthentication entries currently on [this], topmost last. */ +internal fun NavBackStack.reauthEntries(): List = + filterIsInstance() + +/** The reauthentication currently presented, or null. The back stack *is* the presentation marker. */ +internal fun NavBackStack.presentedReauth(): AuthRoute.Reauth? = + reauthEntries().lastOrNull() + +/** Removes every reauthentication entry. Index 0 is always a non-reauth entry, so never empties. */ +internal fun NavBackStack.clearReauth() { + removeAll { it is AuthRoute.Reauth } +} + +/** Drops every reauthentication entry above the first one — the surface's own start step. */ +internal fun NavBackStack.returnToReauthStart() { + val first = indexOfFirst { it is AuthRoute.Reauth } + if (first < 0) return + while (size > first + 1) removeAt(size - 1) +} + +/** + * Moves reauthentication to [step], replacing any entry already showing that step type — the same + * rule [com.firebase.ui.auth.ui.screens.email.navigateToEmailStep] applies, scoped to the wrapper. + */ +internal fun NavBackStack.navigateReauth( + marker: AuthRoute.Reauth, + step: AuthRoute.Destination, +) { + val target = marker.copy(step = step) + val existing = indexOfFirst { it is AuthRoute.Reauth && it.step::class == step::class } + add(target) + if (existing >= 0) { + while (size > existing + 1) removeAt(existing) + } +} + +/** + * Registers reauthentication as a single wrapped destination on the host's own back stack. + * + * One entry type rather than a parallel route family: the same key in one stack cannot mean two + * configurations, and [AuthRoute.Reauth] says "this step, in reauthentication mode" without + * duplicating the hierarchy. [ReauthSceneStrategy] is what turns the entry into a modal sheet, or + * leaves it bare when [reauthContent] owns presentation. + * + * @param surface The one condition for the reauthentication surface. [ReauthSceneStrategy] gates + * the sheet on it and the entry renders what it resolves to, so an entry with no request outstanding is + * never composed at all. + * @param phoneFlowState What the reauthentication phone steps share across a step switch — see + * [PhoneAuthFlowState]. Reauthentication's own instance, whose lifetime is the request's: nothing + * the host flow typed reaches it, and nothing it holds outlives the request. + */ +@OptIn(ExperimentalMaterial3Api::class) +internal fun EntryProviderScope.reauthDestinations( + backStack: NavBackStack, + authUI: FirebaseAuthUI, + activity: android.app.Activity?, + context: android.content.Context, + configuration: AuthUIConfiguration, + stringProvider: AuthUIStringProvider, + surface: State, + reauthFlowState: ReauthFlowState, + phoneFlowState: PhoneAuthFlowState, + emailContent: (@Composable (EmailAuthContentState) -> Unit)?, + phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?, + mfaChallengeContent: (@Composable (MfaChallengeContentState) -> Unit)?, + reauthContent: (@Composable (ReauthContentState) -> Unit)?, + customMethodPickerLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)?, + onDismiss: () -> Unit, + onLeaveStep: (AuthRoute.Reauth) -> Unit, +) { + entry( + metadata = { key -> + val presentation = + if (reauthContent != null && key.step is AuthRoute.MethodPicker) { + ReauthPresentation.Bare + } else { + ReauthPresentation.Sheet + } + authRouteMetadata(key.step) + reauthOverlayMetadata(key.requestId, presentation) + }, + ) { key -> + // Read through the snapshot, never through captured values — the entry rule at + // FirebaseAuthScreen's entryProvider. No request outstanding is the same condition the sheet exists + // on; a key naming an older request is the host's stack one composition behind the state, + // and this entry writes to the id it names, so it renders nothing rather than the wrong one. + val reauthSurface = surface.value ?: return@entry + if (reauthSurface.request.requestId != key.requestId) return@entry + val reauthState = reauthSurface.state + val request = reauthSurface.request + val reauthConfig = reauthSurface.configuration + val reauthRequired = AuthState.Reauthentication.Required(request) + val mfaResolver = (reauthState as? AuthState.Reauthentication.RequiresMfa)?.resolver + val isLoading = reauthState is AuthState.Reauthentication.Authenticating || + reauthState is AuthState.Reauthentication.Succeeded + val exception = (reauthState as? AuthState.Reauthentication.AttemptFailed) + ?.exception + ?.let { if (it is AuthException) it else AuthException.from(it, stringProvider) } + val error = exception?.let { getRecoveryMessage(it, stringProvider) } + + // Built here, where the entry's early return guarantees the configuration exists. + val reauthStateHolder = remember(reauthFlowState) { + derivedStateOf { reauthFlowState.phase ?: AuthState.Idle } + } + val reauthScope = remember(authUI, reauthConfig, reauthFlowState, reauthStateHolder) { + AuthFlowScope( + auth = authUI.auth, + config = reauthConfig, + credentialManagerProvider = authUI.testCredentialManagerProvider, + loginManagerProvider = authUI.testLoginManagerProvider, + state = reauthStateHolder, + sink = reauthFlowState.sink(hostFallback = { authUI.updateAuthState(it) }), + ) + } + + val onProviderSelected = reauthScope.rememberOnProviderSelected( + context = context, + activity = activity, + onNavigate = { route -> backStack.navigateReauth(key, route.toKey()) }, + ) + + CompositionLocalProvider(LocalAuthFlowScope provides reauthScope) { + when (val step = key.step) { + is AuthRoute.MethodPicker -> { + if (reauthContent != null) { + reauthContent( + ReauthContentState( + user = reauthRequired.user, + reason = reauthRequired.reason, + providers = reauthConfig.providers, + onProviderSelected = { provider -> + if (provider !is AuthProvider.Email && + provider !is AuthProvider.Phone + ) { + reauthFlowState.update(key.requestId) { + it.attemptStarted() + } + } + onProviderSelected(provider) + }, + isLoading = isLoading, + error = error, + onDismiss = onDismiss, + exception = exception, + ) + ) + } else if (customMethodPickerLayout != null) { + Box(modifier = Modifier.fillMaxSize()) { + customMethodPickerLayout(reauthConfig.providers, onProviderSelected) + } + } else { + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> + AuthMethodPicker( + modifier = Modifier.padding(innerPadding), + providers = reauthConfig.providers, + onProviderSelected = onProviderSelected, + ) + } + } + } + + is AuthRoute.Email.Step -> EmailAuthStep( + step = step, + // The wrapper, not the bare step: it is what is actually on the stack. + entryKey = key, + backStack = backStack, + context = context, + configuration = reauthConfig, + authUI = authUI, + content = emailContent, + navigateToStep = { backStack.navigateReauth(key, it) }, + // onLeaveStep owns pop-vs-dismiss, and cancels the attempt with it. + isStepBelow = { false }, + onCancel = { onLeaveStep(key) }, + prefillEmail = { reauthRequired.user.email }, + onNotificationConsumed = { + reauthFlowState.update(key.requestId) { it.returnedToProviderSelection() } + }, + ) + + is AuthRoute.Phone.Step -> PhoneAuthScreen( + context = context, + configuration = reauthConfig, + authUI = authUI, + content = phoneContent, + onSuccess = {}, + onError = {}, + // onLeaveStep owns pop-vs-dismiss, and cancels the attempt with it. + onCancel = { onLeaveStep(key) }, + step = step.phoneStep, + onNavigateToStep = { target -> + backStack.navigateReauth(key, AuthRoute.Phone.stepFor(target)) + }, + // Number entry inside the surface: a pop while it is below, a move to it when not. + onNavigateBack = { + backStack.navigateReauth(key, AuthRoute.Phone.EnterPhoneNumber) + }, + flowState = phoneFlowState, + onNotificationConsumed = { + reauthFlowState.update(key.requestId) { it.returnedToProviderSelection() } + }, + onAttemptStarted = { + reauthFlowState.update(key.requestId) { it.attemptStarted() } + }, + ) + + // Only the state moves: the host pops the entry off whatever the state becomes, so + // the challenge is on the stack exactly while the request needs one. + is AuthRoute.MfaChallenge -> if (mfaResolver != null) { + MfaChallengeScreen( + resolver = mfaResolver, + auth = authUI.auth, + content = mfaChallengeContent, + // The one exchange no provider owns, so the stamp is made here. + onSuccess = { + val reauthenticated = authUI.auth.currentUser + if (reauthenticated == null) { + reauthFlowState.update(key.requestId) { + AuthState.Reauthentication.AttemptFailed( + request, + AuthException.UserNotFoundException( + message = "No user is currently signed in for reauthentication" + ), + ) + } + } else { + reauthFlowState.update(key.requestId) { + AuthState.Reauthentication.Succeeded( + request, + AuthState.Success( + result = null, + user = reauthenticated, + reauthenticatedUid = reauthenticated.uid, + ), + ) + } + } + }, + onCancel = { + reauthFlowState.update(key.requestId) { it.attemptCancelled() } + }, + // The request's flow: the public channel would report this as a sign-in error. + onError = { e -> reauthScope.emit(AuthState.Error(e)) }, + ) + } + + else -> Unit + } + } + } +} + +/** + * [this] narrowed to what [user] is actually linked to, in reauthentication mode, or null when + * nothing configured is linked and there is therefore no reauthentication UI to show. + */ +internal fun AuthUIConfiguration.toReauthConfiguration(user: FirebaseUser): AuthUIConfiguration? = + providers.filterToLinkedProviders(user) + .takeIf { it.isNotEmpty() } + ?.let { + copy( + providers = it, + isAnonymousUpgradeEnabled = false, + isCredentialLinkingEnabled = false, + isNewEmailAccountsAllowed = false, + isReauthenticationMode = true, + ) + } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthFlowState.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthFlowState.kt new file mode 100644 index 0000000000..a4f9323dfb --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthFlowState.kt @@ -0,0 +1,148 @@ +/* + * 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.reauth + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.AuthStateSink + +/** + * The reauthentication phase machine of one + * [com.firebase.ui.auth.ui.screens.FirebaseAuthScreen], scoped to its composition. + * + * @since 10.0.0 + */ +internal class ReauthFlowState internal constructor( + private val phaseState: MutableState, +) { + /** The live phase, or null when no request is outstanding. */ + val phase: AuthState.Reauthentication? get() = phaseState.value + + /** The live request, or null when none is outstanding. */ + val request: AuthState.Reauthentication.Request? get() = phaseState.value?.request + + /** Arms [required], replacing any request already held. */ + fun accept(required: AuthState.Reauthentication.Required) { + phaseState.value = required + } + + /** + * Drops the request and tells its awaiting caller whether to retry. Every way a request ends + * comes through here, so no caller is left suspended. + */ + fun finish(retryOperation: Boolean) { + val request = phaseState.value?.request + phaseState.value = null + if (retryOperation) request?.resolve() else request?.decline() + } + + /** + * Applies [transition] to the live phase while [requestId] still names it — the caller's key + * may be a composition behind. A null transition result is a no-op. + */ + fun update(requestId: String, transition: (AuthState.Reauthentication) -> AuthState?) { + val current = phaseState.value ?: return + if (current.requestId != requestId) return + val next = transition(current) as? AuthState.Reauthentication ?: return + phaseState.value = next + } + + /** Moves the live phase to [phase] unconditionally. */ + fun moveTo(phase: AuthState.Reauthentication) { + phaseState.value = phase + } + + /** + * This request's state sink, for the provider code driving its credential exchange. Everything + * [fold] absorbs becomes a phase; [hostFallback] takes what it declines. + */ + fun sink(hostFallback: AuthStateSink): AuthStateSink = AuthStateSink { state -> + if (fold(state) == null) hostFallback.emit(state) + } + + /** + * Folds an ordinary [state] published by provider code into the live phase, returning the + * phase it became, or null when [state] is not part of the credential exchange. + */ + fun fold(state: AuthState): AuthState.Reauthentication? { + if (state is AuthState.Reauthentication) return null + val current = phaseState.value ?: return null + val request = current.request ?: return null + + val next = when (state) { + is AuthState.Loading -> AuthState.Reauthentication.Authenticating(request, state.message) + + is AuthState.Error -> + if (state.exception is AuthException.AuthCancelledException) { + AuthState.Reauthentication.Required(request) + } else { + AuthState.Reauthentication.AttemptFailed(request, state.exception) + } + + is AuthState.Cancelled -> AuthState.Reauthentication.Required(request) + + is AuthState.RequiresMfa -> + AuthState.Reauthentication.RequiresMfa(request, state.resolver, state.hint) + + is AuthState.PhoneNumberVerificationRequired -> + AuthState.Reauthentication.PhoneNumberVerificationRequired( + request = request, + verificationId = state.verificationId, + forceResendingToken = state.forceResendingToken, + ) + + is AuthState.SMSAutoVerified -> + AuthState.Reauthentication.SmsAutoVerified(request, state.credential) + + is AuthState.PasswordResetLinkSent -> + AuthState.Reauthentication.PasswordResetLinkSent(request) + + is AuthState.EmailSignInLinkSent -> + AuthState.Reauthentication.EmailSignInLinkSent(request) + + // Only a stamped Success proves this user was re-verified. + is AuthState.Success -> + if (state.reauthenticatedUid != null) { + AuthState.Reauthentication.Succeeded(request, state) + } else { + current + } + + // Must not detach the request from the caller waiting on it. + is AuthState.Idle, + is AuthState.RequiresEmailVerification, + is AuthState.RequiresProfileCompletion, + -> current + + // Not part of the credential exchange: let the host flow handle it. + else -> return null + } + phaseState.value = next + return next + } +} + +/** + * Creates and remembers the [ReauthFlowState] for one screen, alongside + * `rememberPhoneAuthFlowState` and `rememberMfaEnrollmentFlowState`. The phase does not survive + * recreation; the request on `FirebaseAuthUI.pendingReauth` does. + */ +@Composable +internal fun rememberReauthFlowState(): ReauthFlowState = + remember { ReauthFlowState(mutableStateOf(null)) } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSceneStrategy.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSceneStrategy.kt new file mode 100644 index 0000000000..d4eb5048f2 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSceneStrategy.kt @@ -0,0 +1,211 @@ +/* + * 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.reauth + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.NavMetadataKey +import androidx.navigation3.runtime.get +import androidx.navigation3.runtime.metadata +import androidx.navigation3.scene.OverlayScene +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.SceneStrategyScope +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds + +/** + * How a reauthentication entry is presented: the library's own modal sheet, or bare, over the + * flow underneath, for a consumer slot that owns its own presentation. + */ +internal enum class ReauthPresentation { Sheet, Bare } + +/** + * Which reauthentication request an entry belongs to, and how it is presented. + * + * [requestId] identifies the *surface*: every entry of one request carries the same one, which is + * what keeps the overlay — and therefore its sheet — a single one across the request's steps. + */ +internal data class ReauthOverlay( + val requestId: String, + val presentation: ReauthPresentation, +) + +internal object ReauthOverlayKey : NavMetadataKey + +internal fun reauthOverlayMetadata( + requestId: String, + presentation: ReauthPresentation, +): Map = metadata { put(ReauthOverlayKey, ReauthOverlay(requestId, presentation)) } + +private fun NavEntry.reauthOverlay(): ReauthOverlay? = metadata[ReauthOverlayKey] + +/** + * Renders the *trailing run* of reauthentication entries as one overlay over everything below. + * + * A run, not a single entry, because reauthentication is several steps deep: the recipe's + * one-entry strategy would push the previous step out from under the sheet and render it + * full-screen behind the scrim. Only the topmost entry of the run is composed; the rest stay + * owned by the scene so `NavDisplay` keeps their saveable state. + * + * @param surface The one condition for the surface: the sheet composes only while this resolves, + * so a reauthentication entry with no request outstanding shows neither sheet nor scrim, and the entry it + * would have composed is never reached. + * @param transitionSpec Applied to step changes inside the overlay, so they animate the way the + * flow underneath animates. There is no predictive-pop counterpart: that gesture drives + * `NavDisplay`'s own transition, which an overlay is not part of. + */ +internal class ReauthSceneStrategy( + private val surface: State, + private val onDismissRequest: () -> Unit, + private val transitionSpec: + AnimatedContentTransitionScope>.() -> ContentTransform, + private val popTransitionSpec: + AnimatedContentTransitionScope>.() -> ContentTransform, +) : SceneStrategy { + + /** + * The run to render, topmost last. + * + * `NavDisplay` keeps the first scene instance it sees for a given [Scene.key] and renders that + * one until the key leaves the stack, so the scene reads the run from here rather than from + * its own fields. Never written empty: this is what the sheet still shows while it hides. + */ + private val run = mutableStateOf>>(emptyList()) + + override fun SceneStrategyScope.calculateScene( + entries: List>, + ): Scene? { + val reauthRun = entries.takeLastWhile { it.reauthOverlay() != null } + val requestId = reauthRun.lastOrNull()?.reauthOverlay()?.requestId ?: return null + run.value = reauthRun + return ReauthScene( + key = requestId, + runState = run, + surface = surface, + ownedEntries = reauthRun, + previousEntries = entries.dropLast(reauthRun.size), + onDismissRequest = onDismissRequest, + transitionSpec = transitionSpec, + popTransitionSpec = popTransitionSpec, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +private data class ReauthScene( + override val key: String, + private val runState: State>>, + private val surface: State, + private val ownedEntries: List>, + override val previousEntries: List>, + private val onDismissRequest: () -> Unit, + private val transitionSpec: + AnimatedContentTransitionScope>.() -> ContentTransform, + private val popTransitionSpec: + AnimatedContentTransitionScope>.() -> ContentTransform, +) : OverlayScene { + + override val entries: List> = ownedEntries + override val overlaidEntries: List> = previousEntries + + /** The sheet composed right now, if any. What [onRemove] has to hide before it goes. */ + private var sheetState: SheetState? = null + + override val content: @Composable (() -> Unit) = { + // `NavDisplay` provides this overlay its own LocalLifecycleOwner, resumed while it is the + // topmost one, so the recipe's `rememberLifecycleOwner` here would only nest a second cap + // inside that. + val run = runState.value.takeLastWhile { it.reauthOverlay()?.requestId == key } + val top = run.lastOrNull() + // Existence and content answer to one condition: no surface, no sheet and no scrim. + // Latched for the same reason the run is kept — the surface is released as the entries + // are popped, and dropping the sheet there would cut its hide short. + val hasPresented = remember { mutableStateOf(false) } + if (surface.value != null) hasPresented.value = true + val presentation = top?.reauthOverlay()?.presentation?.takeIf { hasPresented.value } + when (presentation) { + ReauthPresentation.Sheet -> { + val state = rememberModalBottomSheetState(skipPartiallyExpanded = true) + sheetState = state + ModalBottomSheet( + modifier = Modifier.exposeTestTagsAsResourceIds(), + onDismissRequest = onDismissRequest, + sheetState = state, + ) { + Step(run, top) + } + } + + ReauthPresentation.Bare -> { + sheetState = null + Box(modifier = Modifier.fillMaxSize()) { Step(run, top) } + } + + null -> Unit + } + } + + /** Hides the sheet before the entry leaves composition, which is what this hook is for. */ + override suspend fun onRemove() { + sheetState?.hide() + } + + /** [top], transitioned the way the flow underneath transitions between its destinations. */ + @Composable + private fun Step(run: List>, top: NavEntry) { + val target: Scene = ReauthStepScene(top) + AnimatedContent( + targetState = target, + contentKey = { it.key }, + transitionSpec = { + // A step stepped back out of is no longer in the run; one pushed under is. + if (run.none { it.contentKey == initialState.key }) { + popTransitionSpec() + } else { + transitionSpec() + } + }, + label = "ReauthStep", + ) { step -> + step.content() + } + } +} + +/** + * One reauthentication step as a [Scene] value, so a host's configured specs — written against + * [Scene] — apply to step changes inside the overlay unchanged. Never handed to `NavDisplay`: the + * overlay is the scene it renders. + */ +private data class ReauthStepScene(private val entry: NavEntry) : Scene { + override val key: Any = entry.contentKey + override val entries: List> = listOf(entry) + override val previousEntries: List> = emptyList() + override val content: @Composable () -> Unit = { entry.Content() } +} diff --git a/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml b/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml index 85afe860da..8b1102de03 100644 --- a/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml +++ b/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml @@ -1,6 +1,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/auth/src/main/res/layout/fui_phone_layout.xml b/auth/src/main/res/layout/fui_phone_layout.xml deleted file mode 100644 index 3ed6d1be61..0000000000 --- a/auth/src/main/res/layout/fui_phone_layout.xml +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - -