tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 6ecd342a4f9d147f3e79bc7d0710534aa9639373
parent 4f593ec7f60f4f5bd7f78175c123ea7c90aae1b1
Author: t-p-white <towhite@mozilla.com>
Date:   Thu,  8 Jan 2026 10:50:13 +0000

Bug 2008712 - Part 3: Update `TermsOfUseBottomSheet` to use the Nimbus provided prompt title and "Learn more" content. r=android-reviewers,joberhauser

| Learn more UI previews |
| {F53752458}

`TermsOfUsePromptContentOption` integration into `TermsOfUseBottomSheet`
| Treatment A | Treatment B | Treatment C |
| {F53752627} | {F53752641} | {F53752610} |

Experimenter config: https://experimenter.services.mozilla.com/nimbus/android-tou-new-bottom-sheet-strings/summary/

Differential Revision: https://phabricator.services.mozilla.com/D278008

Diffstat:
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/experimentation/TermsOfUsePromptContent.kt | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/ui/TermsOfUseBottomSheet.kt | 73+++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/ui/TermsOfUseBottomSheetFragment.kt | 33++++++++++++++++++++-------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt | 8++++++++
Mmobile/android/fenix/app/src/main/res/values/preference_keys.xml | 1+
Amobile/android/fenix/app/src/test/java/org/mozilla/fenix/termsofuse/experimentation/TermsOfUsePromptContentTest.kt | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 329 insertions(+), 41 deletions(-)

diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/experimentation/TermsOfUsePromptContent.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/experimentation/TermsOfUsePromptContent.kt @@ -0,0 +1,176 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.termsofuse.experimentation + +import android.content.Context +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.PreviewLightDark +import org.mozilla.fenix.R +import org.mozilla.fenix.compose.LinkText +import org.mozilla.fenix.compose.LinkTextState +import org.mozilla.fenix.nimbus.TermsOfUsePromptContentOption +import org.mozilla.fenix.theme.FirefoxTheme + +/* + * This file is to enable experimentation with the Terms of Use prompt content. Code here is likely + * to be modified or removed entirely, hence why it's kept self-contained. + */ + +/** + * Stores the configurable content of the Terms of Use prompt defined in the + * [org.mozilla.fenix.nimbus.TermsOfUsePrompt]. + * + * @property title The prompt title. + * @property learnMoreContent Composable content containing the "Learn more" copy and link. + */ +data class TermsOfUsePromptContent( + val title: String, + val learnMoreContent: @Composable () -> Unit, +) + +@VisibleForTesting +internal fun String.toTermsOfUsePromptContentOption(): TermsOfUsePromptContentOption = + TermsOfUsePromptContentOption.entries.firstOrNull { it.name == this } + ?: TermsOfUsePromptContentOption.VALUE_0 + +/** + * Gets the [TermsOfUsePromptContent] for the given [id], and calls [onLearnMoreClicked] when the + * "Learn more" link is clicked. + * + * @param context The [Context] used to get the string resources. + * @param id The persisted ID of the [TermsOfUsePromptContent]. + * @param onLearnMoreClicked The callback to be called when the "Learn more" link is clicked. + */ +internal fun getTermsOfUsePromptContent( + context: Context, + id: String, + onLearnMoreClicked: () -> Unit, +): TermsOfUsePromptContent = when (id.toTermsOfUsePromptContentOption()) { + TermsOfUsePromptContentOption.VALUE_0 -> getTreatmentC(context, onLearnMoreClicked) + TermsOfUsePromptContentOption.VALUE_1 -> getTreatmentA(context, onLearnMoreClicked) + TermsOfUsePromptContentOption.VALUE_2 -> getTreatmentB(context, onLearnMoreClicked) +} + +/** + * Experimental configuration. + * + * Show the ToU prompt with the "Terms of Use" title and "You can learn more here." learn more text. + */ +internal fun getTreatmentA( + context: Context, + onLearnMoreClicked: () -> Unit, +): TermsOfUsePromptContent = + TermsOfUsePromptContent( + title = context.getString(R.string.terms_of_use_prompt_title_option_a), + learnMoreContent = { LearnMoreContentAlternative(onLearnMoreClicked) }, + ) + +/** + * Experimental configuration. + * + * Show the ToU prompt with "A note from Firefox" title and "You can learn more here." learn more text. + */ +internal fun getTreatmentB( + context: Context, + onLearnMoreClicked: () -> Unit, +): TermsOfUsePromptContent = + TermsOfUsePromptContent( + title = context.getString( + R.string.terms_of_use_prompt_title_option_b, + context.getString(R.string.firefox), + ), + learnMoreContent = { LearnMoreContentAlternative(onLearnMoreClicked) }, + ) + +@Composable +private fun LearnMoreContentAlternative(onLearnMoreClicked: () -> Unit) { + LearnMoreContent( + copyTextRes = R.string.terms_of_use_prompt_body_line_two_alternative, + linkTextRes = R.string.terms_of_use_prompt_body_line_two_alternative_link, + onLearnMoreClicked = onLearnMoreClicked, + ) +} + +/** + * Default configuration. + * + * Show the ToU prompt with the current defaults, "We’ve got an update" title and + * "Please take a moment to review and accept. Learn more." learn more text. + */ +internal fun getTreatmentC( + context: Context, + onLearnMoreClicked: () -> Unit, +): TermsOfUsePromptContent = + TermsOfUsePromptContent( + title = context.getString(R.string.terms_of_use_prompt_title), + learnMoreContent = { + LearnMoreContent( + copyTextRes = R.string.terms_of_use_prompt_message_2, + linkTextRes = R.string.terms_of_use_prompt_link_learn_more, + onLearnMoreClicked = onLearnMoreClicked, + ) + }, + ) + +@Composable +private fun LearnMoreContent( + @StringRes copyTextRes: Int, + @StringRes linkTextRes: Int, + onLearnMoreClicked: () -> Unit, +) { + val linkText = stringResource(linkTextRes) + + val learnMoreLinkState = LinkTextState( + text = linkText, + url = "", // URL is unused; navigation is handled via onLearnMoreClicked. + onClick = { onLearnMoreClicked() }, + ) + + LinkText( + text = stringResource(copyTextRes, linkText), + linkTextStates = listOf(learnMoreLinkState), + style = FirefoxTheme.typography.body2.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + linkTextDecoration = TextDecoration.Underline, + ) +} + +@PreviewLightDark +@Composable +private fun LearnMoreContentPreviewTreatmentA() { + FirefoxTheme { + Surface { + getTreatmentA(LocalContext.current) {}.learnMoreContent() + } + } +} + +@PreviewLightDark +@Composable +private fun LearnMoreContentPreviewTreatmentB() { + FirefoxTheme { + Surface { + getTreatmentB(LocalContext.current) {}.learnMoreContent() + } + } +} + +@PreviewLightDark +@Composable +private fun LearnMoreContentPreviewTreatmentC() { + FirefoxTheme { + Surface { + getTreatmentC(LocalContext.current) {}.learnMoreContent() + } + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/ui/TermsOfUseBottomSheet.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/ui/TermsOfUseBottomSheet.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextDecoration @@ -38,6 +39,10 @@ import mozilla.components.compose.base.button.OutlinedButton import org.mozilla.fenix.R import org.mozilla.fenix.compose.LinkText import org.mozilla.fenix.compose.LinkTextState +import org.mozilla.fenix.termsofuse.experimentation.TermsOfUsePromptContent +import org.mozilla.fenix.termsofuse.experimentation.getTreatmentA +import org.mozilla.fenix.termsofuse.experimentation.getTreatmentB +import org.mozilla.fenix.termsofuse.experimentation.getTreatmentC import org.mozilla.fenix.theme.FirefoxTheme private val sheetMaxWidth = 450.dp @@ -46,6 +51,7 @@ private val sheetMaxWidth = 450.dp * The terms of service prompt. * * @param showDragHandle If the user should see and be able to use a drag handle to dismiss the prompt. + * @param termsOfUsePromptContent Configurable data that define the prompt title and "learn more" content. * @param onDismiss The callback to invoke when the prompt is dismissed. * @param onDismissRequest The callback to invoke when the user clicks outside of the bottom sheet, * after sheet animates to Hidden. See [ModalBottomSheet]. @@ -53,19 +59,18 @@ private val sheetMaxWidth = 450.dp * @param onRemindMeLaterClicked The callback to invoke when the user clicks "Remind me later". * @param onTermsOfUseClicked The callback to invoke when the user clicks on the terms of use link. * @param onPrivacyNoticeClicked The callback to invoke when the user clicks on the privacy notice link. - * @param onLearnMoreClicked The callback to invoke when the user clicks on the learn more link. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun TermsOfUseBottomSheet( showDragHandle: Boolean = true, + termsOfUsePromptContent: TermsOfUsePromptContent, onDismiss: () -> Unit, onDismissRequest: () -> Unit, onAcceptClicked: () -> Unit, onRemindMeLaterClicked: () -> Unit, onTermsOfUseClicked: () -> Unit, onPrivacyNoticeClicked: () -> Unit, - onLearnMoreClicked: () -> Unit, ) { val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, @@ -77,6 +82,7 @@ fun TermsOfUseBottomSheet( BottomSheet( showDragHandle = showDragHandle, + termsOfUsePromptContent = termsOfUsePromptContent, sheetState = sheetState, onDismiss = onDismiss, onDismissRequest = onDismissRequest, @@ -84,7 +90,6 @@ fun TermsOfUseBottomSheet( onRemindMeLaterClicked = onRemindMeLaterClicked, onTermsOfUseClicked = onTermsOfUseClicked, onPrivacyNoticeClicked = onPrivacyNoticeClicked, - onLearnMoreClicked = onLearnMoreClicked, ) } @@ -92,6 +97,7 @@ fun TermsOfUseBottomSheet( @Composable private fun BottomSheet( showDragHandle: Boolean, + termsOfUsePromptContent: TermsOfUsePromptContent, sheetState: SheetState, onDismiss: () -> Unit = {}, onDismissRequest: () -> Unit = {}, @@ -99,7 +105,6 @@ private fun BottomSheet( onRemindMeLaterClicked: () -> Unit = {}, onTermsOfUseClicked: () -> Unit = {}, onPrivacyNoticeClicked: () -> Unit = {}, - onLearnMoreClicked: () -> Unit = {}, ) { ModalBottomSheet( sheetGesturesEnabled = showDragHandle, @@ -118,13 +123,13 @@ private fun BottomSheet( ) { BottomSheetContent( showDragHandle = showDragHandle, + termsOfUsePromptContent = termsOfUsePromptContent, sheetState = sheetState, onDismiss = onDismiss, onAcceptClicked = onAcceptClicked, onRemindMeLaterClicked = onRemindMeLaterClicked, onTermsOfUseClicked = onTermsOfUseClicked, onPrivacyNoticeClicked = onPrivacyNoticeClicked, - onLearnMoreClicked = onLearnMoreClicked, ) } } @@ -133,13 +138,13 @@ private fun BottomSheet( @Composable private fun BottomSheetContent( showDragHandle: Boolean, + termsOfUsePromptContent: TermsOfUsePromptContent, sheetState: SheetState, onDismiss: () -> Unit, onAcceptClicked: () -> Unit = {}, onRemindMeLaterClicked: () -> Unit = {}, onTermsOfUseClicked: () -> Unit = {}, onPrivacyNoticeClicked: () -> Unit = {}, - onLearnMoreClicked: () -> Unit = {}, ) { val coroutineScope = rememberCoroutineScope() val scrollState = rememberScrollState() @@ -165,7 +170,7 @@ private fun BottomSheetContent( Text( modifier = Modifier.align(Alignment.CenterHorizontally), - text = stringResource(R.string.terms_of_use_prompt_title), + text = termsOfUsePromptContent.title, style = FirefoxTheme.typography.headline6, ) @@ -175,7 +180,7 @@ private fun BottomSheetContent( Spacer(Modifier.size(20.dp)) - LearnMoreContent(onLearnMoreClicked) + termsOfUsePromptContent.learnMoreContent() Spacer(Modifier.size(34.dp)) @@ -241,38 +246,46 @@ private fun TermsOfUseContent( ) } +@OptIn(ExperimentalMaterial3Api::class) +@PreviewLightDark @Composable -private fun LearnMoreContent(onLearnMoreClicked: () -> Unit) { - val learnMoreLinkState = LinkTextState( - text = stringResource(R.string.terms_of_use_prompt_link_learn_more), - url = "", - onClick = { onLearnMoreClicked() }, - ) +private fun TermsOfUseBottomSheetMobilePortraitPreviewTreatmentA() { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - LinkText( - text = stringResource( - id = R.string.terms_of_use_prompt_message_2, - stringResource(R.string.terms_of_use_prompt_link_learn_more), - ), - linkTextStates = listOf( - learnMoreLinkState, - ), - style = FirefoxTheme.typography.body2.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - linkTextDecoration = TextDecoration.Underline, - ) + FirefoxTheme { + BottomSheet( + showDragHandle = true, + termsOfUsePromptContent = getTreatmentA(LocalContext.current) {}, + sheetState = sheetState, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@PreviewLightDark +@Composable +private fun TermsOfUseBottomSheetMobilePortraitPreviewTreatmentB() { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + FirefoxTheme { + BottomSheet( + showDragHandle = true, + termsOfUsePromptContent = getTreatmentB(LocalContext.current) {}, + sheetState = sheetState, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @PreviewLightDark @Composable -private fun TermsOfUseBottomSheetMobilePortraitPreview() { +private fun TermsOfUseBottomSheetMobilePortraitPreviewTreatmentC() { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) FirefoxTheme { BottomSheet( showDragHandle = true, + termsOfUsePromptContent = getTreatmentC(LocalContext.current) {}, sheetState = sheetState, ) } @@ -287,6 +300,7 @@ private fun TermsOfUseBottomSheetMobilePortraitNoHandlePreview() { FirefoxTheme { BottomSheet( showDragHandle = false, + termsOfUsePromptContent = getTreatmentC(LocalContext.current) {}, sheetState = sheetState, ) } @@ -306,6 +320,7 @@ private fun TermsOfUseBottomSheetMobileLandscapePreview() { FirefoxTheme { BottomSheet( showDragHandle = true, + termsOfUsePromptContent = getTreatmentC(LocalContext.current) {}, sheetState = sheetState, ) } @@ -325,6 +340,7 @@ private fun TermsOfUseBottomSheetTabletPortraitPreview() { FirefoxTheme { BottomSheet( showDragHandle = true, + termsOfUsePromptContent = getTreatmentC(LocalContext.current) {}, sheetState = sheetState, ) } @@ -340,6 +356,7 @@ private fun TermsOfUseBottomSheetTabletLandscapePreview() { FirefoxTheme { BottomSheet( showDragHandle = true, + termsOfUsePromptContent = getTreatmentC(LocalContext.current) {}, sheetState = sheetState, ) } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/ui/TermsOfUseBottomSheetFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/ui/TermsOfUseBottomSheetFragment.kt @@ -16,6 +16,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import org.mozilla.fenix.ext.settings import org.mozilla.fenix.settings.SupportUtils +import org.mozilla.fenix.termsofuse.experimentation.getTermsOfUsePromptContent import org.mozilla.fenix.termsofuse.store.DefaultTermsOfUsePromptRepository import org.mozilla.fenix.termsofuse.store.TermsOfUsePromptAction import org.mozilla.fenix.termsofuse.store.TermsOfUsePromptPreferencesMiddleware @@ -63,8 +64,27 @@ class TermsOfUseBottomSheetFragment : BottomSheetDialogFragment() { ): View = ComposeView(requireContext()).apply { setContent { FirefoxTheme { + val termsOfUsePromptContent = getTermsOfUsePromptContent( + context = requireActivity().applicationContext, + id = settings().termsOfUsePromptContentOptionId, + onLearnMoreClicked = { + termsOfUsePromptStore.dispatch( + TermsOfUsePromptAction.OnLearnMoreClicked(args.surface), + ) + SupportUtils.launchSandboxCustomTab( + context, + SupportUtils.getSumoURLForTopic( + context, + SupportUtils.SumoTopic.TERMS_OF_USE, + useMobilePage = false, + ), + ) + }, + ) + TermsOfUseBottomSheet( showDragHandle = settings().shouldShowTermsOfUsePromptDragHandle, + termsOfUsePromptContent = termsOfUsePromptContent, onDismiss = { dismiss() }, onDismissRequest = { termsOfUsePromptStore.dispatch( @@ -99,19 +119,6 @@ class TermsOfUseBottomSheetFragment : BottomSheetDialogFragment() { SupportUtils.getMozillaPageUrl(SupportUtils.MozillaPage.PRIVACY_NOTICE), ) }, - onLearnMoreClicked = { - termsOfUsePromptStore.dispatch( - TermsOfUsePromptAction.OnLearnMoreClicked(args.surface), - ) - SupportUtils.launchSandboxCustomTab( - context, - SupportUtils.getSumoURLForTopic( - context, - SupportUtils.SumoTopic.TERMS_OF_USE, - useMobilePage = false, - ), - ) - }, ) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -637,6 +637,14 @@ class Settings( ) /** + * The ID of the content option for the Terms of Use prompt. + */ + var termsOfUsePromptContentOptionId by stringPreference( + key = appContext.getPreferenceKey(R.string.pref_key_terms_prompt_content_option), + default = { FxNimbus.features.termsOfUsePrompt.value().contentOption.name }, + ) + + /** * The maximum number of times the Terms of Use prompt should be displayed. * * Use a function to ensure the most up-to-date Nimbus value is retrieved. diff --git a/mobile/android/fenix/app/src/main/res/values/preference_keys.xml b/mobile/android/fenix/app/src/main/res/values/preference_keys.xml @@ -89,6 +89,7 @@ <string name="pref_key_terms_prompt_enabled" translatable="false">pref_key_terms_prompt_enabled</string> <string name="pref_key_terms_prompt_drag_handle_enabled" translatable="false">pref_key_terms_prompt_drag_handle_enabled</string> <string name="pref_key_terms_prompt_displayed_count" translatable="false">pref_key_terms_prompt_displayed_count</string> + <string name="pref_key_terms_prompt_content_option" translatable="false">pref_key_terms_prompt_content_option</string> <string name="pref_key_terms_last_prompt_time" translatable="false">pref_key_terms_last_prompt_time</string> <string name="pref_key_terms_postponed" translatable="false">pref_key_terms_postponed</string> <string name="pref_key_terms_latest_date" translatable="false">pref_key_terms_latest_date</string> diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/termsofuse/experimentation/TermsOfUsePromptContentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/termsofuse/experimentation/TermsOfUsePromptContentTest.kt @@ -0,0 +1,79 @@ +package org.mozilla.fenix.termsofuse.experimentation + +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.nimbus.TermsOfUsePromptContentOption +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class TermsOfUsePromptContentTest { + + // Tests for toTermsOfUsePromptContentOption + @Test + fun `WHEN string matches the VALUE_0 name THEN toTermsOfUsePromptContentOption returns VALUE_0`() { + assertEquals( + TermsOfUsePromptContentOption.VALUE_0, + "VALUE_0".toTermsOfUsePromptContentOption(), + ) + } + + @Test + fun `WHEN string matches the VALUE_1 name THEN toTermsOfUsePromptContentOption returns VALUE_1`() { + assertEquals( + TermsOfUsePromptContentOption.VALUE_1, + "VALUE_1".toTermsOfUsePromptContentOption(), + ) + } + + @Test + fun `WHEN string matches the VALUE_2 name THEN toTermsOfUsePromptContentOption returns VALUE_2`() { + assertEquals( + TermsOfUsePromptContentOption.VALUE_2, + "VALUE_2".toTermsOfUsePromptContentOption(), + ) + } + + @Test + fun `WHEN string does not match any TermsOfUsePromptContentOption name THEN toTermsOfUsePromptContentOption returns VALUE_0`() { + assertEquals( + TermsOfUsePromptContentOption.VALUE_0, + "test".toTermsOfUsePromptContentOption(), + ) + } + + // Tests for getTermsOfUsePromptContent title only + @Test + fun `WHEN TermsOfUsePromptContentOption is VALUE_0 THEN getTermsOfUsePromptContent title is as expected`() { + val expectedTitle = "We’ve got an update" + val result = getTermsOfUsePromptContent( + testContext, + TermsOfUsePromptContentOption.VALUE_0.name, + ) {}.title + + assertEquals(expectedTitle, result) + } + + @Test + fun `WHEN TermsOfUsePromptContentOption is VALUE_1 THEN getTermsOfUsePromptContent title is as expected`() { + val expectedTitle = "Terms of Use" + val result = getTermsOfUsePromptContent( + testContext, + TermsOfUsePromptContentOption.VALUE_1.name, + ) {}.title + + assertEquals(expectedTitle, result) + } + + @Test + fun `WHEN TermsOfUsePromptContentOption is VALUE_2 THEN getTermsOfUsePromptContent title is as expected`() { + val expectedTitle = "A note from Firefox" + val result = getTermsOfUsePromptContent( + testContext, + TermsOfUsePromptContentOption.VALUE_2.name, + ) {}.title + + assertEquals(expectedTitle, result) + } +}