diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 5b3de950acaf..a486802f961b 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -191,6 +191,7 @@ import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.InstantSchedulersRule import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.device.DeviceInfo +import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.downloads.api.DownloadStateListener import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload @@ -222,6 +223,8 @@ import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc.Companion.GPC_HEADER import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc.Companion.GPC_HEADER_VALUE import com.duckduckgo.privacy.config.store.features.gpc.GpcRepository +import com.duckduckgo.privacy.dashboard.api.PrivacyProtectionTogglePlugin +import com.duckduckgo.privacy.dashboard.api.PrivacyToggleOrigin import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardPixels import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupManager @@ -490,6 +493,8 @@ class BrowserTabViewModelTest { private val mockAutoCompleteRepository: AutoCompleteRepository = mock() private val changeOmnibarPositionFeature: ChangeOmnibarPositionFeature = mock() private val mockHighlightsOnboardingExperimentManager: HighlightsOnboardingExperimentManager = mock() + private val protectionTogglePlugin = FakePrivacyProtectionTogglePlugin() + private val protectionTogglePluginPoint = FakePluginPoint(protectionTogglePlugin) private var fakeAndroidConfigBrowserFeature = FakeFeatureToggleFactory.create(AndroidBrowserConfigFeature::class.java) private val mockAutocompleteTabsFeature: AutocompleteTabsFeature = mock() @@ -662,6 +667,7 @@ class BrowserTabViewModelTest { refreshPixelSender = refreshPixelSender, changeOmnibarPositionFeature = changeOmnibarPositionFeature, highlightsOnboardingExperimentManager = mockHighlightsOnboardingExperimentManager, + privacyProtectionTogglePlugin = protectionTogglePluginPoint, ) testee.loadData("abc", null, false, false) @@ -1999,6 +2005,7 @@ class BrowserTabViewModelTest { testee.onPrivacyProtectionMenuClicked() verify(mockUserAllowListRepository).addDomainToUserAllowList("www.example.com") verify(mockPixel).fire(AppPixelName.BROWSER_MENU_ALLOWLIST_ADD) + assertEquals(1, protectionTogglePlugin.toggleOff) } @Test @@ -2018,6 +2025,7 @@ class BrowserTabViewModelTest { testee.onPrivacyProtectionMenuClicked() verify(mockUserAllowListRepository).removeDomainFromUserAllowList("www.example.com") verify(mockPixel).fire(AppPixelName.BROWSER_MENU_ALLOWLIST_REMOVE) + assertEquals(1, protectionTogglePlugin.toggleOn) } @Test @@ -5349,6 +5357,8 @@ class BrowserTabViewModelTest { verify(mockPixel).fire(AppPixelName.BROWSER_MENU_ALLOWLIST_ADD, params, type = Count) verify(mockPixel).fire(AppPixelName.BROWSER_MENU_ALLOWLIST_REMOVE, params, type = Count) + assertEquals(1, protectionTogglePlugin.toggleOff) + assertEquals(1, protectionTogglePlugin.toggleOn) verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromBrowserMenu(protectionsEnabled = false) verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromBrowserMenu(protectionsEnabled = true) } @@ -6353,4 +6363,22 @@ class BrowserTabViewModelTest { override suspend fun canGeneratePasswordFromWebView(url: String) = enabled override suspend fun canAccessCredentialManagementScreen() = enabled } + + class FakePluginPoint(val plugin: FakePrivacyProtectionTogglePlugin) : PluginPoint { + override fun getPlugins(): Collection { + return listOf(plugin) + } + } + + class FakePrivacyProtectionTogglePlugin : PrivacyProtectionTogglePlugin { + var toggleOff = 0 + var toggleOn = 0 + + override suspend fun onToggleOff(origin: PrivacyToggleOrigin) { + toggleOff++ + } + override suspend fun onToggleOn(origin: PrivacyToggleOrigin) { + toggleOn++ + } + } } diff --git a/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt b/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt index 41a66f1a36a5..3fc40120dbcf 100644 --- a/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt +++ b/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt @@ -23,6 +23,7 @@ import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.app.trackerdetection.blocklist.activeTdsFlag import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.brokensite.api.BrokenSite @@ -40,6 +41,8 @@ import com.duckduckgo.common.utils.extensions.toSanitizedLanguageTag import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.feature.toggles.api.FeatureToggle +import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory +import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.networkprotection.api.NetworkProtectionState import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.privacy.config.api.ContentBlocking @@ -75,6 +78,7 @@ class BrokenSiteSubmitter @Inject constructor( private val networkProtectionState: NetworkProtectionState, private val webViewVersionProvider: WebViewVersionProvider, private val ampLinks: AmpLinks, + private val inventory: FeatureTogglesInventory, ) : BrokenSiteSender { override fun submitBrokenSiteFeedback(brokenSite: BrokenSite) { @@ -97,6 +101,8 @@ class BrokenSiteSubmitter @Inject constructor( val vpnOn = runCatching { networkProtectionState.isRunning() }.getOrNull() val locale = appBuildConfig.deviceLocale.toSanitizedLanguageTag() + val blockListToggle: Toggle? = inventory.activeTdsFlag() + val params = mutableMapOf( CATEGORY_KEY to brokenSite.category.orEmpty(), DESCRIPTION_KEY to brokenSite.description.orEmpty(), @@ -131,6 +137,12 @@ class BrokenSiteSubmitter @Inject constructor( params[REPORT_FLOW] = reportFlow.toStringValue() } + blockListToggle?.let { toggle -> + toggle.getCohort()?.let { cohort -> + params[BLOCKLIST_EXPERIMENT] = "${toggle.featureName().name}_${cohort.name}" + } + } + val lastSentDay = brokenSiteLastSentReport.getLastSentDay(domain.orEmpty()) if (lastSentDay != null) { params[LAST_SENT_DAY] = lastSentDay @@ -201,6 +213,7 @@ class BrokenSiteSubmitter @Inject constructor( private const val USER_REFRESH_COUNT = "userRefreshCount" private const val OPENER_CONTEXT = "openerContext" private const val JS_PERFORMANCE = "jsPerformance" + private const val BLOCKLIST_EXPERIMENT = "blockListExperiment" } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index 689933308bbc..0f86a287c01f 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -266,6 +266,7 @@ import com.duckduckgo.common.utils.baseHost import com.duckduckgo.common.utils.device.DeviceInfo import com.duckduckgo.common.utils.extensions.asLocationPermissionOrigin import com.duckduckgo.common.utils.isMobileSite +import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.common.utils.toDesktopUri import com.duckduckgo.di.scopes.FragmentScope import com.duckduckgo.downloads.api.DownloadCommand @@ -283,6 +284,8 @@ import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.privacy.config.api.ContentBlocking import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.privacy.config.api.TrackingParameters +import com.duckduckgo.privacy.dashboard.api.PrivacyProtectionTogglePlugin +import com.duckduckgo.privacy.dashboard.api.PrivacyToggleOrigin import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardPixels import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupManager @@ -427,6 +430,7 @@ class BrowserTabViewModel @Inject constructor( private val refreshPixelSender: RefreshPixelSender, private val changeOmnibarPositionFeature: ChangeOmnibarPositionFeature, private val highlightsOnboardingExperimentManager: HighlightsOnboardingExperimentManager, + private val privacyProtectionTogglePlugin: PluginPoint, ) : WebViewClientListener, EditSavedSiteListener, DeleteBookmarkListener, @@ -2468,6 +2472,9 @@ class BrowserTabViewModel @Inject constructor( } privacyProtectionsPopupExperimentExternalPixels.tryReportProtectionsToggledFromBrowserMenu(protectionsEnabled = false) userAllowListRepository.addDomainToUserAllowList(domain) + privacyProtectionTogglePlugin.getPlugins().forEach { + it.onToggleOff(PrivacyToggleOrigin.MENU) + } withContext(dispatchers.main()) { command.value = ShowPrivacyProtectionDisabledConfirmation(domain) browserViewState.value = currentBrowserViewState().copy(isPrivacyProtectionDisabled = true) @@ -2486,6 +2493,9 @@ class BrowserTabViewModel @Inject constructor( } privacyProtectionsPopupExperimentExternalPixels.tryReportProtectionsToggledFromBrowserMenu(protectionsEnabled = true) userAllowListRepository.removeDomainFromUserAllowList(domain) + privacyProtectionTogglePlugin.getPlugins().forEach { + it.onToggleOn(PrivacyToggleOrigin.MENU) + } withContext(dispatchers.main()) { command.value = ShowPrivacyProtectionEnabledConfirmation(domain) browserViewState.value = currentBrowserViewState().copy(isPrivacyProtectionDisabled = false) diff --git a/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSender.kt b/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSender.kt index 6984ea7239e3..01ea335fcef2 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSender.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSender.kt @@ -24,6 +24,9 @@ import com.duckduckgo.app.pixels.AppPixelName.RELOAD_TWICE_WITHIN_12_SECONDS import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LOADING_BAR_EXPERIMENT import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily +import com.duckduckgo.app.trackerdetection.blocklist.BlockListPixelsPlugin +import com.duckduckgo.app.trackerdetection.blocklist.get2XRefresh +import com.duckduckgo.app.trackerdetection.blocklist.get3XRefresh import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.extensions.toBinaryString @@ -48,6 +51,7 @@ class DuckDuckGoRefreshPixelSender @Inject constructor( private val dao: RefreshDao, private val loadingBarExperimentManager: LoadingBarExperimentManager, private val currentTimeProvider: CurrentTimeProvider, + private val blockListPixelsPlugin: BlockListPixelsPlugin, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, ) : RefreshPixelSender { @@ -108,9 +112,16 @@ class DuckDuckGoRefreshPixelSender @Inject constructor( if (refreshes.count { it.timestamp >= twelveSecondsAgo } >= 2) { pixel.fire(RELOAD_TWICE_WITHIN_12_SECONDS) + blockListPixelsPlugin.get2XRefresh()?.getPixelDefinitions()?.forEach { + pixel.fire(it.pixelName, it.params) + } } if (refreshes.size >= 3) { pixel.fire(RELOAD_THREE_TIMES_WITHIN_20_SECONDS) + + blockListPixelsPlugin.get3XRefresh()?.getPixelDefinitions()?.forEach { + pixel.fire(it.pixelName, it.params) + } } } } diff --git a/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockList.kt b/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockList.kt new file mode 100644 index 000000000000..e98dc1d630f5 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockList.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.trackerdetection.blocklist + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.trackerdetection.api.TrackerDataDownloader +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.EXPERIMENT_PREFIX +import com.duckduckgo.app.trackerdetection.blocklist.ExperimentTestAA.Cohorts.CONTROL +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.ConversionWindow +import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory +import com.duckduckgo.feature.toggles.api.MetricsPixel +import com.duckduckgo.feature.toggles.api.MetricsPixelPlugin +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName +import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "blockList", +) +interface BlockList { + @Toggle.DefaultValue(false) + fun self(): Toggle + + @Toggle.DefaultValue(false) + fun tdsNextExperimentBaseline(): Toggle + + @Toggle.DefaultValue(false) + fun tdsNextExperimentBaselineBackup(): Toggle + + @Toggle.DefaultValue(false) + fun tdsNextExperimentNov24(): Toggle + + @Toggle.DefaultValue(false) + fun tdsNextExperimentDec24(): Toggle + + @Toggle.DefaultValue(false) + fun tdsNextExperimentJan25(): Toggle + + @Toggle.DefaultValue(false) + fun tdsNextExperimentFeb25(): Toggle + + @Toggle.DefaultValue(false) + fun tdsNextExperimentMar25(): Toggle + + enum class Cohorts(override val cohortName: String) : CohortName { + CONTROL("control"), + TREATMENT("treatment"), + } + + companion object { + const val EXPERIMENT_PREFIX = "tds" + const val TREATMENT_URL = "treatmentUrl" + const val CONTROL_URL = "controlUrl" + const val NEXT_URL = "nextUrl" + } +} + +@ContributesMultibinding(AppScope::class) +class BlockListPixelsPlugin @Inject constructor(private val inventory: FeatureTogglesInventory) : MetricsPixelPlugin { + + override suspend fun getMetrics(): List { + val activeToggle = inventory.activeTdsFlag() ?: return emptyList() + + return listOf( + MetricsPixel( + metric = "2xRefresh", + value = "1", + toggle = activeToggle, + conversionWindow = (0..5).map { ConversionWindow(lowerWindow = 0, upperWindow = it) }, + ), + MetricsPixel( + metric = "3xRefresh", + value = "1", + toggle = activeToggle, + conversionWindow = (0..5).map { ConversionWindow(lowerWindow = 0, upperWindow = it) }, + ), + MetricsPixel( + metric = "privacyToggleUsed", + value = "1", + toggle = activeToggle, + conversionWindow = (0..5).map { ConversionWindow(lowerWindow = 0, upperWindow = it) }, + ), + ) + } +} + +@ContributesMultibinding(AppScope::class) +class BlockListPrivacyConfigCallbackPlugin @Inject constructor( + private val inventory: FeatureTogglesInventory, + private val trackerDataDownloader: TrackerDataDownloader, + @AppCoroutineScope private val coroutineScope: CoroutineScope, + private val experimentAA: ExperimentTestAA, + private val dispatcherProvider: DispatcherProvider, +) : PrivacyConfigCallbackPlugin { + override fun onPrivacyConfigDownloaded() { + coroutineScope.launch(dispatcherProvider.io()) { + experimentAA.experimentTestAA().isEnabled(CONTROL) + inventory.activeTdsFlag()?.let { + trackerDataDownloader.downloadTds() + } + } + } +} + +internal suspend fun BlockListPixelsPlugin.get2XRefresh(): MetricsPixel? { + return this.getMetrics().firstOrNull { it.metric == "2xRefresh" } +} + +suspend fun BlockListPixelsPlugin.get3XRefresh(): MetricsPixel? { + return this.getMetrics().firstOrNull { it.metric == "3xRefresh" } +} + +suspend fun BlockListPixelsPlugin.getPrivacyToggleUsed(): MetricsPixel? { + return this.getMetrics().firstOrNull { it.metric == "privacyToggleUsed" } +} + +suspend fun FeatureTogglesInventory.activeTdsFlag(): Toggle? { + return this.getAllTogglesForParent("blockList").firstOrNull { + it.featureName().name.startsWith(EXPERIMENT_PREFIX) && it.isEnabled() + } +} + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "experimentTest", +) +interface ExperimentTestAA { + @Toggle.DefaultValue(false) + fun self(): Toggle + + @Toggle.DefaultValue(false) + fun experimentTestAA(): Toggle + + enum class Cohorts(override val cohortName: String) : CohortName { + CONTROL("control"), + TREATMENT("treatment"), + } +} diff --git a/app/src/main/java/com/duckduckgo/app/trackerdetection/BlockList.kt b/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListInterceptorApiPlugin.kt similarity index 54% rename from app/src/main/java/com/duckduckgo/app/trackerdetection/BlockList.kt rename to app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListInterceptorApiPlugin.kt index cddd94edca07..b527a9844ede 100644 --- a/app/src/main/java/com/duckduckgo/app/trackerdetection/BlockList.kt +++ b/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListInterceptorApiPlugin.kt @@ -14,21 +14,17 @@ * limitations under the License. */ -package com.duckduckgo.app.trackerdetection +package com.duckduckgo.app.trackerdetection.blocklist -import com.duckduckgo.anvil.annotations.ContributesRemoteFeature import com.duckduckgo.app.global.api.ApiInterceptorPlugin -import com.duckduckgo.app.trackerdetection.BlockList.Cohorts.CONTROL -import com.duckduckgo.app.trackerdetection.BlockList.Cohorts.TREATMENT -import com.duckduckgo.app.trackerdetection.BlockList.Companion.CONTROL_URL -import com.duckduckgo.app.trackerdetection.BlockList.Companion.EXPERIMENT_PREFIX -import com.duckduckgo.app.trackerdetection.BlockList.Companion.NEXT_URL -import com.duckduckgo.app.trackerdetection.BlockList.Companion.TREATMENT_URL import com.duckduckgo.app.trackerdetection.api.TDS_BASE_URL +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Cohorts.CONTROL +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Cohorts.TREATMENT +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.CONTROL_URL +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.NEXT_URL +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.TREATMENT_URL import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory -import com.duckduckgo.feature.toggles.api.Toggle -import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName import com.squareup.anvil.annotations.ContributesMultibinding import javax.inject.Inject import kotlinx.coroutines.runBlocking @@ -36,45 +32,6 @@ import okhttp3.Interceptor import okhttp3.Interceptor.Chain import okhttp3.Response -@ContributesRemoteFeature( - scope = AppScope::class, - featureName = "blockList", -) -interface BlockList { - @Toggle.DefaultValue(false) - fun self(): Toggle - - @Toggle.DefaultValue(false) - fun tdsNextExperimentBaseline(): Toggle - - @Toggle.DefaultValue(false) - fun tdsNextExperimentNov24(): Toggle - - @Toggle.DefaultValue(false) - fun tdsNextExperimentDec24(): Toggle - - @Toggle.DefaultValue(false) - fun tdsNextExperimentJan25(): Toggle - - @Toggle.DefaultValue(false) - fun tdsNextExperimentFeb25(): Toggle - - @Toggle.DefaultValue(false) - fun tdsNextExperimentMar25(): Toggle - - enum class Cohorts(override val cohortName: String) : CohortName { - CONTROL("control"), - TREATMENT("treatment"), - } - - companion object { - const val EXPERIMENT_PREFIX = "tds" - const val TREATMENT_URL = "treatmentUrl" - const val CONTROL_URL = "controlUrl" - const val NEXT_URL = "nextUrl" - } -} - @ContributesMultibinding( scope = AppScope::class, boundType = ApiInterceptorPlugin::class, @@ -90,9 +47,7 @@ class BlockListInterceptorApiPlugin @Inject constructor( return chain.proceed(request.build()) } val activeExperiment = runBlocking { - inventory.getAllTogglesForParent("blockList").firstOrNull { - it.featureName().name.startsWith(EXPERIMENT_PREFIX) && it.isEnabled() - } + inventory.activeTdsFlag() } return activeExperiment?.let { diff --git a/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListPrivacyTogglePlugin.kt b/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListPrivacyTogglePlugin.kt new file mode 100644 index 000000000000..09894db5740a --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListPrivacyTogglePlugin.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.trackerdetection.blocklist + +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacy.dashboard.api.PrivacyProtectionTogglePlugin +import com.duckduckgo.privacy.dashboard.api.PrivacyToggleOrigin +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class BlockListPrivacyTogglePlugin @Inject constructor( + private val blockListPixelsPlugin: BlockListPixelsPlugin, + private val pixel: Pixel, +) : PrivacyProtectionTogglePlugin { + + override suspend fun onToggleOn(origin: PrivacyToggleOrigin) { + // NOOP + } + + override suspend fun onToggleOff(origin: PrivacyToggleOrigin) { + if (origin == PrivacyToggleOrigin.DASHBOARD) { + blockListPixelsPlugin.getPrivacyToggleUsed()?.getPixelDefinitions()?.forEach { + pixel.fire(it.pixelName, it.params) + } + } + } +} diff --git a/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt b/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt index 666277f35099..8159ac10d9ba 100644 --- a/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt +++ b/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt @@ -1,5 +1,6 @@ package com.duckduckgo.app.brokensite.api +import android.annotation.SuppressLint import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.brokensite.BrokenSiteViewModel import com.duckduckgo.app.pixels.AppPixelName.BROKEN_SITE_REPORT @@ -9,6 +10,11 @@ import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Cohorts.TREATMENT +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.CONTROL_URL +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.TREATMENT_URL +import com.duckduckgo.app.trackerdetection.blocklist.FakeFeatureTogglesInventory +import com.duckduckgo.app.trackerdetection.blocklist.TestBlockListFeature import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.brokensite.api.BrokenSite @@ -21,7 +27,12 @@ import com.duckduckgo.browser.api.brokensite.BrokenSiteOpenerContext.NAVIGATION import com.duckduckgo.browser.api.brokensite.BrokenSiteOpenerContext.SERP import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.experiments.api.VariantManager +import com.duckduckgo.feature.toggles.api.FakeToggleStore import com.duckduckgo.feature.toggles.api.FeatureToggle +import com.duckduckgo.feature.toggles.api.FeatureToggles +import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.feature.toggles.impl.RealFeatureTogglesInventory import com.duckduckgo.networkprotection.api.NetworkProtectionState import com.duckduckgo.privacy.config.api.AmpLinkInfo import com.duckduckgo.privacy.config.api.AmpLinks @@ -32,6 +43,9 @@ import com.duckduckgo.privacy.config.api.PrivacyConfigData import com.duckduckgo.privacy.config.api.PrivacyFeatureName import com.duckduckgo.privacy.config.api.UnprotectedTemporary import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit import java.util.* import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope @@ -49,6 +63,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) +@SuppressLint("DenyListedApi") class BrokenSiteSubmitterTest { @get:Rule var coroutineRule = CoroutineTestRule() @@ -87,6 +102,9 @@ class BrokenSiteSubmitterTest { private val ampLinks: AmpLinks = mock() + private lateinit var testBlockListFeature: TestBlockListFeature + private lateinit var inventory: FeatureTogglesInventory + private lateinit var testee: BrokenSiteSubmitter @Before @@ -103,6 +121,23 @@ class BrokenSiteSubmitterTest { whenever(mockPrivacyConfig.privacyConfigData()).thenReturn(PrivacyConfigData(version = "v", eTag = "e")) runBlocking { whenever(networkProtectionState.isRunning()) }.thenReturn(false) + testBlockListFeature = FeatureToggles.Builder( + FakeToggleStore(), + featureName = "blockList", + ).build().create(TestBlockListFeature::class.java) + + inventory = RealFeatureTogglesInventory( + setOf( + FakeFeatureTogglesInventory( + features = listOf( + testBlockListFeature.tdsNextExperimentTest(), + testBlockListFeature.tdsNextExperimentAnotherTest(), + ), + ), + ), + coroutineRule.testDispatcherProvider, + ) + testee = BrokenSiteSubmitter( mockStatisticsDataStore, mockVariantManager, @@ -122,6 +157,7 @@ class BrokenSiteSubmitterTest { networkProtectionState, webViewVersionProvider, ampLinks, + inventory, ) } @@ -551,6 +587,33 @@ class BrokenSiteSubmitterTest { assertEquals(TRACKING_URL, paramsCaptor.lastValue[Pixel.PixelParameter.URL]) } + @Test + fun whenSubmitReportAndBlockListExperimentActiveThenAddParameter() { + assignToExperiment() + val brokenSite = getBrokenSite() + + testee.submitBrokenSiteFeedback(brokenSite) + + val paramsCaptor = argumentCaptor>() + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), parameters = paramsCaptor.capture(), any(), eq(Count)) + assertEquals("tdsNextExperimentTest_treatment", paramsCaptor.lastValue["blockListExperiment"]) + } + + private fun assignToExperiment() { + val enrollmentDateET = ZonedDateTime.now(ZoneId.of("America/New_York")).truncatedTo(ChronoUnit.DAYS).toString() + testBlockListFeature.tdsNextExperimentTest().setRawStoredState( + State( + remoteEnableState = true, + enable = true, + config = mapOf( + TREATMENT_URL to "treatmentUrl", + CONTROL_URL to "controlUrl", + ), + assignedCohort = State.Cohort(name = TREATMENT.cohortName, weight = 1, enrollmentDateET = enrollmentDateET), + ), + ) + } + private fun getBrokenSite(): BrokenSite { return BrokenSite( category = "category", diff --git a/app/src/test/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSenderTest.kt b/app/src/test/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSenderTest.kt index cfda951d5a71..9f6f3d428571 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSenderTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSenderTest.kt @@ -1,5 +1,6 @@ package com.duckduckgo.app.browser.refreshpixels +import android.annotation.SuppressLint import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry @@ -9,9 +10,26 @@ import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LOADING_BAR_EXPERIMENT import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Cohorts.TREATMENT +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.CONTROL_URL +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.TREATMENT_URL +import com.duckduckgo.app.trackerdetection.blocklist.BlockListPixelsPlugin +import com.duckduckgo.app.trackerdetection.blocklist.FakeFeatureTogglesInventory +import com.duckduckgo.app.trackerdetection.blocklist.TestBlockListFeature +import com.duckduckgo.app.trackerdetection.blocklist.get2XRefresh +import com.duckduckgo.app.trackerdetection.blocklist.get3XRefresh import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager +import com.duckduckgo.feature.toggles.api.FakeToggleStore +import com.duckduckgo.feature.toggles.api.FeatureToggles +import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.feature.toggles.impl.RealFeatureTogglesInventory +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import junit.framework.TestCase.assertNull import junit.framework.TestCase.assertTrue import kotlinx.coroutines.test.runTest import org.junit.After @@ -26,6 +44,7 @@ import org.mockito.Mockito.verify import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) +@SuppressLint("DenyListedApi") class RefreshPixelSenderTest { @get:Rule @@ -36,11 +55,33 @@ class RefreshPixelSenderTest { private val mockPixel: Pixel = mock() private val mockLoadingBarExperimentManager: LoadingBarExperimentManager = mock() private val mockCurrentTimeProvider: CurrentTimeProvider = mock() + private lateinit var testBlockListFeature: TestBlockListFeature + private lateinit var inventory: FeatureTogglesInventory + private lateinit var blockListPixelsPlugin: BlockListPixelsPlugin private lateinit var testee: DuckDuckGoRefreshPixelSender @Before fun setUp() { + testBlockListFeature = FeatureToggles.Builder( + FakeToggleStore(), + featureName = "blockList", + ).build().create(TestBlockListFeature::class.java) + + inventory = RealFeatureTogglesInventory( + setOf( + FakeFeatureTogglesInventory( + features = listOf( + testBlockListFeature.tdsNextExperimentTest(), + testBlockListFeature.tdsNextExperimentAnotherTest(), + ), + ), + ), + coroutineTestRule.testDispatcherProvider, + ) + + blockListPixelsPlugin = BlockListPixelsPlugin(inventory) + db = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java) .allowMainThreadQueries() .build() @@ -56,6 +97,7 @@ class RefreshPixelSenderTest { currentTimeProvider = mockCurrentTimeProvider, appCoroutineScope = coroutineTestRule.testScope, dispatcherProvider = coroutineTestRule.testDispatcherProvider, + blockListPixelsPlugin = BlockListPixelsPlugin(inventory), ) } @@ -183,6 +225,7 @@ class RefreshPixelSenderTest { loadingBarExperimentManager = mockLoadingBarExperimentManager, currentTimeProvider = mockCurrentTimeProvider, appCoroutineScope = coroutineTestRule.testScope, + blockListPixelsPlugin = blockListPixelsPlugin, dispatcherProvider = coroutineTestRule.testDispatcherProvider, ) @@ -201,6 +244,7 @@ class RefreshPixelSenderTest { loadingBarExperimentManager = mockLoadingBarExperimentManager, currentTimeProvider = mockCurrentTimeProvider, appCoroutineScope = coroutineTestRule.testScope, + blockListPixelsPlugin = blockListPixelsPlugin, dispatcherProvider = coroutineTestRule.testDispatcherProvider, ) @@ -219,6 +263,7 @@ class RefreshPixelSenderTest { loadingBarExperimentManager = mockLoadingBarExperimentManager, currentTimeProvider = mockCurrentTimeProvider, appCoroutineScope = coroutineTestRule.testScope, + blockListPixelsPlugin = blockListPixelsPlugin, dispatcherProvider = coroutineTestRule.testDispatcherProvider, ) @@ -243,9 +288,21 @@ class RefreshPixelSenderTest { verify(mockPixel).fire(AppPixelName.RELOAD_TWICE_WITHIN_12_SECONDS) verify(mockPixel, never()).fire(AppPixelName.RELOAD_THREE_TIMES_WITHIN_20_SECONDS) + assertNull(blockListPixelsPlugin.get2XRefresh()) assertTrue(refreshDao.all().size == 2) } + @Test + fun whenRefreshedTwiceAndAssignedToExperimentThen2XRefreshPixelsFired() = runTest { + assignToExperiment() + testee.sendMenuRefreshPixels() + testee.sendMenuRefreshPixels() + + blockListPixelsPlugin.get2XRefresh()!!.getPixelDefinitions().forEach { + verify(mockPixel).fire(it.pixelName, it.params) + } + } + @Test fun whenRefreshedThreeTimesThenReloadTwicePixelFiredTwiceAndReloadThricePixelFired() = runTest { testee.sendMenuRefreshPixels() @@ -254,9 +311,22 @@ class RefreshPixelSenderTest { verify(mockPixel, times(2)).fire(AppPixelName.RELOAD_TWICE_WITHIN_12_SECONDS) verify(mockPixel).fire(AppPixelName.RELOAD_THREE_TIMES_WITHIN_20_SECONDS) + assertNull(blockListPixelsPlugin.get3XRefresh()) assertTrue(refreshDao.all().size == 3) } + @Test + fun whenRefreshedThreeTimesAndAssignedToExperimentThen3XRefreshPixelsFired() = runTest { + assignToExperiment() + testee.sendMenuRefreshPixels() + testee.sendMenuRefreshPixels() + testee.sendMenuRefreshPixels() + + blockListPixelsPlugin.get3XRefresh()!!.getPixelDefinitions().forEach { + verify(mockPixel).fire(it.pixelName, it.params) + } + } + @Test fun whenSendTimeBasedPixelsAndNoRecentRefreshesThenNoPixelsFired() = runTest { refreshDao.insert(RefreshEntity(timestamp = CURRENT_TIME - 75000)) @@ -311,6 +381,21 @@ class RefreshPixelSenderTest { assertTrue(refreshDao.all().size == 3) } + private fun assignToExperiment() { + val enrollmentDateET = ZonedDateTime.now(ZoneId.of("America/New_York")).truncatedTo(ChronoUnit.DAYS).toString() + testBlockListFeature.tdsNextExperimentTest().setRawStoredState( + State( + remoteEnableState = true, + enable = true, + config = mapOf( + TREATMENT_URL to "treatmentUrl", + CONTROL_URL to "controlUrl", + ), + assignedCohort = State.Cohort(name = TREATMENT.cohortName, weight = 1, enrollmentDateET = enrollmentDateET), + ), + ) + } + companion object { private const val CURRENT_TIME = 100000L } diff --git a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt index 36ca03a39377..fae896f87ae3 100644 --- a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt +++ b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt @@ -23,6 +23,8 @@ import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.app.trackerdetection.blocklist.FakeFeatureTogglesInventory +import com.duckduckgo.app.trackerdetection.blocklist.TestBlockListFeature import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.brokensite.api.BrokenSite @@ -32,7 +34,11 @@ import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities import com.duckduckgo.experiments.api.VariantManager +import com.duckduckgo.feature.toggles.api.FakeToggleStore import com.duckduckgo.feature.toggles.api.FeatureToggle +import com.duckduckgo.feature.toggles.api.FeatureToggles +import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory +import com.duckduckgo.feature.toggles.impl.RealFeatureTogglesInventory import com.duckduckgo.networkprotection.api.NetworkProtectionState import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.privacy.config.api.PrivacyConfig @@ -94,6 +100,9 @@ class BrokenSitesMultipleReportReferenceTest(private val testCase: MultipleRepor private val webViewVersionProvider: WebViewVersionProvider = mock() + private lateinit var testBlockListFeature: TestBlockListFeature + private lateinit var inventory: FeatureTogglesInventory + private lateinit var testee: BrokenSiteSubmitter companion object { @@ -121,6 +130,23 @@ class BrokenSitesMultipleReportReferenceTest(private val testCase: MultipleRepor MockitoAnnotations.openMocks(this) runBlocking { whenever(networkProtectionState.isRunning()) }.thenReturn(false) + testBlockListFeature = FeatureToggles.Builder( + FakeToggleStore(), + featureName = "blockList", + ).build().create(TestBlockListFeature::class.java) + + inventory = RealFeatureTogglesInventory( + setOf( + FakeFeatureTogglesInventory( + features = listOf( + testBlockListFeature.tdsNextExperimentTest(), + testBlockListFeature.tdsNextExperimentAnotherTest(), + ), + ), + ), + coroutineRule.testDispatcherProvider, + ) + testee = BrokenSiteSubmitter( mockStatisticsDataStore, mockVariantManager, @@ -140,6 +166,7 @@ class BrokenSitesMultipleReportReferenceTest(private val testCase: MultipleRepor networkProtectionState, webViewVersionProvider, ampLinks = mock(), + inventory, ) } diff --git a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt index e51aa891e919..d095ed0f015b 100644 --- a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt +++ b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt @@ -25,6 +25,8 @@ import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.app.trackerdetection.blocklist.FakeFeatureTogglesInventory +import com.duckduckgo.app.trackerdetection.blocklist.TestBlockListFeature import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.brokensite.api.BrokenSite @@ -34,7 +36,11 @@ import com.duckduckgo.browser.api.brokensite.BrokenSiteOpenerContext.SERP import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities import com.duckduckgo.experiments.api.VariantManager +import com.duckduckgo.feature.toggles.api.FakeToggleStore import com.duckduckgo.feature.toggles.api.FeatureToggle +import com.duckduckgo.feature.toggles.api.FeatureToggles +import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory +import com.duckduckgo.feature.toggles.impl.RealFeatureTogglesInventory import com.duckduckgo.networkprotection.api.NetworkProtectionState import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.privacy.config.api.PrivacyConfig @@ -92,6 +98,8 @@ class BrokenSitesReferenceTest(private val testCase: TestCase) { } private val webViewVersionProvider: WebViewVersionProvider = mock() + private lateinit var testBlockListFeature: TestBlockListFeature + private lateinit var inventory: FeatureTogglesInventory private lateinit var testee: BrokenSiteSubmitter @@ -119,6 +127,23 @@ class BrokenSitesReferenceTest(private val testCase: TestCase) { whenever(mockAppBuildConfig.deviceLocale).thenReturn(Locale.ENGLISH) runBlocking { whenever(networkProtectionState.isRunning()) }.thenReturn(false) + testBlockListFeature = FeatureToggles.Builder( + FakeToggleStore(), + featureName = "blockList", + ).build().create(TestBlockListFeature::class.java) + + inventory = RealFeatureTogglesInventory( + setOf( + FakeFeatureTogglesInventory( + features = listOf( + testBlockListFeature.tdsNextExperimentTest(), + testBlockListFeature.tdsNextExperimentAnotherTest(), + ), + ), + ), + coroutineRule.testDispatcherProvider, + ) + testee = BrokenSiteSubmitter( mockStatisticsDataStore, mockVariantManager, @@ -138,6 +163,7 @@ class BrokenSitesReferenceTest(private val testCase: TestCase) { networkProtectionState, webViewVersionProvider, ampLinks = mock(), + inventory, ) } diff --git a/app/src/test/java/com/duckduckgo/app/trackerdetection/BlockListInterceptorApiPluginTest.kt b/app/src/test/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListInterceptorApiPluginTest.kt similarity index 77% rename from app/src/test/java/com/duckduckgo/app/trackerdetection/BlockListInterceptorApiPluginTest.kt rename to app/src/test/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListInterceptorApiPluginTest.kt index 2d33b9545451..b66eb433fb93 100644 --- a/app/src/test/java/com/duckduckgo/app/trackerdetection/BlockListInterceptorApiPluginTest.kt +++ b/app/src/test/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListInterceptorApiPluginTest.kt @@ -1,14 +1,30 @@ -package com.duckduckgo.app.trackerdetection +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.trackerdetection.blocklist import android.annotation.SuppressLint import com.duckduckgo.anvil.annotations.ContributesRemoteFeature -import com.duckduckgo.app.trackerdetection.BlockList.Cohorts.CONTROL -import com.duckduckgo.app.trackerdetection.BlockList.Cohorts.TREATMENT -import com.duckduckgo.app.trackerdetection.BlockList.Companion.CONTROL_URL -import com.duckduckgo.app.trackerdetection.BlockList.Companion.NEXT_URL -import com.duckduckgo.app.trackerdetection.BlockList.Companion.TREATMENT_URL import com.duckduckgo.app.trackerdetection.api.TDS_BASE_URL import com.duckduckgo.app.trackerdetection.api.TDS_PATH +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Cohorts.CONTROL +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Cohorts.TREATMENT +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.CONTROL_URL +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.NEXT_URL +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.TREATMENT_URL import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.api.FakeChain import com.duckduckgo.feature.toggles.api.FakeToggleStore @@ -30,23 +46,23 @@ class BlockListInterceptorApiPluginTest { @Suppress("unused") val coroutineRule = CoroutineTestRule() - private lateinit var testFeature: TestFeature + private lateinit var testBlockListFeature: TestBlockListFeature private lateinit var inventory: FeatureTogglesInventory private lateinit var interceptor: BlockListInterceptorApiPlugin @Before fun setup() { - testFeature = FeatureToggles.Builder( + testBlockListFeature = FeatureToggles.Builder( FakeToggleStore(), featureName = "blockList", - ).build().create(TestFeature::class.java) + ).build().create(TestBlockListFeature::class.java) inventory = RealFeatureTogglesInventory( setOf( FakeFeatureTogglesInventory( features = listOf( - testFeature.tdsNextExperimentTest(), - testFeature.tdsNextExperimentAnotherTest(), + testBlockListFeature.tdsNextExperimentTest(), + testBlockListFeature.tdsNextExperimentAnotherTest(), ), ), ), @@ -58,7 +74,7 @@ class BlockListInterceptorApiPluginTest { @Test fun `when multiple experiments enabled, use the first one`() { - testFeature.tdsNextExperimentTest().setRawStoredState( + testBlockListFeature.tdsNextExperimentTest().setRawStoredState( State( remoteEnableState = true, enable = true, @@ -72,7 +88,7 @@ class BlockListInterceptorApiPluginTest { ), ), ) - testFeature.tdsNextExperimentAnotherTest().setRawStoredState( + testBlockListFeature.tdsNextExperimentAnotherTest().setRawStoredState( State( remoteEnableState = true, enable = true, @@ -94,7 +110,7 @@ class BlockListInterceptorApiPluginTest { @Test fun `when cohort is treatment use treatment URL`() { - testFeature.tdsNextExperimentAnotherTest().setRawStoredState( + testBlockListFeature.tdsNextExperimentAnotherTest().setRawStoredState( State( remoteEnableState = true, enable = true, @@ -116,7 +132,7 @@ class BlockListInterceptorApiPluginTest { @Test fun `when cohort is control use control URL`() { - testFeature.tdsNextExperimentAnotherTest().setRawStoredState( + testBlockListFeature.tdsNextExperimentAnotherTest().setRawStoredState( State( remoteEnableState = true, enable = true, @@ -138,7 +154,7 @@ class BlockListInterceptorApiPluginTest { @Test fun `when feature is for next URL rollout then use next url`() { - testFeature.tdsNextExperimentAnotherTest().setRawStoredState( + testBlockListFeature.tdsNextExperimentAnotherTest().setRawStoredState( State( remoteEnableState = true, enable = true, @@ -162,7 +178,7 @@ class BlockListInterceptorApiPluginTest { @Test fun `when feature name doesn't match prefix, it is ignored`() { - testFeature.nonMatchingFeatureName().setRawStoredState( + testBlockListFeature.nonMatchingFeatureName().setRawStoredState( State( remoteEnableState = true, enable = true, @@ -197,7 +213,7 @@ abstract class TriggerTestScope private constructor() scope = TriggerTestScope::class, featureName = "blockList", ) -interface TestFeature { +interface TestBlockListFeature { @DefaultValue(false) fun self(): Toggle diff --git a/app/src/test/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListPrivacyTogglePluginTest.kt b/app/src/test/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListPrivacyTogglePluginTest.kt new file mode 100644 index 000000000000..15aa52ba4bc6 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListPrivacyTogglePluginTest.kt @@ -0,0 +1,99 @@ +package com.duckduckgo.app.trackerdetection.blocklist + +import android.annotation.SuppressLint +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Cohorts.TREATMENT +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.CONTROL_URL +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.TREATMENT_URL +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeToggleStore +import com.duckduckgo.feature.toggles.api.FeatureToggles +import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.feature.toggles.impl.RealFeatureTogglesInventory +import com.duckduckgo.privacy.dashboard.api.PrivacyToggleOrigin.BREAKAGE_FORM +import com.duckduckgo.privacy.dashboard.api.PrivacyToggleOrigin.DASHBOARD +import com.duckduckgo.privacy.dashboard.api.PrivacyToggleOrigin.MENU +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.verify +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions + +@SuppressLint("DenyListedApi") +class BlockListPrivacyTogglePluginTest { + + @get:Rule + var coroutineRule = CoroutineTestRule() + + private val pixel: Pixel = mock() + private lateinit var testBlockListFeature: TestBlockListFeature + private lateinit var inventory: FeatureTogglesInventory + private lateinit var blockListPixelsPlugin: BlockListPixelsPlugin + private lateinit var blockListPrivacyTogglePlugin: BlockListPrivacyTogglePlugin + + @Before + fun setUp() { + testBlockListFeature = FeatureToggles.Builder( + FakeToggleStore(), + featureName = "blockList", + ).build().create(TestBlockListFeature::class.java) + + inventory = RealFeatureTogglesInventory( + setOf( + FakeFeatureTogglesInventory( + features = listOf( + testBlockListFeature.tdsNextExperimentTest(), + testBlockListFeature.tdsNextExperimentAnotherTest(), + ), + ), + ), + coroutineRule.testDispatcherProvider, + ) + + blockListPixelsPlugin = BlockListPixelsPlugin(inventory) + blockListPrivacyTogglePlugin = BlockListPrivacyTogglePlugin(blockListPixelsPlugin, pixel) + } + + @Test + fun `when toggle is off and assigned to experiment and origin dashboard then send pixels`() = runTest { + assignToExperiment() + + blockListPrivacyTogglePlugin.onToggleOff(DASHBOARD) + + blockListPixelsPlugin.getPrivacyToggleUsed()!!.getPixelDefinitions().forEach { + verify(pixel).fire(it.pixelName, it.params) + } + } + + @Test + fun `when toggle is off and assigned to experiment and origin is not dashboard then do not send pixels`() = runTest { + assignToExperiment() + + blockListPrivacyTogglePlugin.onToggleOff(MENU) + verifyNoInteractions(pixel) + + blockListPrivacyTogglePlugin.onToggleOff(BREAKAGE_FORM) + verifyNoInteractions(pixel) + } + + private fun assignToExperiment() { + val enrollmentDateET = ZonedDateTime.now(ZoneId.of("America/New_York")).truncatedTo(ChronoUnit.DAYS).toString() + testBlockListFeature.tdsNextExperimentTest().setRawStoredState( + State( + remoteEnableState = true, + enable = true, + config = mapOf( + TREATMENT_URL to "treatmentUrl", + CONTROL_URL to "controlUrl", + ), + assignedCohort = State.Cohort(name = TREATMENT.cohortName, weight = 1, enrollmentDateET = enrollmentDateET), + ), + ) + } +} diff --git a/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/MetricPixelInterceptor.kt b/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/MetricPixelInterceptor.kt index db13e5d9657c..ea8e66711a0b 100644 --- a/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/MetricPixelInterceptor.kt +++ b/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/MetricPixelInterceptor.kt @@ -113,7 +113,7 @@ class MetricPixelInterceptor @Inject constructor( logcat { "Pixel URL request dropped: ${chain.request()}" } return Response.Builder() - .code(200) + .code(500) .protocol(Protocol.HTTP_2) .body("Experiment metrics pixel dropped".toResponseBody()) .message("Dropped experiment metrics pixel") diff --git a/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/MetricsPixelStore.kt b/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/MetricsPixelStore.kt index 78b583142863..e2081089063e 100644 --- a/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/MetricsPixelStore.kt +++ b/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/MetricsPixelStore.kt @@ -43,7 +43,7 @@ interface MetricsPixelStore { /** * Increases the count of searches for the [featureName] passed as parameter */ - fun increaseMetricForPixelDefinition(definition: PixelDefinition, metric: RetentionMetric) + suspend fun increaseMetricForPixelDefinition(definition: PixelDefinition, metric: RetentionMetric): Int /** * Returns the number [Int] of app use for the given [featureName] @@ -86,13 +86,16 @@ class RealMetricsPixelStore @Inject constructor( } } - override fun increaseMetricForPixelDefinition(definition: PixelDefinition, metric: RetentionMetric) { - val tag = "${definition}_$metric" - coroutineScope.launch(dispatcherProvider.io()) { + override suspend fun increaseMetricForPixelDefinition(definition: PixelDefinition, metric: RetentionMetric) = + withContext(dispatcherProvider.io()) { + val tag = "${definition}_$metric" val count = preferences.getInt(tag, 0) - preferences.edit { putInt(tag, count + 1) } + preferences.edit { + putInt(tag, count + 1) + apply() + } + preferences.getInt(tag, 0) } - } override suspend fun getMetricForPixelDefinition(definition: PixelDefinition, metric: RetentionMetric): Int { val tag = "${definition}_$metric" diff --git a/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/metrics/RetentionMetricsAtbLifecyclePlugin.kt b/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/metrics/RetentionMetricsAtbLifecyclePlugin.kt index c2f8188c0ac5..9f5b9eeecb65 100644 --- a/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/metrics/RetentionMetricsAtbLifecyclePlugin.kt +++ b/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/metrics/RetentionMetricsAtbLifecyclePlugin.kt @@ -20,10 +20,15 @@ import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.PixelDefinition import com.duckduckgo.feature.toggles.impl.MetricsPixelStore import com.duckduckgo.feature.toggles.impl.RetentionMetric.APP_USE import com.duckduckgo.feature.toggles.impl.RetentionMetric.SEARCH import com.squareup.anvil.annotations.ContributesMultibinding +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -41,10 +46,12 @@ class RetentionMetricsAtbLifecyclePlugin @Inject constructor( appCoroutineScope.launch { searchMetricPixelsPlugin.getMetrics().forEach { metric -> metric.getPixelDefinitions().forEach { definition -> - store.increaseMetricForPixelDefinition(definition, SEARCH) - val searches = store.getMetricForPixelDefinition(definition, SEARCH) - if (searches == metric.value.toInt()) { - pixel.fire(definition.pixelName, definition.params) + if (isInConversionWindow(definition)) { + store.getMetricForPixelDefinition(definition, SEARCH).takeIf { it < metric.value.toInt() }?.let { + store.increaseMetricForPixelDefinition(definition, SEARCH).takeIf { it == metric.value.toInt() }?.apply { + pixel.fire(definition.pixelName, definition.params) + } + } } } } @@ -55,13 +62,31 @@ class RetentionMetricsAtbLifecyclePlugin @Inject constructor( appCoroutineScope.launch { appUseMetricPixelsPlugin.getMetrics().forEach { metric -> metric.getPixelDefinitions().forEach { definition -> - store.increaseMetricForPixelDefinition(definition, APP_USE) - val appUse = store.getMetricForPixelDefinition(definition, APP_USE) - if (appUse == metric.value.toInt()) { - pixel.fire(definition.pixelName, definition.params) + if (isInConversionWindow(definition)) { + store.getMetricForPixelDefinition(definition, APP_USE).takeIf { it < metric.value.toInt() }?.let { + store.increaseMetricForPixelDefinition(definition, APP_USE).takeIf { it == metric.value.toInt() }?.apply { + pixel.fire(definition.pixelName, definition.params) + } + } } } } } } + + private fun isInConversionWindow(definition: PixelDefinition): Boolean { + val enrollmentDate = definition.params["enrollmentDate"] ?: return false + val lowerWindow = definition.params["conversionWindowDays"]?.split("-")?.first()?.toInt() ?: return false + val upperWindow = definition.params["conversionWindowDays"]?.split("-")?.last()?.toInt() ?: return false + val daysDiff = daysBetweenTodayAnd(enrollmentDate) + + return (daysDiff in lowerWindow..upperWindow) + } + + private fun daysBetweenTodayAnd(date: String): Long { + val today = ZonedDateTime.now(ZoneId.of("America/New_York")) + val localDate = LocalDate.parse(date) + val zoneDateTime: ZonedDateTime = localDate.atStartOfDay(ZoneId.of("America/New_York")) + return ChronoUnit.DAYS.between(zoneDateTime, today) + } } diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/MetricPixelInterceptorTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/MetricPixelInterceptorTest.kt index 804f10629d06..f655063d4505 100644 --- a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/MetricPixelInterceptorTest.kt +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/MetricPixelInterceptorTest.kt @@ -312,10 +312,11 @@ class FakeStore : MetricsPixelStore { list.add(tag) } - override fun increaseMetricForPixelDefinition(definition: PixelDefinition, metric: RetentionMetric) { + override suspend fun increaseMetricForPixelDefinition(definition: PixelDefinition, metric: RetentionMetric): Int { val tag = "${definition}_$metric" val count = metrics.getOrDefault(tag, 0) metrics[tag] = count + 1 + return metrics[tag]!! } override suspend fun getMetricForPixelDefinition(definition: PixelDefinition, metric: RetentionMetric): Int { diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/metrics/RetentionMetricsAtbLifecyclePluginTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/metrics/RetentionMetricsAtbLifecyclePluginTest.kt index 4b5f61a102b8..4b3fc51e46cd 100644 --- a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/metrics/RetentionMetricsAtbLifecyclePluginTest.kt +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/metrics/RetentionMetricsAtbLifecyclePluginTest.kt @@ -32,8 +32,10 @@ import com.duckduckgo.feature.toggles.impl.RealFeatureTogglesInventory import com.duckduckgo.feature.toggles.impl.RetentionMetric import java.time.ZoneId import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -87,42 +89,37 @@ class RetentionMetricsAtbLifecyclePluginTest { } @Test - fun `when search atb refreshed and matches metric, pixel sent to all active experiments`() = runTest { - setCohorts() + fun `when search atb refreshed and matches metric and conversion window, pixel sent to all active experiments`() = runTest { + val today = ZonedDateTime.now(ZoneId.of("America/New_York")).truncatedTo(ChronoUnit.DAYS).toString() + val parsedDate: String = ZonedDateTime.parse(today).format(DateTimeFormatter.ISO_LOCAL_DATE).toString() + + setCohorts(today) atbLifecyclePlugin.onSearchRetentionAtbRefreshed("", "") - searchMetricPixelsPlugin.getMetrics().forEach { metric -> - metric.getPixelDefinitions().forEach { definition -> - if (metric.value == "1") { - assertTrue(pixel.firedPixels.contains("${definition.pixelName}${definition.params}")) - } else { - assertFalse(pixel.firedPixels.contains("${definition.pixelName}${definition.params}")) - } - } - } + val pixel1 = "experiment_metrics_experimentFooFeature_control{metric=search, value=1, enrollmentDate=$parsedDate, conversionWindowDays=0}" + val pixel2 = "experiment_metrics_fooFeature_control{metric=search, value=1, enrollmentDate=$parsedDate, conversionWindowDays=0}" + + assertEquals(2, pixel.firedPixels.size) + assertTrue(pixel.firedPixels.contains(pixel1)) + assertTrue(pixel.firedPixels.contains(pixel2)) } @Test - fun `when app use atb refreshed and matches metric, pixel sent to all active experiments`() = runTest { - setCohorts() + fun `when app use atb refreshed and no metric match conversion window, do not send pixels`() = runTest { + val today = ZonedDateTime.now(ZoneId.of("America/New_York")).truncatedTo(ChronoUnit.DAYS).toString() + setCohorts(today) atbLifecyclePlugin.onAppRetentionAtbRefreshed("", "") - - appUseMetricPixelsPlugin.getMetrics().forEach { metric -> - metric.getPixelDefinitions().forEach { definition -> - if (metric.value == "1") { - assertTrue(pixel.firedPixels.contains("${definition.pixelName}${definition.params}")) - } else { - assertFalse(pixel.firedPixels.contains("${definition.pixelName}${definition.params}")) - } - } - } + assertTrue(pixel.firedPixels.isEmpty()) } @Test - fun `when search atb refreshed, fire all pixels which metric matches the number of searches done`() = runTest { - setCohorts() + fun `when search atb refreshed, fire all pixels which metric matches the number of searches done and conversion window`() = runTest { + val today = ZonedDateTime.now(ZoneId.of("America/New_York")).minusDays(6).truncatedTo(ChronoUnit.DAYS).toString() + val parsedDate: String = ZonedDateTime.parse(today).format(DateTimeFormatter.ISO_LOCAL_DATE).toString() + + setCohorts(today) searchMetricPixelsPlugin.getMetrics().forEach { metric -> metric.getPixelDefinitions().forEach { definition -> @@ -133,20 +130,20 @@ class RetentionMetricsAtbLifecyclePluginTest { atbLifecyclePlugin.onSearchRetentionAtbRefreshed("", "") - searchMetricPixelsPlugin.getMetrics().forEach { metric -> - metric.getPixelDefinitions().forEach { definition -> - if (metric.value == "4") { - assertTrue(pixel.firedPixels.contains("${definition.pixelName}${definition.params}")) - } else { - assertFalse(pixel.firedPixels.contains("${definition.pixelName}${definition.params}")) - } - } - } + val pixel1 = "experiment_metrics_experimentFooFeature_control{metric=search, value=4, enrollmentDate=$parsedDate, conversionWindowDays=5-7}" + val pixel2 = "experiment_metrics_fooFeature_control{metric=search, value=4, enrollmentDate=$parsedDate, conversionWindowDays=5-7}" + + assertEquals(2, pixel.firedPixels.size) + assertTrue(pixel.firedPixels.contains(pixel1)) + assertTrue(pixel.firedPixels.contains(pixel2)) } @Test - fun `when app use atb refreshed, fire all pixels which metric matches the number of app use`() = runTest { - setCohorts() + fun `when app use atb refreshed, fire all pixels which metric matches the number of app use and conversion window`() = runTest { + val today = ZonedDateTime.now(ZoneId.of("America/New_York")).minusDays(6).truncatedTo(ChronoUnit.DAYS).toString() + val parsedDate: String = ZonedDateTime.parse(today).format(DateTimeFormatter.ISO_LOCAL_DATE).toString() + + setCohorts(today) appUseMetricPixelsPlugin.getMetrics().forEach { metric -> metric.getPixelDefinitions().forEach { definition -> @@ -157,15 +154,12 @@ class RetentionMetricsAtbLifecyclePluginTest { atbLifecyclePlugin.onAppRetentionAtbRefreshed("", "") - appUseMetricPixelsPlugin.getMetrics().forEach { metric -> - metric.getPixelDefinitions().forEach { definition -> - if (metric.value == "4") { - assertTrue(pixel.firedPixels.contains("${definition.pixelName}${definition.params}")) - } else { - assertFalse(pixel.firedPixels.contains("${definition.pixelName}${definition.params}")) - } - } - } + val pixel1 = "experiment_metrics_experimentFooFeature_control{metric=app_use, value=4, enrollmentDate=$parsedDate, conversionWindowDays=5-7}" + val pixel2 = "experiment_metrics_fooFeature_control{metric=app_use, value=4, enrollmentDate=$parsedDate, conversionWindowDays=5-7}" + + assertEquals(2, pixel.firedPixels.size) + assertTrue(pixel.firedPixels.contains(pixel1)) + assertTrue(pixel.firedPixels.contains(pixel2)) } @Test @@ -189,6 +183,7 @@ class RetentionMetricsAtbLifecyclePluginTest { atbLifecyclePlugin.onSearchRetentionAtbRefreshed("", "") assertTrue(pixel.firedPixels.none { it.contains("experimentFooFeature") }) + assertFalse(pixel.firedPixels.none { it.contains("fooFeature") }) } @Test @@ -212,10 +207,60 @@ class RetentionMetricsAtbLifecyclePluginTest { atbLifecyclePlugin.onSearchRetentionAtbRefreshed("", "") assertTrue(pixel.firedPixels.none { it.contains("experimentFooFeature") }) + assertFalse(pixel.firedPixels.none { it.contains("fooFeature") }) } - private fun setCohorts() { - val today = ZonedDateTime.now(ZoneId.of("America/New_York")).truncatedTo(ChronoUnit.DAYS).toString() + @Test + fun `when app use refreshed, only fire pixels with active experiments`() = runTest { + val today = ZonedDateTime.now(ZoneId.of("America/New_York")).minusDays(1).truncatedTo(ChronoUnit.DAYS).toString() + + testFeature.experimentFooFeature().setRawStoredState( + State( + remoteEnableState = false, + enable = false, + assignedCohort = State.Cohort(name = "control", weight = 1, enrollmentDateET = today), + ), + ) + testFeature.fooFeature().setRawStoredState( + State( + remoteEnableState = true, + enable = true, + assignedCohort = State.Cohort(name = "control", weight = 1, enrollmentDateET = today), + ), + ) + + atbLifecyclePlugin.onAppRetentionAtbRefreshed("", "") + + assertTrue(pixel.firedPixels.none { it.contains("experimentFooFeature") }) + assertFalse(pixel.firedPixels.none { it.contains("fooFeature") }) + } + + @Test + fun `when app use refreshed, only fire pixels from experiments with cohorts assigned`() = runTest { + val today = ZonedDateTime.now(ZoneId.of("America/New_York")).minusDays(1).truncatedTo(ChronoUnit.DAYS).toString() + + testFeature.experimentFooFeature().setRawStoredState( + State( + remoteEnableState = true, + enable = true, + assignedCohort = null, + ), + ) + testFeature.fooFeature().setRawStoredState( + State( + remoteEnableState = true, + enable = true, + assignedCohort = State.Cohort(name = "control", weight = 1, enrollmentDateET = today), + ), + ) + + atbLifecyclePlugin.onAppRetentionAtbRefreshed("", "") + + assertTrue(pixel.firedPixels.none { it.contains("experimentFooFeature") }) + assertFalse(pixel.firedPixels.none { it.contains("fooFeature") }) + } + + private fun setCohorts(today: String) { testFeature.experimentFooFeature().setRawStoredState( State( remoteEnableState = true, diff --git a/privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/PrivacyProtectionTogglePlugin.kt b/privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/PrivacyProtectionTogglePlugin.kt new file mode 100644 index 000000000000..68d1fd922a19 --- /dev/null +++ b/privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/PrivacyProtectionTogglePlugin.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.privacy.dashboard.api + +interface PrivacyProtectionTogglePlugin { + /** + * Executed when the privacy toggle is switched on. It receives the [PrivacyToggleOrigin]. + */ + suspend fun onToggleOn(origin: PrivacyToggleOrigin) + + /** + * Executed when the privacy toggle is switched off. It receives the [PrivacyToggleOrigin]. + */ + suspend fun onToggleOff(origin: PrivacyToggleOrigin) +} + +enum class PrivacyToggleOrigin { + MENU, + DASHBOARD, + BREAKAGE_FORM, +} diff --git a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/di/PrivacyProtectionTogglePluginPoint.kt b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/di/PrivacyProtectionTogglePluginPoint.kt new file mode 100644 index 000000000000..dff940417aa5 --- /dev/null +++ b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/di/PrivacyProtectionTogglePluginPoint.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.privacy.dashboard.impl.di + +import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacy.dashboard.api.PrivacyProtectionTogglePlugin + +@ContributesPluginPoint( + scope = AppScope::class, + boundType = PrivacyProtectionTogglePlugin::class, +) +@Suppress("unused") +internal interface PrivacyProtectionTogglePluginPoint diff --git a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModel.kt b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModel.kt index df9070fc6212..4f51a92f510b 100644 --- a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModel.kt +++ b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModel.kt @@ -35,7 +35,10 @@ import com.duckduckgo.browser.api.brokensite.BrokenSiteData import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.DASHBOARD import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.baseHost +import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.privacy.dashboard.api.PrivacyProtectionTogglePlugin +import com.duckduckgo.privacy.dashboard.api.PrivacyToggleOrigin import com.duckduckgo.privacy.dashboard.impl.WebBrokenSiteFormFeature import com.duckduckgo.privacy.dashboard.impl.isEnabled import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardCustomTabPixelNames.CUSTOM_TABS_PRIVACY_DASHBOARD_ALLOW_LIST_ADD @@ -92,6 +95,7 @@ class PrivacyDashboardHybridViewModel @Inject constructor( private val webBrokenSiteFormFeature: WebBrokenSiteFormFeature, private val brokenSiteSender: BrokenSiteSender, private val moshi: Moshi, + private val privacyProtectionTogglePlugin: PluginPoint, ) : ViewModel() { private val command = Channel(1, DROP_OLDEST) @@ -331,7 +335,17 @@ class PrivacyDashboardHybridViewModel @Inject constructor( BREAKAGE_FORM -> BROKEN_SITE_ALLOWLIST_REMOVE else -> null } - pixelName?.let { pixel.fire(it, pixelParams, type = Count) } + pixelName?.let { + pixel.fire(it, pixelParams, type = Count) + val origin = if (it == PRIVACY_DASHBOARD_ALLOWLIST_REMOVE) { + PrivacyToggleOrigin.DASHBOARD + } else { + PrivacyToggleOrigin.BREAKAGE_FORM + } + privacyProtectionTogglePlugin.getPlugins().forEach { plugin -> + plugin.onToggleOn(origin) + } + } } } else { userAllowListRepository.addDomainToUserAllowList(domain) @@ -345,7 +359,17 @@ class PrivacyDashboardHybridViewModel @Inject constructor( BREAKAGE_FORM -> BROKEN_SITE_ALLOWLIST_ADD else -> null } - pixelName?.let { pixel.fire(it, pixelParams, type = Count) } + pixelName?.let { it -> + pixel.fire(it, pixelParams, type = Count) + val origin = if (it == PRIVACY_DASHBOARD_ALLOWLIST_ADD) { + PrivacyToggleOrigin.DASHBOARD + } else { + PrivacyToggleOrigin.BREAKAGE_FORM + } + privacyProtectionTogglePlugin.getPlugins().forEach { plugin -> + plugin.onToggleOff(origin) + } + } } } privacyProtectionsPopupExperimentExternalPixels.tryReportProtectionsToggledFromPrivacyDashboard(event.isProtected) diff --git a/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModelTest.kt b/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModelTest.kt index 0a42401bc897..8fd00c879301 100644 --- a/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModelTest.kt +++ b/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModelTest.kt @@ -33,10 +33,13 @@ import com.duckduckgo.brokensite.api.ReportFlow.DASHBOARD import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.browser.api.brokensite.BrokenSiteContext import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.privacy.config.api.ContentBlocking import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import com.duckduckgo.privacy.dashboard.api.PrivacyProtectionTogglePlugin +import com.duckduckgo.privacy.dashboard.api.PrivacyToggleOrigin import com.duckduckgo.privacy.dashboard.impl.WebBrokenSiteFormFeature import com.duckduckgo.privacy.dashboard.impl.di.JsonModule import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardCustomTabPixelNames @@ -95,6 +98,8 @@ class PrivacyDashboardHybridViewModelTest { private val webBrokenSiteFormFeature = FakeFeatureToggleFactory.create(WebBrokenSiteFormFeature::class.java) private val brokenSiteSender: BrokenSiteSender = mock() + private val protectionTogglePlugin = FakePrivacyProtectionTogglePlugin() + private val pluginPoint = FakePluginPoint(protectionTogglePlugin) private val testee: PrivacyDashboardHybridViewModel by lazy { PrivacyDashboardHybridViewModel( @@ -111,6 +116,7 @@ class PrivacyDashboardHybridViewModelTest { userBrowserProperties = mockUserBrowserProperties, webBrokenSiteFormFeature = webBrokenSiteFormFeature, brokenSiteSender = brokenSiteSender, + privacyProtectionTogglePlugin = pluginPoint, moshi = Moshi.Builder().build(), ) } @@ -211,9 +217,11 @@ class PrivacyDashboardHybridViewModelTest { verify(pixel).fire(PRIVACY_DASHBOARD_OPENED, params, type = Count) verify(privacyProtectionsPopupExperimentExternalPixels).tryReportPrivacyDashboardOpened() verify(pixel).fire(PRIVACY_DASHBOARD_ALLOWLIST_ADD, params, type = Count) + assertEquals(1, protectionTogglePlugin.toggleOff) verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromPrivacyDashboard(protectionsEnabled = false) verify(pixel).fire(PRIVACY_DASHBOARD_ALLOWLIST_REMOVE, params, type = Count) verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromPrivacyDashboard(protectionsEnabled = true) + assertEquals(1, protectionTogglePlugin.toggleOn) } @Test @@ -332,6 +340,7 @@ class PrivacyDashboardHybridViewModelTest { advanceUntilIdle() verify(pixel).fire(BROKEN_SITE_ALLOWLIST_ADD) verify(pixel, never()).fire(PRIVACY_DASHBOARD_ALLOWLIST_ADD) + assertEquals(1, protectionTogglePlugin.toggleOff) } @Test @@ -341,6 +350,7 @@ class PrivacyDashboardHybridViewModelTest { advanceUntilIdle() verify(pixel).fire(BROKEN_SITE_ALLOWLIST_REMOVE) verify(pixel, never()).fire(PRIVACY_DASHBOARD_ALLOWLIST_REMOVE) + assertEquals(1, protectionTogglePlugin.toggleOn) } @Test @@ -350,6 +360,7 @@ class PrivacyDashboardHybridViewModelTest { advanceUntilIdle() verify(pixel).fire(PRIVACY_DASHBOARD_ALLOWLIST_ADD) verify(pixel, never()).fire(BROKEN_SITE_ALLOWLIST_ADD) + assertEquals(1, protectionTogglePlugin.toggleOff) } @Test @@ -359,6 +370,7 @@ class PrivacyDashboardHybridViewModelTest { advanceUntilIdle() verify(pixel).fire(PRIVACY_DASHBOARD_ALLOWLIST_REMOVE) verify(pixel, never()).fire(BROKEN_SITE_ALLOWLIST_REMOVE) + assertEquals(1, protectionTogglePlugin.toggleOn) } private fun site( @@ -397,3 +409,21 @@ private class FakeUserAllowListRepository : UserAllowListRepository { override suspend fun removeDomainFromUserAllowList(domain: String) = domains.update { it - domain } } + +class FakePluginPoint(val plugin: FakePrivacyProtectionTogglePlugin) : PluginPoint { + override fun getPlugins(): Collection { + return listOf(plugin) + } +} + +class FakePrivacyProtectionTogglePlugin : PrivacyProtectionTogglePlugin { + var toggleOff = 0 + var toggleOn = 0 + + override suspend fun onToggleOff(origin: PrivacyToggleOrigin) { + toggleOff++ + } + override suspend fun onToggleOn(origin: PrivacyToggleOrigin) { + toggleOn++ + } +}