diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index a7afe7801..76d2d5ab9 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -68,8 +68,8 @@ android { warningsAsErrors = true abortOnError = true - // Pre-existing debt only: 168 localization findings (CPRN-432) and 12 Compose - // correctness findings (CPRN-436). Every entry is suppressed; new ones still fail. + // Pre-existing debt only: 168 localization findings (CPRN-432). Every entry is + // suppressed; new ones still fail. baseline = file("lint-baseline.xml") } diff --git a/auth/lint-baseline.xml b/auth/lint-baseline.xml index 92a8aae58..b9c0976fa 100644 --- a/auth/lint-baseline.xml +++ b/auth/lint-baseline.xml @@ -12,138 +12,6 @@ column="17"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - , (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) } 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 6a1968ce6..c986fcd4c 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 @@ -55,6 +55,7 @@ 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.navigation3.runtime.entryProvider @@ -182,6 +183,14 @@ fun FirebaseAuthScreen( val coroutineScope = rememberCoroutineScope() val stringProvider = remember(context) { DefaultAuthUIStringProvider(context) } + // 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 @@ -764,7 +773,7 @@ fun FirebaseAuthScreen( authUI.updateAuthState( AuthState.Error( AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_interrupted) + reauthInterruptedMessage ) ) ) @@ -781,7 +790,7 @@ fun FirebaseAuthScreen( required, AuthState.Error( AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_no_linked_providers) + reauthNoLinkedProvidersMessage ) ), ) @@ -793,7 +802,7 @@ fun FirebaseAuthScreen( required, AuthState.Error( AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_interrupted) + reauthInterruptedMessage ) ), ) @@ -839,7 +848,7 @@ fun FirebaseAuthScreen( AuthState.Reauthentication.AttemptFailed( request, AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_incomplete) + reauthIncompleteMessage ), ) } @@ -848,7 +857,7 @@ fun FirebaseAuthScreen( // A Success here would claim the pending operation had already succeeded. val terminal = if (request.hasPendingOperation) { AuthState.Loading( - context.getString(R.string.fui_loading_reauth_retrying) + reauthRetryingMessage ) } else { AuthState.Success(result = null, user = request.user) 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 index d7c447461..ebe556025 100644 --- 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 @@ -124,7 +124,6 @@ internal fun EmailAuthStep( context: Context, configuration: AuthUIConfiguration, authUI: FirebaseAuthUI, - content: (@Composable (EmailAuthContentState) -> Unit)?, navigateToStep: (AuthRoute.Email.Step) -> Unit, isStepBelow: (NavKey?) -> Boolean, onCancel: () -> Unit, @@ -136,6 +135,7 @@ internal fun EmailAuthStep( 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) { 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 c4413fded..2f51eaf35 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 @@ -49,6 +49,7 @@ 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 @@ -242,7 +243,7 @@ fun SignInUI( modifier = Modifier .align(Alignment.Start) .testTag(FirebaseAuthTestTags.SignIn.REAUTH_PASSWORD_NOTICE), - text = context.getString(R.string.fui_reauth_password_required_notice), + text = stringResource(R.string.fui_reauth_password_required_notice), style = MaterialTheme.typography.bodySmall, ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt index c162fafce..a892fbcd1 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt @@ -46,6 +46,7 @@ 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 @@ -361,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( @@ -394,10 +406,9 @@ 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 diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt index 8d720a972..f3322f9ce 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt @@ -33,6 +33,7 @@ import com.firebase.ui.auth.mfa.TotpEnrollmentHandler 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 @@ -156,7 +157,11 @@ internal fun MfaEnrollmentScreenInternal( val isLoading = remember { mutableStateOf(false) } val error = remember { mutableStateOf(null) } val lastException = remember { mutableStateOf(null) } - val enrolledFactors = remember { mutableStateOf(user.multiFactor.enrolledFactors) } + // 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()) + } // 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 @@ -269,7 +274,7 @@ internal fun MfaEnrollmentScreenInternal( try { user.multiFactor.unenroll(factorInfo).addOnCompleteListener { task -> if (task.isSuccessful) { - enrolledFactors.value = user.multiFactor.enrolledFactors + enrolledFactors.value = user.multiFactor.enrolledFactors.toList() error.value = null } else { error.value = task.exception?.message @@ -361,7 +366,7 @@ internal fun MfaEnrollmentScreenInternal( null -> throw IllegalStateException("No factor selected") } - enrolledFactors.value = user.multiFactor.enrolledFactors + enrolledFactors.value = user.multiFactor.enrolledFactors.toList() onComplete() error.value = null diff --git a/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt b/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt index b570b7c12..ffafb36a0 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt @@ -78,7 +78,7 @@ internal class ReauthScopeProbe { /** Call from inside a content slot. */ @Composable - fun capture() { + fun Capture() { scope = LocalAuthFlowScope.current } diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenEmailRecoveryTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenEmailRecoveryTest.kt index a1024c42a..ab7efb410 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenEmailRecoveryTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenEmailRecoveryTest.kt @@ -555,7 +555,7 @@ class FirebaseAuthScreenEmailRecoveryTest { onSignInFailure = {}, onSignInCancelled = {}, // One provider, so the sheet opens at the email step and the picker never composes. - emailContent = { probe.capture() }, + emailContent = { probe.Capture() }, ) } composeTestRule.waitForIdle() diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt index 75d2dae0a..261b7a908 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt @@ -305,7 +305,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = { cancelledCount++ }, reauthContent = { - probe.capture() + probe.Capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) @@ -363,7 +363,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInCancelled = { cancelledCount++ }, // The default sheet has no app slot; the picker layout is inside the request's flow. customMethodPickerLayout = { providers, onSelected -> - probe.capture() + probe.Capture() AuthMethodPicker(providers = providers, onProviderSelected = onSelected) }, ) @@ -414,7 +414,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { state -> - probe.capture() + probe.Capture() captured = state Button( onClick = { state.onProviderSelected(state.providers.first()) }, @@ -618,7 +618,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { - probe.capture() + probe.Capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, @@ -673,7 +673,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInCancelled = {}, // The default sheet has no app slot; the picker layout is inside the request's flow. customMethodPickerLayout = { providers, onSelected -> - probe.capture() + probe.Capture() AuthMethodPicker(providers = providers, onProviderSelected = onSelected) }, ) @@ -820,7 +820,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { - probe.capture() + probe.Capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) @@ -873,7 +873,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { - probe.capture() + probe.Capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, @@ -925,7 +925,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { state -> - probe.capture() + probe.Capture() captured = state Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } @@ -1044,7 +1044,7 @@ class FirebaseAuthScreenReauthContentStateTest { Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) }, reauthContent = { - probe.capture() + probe.Capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, @@ -1102,7 +1102,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, // Password-only, so the sheet opens at the email step and the picker never composes. - emailContent = { probe.capture() }, + emailContent = { probe.Capture() }, mfaChallengeContent = { state -> challenge = state Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) @@ -1165,7 +1165,7 @@ class FirebaseAuthScreenReauthContentStateTest { Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) }, reauthContent = { - probe.capture() + probe.Capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, @@ -1232,7 +1232,7 @@ class FirebaseAuthScreenReauthContentStateTest { Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) }, reauthContent = { state -> - probe.capture() + probe.Capture() captured = state Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, @@ -1296,7 +1296,7 @@ class FirebaseAuthScreenReauthContentStateTest { Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) }, reauthContent = { state -> - probe.capture() + probe.Capture() captured = state Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, @@ -1404,7 +1404,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { state -> - probe.capture() + probe.Capture() captured = state Text(text = "SLOT_ERROR=${state.error}", modifier = Modifier.testTag("slot")) } @@ -1500,7 +1500,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { - probe.capture() + probe.Capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) @@ -1552,7 +1552,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { - probe.capture() + probe.Capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) @@ -1609,7 +1609,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { - probe.capture() + probe.Capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) @@ -1666,7 +1666,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { - probe.capture() + probe.Capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) @@ -1715,7 +1715,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { - probe.capture() + probe.Capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) @@ -1801,7 +1801,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { - probe.capture() + probe.Capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) @@ -1949,7 +1949,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, emailContent = { - probe.capture() + probe.Capture() Text(text = "EMAIL", modifier = Modifier.testTag("reauth_email")) }, mfaChallengeContent = { @@ -2001,7 +2001,7 @@ class FirebaseAuthScreenReauthContentStateTest { Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) }, reauthContent = { - probe.capture() + probe.Capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, @@ -2052,7 +2052,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { - probe.capture() + probe.Capture() Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) } ) @@ -2092,7 +2092,7 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInFailure = {}, onSignInCancelled = {}, emailContent = { - probe.capture() + probe.Capture() Text(text = "EMAIL", modifier = Modifier.testTag("reauth_email")) }, authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt index cb0302679..fc11c8126 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt @@ -125,7 +125,7 @@ class FirebaseAuthScreenReauthIdleResetTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { state -> - probe.capture() + probe.Capture() capturedError = state.error Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker")) } @@ -193,7 +193,7 @@ class FirebaseAuthScreenReauthIdleResetTest { onSignInFailure = {}, onSignInCancelled = {}, reauthContent = { - probe.capture() + probe.Capture() Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker")) } )