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"))
}
)