diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 72f1b8025..991e55b60 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -79,6 +79,7 @@ android:label="Custom Method Picker Layout" android:exported="false" android:theme="@style/Theme.FirebaseUIAndroid" /> + diff --git a/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt index cd73ce212..54eadccc3 100644 --- a/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/CustomMethodPickerDemoActivity.kt @@ -8,6 +8,7 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.fillMaxSize @@ -20,6 +21,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -27,6 +29,10 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -45,6 +51,7 @@ import com.firebase.ui.auth.configuration.theme.AuthUIAsset import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults import com.firebase.ui.auth.ui.components.AuthProviderButton +import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen class CustomMethodPickerDemoActivity : ComponentActivity() { @@ -119,6 +126,8 @@ class CustomMethodPickerDemoActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { + var termsAccepted by remember { mutableStateOf(false) } + FirebaseAuthScreen( configuration = configuration, authUI = authUI, @@ -134,9 +143,30 @@ class CustomMethodPickerDemoActivity : ComponentActivity() { customMethodPickerLayout = { providers, onProviderSelected -> SpotlightMethodPicker( providers = providers, - onProviderSelected = onProviderSelected + onProviderSelected = onProviderSelected, + enabled = termsAccepted ) - } + }, + customMethodPickerTermsConfiguration = MethodPickerTermsConfiguration( + content = { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = termsAccepted, + onCheckedChange = { termsAccepted = it } + ) + Text( + text = "I have read and accept the Terms of Service and Privacy Policy", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 8.dp) + ) + } + }, + accepted = termsAccepted, + disableProvidersUntilAccepted = true, + ), ) } } @@ -148,6 +178,7 @@ class CustomMethodPickerDemoActivity : ComponentActivity() { fun SpotlightMethodPicker( providers: List, onProviderSelected: (AuthProvider) -> Unit, + enabled: Boolean = true, ) { val stringProvider = LocalAuthUIStringProvider.current @@ -195,6 +226,7 @@ fun SpotlightMethodPicker( .padding(horizontal = 32.dp), provider = provider, onClick = { onProviderSelected(provider) }, + enabled = enabled, stringProvider = stringProvider ) } @@ -219,6 +251,7 @@ fun SpotlightMethodPicker( ProviderIconButton( style = style, contentDescription = provider.providerId, + enabled = enabled, onClick = { onProviderSelected(provider) } ) } @@ -242,6 +275,7 @@ fun SpotlightMethodPicker( .padding(horizontal = 32.dp), provider = provider, onClick = { onProviderSelected(provider) }, + enabled = enabled, stringProvider = stringProvider ) } @@ -250,7 +284,7 @@ fun SpotlightMethodPicker( anonymous?.let { item { Spacer(modifier = Modifier.height(8.dp)) - TextButton(onClick = { onProviderSelected(it) }) { + TextButton(onClick = { onProviderSelected(it) }, enabled = enabled) { Text("Continue as guest") } } @@ -263,9 +297,11 @@ private fun ProviderIconButton( style: AuthUITheme.ProviderStyle, contentDescription: String, onClick: () -> Unit, + enabled: Boolean = true, ) { Button( onClick = onClick, + enabled = enabled, modifier = Modifier.size(52.dp), shape = CircleShape, colors = ButtonDefaults.buttonColors(containerColor = style.backgroundColor), diff --git a/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt index 4aa50b05f..4b824eed5 100644 --- a/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt @@ -102,8 +102,8 @@ fun CustomSlotsDemoChooser( ) DemoCard( - title = "Custom Method Picker Layout", - description = "Replace the default vertical provider list with a 2-column grid using customMethodPickerLayout on FirebaseAuthScreen.", + title = "Custom Method Picker Layout & Terms", + description = "Replace the default provider list with a custom layout, and swap the 'By continuing...' footer with a checkbox using customMethodPickerLayout and customMethodPickerTermsConfiguration on FirebaseAuthScreen.", onClick = onCustomMethodPickerClick ) } 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 083148016..feb04fb7c 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 @@ -46,6 +46,24 @@ import com.firebase.ui.auth.configuration.theme.AuthUIAsset import com.firebase.ui.auth.ui.components.AuthProviderButton import com.firebase.ui.auth.util.SignInPreferenceManager +/** + * Configuration for a custom Terms of Service/Privacy Policy footer in [AuthMethodPicker]. + * + * @param content A composable that replaces the default "By continuing..." footer. Use this to + * supply a checkbox or any custom consent UI. + * @param accepted The current acceptance state. Only used when [disableProvidersUntilAccepted] + * is true. + * @param disableProvidersUntilAccepted When true, provider buttons are disabled until [accepted] + * is true. Defaults to false — buttons remain enabled unless explicitly opted in. + * + * @since 10.0.0 + */ +class MethodPickerTermsConfiguration( + val content: @Composable () -> Unit, + val accepted: Boolean = true, + val disableProvidersUntilAccepted: Boolean = false, +) + /** * Renders the provider selection screen. * @@ -68,6 +86,8 @@ import com.firebase.ui.auth.util.SignInPreferenceManager * @param termsOfServiceUrl The URL for the Terms of Service. * @param privacyPolicyUrl The URL for the Privacy Policy. * @param lastSignInPreference The last sign-in preference to show a "Continue as..." button. + * @param termsConfiguration Optional configuration for a custom ToS/Privacy Policy footer. + * When provided, replaces the default "By continuing..." text. See [MethodPickerTermsConfiguration]. * * @since 10.0.0 */ @@ -81,10 +101,14 @@ fun AuthMethodPicker( privacyPolicyUrl: String? = null, lastSignInPreference: SignInPreferenceManager.SignInPreference? = null, customLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null, + termsConfiguration: MethodPickerTermsConfiguration? = null, ) { val context = LocalContext.current val inPreview = LocalInspectionMode.current val stringProvider = LocalAuthUIStringProvider.current + val providerButtonsEnabled = termsConfiguration == null || + !termsConfiguration.disableProvidersUntilAccepted || + termsConfiguration.accepted Column( modifier = modifier @@ -100,7 +124,9 @@ fun AuthMethodPicker( ) } if (customLayout != null) { - customLayout(providers, onProviderSelected) + Box(modifier = Modifier.weight(1f)) { + customLayout(providers, onProviderSelected) + } } else { BoxWithConstraints( modifier = Modifier @@ -121,6 +147,7 @@ fun AuthMethodPicker( ContinueAsButton( provider = lastProvider, identifier = preference.identifier, + enabled = providerButtonsEnabled, onClick = { onProviderSelected(lastProvider) } ) Spacer(modifier = Modifier.height(24.dp)) @@ -155,6 +182,7 @@ fun AuthMethodPicker( onClick = { onProviderSelected(provider) }, + enabled = providerButtonsEnabled, provider = provider, stringProvider = LocalAuthUIStringProvider.current ) @@ -163,20 +191,24 @@ fun AuthMethodPicker( } } } - AnnotatedStringResource( - modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp), - context = context, - inPreview = inPreview, - previewText = "By continuing, you accept our Terms of Service and Privacy Policy.", - text = stringProvider.tosAndPrivacyPolicy( - termsOfServiceLabel = stringProvider.termsOfService, - privacyPolicyLabel = stringProvider.privacyPolicy - ), - links = arrayOf( - stringProvider.termsOfService to (termsOfServiceUrl ?: ""), - stringProvider.privacyPolicy to (privacyPolicyUrl ?: "") + if (termsConfiguration != null) { + termsConfiguration.content() + } else { + AnnotatedStringResource( + modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp), + context = context, + inPreview = inPreview, + previewText = "By continuing, you accept our Terms of Service and Privacy Policy.", + text = stringProvider.tosAndPrivacyPolicy( + termsOfServiceLabel = stringProvider.termsOfService, + privacyPolicyLabel = stringProvider.privacyPolicy + ), + links = arrayOf( + stringProvider.termsOfService to (termsOfServiceUrl ?: ""), + stringProvider.privacyPolicy to (privacyPolicyUrl ?: "") + ) ) - ) + } } } @@ -191,6 +223,7 @@ fun AuthMethodPicker( private fun ContinueAsButton( provider: AuthProvider, identifier: String?, + enabled: Boolean = true, onClick: () -> Unit ) { val stringProvider = LocalAuthUIStringProvider.current @@ -200,6 +233,7 @@ private fun ContinueAsButton( .fillMaxWidth() .testTag("ContinueAsButton"), onClick = onClick, + enabled = enabled, provider = provider, stringProvider = stringProvider, subtitle = identifier, 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 a919223ad..e82caf88b 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 @@ -77,6 +77,7 @@ import com.firebase.ui.auth.ui.components.rememberTopLevelDialogController import com.firebase.ui.auth.mfa.MfaChallengeContentState import com.firebase.ui.auth.mfa.MfaEnrollmentContentState import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker +import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState @@ -112,6 +113,7 @@ fun FirebaseAuthScreen( emailLink: String? = null, mfaConfiguration: MfaConfiguration = MfaConfiguration(), customMethodPickerLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null, + customMethodPickerTermsConfiguration: MethodPickerTermsConfiguration? = null, emailContent: (@Composable (EmailAuthContentState) -> Unit)? = null, phoneContent: (@Composable (PhoneAuthContentState) -> Unit)? = null, mfaEnrollmentContent: (@Composable (MfaEnrollmentContentState) -> Unit)? = null, @@ -277,6 +279,7 @@ fun FirebaseAuthScreen( privacyPolicyUrl = configuration.privacyPolicyUrl, lastSignInPreference = lastSignInPreference.value, customLayout = customMethodPickerLayout, + termsConfiguration = customMethodPickerTermsConfiguration, onProviderSelected = { provider -> when (provider) { is AuthProvider.Anonymous -> onSignInAnonymously?.invoke() diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt index b40917d5b..c4029b198 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt @@ -7,7 +7,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription @@ -18,6 +20,7 @@ import androidx.compose.ui.test.performScrollToNode import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.configuration.theme.AuthUIAsset @@ -280,6 +283,146 @@ class AuthMethodPickerTest { Truth.assertThat(selectedProvider).isEqualTo(googleProvider) } + @Test + fun `AuthMethodPicker still renders default ToS text when customLayout is provided`() { + val links = arrayOf("Terms of Service" to "", "Privacy Policy" to "") + val labels = links.map { it.first }.toTypedArray() + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + + setContentWithStringProvider { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it }, + customLayout = { _, _ -> Text("Custom Layout") } + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_tos_and_pp, *labels)) + .assertIsDisplayed() + } + + // ============================================================================================= + // Custom Terms Content Tests + // ============================================================================================= + + @Test + fun `AuthMethodPicker renders termsConfiguration content instead of default ToS when provided`() { + val links = arrayOf("Terms of Service" to "", "Privacy Policy" to "") + val labels = links.map { it.first }.toTypedArray() + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + + setContentWithStringProvider { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it }, + termsConfiguration = MethodPickerTermsConfiguration( + content = { Text("Custom ToS checkbox") } + ) + ) + } + + composeTestRule + .onNodeWithText("Custom ToS checkbox") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_tos_and_pp, *labels)) + .assertDoesNotExist() + } + + @Test + fun `AuthMethodPicker still renders providers when termsConfiguration is provided`() { + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ) + + setContentWithStringProvider { + AuthMethodPicker( + providers = providers, + onProviderSelected = { selectedProvider = it }, + termsConfiguration = MethodPickerTermsConfiguration( + content = { Text("Custom ToS checkbox") } + ) + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsDisplayed() + } + + // ============================================================================================= + // Terms Accepted / Gating Tests + // ============================================================================================= + + @Test + fun `AuthMethodPicker disables provider buttons when disableProvidersUntilAccepted is true and accepted is false`() { + val googleProvider = AuthProvider.Google(scopes = emptyList(), serverClientId = null) + + setContentWithStringProvider { + AuthMethodPicker( + providers = listOf(googleProvider), + onProviderSelected = { selectedProvider = it }, + termsConfiguration = MethodPickerTermsConfiguration( + content = { Text("Checkbox") }, + accepted = false, + disableProvidersUntilAccepted = true + ) + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsNotEnabled() + } + + @Test + fun `AuthMethodPicker enables provider buttons when disableProvidersUntilAccepted is true and accepted is true`() { + val googleProvider = AuthProvider.Google(scopes = emptyList(), serverClientId = null) + + setContentWithStringProvider { + AuthMethodPicker( + providers = listOf(googleProvider), + onProviderSelected = { selectedProvider = it }, + termsConfiguration = MethodPickerTermsConfiguration( + content = { Text("Checkbox") }, + accepted = true, + disableProvidersUntilAccepted = true + ) + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsEnabled() + } + + @Test + fun `AuthMethodPicker ignores accepted when disableProvidersUntilAccepted is false`() { + val googleProvider = AuthProvider.Google(scopes = emptyList(), serverClientId = null) + + setContentWithStringProvider { + AuthMethodPicker( + providers = listOf(googleProvider), + onProviderSelected = { selectedProvider = it }, + termsConfiguration = MethodPickerTermsConfiguration( + content = { Text("Checkbox") }, + accepted = false, + disableProvidersUntilAccepted = false + ) + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.fui_sign_in_with_google)) + .assertIsEnabled() + } + // ============================================================================================= // Scrolling Tests // =============================================================================================