diff --git a/app/build.gradle b/app/build.gradle index 5188725100dc..a70614ac42a3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -429,6 +429,10 @@ dependencies { implementation project(':breakage-reporting-impl') + implementation project(':web-interference-detection-impl') + + implementation project(':web-detection-impl') + implementation project(':dax-prompts-api') implementation project(':dax-prompts-impl') diff --git a/app/src/main/java/com/duckduckgo/app/brokensite/RealBrokenSiteContext.kt b/app/src/main/java/com/duckduckgo/app/brokensite/RealBrokenSiteContext.kt index c76209e52388..83340c746766 100644 --- a/app/src/main/java/com/duckduckgo/app/brokensite/RealBrokenSiteContext.kt +++ b/app/src/main/java/com/duckduckgo/app/brokensite/RealBrokenSiteContext.kt @@ -39,6 +39,8 @@ class RealBrokenSiteContext @Inject constructor( override var jsPerformance: DoubleArray? = null + override var breakageData: String? = null + override fun onUserTriggeredRefresh() { userRefreshCount++ } @@ -69,4 +71,9 @@ class RealBrokenSiteContext @Inject constructor( jsPerformance = recordedJsValues logcat { "jsPerformance recorded as $performanceMetrics" } } + + override fun recordBreakageData(breakageData: String?) { + this.breakageData = breakageData + logcat { "breakageData recorded as $breakageData" } + } } 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 cb59eca48fcc..c0fabe22de32 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 @@ -172,10 +172,12 @@ class BrokenSiteSubmitter @Inject constructor( params[LOGIN_SITE] = brokenSite.loginSite.orEmpty() } - val encodedParams = mapOf( + val encodedParams = mutableMapOf( BLOCKED_TRACKERS_KEY to brokenSite.blockedTrackers, SURROGATES_KEY to brokenSite.surrogates, ) + // breakageData is pre-encoded by content-scope-scripts, add to encodedParams to avoid re-encoding + brokenSite.breakageData?.let { encodedParams[BREAKAGE_DATA] = it } runCatching { if (toggle) { val unnecessaryKeys = listOf(CATEGORY_KEY, DESCRIPTION_KEY, PROTECTIONS_STATE) @@ -242,6 +244,7 @@ class BrokenSiteSubmitter @Inject constructor( private const val BLOCKLIST_EXPERIMENT = "blockListExperiment" private const val CONTENT_SCOPE_EXPERIMENTS = "contentScopeExperiments" private const val DEBUG_FLAGS = "debugFlags" + private const val BREAKAGE_DATA = "breakageData" } } 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 edf437b9d89b..ba53a15d4ee5 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -4315,9 +4315,12 @@ class BrowserTabViewModel @Inject constructor( val referrer = data.get("referrer") as? String val sanitizedReferrer = referrer?.removeSurrounding("\"") val isExternalLaunch = site?.isExternalLaunch ?: false + // breakageData is pre-encoded by content-scope-scripts, pass as-is without re-encoding + val breakageData = data.optString("breakageData").takeIf { it.isNotEmpty() } site?.realBrokenSiteContext?.recordJsPerformance(jsPerformanceData) site?.realBrokenSiteContext?.inferOpenerContext(sanitizedReferrer, isExternalLaunch) + site?.realBrokenSiteContext?.recordBreakageData(breakageData) } fun onHomeShown() { diff --git a/app/src/test/java/com/duckduckgo/app/brokensite/RealBrokenSiteContextTest.kt b/app/src/test/java/com/duckduckgo/app/brokensite/RealBrokenSiteContextTest.kt index 8fb5ccf6d38a..110577d61868 100644 --- a/app/src/test/java/com/duckduckgo/app/brokensite/RealBrokenSiteContextTest.kt +++ b/app/src/test/java/com/duckduckgo/app/brokensite/RealBrokenSiteContextTest.kt @@ -112,4 +112,25 @@ class RealBrokenSiteContextTest { doubleArrayOf(123.45).contentEquals(testee.jsPerformance) } + + @Test + fun whenInitializedThenBreakageDataIsNull() { + assertNull(testee.breakageData) + } + + @Test + fun whenBreakageDataIsRecordedThenItIsStored() { + val preEncodedBreakageData = "%7B%22test%22%3A%22value%22%7D" + + testee.recordBreakageData(preEncodedBreakageData) + + assertEquals(preEncodedBreakageData, testee.breakageData) + } + + @Test + fun whenBreakageDataIsNullThenItRemainsNull() { + testee.recordBreakageData(null) + + assertNull(testee.breakageData) + } } 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 9d9d6fbfeff3..a8b036683175 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 @@ -639,6 +639,34 @@ class BrokenSiteSubmitterTest { assertEquals("flag1,flag2", params["debugFlags"]) } + @Test + fun whenBreakageDataIsNullThenEncodedParamsDoNotContainIt() = runTest { + val brokenSite = getBrokenSite() + + testee.submitBrokenSiteFeedback(brokenSite, toggle = false) + + val encodedParamsCaptor = argumentCaptor>() + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), any(), encodedParamsCaptor.capture(), eq(Count)) + val encodedParams = encodedParamsCaptor.firstValue + + assertFalse(encodedParams.containsKey("breakageData")) + } + + @Test + fun whenBreakageDataExistsThenItIsIncludedInEncodedParams() = runTest { + // Pre-encoded breakage data from content-scope-scripts + val preEncodedBreakageData = "%7B%22test%22%3A%22value%22%7D" + val brokenSite = getBrokenSite().copy(breakageData = preEncodedBreakageData) + + testee.submitBrokenSiteFeedback(brokenSite, toggle = false) + + val encodedParamsCaptor = argumentCaptor>() + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), any(), encodedParamsCaptor.capture(), eq(Count)) + val encodedParams = encodedParamsCaptor.firstValue + + assertEquals(preEncodedBreakageData, encodedParams["breakageData"]) + } + private fun assignToExperiment() { val enrollmentDateET = ZonedDateTime.now(ZoneId.of("America/New_York")).toString() testBlockListFeature.tdsNextExperimentTest().setRawStoredState( @@ -674,6 +702,7 @@ class BrokenSiteSubmitterTest { jsPerformance = null, contentScopeExperiments = null, debugFlags = null, + breakageData = null, ) } 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 a01dc297c991..5335eb7681f4 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 @@ -211,6 +211,7 @@ class BrokenSitesMultipleReportReferenceTest(private val testCase: MultipleRepor jsPerformance = null, contentScopeExperiments = null, debugFlags = null, + breakageData = report.breakageData, ) testee.submitBrokenSiteFeedback(brokenSite, toggle = false) @@ -279,6 +280,7 @@ class BrokenSitesMultipleReportReferenceTest(private val testCase: MultipleRepor val remoteConfigEtag: String?, val remoteConfigVersion: String?, val lastSentDay: String?, + val breakageData: String?, ) data class UrlParam( 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 f0b044717f14..5f0e8edca168 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 @@ -198,6 +198,7 @@ class BrokenSitesReferenceTest(private val testCase: TestCase) { jsPerformance = listOf(123.45), contentScopeExperiments = null, debugFlags = null, + breakageData = testCase.breakageData, ) testee.submitBrokenSiteFeedback(brokenSite, toggle = false) @@ -250,6 +251,7 @@ class BrokenSitesReferenceTest(private val testCase: TestCase) { val consentSelfTestFailed: String, val remoteConfigEtag: String?, val remoteConfigVersion: String?, + val breakageData: String?, ) data class UrlParam( diff --git a/broken-site/broken-site-api/src/main/java/com/duckduckgo/brokensite/api/BrokenSiteSender.kt b/broken-site/broken-site-api/src/main/java/com/duckduckgo/brokensite/api/BrokenSiteSender.kt index de8b5feb143b..dc96c99df307 100644 --- a/broken-site/broken-site-api/src/main/java/com/duckduckgo/brokensite/api/BrokenSiteSender.kt +++ b/broken-site/broken-site-api/src/main/java/com/duckduckgo/brokensite/api/BrokenSiteSender.kt @@ -43,6 +43,7 @@ data class BrokenSite( val jsPerformance: List?, val contentScopeExperiments: List?, val debugFlags: List?, + val breakageData: String?, ) { companion object { const val SITE_TYPE_DESKTOP = "desktop" diff --git a/browser-api/src/main/java/com/duckduckgo/browser/api/brokensite/BrokenSiteContext.kt b/browser-api/src/main/java/com/duckduckgo/browser/api/brokensite/BrokenSiteContext.kt index 5ccd3b919bd2..74a3c159380f 100644 --- a/browser-api/src/main/java/com/duckduckgo/browser/api/brokensite/BrokenSiteContext.kt +++ b/browser-api/src/main/java/com/duckduckgo/browser/api/brokensite/BrokenSiteContext.kt @@ -25,10 +25,13 @@ interface BrokenSiteContext { var jsPerformance: DoubleArray? + var breakageData: String? + fun onUserTriggeredRefresh() fun inferOpenerContext( referrer: String?, wasLaunchedExternally: Boolean, ) fun recordJsPerformance(performanceMetrics: JSONArray) + fun recordBreakageData(breakageData: String?) } diff --git a/browser-api/src/main/java/com/duckduckgo/browser/api/brokensite/BrokenSiteNav.kt b/browser-api/src/main/java/com/duckduckgo/browser/api/brokensite/BrokenSiteNav.kt index e842616a49af..7add8049c964 100644 --- a/browser-api/src/main/java/com/duckduckgo/browser/api/brokensite/BrokenSiteNav.kt +++ b/browser-api/src/main/java/com/duckduckgo/browser/api/brokensite/BrokenSiteNav.kt @@ -37,6 +37,7 @@ data class BrokenSiteData( val userRefreshCount: Int, val openerContext: BrokenSiteOpenerContext?, val jsPerformance: DoubleArray?, + val breakageData: String?, ) { enum class ReportFlow { MENU, DASHBOARD, TOGGLE_DASHBOARD, TOGGLE_MENU, RELOAD_THREE_TIMES_WITHIN_20_SECONDS } @@ -59,6 +60,7 @@ data class BrokenSiteData( val userRefreshCount = site?.realBrokenSiteContext?.userRefreshCount ?: 0 val openerContext = site?.realBrokenSiteContext?.openerContext val jsPerformance = site?.realBrokenSiteContext?.jsPerformance + val breakageData = site?.realBrokenSiteContext?.breakageData return BrokenSiteData( url = url, blockedTrackers = blockedTrackers, @@ -75,6 +77,7 @@ data class BrokenSiteData( userRefreshCount = userRefreshCount, openerContext = openerContext, jsPerformance = jsPerformance, + breakageData = breakageData, ) } } 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 2d9ad411ab7e..9fb056b19959 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 @@ -437,6 +437,7 @@ class PrivacyDashboardHybridViewModel @Inject constructor( userRefreshCount = site.realBrokenSiteContext.userRefreshCount, openerContext = site.realBrokenSiteContext.openerContext?.context, jsPerformance = site.realBrokenSiteContext.jsPerformance?.toList(), + breakageData = site.realBrokenSiteContext.breakageData, contentScopeExperiments = site.activeContentScopeExperiments, debugFlags = site.debugFlags, ) @@ -534,6 +535,7 @@ class PrivacyDashboardHybridViewModel @Inject constructor( userRefreshCount = site.realBrokenSiteContext.userRefreshCount, openerContext = site.realBrokenSiteContext.openerContext?.context, jsPerformance = site.realBrokenSiteContext.jsPerformance?.toList(), + breakageData = site.realBrokenSiteContext.breakageData, contentScopeExperiments = site.activeContentScopeExperiments, debugFlags = site.debugFlags, ) 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 34376986608d..0cbf95025e9d 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 @@ -201,6 +201,7 @@ class PrivacyDashboardHybridViewModelTest { val brokenSiteContext: BrokenSiteContext = mock { brokenSiteContext -> whenever(brokenSiteContext.userRefreshCount).thenReturn(userRefreshCount) whenever(brokenSiteContext.jsPerformance).thenReturn(jsPerformance) + whenever(brokenSiteContext.breakageData).thenReturn(null) } whenever(site.realBrokenSiteContext).thenReturn(brokenSiteContext) } @@ -236,6 +237,7 @@ class PrivacyDashboardHybridViewModelTest { jsPerformance = jsPerformance.toList(), contentScopeExperiments = null, debugFlags = null, + breakageData = null, ) val isToggleReport = false @@ -265,6 +267,7 @@ class PrivacyDashboardHybridViewModelTest { val brokenSiteContext: BrokenSiteContext = mock { brokenSiteContext -> whenever(brokenSiteContext.userRefreshCount).thenReturn(userRefreshCount) whenever(brokenSiteContext.jsPerformance).thenReturn(jsPerformance) + whenever(brokenSiteContext.breakageData).thenReturn(null) } whenever(site.realBrokenSiteContext).thenReturn(brokenSiteContext) } @@ -300,6 +303,7 @@ class PrivacyDashboardHybridViewModelTest { jsPerformance = jsPerformance.toList(), contentScopeExperiments = listOf(mockToggle), debugFlags = null, + breakageData = null, ) val isToggleReport = false @@ -329,6 +333,7 @@ class PrivacyDashboardHybridViewModelTest { val brokenSiteContext: BrokenSiteContext = mock { brokenSiteContext -> whenever(brokenSiteContext.userRefreshCount).thenReturn(userRefreshCount) whenever(brokenSiteContext.jsPerformance).thenReturn(jsPerformance) + whenever(brokenSiteContext.breakageData).thenReturn(null) } whenever(site.realBrokenSiteContext).thenReturn(brokenSiteContext) } @@ -364,6 +369,73 @@ class PrivacyDashboardHybridViewModelTest { jsPerformance = jsPerformance.toList(), contentScopeExperiments = null, debugFlags = debugFlags, + breakageData = null, + ) + + val isToggleReport = false + + verify(brokenSiteSender).submitBrokenSiteFeedback(expectedBrokenSite, isToggleReport) + verify(pixel).fire(REPORT_BROKEN_SITE_SENT, mapOf("opener" to "dashboard"), type = Count) + } + + @Test + fun whenUserClicksOnSubmitReportWithBreakageDataThenSubmitsReportWithBreakageData() = runTest { + val siteUrl = "https://example.com" + val userRefreshCount = 3 + val jsPerformance = doubleArrayOf(1.0, 2.0, 3.0) + val breakageData = "%7B%22test%22%3A%22value%22%7D" + + val site: Site = mock { site -> + whenever(site.uri).thenReturn(siteUrl.toUri()) + whenever(site.url).thenReturn(siteUrl) + whenever(site.userAllowList).thenReturn(true) + whenever(site.isDesktopMode).thenReturn(false) + whenever(site.upgradedHttps).thenReturn(true) + whenever(site.consentManaged).thenReturn(true) + whenever(site.errorCodeEvents).thenReturn(listOf("401", "401", "500")) + whenever(site.activeContentScopeExperiments).thenReturn(null) + whenever(site.debugFlags).thenReturn(null) + + val brokenSiteContext: BrokenSiteContext = mock { brokenSiteContext -> + whenever(brokenSiteContext.userRefreshCount).thenReturn(userRefreshCount) + whenever(brokenSiteContext.jsPerformance).thenReturn(jsPerformance) + whenever(brokenSiteContext.breakageData).thenReturn(breakageData) + } + whenever(site.realBrokenSiteContext).thenReturn(brokenSiteContext) + } + + testee.onSiteChanged(site) + + val category = "login" + val description = "I can't sign in!" + testee.onSubmitBrokenSiteReport( + payload = """{"category":"$category","description":"$description"}""", + reportFlow = DASHBOARD, + opener = DashboardOpener.DASHBOARD, + ) + + val expectedBrokenSite = BrokenSite( + category = category, + description = description, + siteUrl = siteUrl, + upgradeHttps = true, + blockedTrackers = "", + surrogates = "", + siteType = "mobile", + urlParametersRemoved = false, + consentManaged = true, + consentOptOutFailed = false, + consentSelfTestFailed = false, + errorCodes = """["401","401","500"]""", + httpErrorCodes = "", + loginSite = null, + reportFlow = DASHBOARD, + userRefreshCount = userRefreshCount, + openerContext = null, + jsPerformance = jsPerformance.toList(), + contentScopeExperiments = null, + debugFlags = null, + breakageData = breakageData, ) val isToggleReport = false diff --git a/web-detection/web-detection-impl/.gitignore b/web-detection/web-detection-impl/.gitignore new file mode 100644 index 000000000000..796b96d1c402 --- /dev/null +++ b/web-detection/web-detection-impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/web-detection/web-detection-impl/build.gradle b/web-detection/web-detection-impl/build.gradle new file mode 100644 index 000000000000..400423cc3b41 --- /dev/null +++ b/web-detection/web-detection-impl/build.gradle @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 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. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'com.squareup.anvil' +} + +apply from: "$rootProject.projectDir/gradle/android-library.gradle" + +android { + anvil { + generateDaggerFactories = true + } + namespace 'com.duckduckgo.webdetection.impl' +} + +dependencies { + anvil project(path: ':anvil-compiler') + implementation project(path: ':anvil-annotations') + implementation project(path: ':di') + implementation project(path: ':common-utils') + implementation project(path: ':privacy-config-api') + implementation project(path: ':content-scope-scripts-api') + + api AndroidX.dataStore.preferences + + implementation Google.dagger + + implementation KotlinX.coroutines.core + + testImplementation(KotlinX.coroutines.test) { + exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug" + } + + testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation Testing.junit4 +} diff --git a/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/WebDetectionContentScopeConfigPlugin.kt b/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/WebDetectionContentScopeConfigPlugin.kt new file mode 100644 index 000000000000..2bd4c8e5d045 --- /dev/null +++ b/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/WebDetectionContentScopeConfigPlugin.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 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.webdetection.impl + +import com.duckduckgo.contentscopescripts.api.ContentScopeConfigPlugin +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class WebDetectionContentScopeConfigPlugin @Inject constructor( + private val dataStore: WebDetectionDataStore, +) : ContentScopeConfigPlugin { + + override fun config(): String { + val featureName = WebDetectionFeatureName.WebDetection.value + val config = dataStore.getRemoteConfigJson() + return "\"$featureName\":$config" + } + + override fun preferences(): String? { + return null + } +} diff --git a/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/WebDetectionDataStore.kt b/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/WebDetectionDataStore.kt new file mode 100644 index 000000000000..7c6bdccab218 --- /dev/null +++ b/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/WebDetectionDataStore.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 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.webdetection.impl + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Qualifier + +@Qualifier +internal annotation class WebDetection + +interface WebDetectionDataStore { + fun getRemoteConfigJson(): String + fun setRemoteConfigJson(value: String) +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class SharedPreferencesWebDetectionDataStore @Inject constructor( + @WebDetection private val store: DataStore, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, +) : WebDetectionDataStore { + + private object Keys { + val WEB_DETECTION_RC = stringPreferencesKey(name = "WEB_DETECTION_RC") + } + + @Volatile + private var cachedJson: String = EMPTY_JSON + + init { + appCoroutineScope.launch(dispatcherProvider.io()) { + store.data.collect { prefs -> + cachedJson = prefs[Keys.WEB_DETECTION_RC] ?: EMPTY_JSON + } + } + } + + override fun getRemoteConfigJson(): String { + return cachedJson + } + + override fun setRemoteConfigJson(value: String) { + cachedJson = value + appCoroutineScope.launch(dispatcherProvider.io()) { + store.edit { prefs -> prefs[Keys.WEB_DETECTION_RC] = value } + } + } + + companion object { + const val EMPTY_JSON = "{}" + } +} diff --git a/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/WebDetectionFeatureName.kt b/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/WebDetectionFeatureName.kt new file mode 100644 index 000000000000..b0bfebe36504 --- /dev/null +++ b/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/WebDetectionFeatureName.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 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.webdetection.impl + +enum class WebDetectionFeatureName(val value: String) { + WebDetection("webDetection"), +} diff --git a/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/WebDetectionFeatureNameUtil.kt b/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/WebDetectionFeatureNameUtil.kt new file mode 100644 index 000000000000..928e07c04663 --- /dev/null +++ b/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/WebDetectionFeatureNameUtil.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 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.webdetection.impl + +/** + * Convenience method to get the [WebDetectionFeatureName] from its [String] value + */ +fun webDetectionFeatureValueOf(value: String): WebDetectionFeatureName? { + return WebDetectionFeatureName.values().find { it.value == value } +} diff --git a/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/WebDetectionFeaturePlugin.kt b/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/WebDetectionFeaturePlugin.kt new file mode 100644 index 000000000000..9f43af5a369f --- /dev/null +++ b/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/WebDetectionFeaturePlugin.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 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.webdetection.impl + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacy.config.api.PrivacyFeaturePlugin +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class WebDetectionFeaturePlugin @Inject constructor( + private val dataStore: WebDetectionDataStore, +) : PrivacyFeaturePlugin { + + override fun store(featureName: String, jsonString: String): Boolean { + val feature = webDetectionFeatureValueOf(featureName) ?: return false + if (feature.value == this.featureName) { + dataStore.setRemoteConfigJson(jsonString) + return true + } + return false + } + + override val featureName: String = WebDetectionFeatureName.WebDetection.value +} diff --git a/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/di/WebDetectionModule.kt b/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/di/WebDetectionModule.kt new file mode 100644 index 000000000000..47d5bdea0de4 --- /dev/null +++ b/web-detection/web-detection-impl/src/main/java/com/duckduckgo/webdetection/impl/di/WebDetectionModule.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 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.webdetection.impl.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.webdetection.impl.WebDetection +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides + +@Module +@ContributesTo(AppScope::class) +object WebDetectionModule { + + private val Context.webDetectionDataStore: DataStore by preferencesDataStore( + name = "web_detection", + ) + + @Provides + @WebDetection + fun provideWebDetectionDataStore(context: Context): DataStore = context.webDetectionDataStore +} diff --git a/web-detection/web-detection-impl/src/test/java/com/duckduckgo/webdetection/impl/WebDetectionContentScopeConfigPluginTest.kt b/web-detection/web-detection-impl/src/test/java/com/duckduckgo/webdetection/impl/WebDetectionContentScopeConfigPluginTest.kt new file mode 100644 index 000000000000..6d506d49d247 --- /dev/null +++ b/web-detection/web-detection-impl/src/test/java/com/duckduckgo/webdetection/impl/WebDetectionContentScopeConfigPluginTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 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.webdetection.impl + +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class WebDetectionContentScopeConfigPluginTest { + + lateinit var testee: WebDetectionContentScopeConfigPlugin + + private val mockDataStore: WebDetectionDataStore = mock() + + @Before + fun before() { + testee = WebDetectionContentScopeConfigPlugin(mockDataStore) + } + + @Test + fun whenGetConfigThenReturnCorrectlyFormattedJson() { + whenever(mockDataStore.getRemoteConfigJson()).thenReturn(CONFIG) + assertEquals("\"webDetection\":$CONFIG", testee.config()) + } + + @Test + fun whenGetPreferencesThenReturnNull() { + assertNull(testee.preferences()) + } + + companion object { + const val CONFIG = "{\"key\":\"value\"}" + } +} diff --git a/web-detection/web-detection-impl/src/test/java/com/duckduckgo/webdetection/impl/WebDetectionFeaturePluginTest.kt b/web-detection/web-detection-impl/src/test/java/com/duckduckgo/webdetection/impl/WebDetectionFeaturePluginTest.kt new file mode 100644 index 000000000000..5158006767d4 --- /dev/null +++ b/web-detection/web-detection-impl/src/test/java/com/duckduckgo/webdetection/impl/WebDetectionFeaturePluginTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026 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.webdetection.impl + +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +class WebDetectionFeaturePluginTest { + + lateinit var testee: WebDetectionFeaturePlugin + + private val mockDataStore: WebDetectionDataStore = mock() + + @Before + fun before() { + testee = WebDetectionFeaturePlugin(mockDataStore) + } + + @Test + fun whenFeatureNameDoesNotMatchThenReturnFalse() { + WebDetectionFeatureName.values().filter { it != FEATURE_NAME }.forEach { + assertFalse(testee.store(it.value, JSON_STRING)) + } + } + + @Test + fun whenFeatureNameMatchesThenReturnTrue() { + assertTrue(testee.store(FEATURE_NAME_VALUE, JSON_STRING)) + } + + @Test + fun whenFeatureNameMatchesThenStoreJson() { + testee.store(FEATURE_NAME_VALUE, JSON_STRING) + verify(mockDataStore).setRemoteConfigJson(JSON_STRING) + } + + companion object { + private val FEATURE_NAME = WebDetectionFeatureName.WebDetection + private val FEATURE_NAME_VALUE = FEATURE_NAME.value + private const val JSON_STRING = "{\"key\":\"value\"}" + } +} diff --git a/web-interference-detection/web-interference-detection-impl/.gitignore b/web-interference-detection/web-interference-detection-impl/.gitignore new file mode 100644 index 000000000000..796b96d1c402 --- /dev/null +++ b/web-interference-detection/web-interference-detection-impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/web-interference-detection/web-interference-detection-impl/build.gradle b/web-interference-detection/web-interference-detection-impl/build.gradle new file mode 100644 index 000000000000..831f0d3814a2 --- /dev/null +++ b/web-interference-detection/web-interference-detection-impl/build.gradle @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 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. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'com.squareup.anvil' +} + +apply from: "$rootProject.projectDir/gradle/android-library.gradle" + +android { + anvil { + generateDaggerFactories = true // default is false + } + namespace 'com.duckduckgo.webinterferencedetection.impl' +} + +dependencies { + anvil project(path: ':anvil-compiler') + implementation project(path: ':anvil-annotations') + implementation project(path: ':di') + implementation project(path: ':common-utils') + implementation project(path: ':privacy-config-api') + implementation project(path: ':content-scope-scripts-api') + + api AndroidX.dataStore.preferences + + implementation Google.dagger + + implementation KotlinX.coroutines.core + + testImplementation(KotlinX.coroutines.test) { + exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug" + } + + testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation Testing.junit4 +} diff --git a/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionContentScopeConfigPlugin.kt b/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionContentScopeConfigPlugin.kt new file mode 100644 index 000000000000..90243b8beb4e --- /dev/null +++ b/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionContentScopeConfigPlugin.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 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.webinterferencedetection.impl + +import com.duckduckgo.contentscopescripts.api.ContentScopeConfigPlugin +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class WebInterferenceDetectionContentScopeConfigPlugin @Inject constructor( + private val dataStore: WebInterferenceDetectionDataStore, +) : ContentScopeConfigPlugin { + + override fun config(): String { + val featureName = WebInterferenceDetectionFeatureName.WebInterferenceDetection.value + val config = dataStore.getRemoteConfigJson() + return "\"$featureName\":$config" + } + + override fun preferences(): String? { + return null + } +} diff --git a/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionDataStore.kt b/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionDataStore.kt new file mode 100644 index 000000000000..a1e3f6cf936b --- /dev/null +++ b/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionDataStore.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 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.webinterferencedetection.impl + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Qualifier + +@Qualifier +internal annotation class WebInterferenceDetection + +interface WebInterferenceDetectionDataStore { + fun getRemoteConfigJson(): String + fun setRemoteConfigJson(value: String) +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class SharedPreferencesWebInterferenceDetectionDataStore @Inject constructor( + @WebInterferenceDetection private val store: DataStore, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, +) : WebInterferenceDetectionDataStore { + + private object Keys { + val WEB_INTERFERENCE_DETECTION_RC = stringPreferencesKey(name = "WEB_INTERFERENCE_DETECTION_RC") + } + + @Volatile + private var cachedJson: String = EMPTY_JSON + + init { + appCoroutineScope.launch(dispatcherProvider.io()) { + store.data.collect { prefs -> + cachedJson = prefs[Keys.WEB_INTERFERENCE_DETECTION_RC] ?: EMPTY_JSON + } + } + } + + override fun getRemoteConfigJson(): String { + return cachedJson + } + + override fun setRemoteConfigJson(value: String) { + cachedJson = value + appCoroutineScope.launch(dispatcherProvider.io()) { + store.edit { prefs -> prefs[Keys.WEB_INTERFERENCE_DETECTION_RC] = value } + } + } + + companion object { + const val EMPTY_JSON = "{}" + } +} diff --git a/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionFeatureName.kt b/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionFeatureName.kt new file mode 100644 index 000000000000..30acdd2b1b69 --- /dev/null +++ b/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionFeatureName.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 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.webinterferencedetection.impl + +enum class WebInterferenceDetectionFeatureName(val value: String) { + WebInterferenceDetection("webInterferenceDetection"), +} diff --git a/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionFeatureNameUtil.kt b/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionFeatureNameUtil.kt new file mode 100644 index 000000000000..43f9cdf54419 --- /dev/null +++ b/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionFeatureNameUtil.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 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.webinterferencedetection.impl + +/** + * Convenience method to get the [WebInterferenceDetectionFeatureName] from its [String] value + */ +fun webInterferenceDetectionFeatureValueOf(value: String): WebInterferenceDetectionFeatureName? { + return WebInterferenceDetectionFeatureName.values().find { it.value == value } +} diff --git a/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionFeaturePlugin.kt b/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionFeaturePlugin.kt new file mode 100644 index 000000000000..38bc6dc23974 --- /dev/null +++ b/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionFeaturePlugin.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 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.webinterferencedetection.impl + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacy.config.api.PrivacyFeaturePlugin +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class WebInterferenceDetectionFeaturePlugin @Inject constructor( + private val dataStore: WebInterferenceDetectionDataStore, +) : PrivacyFeaturePlugin { + + override fun store(featureName: String, jsonString: String): Boolean { + val feature = webInterferenceDetectionFeatureValueOf(featureName) ?: return false + if (feature.value == this.featureName) { + dataStore.setRemoteConfigJson(jsonString) + return true + } + return false + } + + override val featureName: String = WebInterferenceDetectionFeatureName.WebInterferenceDetection.value +} diff --git a/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/di/WebInterferenceDetectionModule.kt b/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/di/WebInterferenceDetectionModule.kt new file mode 100644 index 000000000000..1b511dbca8a5 --- /dev/null +++ b/web-interference-detection/web-interference-detection-impl/src/main/java/com/duckduckgo/webinterferencedetection/impl/di/WebInterferenceDetectionModule.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 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.webinterferencedetection.impl.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.webinterferencedetection.impl.WebInterferenceDetection +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides + +@Module +@ContributesTo(AppScope::class) +object WebInterferenceDetectionModule { + + private val Context.webInterferenceDetectionDataStore: DataStore by preferencesDataStore( + name = "web_interference_detection", + ) + + @Provides + @WebInterferenceDetection + fun provideWebInterferenceDetectionDataStore(context: Context): DataStore = + context.webInterferenceDetectionDataStore +} diff --git a/web-interference-detection/web-interference-detection-impl/src/test/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionContentScopeConfigPluginTest.kt b/web-interference-detection/web-interference-detection-impl/src/test/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionContentScopeConfigPluginTest.kt new file mode 100644 index 000000000000..b76727d981fb --- /dev/null +++ b/web-interference-detection/web-interference-detection-impl/src/test/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionContentScopeConfigPluginTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 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.webinterferencedetection.impl + +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class WebInterferenceDetectionContentScopeConfigPluginTest { + + lateinit var testee: WebInterferenceDetectionContentScopeConfigPlugin + + private val mockDataStore: WebInterferenceDetectionDataStore = mock() + + @Before + fun before() { + testee = WebInterferenceDetectionContentScopeConfigPlugin(mockDataStore) + } + + @Test + fun whenGetConfigThenReturnCorrectlyFormattedJson() { + whenever(mockDataStore.getRemoteConfigJson()).thenReturn(CONFIG) + assertEquals("\"webInterferenceDetection\":$CONFIG", testee.config()) + } + + @Test + fun whenGetPreferencesThenReturnNull() { + assertNull(testee.preferences()) + } + + companion object { + const val CONFIG = "{\"key\":\"value\"}" + } +} diff --git a/web-interference-detection/web-interference-detection-impl/src/test/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionFeaturePluginTest.kt b/web-interference-detection/web-interference-detection-impl/src/test/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionFeaturePluginTest.kt new file mode 100644 index 000000000000..902a721f5bbe --- /dev/null +++ b/web-interference-detection/web-interference-detection-impl/src/test/java/com/duckduckgo/webinterferencedetection/impl/WebInterferenceDetectionFeaturePluginTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026 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.webinterferencedetection.impl + +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +class WebInterferenceDetectionFeaturePluginTest { + + lateinit var testee: WebInterferenceDetectionFeaturePlugin + + private val mockDataStore: WebInterferenceDetectionDataStore = mock() + + @Before + fun before() { + testee = WebInterferenceDetectionFeaturePlugin(mockDataStore) + } + + @Test + fun whenFeatureNameDoesNotMatchThenReturnFalse() { + WebInterferenceDetectionFeatureName.values().filter { it != FEATURE_NAME }.forEach { + assertFalse(testee.store(it.value, JSON_STRING)) + } + } + + @Test + fun whenFeatureNameMatchesThenReturnTrue() { + assertTrue(testee.store(FEATURE_NAME_VALUE, JSON_STRING)) + } + + @Test + fun whenFeatureNameMatchesThenStoreJson() { + testee.store(FEATURE_NAME_VALUE, JSON_STRING) + verify(mockDataStore).setRemoteConfigJson(JSON_STRING) + } + + companion object { + private val FEATURE_NAME = WebInterferenceDetectionFeatureName.WebInterferenceDetection + private val FEATURE_NAME_VALUE = FEATURE_NAME.value + private const val JSON_STRING = "{\"key\":\"value\"}" + } +}