diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/CFRPresenter.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/CFRPresenter.kt new file mode 100644 index 000000000000..09e055f59c58 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/toolbar/CFRPresenter.kt @@ -0,0 +1,120 @@ +/* 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 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.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 + +/** + * Delegate for handling all the business logic for showing CFRs in the toolbar. + * + * @param context [Context] used for various Android interactions. + * @param store [BrowserStore] which will be observed for tabs updates + * @param settings [Settings] allowing to read and write persistent user settings + * @param toolbar [BrowserToolbar] that will serve as anchor for the CFRs + * @param sessionId [String] optional custom tab id. + */ +class CFRPresenter( + private val context: Context, + private val store: BrowserStore, + private val settings: Settings, + private val toolbar: BrowserToolbar, + private val sessionId: String? = null +) { + @VisibleForTesting + internal var tcpCfrScope: CoroutineScope? = null + @VisibleForTesting + internal var tcpCfr: CFRPopup? = null + + /** + * Start observing [store] for updates which may trigger showing a CFR. + */ + @Suppress("MagicNumber") + fun start() { + if (settings.shouldShowTotalCookieProtectionCFR) { + tcpCfrScope = store.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 [store] 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( + indicatorDirection = if (settings.toolbarPosition == ToolbarPosition.TOP) { + CFRPopup.IndicatorDirection.UP + } else { + CFRPopup.IndicatorDirection.DOWN + }, + ), + ) { + 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 + ) + ) + tcpCfr?.dismiss() + }, + style = FirefoxTheme.typography.body2.copy( + textDecoration = TextDecoration.Underline + ) + ) + }.apply { + settings.shouldShowTotalCookieProtectionCFR = false + tcpCfr = 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 5960b573e97f..9fe65f28391b 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 = CFRPresenter( + context = context, + store = 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 d2fae905250a..fee072409d7f 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 @@ -48,11 +48,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 59e463638583..c8bc515facdf 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 caae13a2f1df..554faf8e8a11 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -594,6 +594,14 @@ class Settings(private val appContext: Context) : PreferencesHolder { FxNimbus.features.engineSettings.value().totalCookieProtectionEnabled } + /** + * 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( appContext.getPreferenceKey(R.string.pref_key_tracking_protection_custom_cookies_select), appContext.getString(R.string.social) diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index 7f197c22e02d..2171723d4546 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -236,6 +236,8 @@ pref_key_has_inactive_tabs_auto_close_dialog_dismissed pref_key_should_show_jump_back_in_tabs_popup + + 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 327e0cc6f5f9..2b5933065db5 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/CFRPresenterTest.kt b/app/src/test/java/org/mozilla/fenix/components/toolbar/CFRPresenterTest.kt new file mode 100644 index 000000000000..213e76d3802c --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/components/toolbar/CFRPresenterTest.kt @@ -0,0 +1,260 @@ +/* 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.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 CFRPresenterTest { + @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 = BrowserStore( + initialState = BrowserState( + customTabs = listOf(customTab) + ) + ) + val presenter = spyk( + CFRPresenter( + context = mockk(), + store = browserStore, + settings = mockk { + every { shouldShowTotalCookieProtectionCFR } returns true + }, + toolbar = mockk(), + sessionId = customTab.id + ) + ) { + every { showTcpCfr() } just Runs + } + + 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 = BrowserStore( + initialState = BrowserState( + tabs = listOf(normalTab), + selectedTabId = normalTab.id + ) + ) + val presenter = spyk( + CFRPresenter( + context = mockk(), + store = browserStore, + settings = mockk { + every { shouldShowTotalCookieProtectionCFR } returns true + }, + toolbar = mockk() + ) + ) { + every { showTcpCfr() } just Runs + } + + 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 = BrowserStore( + initialState = BrowserState( + tabs = listOf(privateTab), + selectedTabId = privateTab.id + ) + ) + val presenter = spyk( + CFRPresenter( + context = mockk(), + store = browserStore, + settings = mockk { + every { shouldShowTotalCookieProtectionCFR } returns true + }, + toolbar = mockk() + ) + ) { + every { showTcpCfr() } just Runs + } + + 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 = BrowserStore( + initialState = BrowserState( + tabs = listOf(tab), + selectedTabId = tab.id + ) + ) + val presenter = spyk( + CFRPresenter( + context = mockk(), + store = browserStore, + settings = mockk { + every { shouldShowTotalCookieProtectionCFR } returns true + }, + toolbar = mockk(), + ) + ) { + every { showTcpCfr() } just Runs + } + + 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 = CFRPresenter( + context = mockk(), + store = mockk(), + settings = mockk { + every { shouldShowTotalCookieProtectionCFR } returns false + }, + toolbar = mockk(), + ) + + 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 = CFRPresenter( + context = mockk(), + store = mockk(), + settings = mockk(), + toolbar = mockk(), + ) + 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 = CFRPresenter( + context = mockk(relaxed = true), + store = mockk(), + settings = settings, + toolbar = mockk(relaxed = true), + ) + + presenter.showTcpCfr() + + verify { settings.shouldShowTotalCookieProtectionCFR = false } + assertNotNull(presenter.tcpCfr) + } + + @Test + fun `WHEN the TCP CFR is instantiated THEN set the intended properties`() { + val context: Context = mockk { + every { getString(R.string.tcp_cfr_message) } returns "Test" + } + val anchor: View = mockk(relaxed = true) + val toolbar: BrowserToolbar = mockk { + every { findViewById(R.id.mozac_browser_toolbar_security_indicator) } returns anchor + } + val settings: Settings = mockk(relaxed = true) { + every { toolbarPosition } returns ToolbarPosition.BOTTOM + } + val presenter = CFRPresenter( + context = context, + store = mockk(), + settings = settings, + toolbar = toolbar, + ) + + presenter.showTcpCfr() + + verify { settings.shouldShowTotalCookieProtectionCFR = false } + val cfr = presenter.tcpCfr!! + assertNotNull(presenter.tcpCfr) + assertEquals("Test", cfr.text) + assertEquals(anchor, cfr.anchor) + assertEquals(CFRPopup.DEFAULT_WIDTH.dp, cfr.properties.popupWidth) + assertEquals(CFRPopup.IndicatorDirection.DOWN, cfr.properties.indicatorDirection) + assertTrue(cfr.properties.dismissOnBackPress) + assertTrue(cfr.properties.dismissOnClickOutside) + assertFalse(cfr.properties.overlapAnchor) + assertEquals(CFRPopup.DEFAULT_INDICATOR_START_OFFSET.dp, cfr.properties.indicatorArrowStartOffset) + } +} 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 000000000000..279903037aed --- /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() } + } +}