diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarCFRPresenter.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarCFRPresenter.kt new file mode 100644 index 0000000000..c62c5c8cae --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarCFRPresenter.kt @@ -0,0 +1,129 @@ +/* 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.components.toolbar + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.clickable +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.transformWhile +import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.browser.toolbar.BrowserToolbar +import mozilla.components.lib.state.ext.flowScoped +import org.mozilla.fenix.R +import org.mozilla.fenix.compose.cfr.CFRPopup +import org.mozilla.fenix.compose.cfr.CFRPopup.PopupAlignment.INDICATOR_CENTERED_IN_ANCHOR +import org.mozilla.fenix.compose.cfr.CFRPopupProperties +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.settings.SupportUtils +import org.mozilla.fenix.settings.SupportUtils.SumoTopic.TOTAL_COOKIE_PROTECTION +import org.mozilla.fenix.theme.FirefoxTheme +import org.mozilla.fenix.utils.Settings + +/** + * Vertical padding needed to improve the visual alignment of the popup and respect the UX design. + */ +private const val CFR_TO_ANCHOR_VERTICAL_PADDING = -6 + +/** + * Delegate for handling all the business logic for showing CFRs in the toolbar. + * + * @param context used for various Android interactions. + * @param browserStore will be observed for tabs updates + * @param settings used to read and write persistent user settings + * @param toolbar will serve as anchor for the CFRs + * @param sessionId optional custom tab id used to identify the custom tab in which to show a CFR. + */ +class BrowserToolbarCFRPresenter( + private val context: Context, + private val browserStore: BrowserStore, + private val settings: Settings, + private val toolbar: BrowserToolbar, + private val sessionId: String? = null +) { + @VisibleForTesting + internal var tcpCfrScope: CoroutineScope? = null + @VisibleForTesting + internal var tcpCfrPopup: CFRPopup? = null + + /** + * Start observing [browserStore] for updates which may trigger showing a CFR. + */ + @Suppress("MagicNumber") + fun start() { + if (settings.shouldShowTotalCookieProtectionCFR) { + tcpCfrScope = browserStore.flowScoped { flow -> + flow + .mapNotNull { it.findCustomTabOrSelectedTab(sessionId)?.content?.progress } + // The "transformWhile" below ensures that the 100% progress is only collected once. + .transformWhile { progress -> + emit(progress) + progress != 100 + } + .filter { it == 100 } + .collect { + tcpCfrScope?.cancel() + showTcpCfr() + } + } + } + } + + /** + * Stop listening for [browserStore] updates. + * CFRs already shown are not automatically dismissed. + */ + fun stop() { + tcpCfrScope?.cancel() + } + + @VisibleForTesting + internal fun showTcpCfr() { + CFRPopup( + text = context.getString(R.string.tcp_cfr_message), + anchor = toolbar.findViewById( + R.id.mozac_browser_toolbar_security_indicator + ), + properties = CFRPopupProperties( + popupAlignment = INDICATOR_CENTERED_IN_ANCHOR, + indicatorDirection = if (settings.toolbarPosition == ToolbarPosition.TOP) { + CFRPopup.IndicatorDirection.UP + } else { + CFRPopup.IndicatorDirection.DOWN + }, + popupVerticalOffset = CFR_TO_ANCHOR_VERTICAL_PADDING.dp + ), + ) { + Text( + text = context.getString(R.string.tcp_cfr_learn_more), + color = FirefoxTheme.colors.textOnColorPrimary, + modifier = Modifier.clickable { + context.components.useCases.tabsUseCases.selectOrAddTab.invoke( + SupportUtils.getSumoURLForTopic( + context, + TOTAL_COOKIE_PROTECTION + ) + ) + tcpCfrPopup?.dismiss() + }, + style = FirefoxTheme.typography.body2.copy( + textDecoration = TextDecoration.Underline + ) + ) + }.run { + settings.shouldShowTotalCookieProtectionCFR = false + tcpCfrPopup = this + show() + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/ToolbarIntegration.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/ToolbarIntegration.kt index 5960b573e9..4eb077f436 100644 --- a/app/src/main/java/org/mozilla/fenix/components/toolbar/ToolbarIntegration.kt +++ b/app/src/main/java/org/mozilla/fenix/components/toolbar/ToolbarIntegration.kt @@ -5,6 +5,7 @@ package org.mozilla.fenix.components.toolbar import android.content.Context +import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner import mozilla.components.browser.domains.autocomplete.DomainAutocompleteProvider @@ -95,6 +96,15 @@ class DefaultToolbarIntegration( renderStyle = ToolbarFeature.RenderStyle.UncoloredUrl ) { + @VisibleForTesting + internal var cfrPresenter = BrowserToolbarCFRPresenter( + context = context, + browserStore = context.components.core.store, + settings = context.settings(), + toolbar = toolbar, + sessionId = sessionId + ) + init { toolbar.display.menuBuilder = toolbarMenu.menuBuilder toolbar.private = isPrivate @@ -150,4 +160,14 @@ class DefaultToolbarIntegration( } } } + + override fun start() { + super.start() + cfrPresenter.start() + } + + override fun stop() { + cfrPresenter.stop() + super.stop() + } } diff --git a/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopup.kt b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopup.kt index 84aeaa263d..2504fdebf3 100644 --- a/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopup.kt +++ b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopup.kt @@ -54,11 +54,11 @@ data class CFRPopupProperties( * @param action Optional other composable to show just below the popup text. */ class CFRPopup( - private val text: String, - private val anchor: View, - private val properties: CFRPopupProperties = CFRPopupProperties(), - private val onDismiss: (Boolean) -> Unit = {}, - private val action: @Composable (() -> Unit) = {} + @get:VisibleForTesting internal val text: String, + @get:VisibleForTesting internal val anchor: View, + @get:VisibleForTesting internal val properties: CFRPopupProperties = CFRPopupProperties(), + @get:VisibleForTesting internal val onDismiss: (Boolean) -> Unit = {}, + @get:VisibleForTesting internal val action: @Composable (() -> Unit) = {} ) { // This is just a facade for the CFRPopupFullScreenLayout composable offering a cleaner API. diff --git a/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt b/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt index 59e4636385..c8bc515fac 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt @@ -42,6 +42,7 @@ object SupportUtils { PRIVATE_BROWSING_MYTHS("common-myths-about-private-browsing"), YOUR_RIGHTS("your-rights"), TRACKING_PROTECTION("tracking-protection-firefox-android"), + TOTAL_COOKIE_PROTECTION("enhanced-tracking-protection-android"), WHATS_NEW("whats-new-firefox-preview"), OPT_OUT_STUDIES("how-opt-out-studies-firefox-android"), SEND_TABS("send-tab-preview"), diff --git a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt index bb8e9b4ddc..f5e57c7067 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -574,6 +574,14 @@ class Settings(private val appContext: Context) : PreferencesHolder { val enabledTotalCookieProtectionSetting: Boolean get() = mr2022Sections[Mr2022Section.TCP_CFR] == true + /** + * Indicates if the total cookie protection CRF should be shown. + */ + var shouldShowTotalCookieProtectionCFR by booleanPreference( + appContext.getPreferenceKey(R.string.pref_key_should_show_total_cookie_protection_popup), + default = FxNimbus.features.engineSettings.value().totalCookieProtectionEnabled + ) + val blockCookiesSelectionInCustomTrackingProtection by stringPreference( key = appContext.getPreferenceKey(R.string.pref_key_tracking_protection_custom_cookies_select), default = if (enabledTotalCookieProtectionSetting) { diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index 6a21c12fa5..5e3b7a1dd4 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -241,6 +241,8 @@ pref_key_should_show_home_onboarding_dialog pref_key_show_first_run_onboarding_update + + pref_key_should_show_total_cookie_protection_popup pref_key_debug_settings diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d4f8cfb72b..6152a1cb92 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,6 +76,12 @@ Dismiss + + + Our most powerful privacy feature yet isolates cross-site trackers. + + Learn about Total Cookie Protection + Camera access needed. Go to Android settings, tap permissions, and tap allow. diff --git a/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarCFRPresenterTest.kt b/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarCFRPresenterTest.kt new file mode 100644 index 0000000000..5787b1e48c --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarCFRPresenterTest.kt @@ -0,0 +1,243 @@ +/* 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.components.toolbar + +import android.content.Context +import android.view.View +import androidx.compose.ui.unit.dp +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.CustomTabSessionState +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.state.createCustomTab +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.browser.toolbar.BrowserToolbar +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.mozilla.fenix.R +import org.mozilla.fenix.compose.cfr.CFRPopup +import org.mozilla.fenix.utils.Settings + +class BrowserToolbarCFRPresenterTest { + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + @Test + fun `GIVEN the TCP CFR should be shown for a custom tab WHEN the custom tab is fully loaded THEN the TCP CFR is shown`() { + val customTab = createCustomTab(url = "") + val browserStore = createBrowserStore(customTab = customTab) + val presenter = createPresenterThatShowsCFRs( + browserStore = browserStore, + sessionId = customTab.id, + ) + + presenter.start() + + assertNotNull(presenter.tcpCfrScope) + + browserStore.dispatch(ContentAction.UpdateProgressAction(customTab.id, 0)).joinBlocking() + verify(exactly = 0) { presenter.showTcpCfr() } + + browserStore.dispatch(ContentAction.UpdateProgressAction(customTab.id, 33)).joinBlocking() + verify(exactly = 0) { presenter.showTcpCfr() } + + browserStore.dispatch(ContentAction.UpdateProgressAction(customTab.id, 100)).joinBlocking() + verify { presenter.showTcpCfr() } + } + + @Test + fun `GIVEN the TCP CFR should be shown WHEN the current normal tab is fully loaded THEN the TCP CFR is shown`() { + val normalTab = createTab(url = "", private = false) + val browserStore = createBrowserStore( + tab = normalTab, + selectedTabId = normalTab.id, + ) + val presenter = createPresenterThatShowsCFRs(browserStore = browserStore) + + presenter.start() + + assertNotNull(presenter.tcpCfrScope) + + browserStore.dispatch(ContentAction.UpdateProgressAction(normalTab.id, 1)).joinBlocking() + verify(exactly = 0) { presenter.showTcpCfr() } + + browserStore.dispatch(ContentAction.UpdateProgressAction(normalTab.id, 98)).joinBlocking() + verify(exactly = 0) { presenter.showTcpCfr() } + + browserStore.dispatch(ContentAction.UpdateProgressAction(normalTab.id, 100)).joinBlocking() + verify { presenter.showTcpCfr() } + } + + @Test + fun `GIVEN the TCP CFR should be shown WHEN the current private tab is fully loaded THEN the TCP CFR is shown`() { + val privateTab = createTab(url = "", private = true) + val browserStore = createBrowserStore( + tab = privateTab, + selectedTabId = privateTab.id, + ) + val presenter = createPresenterThatShowsCFRs(browserStore = browserStore) + + presenter.start() + + assertNotNull(presenter.tcpCfrScope) + + browserStore.dispatch(ContentAction.UpdateProgressAction(privateTab.id, 14)).joinBlocking() + verify(exactly = 0) { presenter.showTcpCfr() } + + browserStore.dispatch(ContentAction.UpdateProgressAction(privateTab.id, 99)).joinBlocking() + verify(exactly = 0) { presenter.showTcpCfr() } + + browserStore.dispatch(ContentAction.UpdateProgressAction(privateTab.id, 100)).joinBlocking() + verify { presenter.showTcpCfr() } + } + + @Test + fun `GIVEN the TCP CFR should be shown WHEN the current tab is fully loaded THEN the TCP CFR is only shown once`() { + val tab = createTab(url = "") + val browserStore = createBrowserStore( + tab = tab, + selectedTabId = tab.id, + ) + val presenter = createPresenterThatShowsCFRs(browserStore = browserStore) + + presenter.start() + + assertNotNull(presenter.tcpCfrScope) + + browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 99)).joinBlocking() + browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 100)).joinBlocking() + browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 100)).joinBlocking() + browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 100)).joinBlocking() + verify(exactly = 1) { presenter.showTcpCfr() } + } + + @Test + fun `GIVEN the TCP CFR should not be shown WHEN the feature starts THEN don't observe the store for updates`() { + val presenter = createPresenter( + settings = mockk { + every { shouldShowTotalCookieProtectionCFR } returns false + }, + ) + + presenter.start() + + assertNull(presenter.tcpCfrScope) + } + + @Test + fun `GIVEN the store is observed for updates WHEN the presenter is stopped THEN stop observing the store`() { + val tcpScope: CoroutineScope = mockk { + every { cancel() } just Runs + } + val presenter = createPresenter() + presenter.tcpCfrScope = tcpScope + + presenter.stop() + + verify { tcpScope.cancel() } + } + + @Test + fun `WHEN the TCP CFR is to be shown THEN instantiate a new one and remember to not show it again`() { + val settings: Settings = mockk(relaxed = true) + val presenter = createPresenter( + anchor = mockk(relaxed = true), + settings = settings, + ) + + presenter.showTcpCfr() + + verify { settings.shouldShowTotalCookieProtectionCFR = false } + assertNotNull(presenter.tcpCfrPopup) + } + + @Test + fun `WHEN the TCP CFR is instantiated THEN set the intended properties`() { + val anchor: View = mockk(relaxed = true) + val settings: Settings = mockk(relaxed = true) + val presenter = createPresenter( + anchor = anchor, + settings = settings, + ) + + presenter.showTcpCfr() + + verify { settings.shouldShowTotalCookieProtectionCFR = false } + assertNotNull(presenter.tcpCfrPopup) + presenter.tcpCfrPopup?.let { + assertEquals("Test", it.text) + assertEquals(anchor, it.anchor) + assertEquals(CFRPopup.DEFAULT_WIDTH.dp, it.properties.popupWidth) + assertEquals(CFRPopup.IndicatorDirection.DOWN, it.properties.indicatorDirection) + assertTrue(it.properties.dismissOnBackPress) + assertTrue(it.properties.dismissOnClickOutside) + assertFalse(it.properties.overlapAnchor) + assertEquals(CFRPopup.DEFAULT_INDICATOR_START_OFFSET.dp, it.properties.indicatorArrowStartOffset) + } + } + + /** + * Creates and return a [spyk] of a [BrowserToolbarCFRPresenter] that can handle actually showing CFRs. + */ + private fun createPresenterThatShowsCFRs( + context: Context = mockk(), + anchor: View = mockk(), + browserStore: BrowserStore = mockk(), + settings: Settings = mockk { every { shouldShowTotalCookieProtectionCFR } returns true }, + toolbar: BrowserToolbar = mockk(), + sessionId: String? = null + ) = spyk(createPresenter(context, anchor, browserStore, settings, toolbar, sessionId)) { + every { showTcpCfr() } just Runs + } + + /** + * Create and return a [BrowserToolbarCFRPresenter] with all constructor properties mocked by default. + * Calls to show a CFR will fail. If this behavior is needed to work use [createPresenterThatShowsCFRs]. + */ + private fun createPresenter( + context: Context = mockk { every { getString(R.string.tcp_cfr_message) } returns "Test" }, + anchor: View = mockk(), + browserStore: BrowserStore = mockk(), + settings: Settings = mockk(relaxed = true) { every { shouldShowTotalCookieProtectionCFR } returns true }, + toolbar: BrowserToolbar = mockk { + every { findViewById(R.id.mozac_browser_toolbar_security_indicator) } returns anchor + }, + sessionId: String? = null + ) = BrowserToolbarCFRPresenter( + context = context, + browserStore = browserStore, + settings = settings, + toolbar = toolbar, + sessionId = sessionId + ) + + private fun createBrowserStore( + tab: TabSessionState? = null, + customTab: CustomTabSessionState? = null, + selectedTabId: String? = null + ) = BrowserStore( + initialState = BrowserState( + tabs = if (tab != null) listOf(tab) else listOf(), + customTabs = if (customTab != null) listOf(customTab) else listOf(), + selectedTabId = selectedTabId + ) + ) +} diff --git a/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultToolbarIntegrationTest.kt b/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultToolbarIntegrationTest.kt new file mode 100644 index 0000000000..279903037a --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultToolbarIntegrationTest.kt @@ -0,0 +1,73 @@ +/* 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.components.toolbar + +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.support.test.robolectric.testContext +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner + +@RunWith(FenixRobolectricTestRunner::class) +class DefaultToolbarIntegrationTest { + private lateinit var feature: DefaultToolbarIntegration + + @Before + fun setup() { + mockkStatic("org.mozilla.fenix.ext.ContextKt") + every { any().components } returns mockk { + every { core } returns mockk { + every { store } returns BrowserStore() + } + every { publicSuffixList } returns mockk() + every { settings } returns mockk(relaxed = true) + } + + feature = DefaultToolbarIntegration( + context = testContext, + toolbar = mockk(relaxed = true), + toolbarMenu = mockk(relaxed = true), + domainAutocompleteProvider = mockk(), + historyStorage = mockk(), + lifecycleOwner = mockk(), + sessionId = null, + isPrivate = false, + interactor = mockk(), + engine = mockk(), + ) + } + + @After + fun teardown() { + unmockkStatic("org.mozilla.fenix.ext.ContextKt") + } + + @Test + fun `WHEN the feature starts THEN start the cfr presenter`() { + feature.cfrPresenter = mockk(relaxed = true) + + feature.start() + + verify { feature.cfrPresenter.start() } + } + + @Test + fun `WHEN the feature stops THEN stop the cfr presenter`() { + feature.cfrPresenter = mockk(relaxed = true) + + feature.stop() + + verify { feature.cfrPresenter.stop() } + } +}