diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt index 5f7459b7f692..a4fb7687bb9a 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -61,7 +61,6 @@ import com.duckduckgo.app.browser.trafficquality.AndroidFeaturesHeaderPlugin import com.duckduckgo.app.browser.trafficquality.CustomHeaderAllowedChecker import com.duckduckgo.app.browser.trafficquality.remote.AndroidFeaturesHeaderProvider import com.duckduckgo.app.browser.uriloaded.UriLoadedManager -import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.autoconsent.api.Autoconsent @@ -73,7 +72,6 @@ import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.device.DeviceInfo import com.duckduckgo.common.utils.plugins.PluginPoint -import com.duckduckgo.contentscopescripts.api.contentscopeExperiments.ContentScopeExperiments import com.duckduckgo.cookies.api.CookieManagerProvider import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.duckplayer.api.DuckPlayer @@ -112,6 +110,8 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever +private val mockToggle: Toggle = mock() + class BrowserWebViewClientTest { @get:Rule @@ -166,15 +166,12 @@ class BrowserWebViewClientTest { mock(), ) private val mockDuckChat: DuckChat = mock() - private val mockContentScopeExperiments: ContentScopeExperiments = mock() @UiThreadTest @Before fun setup() = runTest { webView = TestWebView(context) whenever(mockDuckPlayer.observeShouldOpenInNewTab()).thenReturn(openInNewTabFlow) - val toggle: Toggle = mock() - whenever(mockContentScopeExperiments.getActiveExperiments()).thenReturn(listOf(toggle)) testee = BrowserWebViewClient( webViewHttpAuthStore, trustedCertificateStore, @@ -207,7 +204,6 @@ class BrowserWebViewClientTest { mockUriLoadedManager, mockAndroidFeaturesHeaderPlugin, mockDuckChat, - mockContentScopeExperiments, ) testee.webViewClientListener = listener whenever(webResourceRequest.url).thenReturn(Uri.EMPTY) @@ -227,11 +223,8 @@ class BrowserWebViewClientTest { @UiThreadTest @Test fun whenOnPageStartedCalledThenListenerNotified() = runTest { - val toggle: Toggle = mock() - whenever(mockContentScopeExperiments.getActiveExperiments()).thenReturn(listOf(toggle)) - testee.onPageStarted(webView, EXAMPLE_URL, null) - verify(listener).pageStarted(any(), eq(listOf(toggle))) + verify(listener).pageStarted(any(), eq(listOf(mockToggle))) } @UiThreadTest @@ -1194,16 +1187,24 @@ class BrowserWebViewClientTest { var countFinished = 0 var countStarted = 0 - override fun onPageStarted( + override suspend fun onInit( + webView: WebView, + ) { + } + + override suspend fun onPageStarted( webView: WebView, url: String?, isDesktopMode: Boolean?, - activeExperiments: List, - ) { + ): List { countStarted++ + return listOf(mockToggle) } - override fun onPageFinished(webView: WebView, url: String?, site: Site?) { + override suspend fun onPageFinished( + webView: WebView, + url: String?, + ) { countFinished++ } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index c50bc30984eb..ffce07c3ad9e 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -3006,6 +3006,7 @@ class BrowserTabFragment : webView?.let { it.isSafeWebViewEnabled = safeWebViewFeature.self().isEnabled() it.webViewClient = webViewClient + webViewClient.triggerJSInit(it) it.webChromeClient = webChromeClient it.clearSslPreferences() diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt index b8c0efffa165..c97299371093 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -70,7 +70,6 @@ import com.duckduckgo.common.utils.AppUrl.ParamKey.QUERY import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.plugins.PluginPoint -import com.duckduckgo.contentscopescripts.api.contentscopeExperiments.ContentScopeExperiments import com.duckduckgo.cookies.api.CookieManagerProvider import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.duckplayer.api.DuckPlayer @@ -125,7 +124,6 @@ class BrowserWebViewClient @Inject constructor( private val uriLoadedManager: UriLoadedManager, private val androidFeaturesHeaderPlugin: AndroidFeaturesHeaderPlugin, private val duckChat: DuckChat, - private val contentScopeExperiments: ContentScopeExperiments, ) : WebViewClient() { var webViewClientListener: WebViewClientListener? = null @@ -441,10 +439,10 @@ class BrowserWebViewClient @Inject constructor( val navigationList = webView.safeCopyBackForwardList() ?: return appCoroutineScope.launch(dispatcherProvider.main()) { - val activeExperiments = contentScopeExperiments.getActiveExperiments() - webViewClientListener?.pageStarted(WebViewNavigationState(navigationList), activeExperiments) - jsPlugins.getPlugins().forEach { - it.onPageStarted(webView, url, webViewClientListener?.getSite()?.isDesktopMode, activeExperiments) + jsPlugins.getPlugins().map { + it.onPageStarted(webView, url, webViewClientListener?.getSite()?.isDesktopMode) + }.flatten().distinct().let { activeExperiments -> + webViewClientListener?.pageStarted(WebViewNavigationState(navigationList), activeExperiments) } } if (url != null && url == lastPageStarted) { @@ -463,14 +461,28 @@ class BrowserWebViewClient @Inject constructor( webView.settings.mediaPlaybackRequiresUserGesture = mediaPlayback.doesMediaPlaybackRequireUserGestureForUrl(url) } + fun triggerJSInit(webView: WebView) { + appCoroutineScope.launch { + jsPlugins.getPlugins().forEach { + it.onInit(webView) + } + } + } + + // TODO check new API @UiThread override fun onPageFinished(webView: WebView, url: String?) { logcat(VERBOSE) { "onPageFinished webViewUrl: ${webView.url} URL: $url progress: ${webView.progress}" } // See https://app.asana.com/0/0/1206159443951489/f (WebView limitations) if (webView.progress == 100) { - jsPlugins.getPlugins().forEach { - it.onPageFinished(webView, url, webViewClientListener?.getSite()) + appCoroutineScope.launch { + jsPlugins.getPlugins().forEach { + it.onPageFinished( + webView, + url, + ) + } } url?.let { diff --git a/browser-api/src/main/java/com/duckduckgo/browser/api/JsInjectorPlugin.kt b/browser-api/src/main/java/com/duckduckgo/browser/api/JsInjectorPlugin.kt index d4bef9d25c26..30a6283dd79f 100644 --- a/browser-api/src/main/java/com/duckduckgo/browser/api/JsInjectorPlugin.kt +++ b/browser-api/src/main/java/com/duckduckgo/browser/api/JsInjectorPlugin.kt @@ -17,23 +17,31 @@ package com.duckduckgo.browser.api import android.webkit.WebView -import com.duckduckgo.app.global.model.Site import com.duckduckgo.feature.toggles.api.Toggle /** Public interface to inject JS code to a website */ interface JsInjectorPlugin { + /** + * On init of webview this is called and receives a [webView] instance. + */ + suspend fun onInit( + webView: WebView, + ) + /** * This method is called during onPageStarted and receives a [webView] instance, the [url] of the website and the [site] */ - fun onPageStarted( + suspend fun onPageStarted( webView: WebView, url: String?, isDesktopMode: Boolean?, - activeExperiments: List = listOf(), - ) + ): List /** * This method is called during onPageFinished and receives a [webView] instance, the [url] of the website and the [site] */ - fun onPageFinished(webView: WebView, url: String?, site: Site?) + suspend fun onPageFinished( + webView: WebView, + url: String?, + ) } diff --git a/content-scope-scripts/content-scope-scripts-impl/build.gradle b/content-scope-scripts/content-scope-scripts-impl/build.gradle index bdb1aafdc8cd..d39852a7b777 100644 --- a/content-scope-scripts/content-scope-scripts-impl/build.gradle +++ b/content-scope-scripts/content-scope-scripts-impl/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation project(':js-messaging-api') implementation project(':duckplayer-api') implementation project(':data-store-api') + implementation AndroidX.webkit anvil project(':anvil-compiler') implementation project(':anvil-annotations') diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeJSReader.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeJSReader.kt index f8b7875093f3..90244eeb0a85 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeJSReader.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeJSReader.kt @@ -21,26 +21,49 @@ import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn import java.io.BufferedReader import javax.inject.Inject +import javax.inject.Named interface ContentScopeJSReader { fun getContentScopeJS(): String } -@SingleInstanceIn(AppScope::class) -@ContributesBinding(AppScope::class) -class RealContentScopeJSReader @Inject constructor() : ContentScopeJSReader { +abstract class GenericContentScopeJSReader { + abstract val fileName: String + private lateinit var contentScopeJS: String - override fun getContentScopeJS(): String { + protected fun getContentScopeJSFile(): String { if (!this::contentScopeJS.isInitialized) { - contentScopeJS = loadJs("contentScope.js") + contentScopeJS = readResource(fileName).use { it?.readText() }.orEmpty() } return contentScopeJS } - fun loadJs(resourceName: String): String = readResource(resourceName).use { it?.readText() }.orEmpty() - private fun readResource(resourceName: String): BufferedReader? { return javaClass.classLoader?.getResource(resourceName)?.openStream()?.bufferedReader() } } + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class, boundType = ContentScopeJSReader::class) +@Named("contentScope") +class RealContentScopeJSReader @Inject constructor() : GenericContentScopeJSReader(), ContentScopeJSReader { + override val fileName: String + get() = "contentScope.js" + + override fun getContentScopeJS(): String { + return getContentScopeJSFile() + } +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class, boundType = ContentScopeJSReader::class) +@Named("adsJS") +class AdsContentScopeJSReader @Inject constructor() : GenericContentScopeJSReader(), ContentScopeJSReader { + override val fileName: String + get() = "adsjsContentScope.js" + + override fun getContentScopeJS(): String { + return getContentScopeJSFile() + } +} diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsFeature.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsFeature.kt index 6e4b232d6b79..7914ce29d899 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsFeature.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsFeature.kt @@ -29,4 +29,7 @@ import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue interface ContentScopeScriptsFeature { @Toggle.DefaultValue(DefaultFeatureValue.TRUE) fun self(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.TRUE) + fun useNewWebCompatApis(): Toggle } diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPlugin.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPlugin.kt index e8d4bc874df8..9c0faf271d51 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPlugin.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPlugin.kt @@ -16,30 +16,78 @@ package com.duckduckgo.contentscopescripts.impl +import android.annotation.SuppressLint import android.webkit.WebView -import com.duckduckgo.app.global.model.Site +import androidx.webkit.ScriptHandler import com.duckduckgo.browser.api.JsInjectorPlugin +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.contentscopescripts.api.contentscopeExperiments.ContentScopeExperiments import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.feature.toggles.api.Toggle import com.squareup.anvil.annotations.ContributesMultibinding import javax.inject.Inject +import kotlinx.coroutines.withContext @ContributesMultibinding(AppScope::class) class ContentScopeScriptsJsInjectorPlugin @Inject constructor( private val coreContentScopeScripts: CoreContentScopeScripts, + private val adsJsContentScopeScripts: AdsJsContentScopeScripts, + private val contentScopeExperiments: ContentScopeExperiments, + private val dispatcherProvider: DispatcherProvider, + private val webViewCompatWrapper: WebViewCompatWrapper, ) : JsInjectorPlugin { - override fun onPageStarted( + private var script: ScriptHandler? = null + private var currentScriptString: String? = null + + private var activeExperiments: List = emptyList() + + @SuppressLint("RequiresFeature") + private suspend fun reloadJSIfNeeded( + webView: WebView, + ) { + activeExperiments = withContext(dispatcherProvider.io()) { contentScopeExperiments.getActiveExperiments() } + + withContext(dispatcherProvider.main()) { + if (!webViewCompatWrapper.isDocumentStartScriptSupported()) { + return@withContext + } + val scriptString = adsJsContentScopeScripts.getScript(activeExperiments) + if (scriptString == currentScriptString) { + return@withContext + } + script?.let { + it.remove() + script = null + } + if (adsJsContentScopeScripts.isEnabled()) { + currentScriptString = scriptString + script = webViewCompatWrapper.addDocumentStartJavaScript(webView, scriptString, setOf("*")) + } + } + } + + override suspend fun onInit( + webView: WebView, + ) { + reloadJSIfNeeded(webView) + } + + override suspend fun onPageStarted( webView: WebView, url: String?, isDesktopMode: Boolean?, - activeExperiments: List, - ) { + ): List { if (coreContentScopeScripts.isEnabled()) { webView.evaluateJavascript("javascript:${coreContentScopeScripts.getScript(isDesktopMode, activeExperiments)}", null) + return activeExperiments } + return listOf() } - override fun onPageFinished(webView: WebView, url: String?, site: Site?) { - // NOOP + override suspend fun onPageFinished( + webView: WebView, + url: String?, + ) { + reloadJSIfNeeded(webView) } } diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/RealAdsJsContentScopeScripts.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/RealAdsJsContentScopeScripts.kt new file mode 100644 index 000000000000..651e61c09884 --- /dev/null +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/RealAdsJsContentScopeScripts.kt @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2022 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.contentscopescripts.impl + +import com.duckduckgo.app.privacy.db.UserAllowListRepository +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.contentscopescripts.api.ContentScopeConfigPlugin +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.FeatureException +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.fingerprintprotection.api.FingerprintProtectionManager +import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi.Builder +import com.squareup.moshi.Types +import dagger.SingleInstanceIn +import java.util.* +import java.util.concurrent.CopyOnWriteArrayList +import javax.inject.Inject +import javax.inject.Named +import kotlinx.coroutines.runBlocking + +interface AdsJsContentScopeScripts { + fun getScript( + activeExperiments: List, + ): String + + suspend fun isEnabled(): Boolean + + val secret: String + val javascriptInterface: String + val callbackName: String +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RealAdsJsContentScopeScripts @Inject constructor( + private val pluginPoint: PluginPoint, + private val userAllowListRepository: UserAllowListRepository, + @Named("adsJS") private val adsContentScopeJSReader: ContentScopeJSReader, + private val appBuildConfig: AppBuildConfig, + private val unprotectedTemporary: UnprotectedTemporary, + private val fingerprintProtectionManager: FingerprintProtectionManager, + private val contentScopeScriptsFeature: ContentScopeScriptsFeature, +) : AdsJsContentScopeScripts { + + private var cachedContentScopeJson: String = getContentScopeJson("", emptyList()) + + private var cachedUserUnprotectedDomains = CopyOnWriteArrayList() + private var cachedUserUnprotectedDomainsJson: String = emptyJsonList + + private var cachedUserPreferencesJson: String = emptyJson + + private var cachedUnprotectTemporaryExceptions = CopyOnWriteArrayList() + private var cachedUnprotectTemporaryExceptionsJson: String = emptyJsonList + + private lateinit var cachedAdsJS: String + + override val secret: String = getSecret() + override val javascriptInterface: String = getSecret() + override val callbackName: String = getSecret() + + override fun getScript( + activeExperiments: List, + ): String { + var updateJS = false + + val pluginParameters = getPluginParameters() + + if (cachedUnprotectTemporaryExceptions != unprotectedTemporary.unprotectedTemporaryExceptions) { + cacheUserUnprotectedTemporaryExceptions(unprotectedTemporary.unprotectedTemporaryExceptions) + updateJS = true + } + + val contentScopeJson = getContentScopeJson(pluginParameters.config, cachedUnprotectTemporaryExceptions) + if (cachedContentScopeJson != contentScopeJson) { + cachedContentScopeJson = contentScopeJson + updateJS = true + } + + if (cachedUserUnprotectedDomains != userAllowListRepository.domainsInUserAllowList()) { + cacheUserUnprotectedDomains(userAllowListRepository.domainsInUserAllowList()) + updateJS = true + } + + val userPreferencesJson = getUserPreferencesJson(pluginParameters.preferences, activeExperiments = activeExperiments) + if (cachedUserPreferencesJson != userPreferencesJson) { + cachedUserPreferencesJson = userPreferencesJson + updateJS = true + } + + if (!this::cachedAdsJS.isInitialized || updateJS) { + cacheJs() + } + return cachedAdsJS + } + + override suspend fun isEnabled(): Boolean { + return contentScopeScriptsFeature.self().isEnabled() && contentScopeScriptsFeature.useNewWebCompatApis().isEnabled() + } + + private fun getSecretKeyValuePair() = "\"messageSecret\":\"$secret\"" + private fun getCallbackKeyValuePair() = "\"messageCallback\":\"$callbackName\"" + private fun getInterfaceKeyValuePair() = "\"javascriptInterface\":\"$javascriptInterface\"" + + private fun getPluginParameters(): PluginParameters { + var config = "" + var preferences = "" + val plugins = pluginPoint.getPlugins() + plugins.forEach { plugin -> + if (config.isNotEmpty()) { + config += "," + } + config += plugin.config() + + plugin.preferences()?.let { pluginPreferences -> + if (preferences.isNotEmpty()) { + preferences += "," + } + preferences += pluginPreferences + } + } + return PluginParameters(config, preferences) + } + + private fun cacheUserUnprotectedDomains(userUnprotectedDomains: List) { + cachedUserUnprotectedDomains.clear() + if (userUnprotectedDomains.isEmpty()) { + cachedUserUnprotectedDomainsJson = emptyJsonList + } else { + cachedUserUnprotectedDomainsJson = getUserUnprotectedDomainsJson(userUnprotectedDomains) + cachedUserUnprotectedDomains.addAll(userUnprotectedDomains) + } + } + + private fun cacheUserUnprotectedTemporaryExceptions(unprotectedTemporaryExceptions: List) { + cachedUnprotectTemporaryExceptions.clear() + if (unprotectedTemporaryExceptions.isEmpty()) { + cachedUnprotectTemporaryExceptionsJson = emptyJsonList + } else { + cachedUnprotectTemporaryExceptionsJson = getUnprotectedTemporaryJson(unprotectedTemporaryExceptions) + cachedUnprotectTemporaryExceptions.addAll(unprotectedTemporaryExceptions) + } + } + + private fun cacheJs() { + val adsContentScopeJs = adsContentScopeJSReader.getContentScopeJS() + + cachedAdsJS = adsContentScopeJs + .replace(contentScope, cachedContentScopeJson) + .replace(userUnprotectedDomains, cachedUserUnprotectedDomainsJson) + .replace(userPreferences, cachedUserPreferencesJson) + .replace(messagingParameters, "${getSecretKeyValuePair()},${getCallbackKeyValuePair()},${getInterfaceKeyValuePair()}") + } + + private fun getUserUnprotectedDomainsJson(userUnprotectedDomains: List): String { + val type = Types.newParameterizedType(MutableList::class.java, String::class.java) + val moshi = Builder().build() + val jsonAdapter: JsonAdapter> = moshi.adapter(type) + return jsonAdapter.toJson(userUnprotectedDomains) + } + + private fun getUnprotectedTemporaryJson(unprotectedTemporaryExceptions: List): String { + val type = Types.newParameterizedType(MutableList::class.java, FeatureException::class.java) + val moshi = Builder().build() + val jsonAdapter: JsonAdapter> = moshi.adapter(type) + return jsonAdapter.toJson(unprotectedTemporaryExceptions) + } + + private fun getUserPreferencesJson( + userPreferences: String, + isDesktopMode: Boolean? = null, + activeExperiments: List, + ): String { + val experiments = getExperimentsKeyValuePair(activeExperiments) + val defaultParameters = "${getVersionNumberKeyValuePair()},${getPlatformKeyValuePair()},${getLanguageKeyValuePair()}," + + "${getSessionKeyValuePair()},${getDesktopModeKeyValuePair(isDesktopMode ?: false)},$messagingParameters" + if (userPreferences.isEmpty()) { + return "{$experiments,$defaultParameters}" + } + return "{$userPreferences,$experiments,$defaultParameters}" + } + + private fun getVersionNumberKeyValuePair() = "\"versionNumber\":${appBuildConfig.versionCode}" + private fun getPlatformKeyValuePair() = "\"platform\":{\"name\":\"android\"}" + private fun getLanguageKeyValuePair() = "\"locale\":\"${Locale.getDefault().language}\"" + private fun getDesktopModeKeyValuePair(isDesktopMode: Boolean) = "\"desktopModeEnabled\":$isDesktopMode" + private fun getSessionKeyValuePair() = "\"sessionKey\":\"${fingerprintProtectionManager.getSeed()}\"" + private fun getExperimentsKeyValuePair(activeExperiments: List): String { + return runBlocking { + val type = Types.newParameterizedType(List::class.java, Experiment::class.java) + val moshi = Builder().build() + val jsonAdapter: JsonAdapter> = moshi.adapter(type) + activeExperiments + .filter { it.getCohort() != null && it.featureName().parentName != null } + .map { + Experiment( + cohort = it.getCohort()!!.name, + feature = it.featureName().parentName!!, + subfeature = it.featureName().name, + ) + }.let { + return@runBlocking "\"currentCohorts\":${jsonAdapter.toJson(it)}" + } + } + } + + private fun getContentScopeJson(config: String, unprotectedTemporaryExceptions: List): String = ( + "{\"features\":{$config},\"unprotectedTemporary\":${getUnprotectedTemporaryJson(unprotectedTemporaryExceptions)}}" + ) + + companion object { + const val emptyJsonList = "[]" + const val emptyJson = "{}" + const val contentScope = "\$CONTENT_SCOPE$" + const val userUnprotectedDomains = "\$USER_UNPROTECTED_DOMAINS$" + const val userPreferences = "\$USER_PREFERENCES$" + const val messagingParameters = "\$ANDROID_MESSAGING_PARAMETERS$" + + private fun getSecret(): String { + return UUID.randomUUID().toString().replace("-", "") + } + } +} diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScripts.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScripts.kt index 05850e075611..15a0502e1d97 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScripts.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScripts.kt @@ -33,6 +33,7 @@ import dagger.SingleInstanceIn import java.util.* import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject +import javax.inject.Named import kotlinx.coroutines.runBlocking interface CoreContentScopeScripts { @@ -40,6 +41,7 @@ interface CoreContentScopeScripts { isDesktopMode: Boolean?, activeExperiments: List, ): String + fun isEnabled(): Boolean val secret: String @@ -52,7 +54,7 @@ interface CoreContentScopeScripts { class RealContentScopeScripts @Inject constructor( private val pluginPoint: PluginPoint, private val userAllowListRepository: UserAllowListRepository, - private val contentScopeJSReader: ContentScopeJSReader, + @Named("contentScope") private val contentScopeJSReader: ContentScopeJSReader, private val appBuildConfig: AppBuildConfig, private val unprotectedTemporary: UnprotectedTemporary, private val fingerprintProtectionManager: FingerprintProtectionManager, diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/RealWebViewCompatWrapper.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/RealWebViewCompatWrapper.kt new file mode 100644 index 000000000000..59a4c29556b4 --- /dev/null +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/RealWebViewCompatWrapper.kt @@ -0,0 +1,41 @@ +/* + * 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.contentscopescripts.impl + +import android.annotation.SuppressLint +import androidx.webkit.ScriptHandler +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +@SuppressLint("RequiresFeature") +@ContributesBinding(AppScope::class) +class RealWebViewCompatWrapper @Inject constructor() : WebViewCompatWrapper { + override fun isDocumentStartScriptSupported(): Boolean { + return WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT) + } + + override fun addDocumentStartJavaScript( + webView: android.webkit.WebView, + script: String, + allowedOriginRules: Set, + ): ScriptHandler { + return WebViewCompat.addDocumentStartJavaScript(webView, script, allowedOriginRules) + } +} diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/WebViewCompatWrapper.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/WebViewCompatWrapper.kt new file mode 100644 index 000000000000..4c055dab8c21 --- /dev/null +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/WebViewCompatWrapper.kt @@ -0,0 +1,31 @@ +/* + * 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.contentscopescripts.impl + +import android.webkit.WebView +import androidx.webkit.ScriptHandler + +interface WebViewCompatWrapper { + + fun isDocumentStartScriptSupported(): Boolean + + fun addDocumentStartJavaScript( + webView: WebView, + script: String, + allowedOriginRules: Set, + ): ScriptHandler +} diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPluginTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPluginTest.kt index 986907729b7b..ce4454cb4764 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPluginTest.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPluginTest.kt @@ -1,53 +1,210 @@ package com.duckduckgo.contentscopescripts.impl import android.webkit.WebView -import java.util.* +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.contentscopescripts.api.contentscopeExperiments.ContentScopeExperiments +import com.duckduckgo.feature.toggles.api.Toggle +import kotlinx.coroutines.test.runTest import org.junit.Assert.* import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever +@RunWith(AndroidJUnit4::class) class ContentScopeScriptsJsInjectorPluginTest { + @get:Rule + var coroutineRule = CoroutineTestRule() + private val mockCoreContentScopeScripts: CoreContentScopeScripts = mock() + private val mockAdsJsContentScopeScripts: AdsJsContentScopeScripts = mock() private val mockWebView: WebView = mock() + private val mockContentScopeExperiments: ContentScopeExperiments = mock() + private val mockWebViewCompatWrapper: WebViewCompatWrapper = mock() + private val mockToggle = mock() private lateinit var contentScopeScriptsJsInjectorPlugin: ContentScopeScriptsJsInjectorPlugin @Before - fun setUp() { - contentScopeScriptsJsInjectorPlugin = ContentScopeScriptsJsInjectorPlugin(mockCoreContentScopeScripts) + fun setUp() = runTest { + whenever(mockWebViewCompatWrapper.isDocumentStartScriptSupported()).thenReturn(true) + whenever(mockContentScopeExperiments.getActiveExperiments()).thenReturn(listOf(mockToggle)) + contentScopeScriptsJsInjectorPlugin = ContentScopeScriptsJsInjectorPlugin( + mockCoreContentScopeScripts, + mockAdsJsContentScopeScripts, + mockContentScopeExperiments, + coroutineRule.testDispatcherProvider, + mockWebViewCompatWrapper, + ) } @Test - fun whenEnabledAndInjectContentScopeScriptsThenPopulateMessagingParameters() { + fun whenEnabledAndInjectContentScopeScriptsThenPopulateMessagingParameters() = runTest { whenever(mockCoreContentScopeScripts.isEnabled()).thenReturn(true) + whenever(mockAdsJsContentScopeScripts.isEnabled()).thenReturn(true) whenever(mockCoreContentScopeScripts.getScript(null, listOf())).thenReturn("") - contentScopeScriptsJsInjectorPlugin.onPageStarted(mockWebView, null, null, listOf()) + contentScopeScriptsJsInjectorPlugin.onPageStarted(mockWebView, null, null) verify(mockCoreContentScopeScripts).getScript(null, listOf()) verify(mockWebView).evaluateJavascript(any(), anyOrNull()) } @Test - fun whenDisabledAndInjectContentScopeScriptsThenDoNothing() { + fun whenDisabledAndInjectContentScopeScriptsThenDoNothing() = runTest { whenever(mockCoreContentScopeScripts.isEnabled()).thenReturn(false) - contentScopeScriptsJsInjectorPlugin.onPageStarted(mockWebView, null, null, listOf()) + contentScopeScriptsJsInjectorPlugin.onPageStarted(mockWebView, null, null) verifyNoInteractions(mockWebView) } @Test - fun whenEnabledAndInjectContentScopeScriptsThenUseParams() { + fun whenEnabledAndInjectContentScopeScriptsThenUseParams() = runTest { whenever(mockCoreContentScopeScripts.isEnabled()).thenReturn(true) + whenever(mockAdsJsContentScopeScripts.isEnabled()).thenReturn(true) whenever(mockCoreContentScopeScripts.getScript(true, listOf())).thenReturn("") - contentScopeScriptsJsInjectorPlugin.onPageStarted(mockWebView, null, true, listOf()) + contentScopeScriptsJsInjectorPlugin.onPageStarted(mockWebView, null, true) verify(mockCoreContentScopeScripts).getScript(true, listOf()) } + + @Test + fun whenEnabledAndPageStartedWithNoInitJsThenReturnEmptyActiveExperiments() = runTest { + whenever(mockCoreContentScopeScripts.isEnabled()).thenReturn(true) + + val result = contentScopeScriptsJsInjectorPlugin.onPageStarted(mockWebView, null, null) + + assertTrue(result.isEmpty()) + } + + @Test + fun whenEnabledAndPageStartedWithInitJsThenReturnActiveExperiments() = runTest { + whenever(mockCoreContentScopeScripts.isEnabled()).thenReturn(true) + + contentScopeScriptsJsInjectorPlugin.onInit(mockWebView) + val result = contentScopeScriptsJsInjectorPlugin.onPageStarted(mockWebView, null, null) + + assertEquals(listOf(mockToggle), result) + } + + @Test + fun whenEnabledAndPageStartedWithInitJsAndActiveExperimentsChangedAfterwardsThenReturnActiveExperimentsFromInit() = runTest { + val mockToggle2 = mock() + whenever(mockCoreContentScopeScripts.isEnabled()).thenReturn(true) + whenever(mockAdsJsContentScopeScripts.isEnabled()).thenReturn(true) + + contentScopeScriptsJsInjectorPlugin.onInit(mockWebView) + + whenever(mockContentScopeExperiments.getActiveExperiments()).thenReturn(listOf(mockToggle2)) + + val result = contentScopeScriptsJsInjectorPlugin.onPageStarted(mockWebView, null, null) + + assertEquals(listOf(mockToggle), result) + } + + @Test + fun whenInitJsActiveExperimentsUpdated() = runTest { + val mockToggle2 = mock() + whenever(mockCoreContentScopeScripts.isEnabled()).thenReturn(true) + whenever(mockAdsJsContentScopeScripts.isEnabled()).thenReturn(true) + contentScopeScriptsJsInjectorPlugin.onInit(mockWebView) + + val result = contentScopeScriptsJsInjectorPlugin.onPageStarted(mockWebView, null, null) + + assertEquals(listOf(mockToggle), result) + + whenever(mockContentScopeExperiments.getActiveExperiments()).thenReturn(listOf(mockToggle2)) + contentScopeScriptsJsInjectorPlugin.onInit(mockWebView) + + val result2 = contentScopeScriptsJsInjectorPlugin.onPageStarted(mockWebView, null, null) + + assertEquals(listOf(mockToggle2), result2) + } + + @Test + fun whenPageFinishedActiveExperimentsUpdated() = runTest { + val mockToggle2 = mock() + whenever(mockCoreContentScopeScripts.isEnabled()).thenReturn(true) + whenever(mockAdsJsContentScopeScripts.isEnabled()).thenReturn(true) + contentScopeScriptsJsInjectorPlugin.onInit(mockWebView) + + val result = contentScopeScriptsJsInjectorPlugin.onPageStarted(mockWebView, null, null) + + assertEquals(listOf(mockToggle), result) + + whenever(mockContentScopeExperiments.getActiveExperiments()).thenReturn(listOf(mockToggle2)) + contentScopeScriptsJsInjectorPlugin.onPageFinished(mockWebView, null) + + val result2 = contentScopeScriptsJsInjectorPlugin.onPageStarted(mockWebView, null, null) + + assertEquals(listOf(mockToggle2), result2) + } + + @Test + fun whenDocumentStartScriptSupportedAndInitCalledWithScriptChangedThenScriptInjected() = runTest { + whenever(mockWebViewCompatWrapper.isDocumentStartScriptSupported()).thenReturn(true) + whenever(mockAdsJsContentScopeScripts.isEnabled()).thenReturn(true) + whenever(mockAdsJsContentScopeScripts.getScript(any())).thenReturn("mockScript") + + contentScopeScriptsJsInjectorPlugin.onInit(mockWebView) + + verify(mockAdsJsContentScopeScripts).getScript(listOf(mockToggle)) + verify(mockWebViewCompatWrapper).addDocumentStartJavaScript(any(), eq("mockScript"), any()) + } + + @Test + fun whenDocumentStartScriptNotSupportedAndInitCalledThenNoScriptInjected() = runTest { + whenever(mockWebViewCompatWrapper.isDocumentStartScriptSupported()).thenReturn(false) + whenever(mockAdsJsContentScopeScripts.isEnabled()).thenReturn(true) + whenever(mockAdsJsContentScopeScripts.getScript(any())).thenReturn("mockScript") + + contentScopeScriptsJsInjectorPlugin.onInit(mockWebView) + + verifyNoInteractions(mockAdsJsContentScopeScripts) + verify(mockWebViewCompatWrapper, never()).addDocumentStartJavaScript(any(), any(), any()) + } + + @Test + fun whenAdsjsIsNotEnabledAndInitCalledThenNoScriptInjected() = runTest { + whenever(mockWebViewCompatWrapper.isDocumentStartScriptSupported()).thenReturn(true) + whenever(mockCoreContentScopeScripts.isEnabled()).thenReturn(true) + whenever(mockAdsJsContentScopeScripts.isEnabled()).thenReturn(false) + whenever(mockAdsJsContentScopeScripts.getScript(any())).thenReturn("mockScript") + + contentScopeScriptsJsInjectorPlugin.onInit(mockWebView) + + verify(mockWebViewCompatWrapper, never()).addDocumentStartJavaScript(any(), any(), any()) + } + + @Test + fun whenAdsjsIsNotEnabledAndPageFinishedCalledThenNoScriptInjected() = runTest { + whenever(mockWebViewCompatWrapper.isDocumentStartScriptSupported()).thenReturn(true) + whenever(mockCoreContentScopeScripts.isEnabled()).thenReturn(true) + whenever(mockAdsJsContentScopeScripts.isEnabled()).thenReturn(false) + whenever(mockAdsJsContentScopeScripts.getScript(any())).thenReturn("mockScript") + + contentScopeScriptsJsInjectorPlugin.onPageFinished(mockWebView, null) + + verify(mockWebViewCompatWrapper, never()).addDocumentStartJavaScript(any(), any(), any()) + } + + @Test + fun whenDocumentStartScriptNotSupportedAndPageFinishedCalledThenNoScriptInjected() = runTest { + whenever(mockWebViewCompatWrapper.isDocumentStartScriptSupported()).thenReturn(false) + whenever(mockAdsJsContentScopeScripts.isEnabled()).thenReturn(true) + + contentScopeScriptsJsInjectorPlugin.onPageFinished(mockWebView, null) + + verifyNoInteractions(mockAdsJsContentScopeScripts) + verify(mockWebViewCompatWrapper, never()).addDocumentStartJavaScript(any(), any(), any()) + } } diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealAdsJsContentScopeScriptsTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealAdsJsContentScopeScriptsTest.kt new file mode 100644 index 000000000000..0d194065d159 --- /dev/null +++ b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealAdsJsContentScopeScriptsTest.kt @@ -0,0 +1,402 @@ +/* + * Copyright (c) 2022 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.contentscopescripts.impl + +import android.annotation.SuppressLint +import com.duckduckgo.app.privacy.db.UserAllowListRepository +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.contentscopescripts.api.ContentScopeConfigPlugin +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.FeatureException +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.FeatureName +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.feature.toggles.api.Toggle.State.Cohort +import com.duckduckgo.fingerprintprotection.api.FingerprintProtectionManager +import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@SuppressLint("DenyListedApi") +class RealAdsjsContentScopeScriptsTest { + + private val mockPluginPoint: PluginPoint = mock() + private val mockUserAllowListRepository: UserAllowListRepository = mock() + private val mockContentScopeJsReader: ContentScopeJSReader = mock() + private val mockPlugin1: ContentScopeConfigPlugin = mock() + private val mockPlugin2: ContentScopeConfigPlugin = mock() + private val mockAppBuildConfig: AppBuildConfig = mock() + private val mockUnprotectedTemporary: UnprotectedTemporary = mock() + private val mockFingerprintProtectionManager: FingerprintProtectionManager = mock() + private val contentScopeScriptsFeature = FakeFeatureToggleFactory.create(ContentScopeScriptsFeature::class.java) + + lateinit var testee: AdsJsContentScopeScripts + + @Before + fun setup() { + testee = RealAdsJsContentScopeScripts( + mockPluginPoint, + mockUserAllowListRepository, + mockContentScopeJsReader, + mockAppBuildConfig, + mockUnprotectedTemporary, + mockFingerprintProtectionManager, + contentScopeScriptsFeature, + ) + whenever(mockPlugin1.config()).thenReturn(config1) + whenever(mockPlugin2.config()).thenReturn(config2) + whenever(mockPluginPoint.getPlugins()).thenReturn(listOf(mockPlugin1, mockPlugin2)) + whenever(mockUserAllowListRepository.domainsInUserAllowList()).thenReturn(listOf(exampleUrl)) + whenever(mockContentScopeJsReader.getContentScopeJS()).thenReturn(contentScopeJS) + whenever(mockAppBuildConfig.versionCode).thenReturn(versionCode) + whenever(mockUnprotectedTemporary.unprotectedTemporaryExceptions) + .thenReturn(listOf(unprotectedTemporaryException, unprotectedTemporaryException2)) + whenever(mockFingerprintProtectionManager.getSeed()).thenReturn(sessionKey) + } + + @Test + fun whenGetScriptWhenVariablesAreCachedAndNoChangesThenUseCachedVariables() { + var js = testee.getScript(listOf()) + verifyJsScript(js) + + js = testee.getScript(listOf()) + + verifyJsScript(js) + verify(mockContentScopeJsReader).getContentScopeJS() + verify(mockUnprotectedTemporary, times(3)).unprotectedTemporaryExceptions + verify(mockUserAllowListRepository, times(3)).domainsInUserAllowList() + } + + @Test + fun whenGetScriptAndVariablesAreCachedAndAllowListChangedThenUseNewAllowListValue() { + var js = testee.getScript(listOf()) + verifyJsScript(js) + + val newRegEx = Regex( + "^processConfig\\(\\{\"features\":\\{" + + "\"config1\":\\{\"state\":\"enabled\"\\}," + + "\"config2\":\\{\"state\":\"disabled\"\\}\\}," + + "\"unprotectedTemporary\":\\[" + + "\\{\"domain\":\"example\\.com\",\"reason\":\"reason\"\\}," + + "\\{\"domain\":\"foo\\.com\",\"reason\":\"reason2\"\\}\\]\\}, \\[\"foo\\.com\"\\], " + + "\\{\"currentCohorts\":\\[\\],\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\},\"locale\":\"en\"," + + "\"sessionKey\":\"5678\",\"desktopModeEnabled\":false," + + "\"messageSecret\":\"([\\da-f]{32})\"," + + "\"messageCallback\":\"([\\da-f]{32})\"," + + "\"javascriptInterface\":\"([\\da-f]{32})\"\\}\\)$", + ) + whenever(mockUserAllowListRepository.domainsInUserAllowList()).thenReturn(listOf(exampleUrl2)) + js = testee.getScript(listOf()) + + verifyJsScript(js, newRegEx) + verify(mockUnprotectedTemporary, times(3)).unprotectedTemporaryExceptions + verify(mockUserAllowListRepository, times(4)).domainsInUserAllowList() + verify(mockContentScopeJsReader, times(2)).getContentScopeJS() + } + + @Test + fun whenGetScriptAndVariablesAreCachedAndGpcChangedThenUseNewGpcValue() { + var js = testee.getScript(listOf()) + verifyJsScript(js) + + val newRegEx = Regex( + "^processConfig\\(\\{\"features\":\\{" + + "\"config1\":\\{\"state\":\"enabled\"\\}," + + "\"config2\":\\{\"state\":\"disabled\"\\}\\}," + + "\"unprotectedTemporary\":\\[" + + "\\{\"domain\":\"example\\.com\",\"reason\":\"reason\"\\}," + + "\\{\"domain\":\"foo\\.com\",\"reason\":\"reason2\"\\}\\]\\}, \\[\"example\\.com\"\\], " + + "\\{\"globalPrivacyControlValue\":false,\"currentCohorts\":\\[\\],\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\}," + + "\"locale\":\"en\",\"sessionKey\":\"5678\"," + + "\"desktopModeEnabled\":false,\"messageSecret\":\"([\\da-f]{32})\"," + + "\"messageCallback\":\"([\\da-f]{32})\"," + + "\"javascriptInterface\":\"([\\da-f]{32})\"\\}\\)$", + ) + whenever(mockPlugin2.preferences()).thenReturn("\"globalPrivacyControlValue\":false") + js = testee.getScript(listOf()) + + verifyJsScript(js, newRegEx) + verify(mockUnprotectedTemporary, times(3)).unprotectedTemporaryExceptions + verify(mockUserAllowListRepository, times(3)).domainsInUserAllowList() + verify(mockContentScopeJsReader, times(2)).getContentScopeJS() + } + + @Test + fun whenGetScriptAndVariablesAreCachedAndConfigChangedThenUseNewConfigValue() { + var js = testee.getScript(listOf()) + verifyJsScript(js) + + val newRegEx = Regex( + "^processConfig\\(\\{\"features\":\\{" + + "\"config1\":\\{\"state\":\"enabled\"\\}\\}," + + "\"unprotectedTemporary\":\\[" + + "\\{\"domain\":\"example\\.com\",\"reason\":\"reason\"\\}," + + "\\{\"domain\":\"foo\\.com\",\"reason\":\"reason2\"\\}\\]\\}, \\[\"example\\.com\"\\], " + + "\\{\"globalPrivacyControlValue\":true,\"currentCohorts\":\\[\\],\"versionNumber\":1234," + + "\"platform\":\\{\"name\":\"android\"\\},\"locale\":\"en\"," + + "\"sessionKey\":\"5678\"," + + "\"desktopModeEnabled\":false,\"messageSecret\":\"([\\da-f]{32})\"," + + "\"messageCallback\":\"([\\da-f]{32})\"," + + "\"javascriptInterface\":\"([\\da-f]{32})\"\\}\\)$", + ) + whenever(mockPlugin1.preferences()).thenReturn("\"globalPrivacyControlValue\":true") + whenever(mockPluginPoint.getPlugins()).thenReturn(listOf(mockPlugin1)) + js = testee.getScript(listOf()) + + verifyJsScript(js, newRegEx) + verify(mockUnprotectedTemporary, times(3)).unprotectedTemporaryExceptions + verify(mockUserAllowListRepository, times(3)).domainsInUserAllowList() + verify(mockContentScopeJsReader, times(2)).getContentScopeJS() + } + + @Test + fun whenGetScriptAndVariablesAreCachedAndUnprotectedTemporaryChangedThenUseNewUnprotectedTemporaryValue() { + var js = testee.getScript(listOf()) + verifyJsScript(js) + + val newRegEx = Regex( + "^processConfig\\(\\{\"features\":\\{" + + "\"config1\":\\{\"state\":\"enabled\"\\}," + + "\"config2\":\\{\"state\":\"disabled\"\\}\\}," + + "\"unprotectedTemporary\":\\[" + + "\\{\"domain\":\"example\\.com\",\"reason\":\"reason\"\\}\\]\\}, \\[\"example\\.com\"\\], " + + "\\{\"currentCohorts\":\\[\\],\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\}," + + "\"locale\":\"en\",\"sessionKey\":\"5678\"," + + "\"desktopModeEnabled\":false," + + "\"messageSecret\":\"([\\da-f]{32})\"," + + "\"messageCallback\":\"([\\da-f]{32})\"," + + "\"javascriptInterface\":\"([\\da-f]{32})\"\\}\\)$", + ) + whenever(mockUnprotectedTemporary.unprotectedTemporaryExceptions).thenReturn(listOf(unprotectedTemporaryException)) + js = testee.getScript(listOf()) + + verifyJsScript(js, newRegEx) + verify(mockUnprotectedTemporary, times(4)).unprotectedTemporaryExceptions + verify(mockUserAllowListRepository, times(3)).domainsInUserAllowList() + verify(mockContentScopeJsReader, times(2)).getContentScopeJS() + } + + @Test + fun whenGetScriptAndVariablesAreCachedAndCurrentCohortsChangedThenUseNewCurrentCohortsValue() = runTest { + var js = testee.getScript(listOf()) + verifyJsScript(js) + + val newRegEx = Regex( + "^processConfig\\(\\{\"features\":\\{" + + "\"config1\":\\{\"state\":\"enabled\"\\}," + + "\"config2\":\\{\"state\":\"disabled\"\\}\\}," + + "\"unprotectedTemporary\":\\[" + + "\\{\"domain\":\"example\\.com\",\"reason\":\"reason\"\\}," + + "\\{\"domain\":\"foo\\.com\",\"reason\":\"reason2\"\\}\\]\\}, \\[\"example\\.com\"\\], " + + "\\{\"currentCohorts\":\\[\\{\"cohort\":\"control\",\"feature\":\"contentScopeExperiments\",\"subfeature\":\"test\"}]," + + "\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\}," + + "\"locale\":\"en\",\"sessionKey\":\"5678\"," + + "\"desktopModeEnabled\":false,\"messageSecret\":\"([\\da-f]{32})\"," + + "\"messageCallback\":\"([\\da-f]{32})\"," + + "\"javascriptInterface\":\"([\\da-f]{32})\"\\}\\)$", + ) + + val mockToggle = mock() + whenever(mockToggle.getCohort()).thenReturn(Cohort("control", weight = 1)) + whenever(mockToggle.featureName()).thenReturn(FeatureName("contentScopeExperiments", "test")) + + val activeExperiments = listOf(mockToggle) + + js = testee.getScript(activeExperiments) + + verifyJsScript(js, newRegEx) + verify(mockUnprotectedTemporary, times(3)).unprotectedTemporaryExceptions + verify(mockUserAllowListRepository, times(3)).domainsInUserAllowList() + verify(mockContentScopeJsReader, times(2)).getContentScopeJS() + } + + @Test + fun whenGetScriptWithMultipleActiveExperimentsThenFormatsCorrectly() = runTest { + val newRegEx = Regex( + "^processConfig\\(\\{\"features\":\\{" + + "\"config1\":\\{\"state\":\"enabled\"\\}," + + "\"config2\":\\{\"state\":\"disabled\"\\}\\}," + + "\"unprotectedTemporary\":\\[" + + "\\{\"domain\":\"example\\.com\",\"reason\":\"reason\"\\}," + + "\\{\"domain\":\"foo\\.com\",\"reason\":\"reason2\"\\}\\]\\}, \\[\"example\\.com\"\\], " + + "\\{\"currentCohorts\":\\[" + + "\\{\"cohort\":\"treatment\",\"feature\":\"contentScopeExperiments\",\"subfeature\":\"test\"}," + + "\\{\"cohort\":\"control\",\"feature\":\"contentScopeExperiments\",\"subfeature\":\"bloops\"}\\]," + + "\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\}," + + "\"locale\":\"en\",\"sessionKey\":\"5678\"," + + "\"desktopModeEnabled\":false,\"messageSecret\":\"([\\da-f]{32})\"," + + "\"messageCallback\":\"([\\da-f]{32})\"," + + "\"javascriptInterface\":\"([\\da-f]{32})\"\\}\\)$", + ) + + val mockToggle1 = mock() + whenever(mockToggle1.getCohort()).thenReturn(Cohort("treatment", weight = 1)) + whenever(mockToggle1.featureName()).thenReturn(FeatureName("contentScopeExperiments", "test")) + + val mockToggle2 = mock() + whenever(mockToggle2.getCohort()).thenReturn(Cohort("control", weight = 1)) + whenever(mockToggle2.featureName()).thenReturn(FeatureName("contentScopeExperiments", "bloops")) + + val activeExperiments = listOf(mockToggle1, mockToggle2) + + val js = testee.getScript(activeExperiments) + + verifyJsScript(js, newRegEx) + } + + @Test + fun whenGetScriptWithExperimentWithoutCohortThenFormatsCorrectly() = runTest { + val mockToggle = mock() + whenever(mockToggle.getCohort()).thenReturn(null) + whenever(mockToggle.featureName()).thenReturn(FeatureName("contentScopeExperiments", "test")) + + val activeExperiments = listOf(mockToggle) + + val js = testee.getScript(activeExperiments) + + verifyJsScript(js) + } + + @Test + fun whenGetScriptWithNoActiveExperimentsThenFormatsCorrectly() = runTest { + val js = testee.getScript(listOf()) + + verifyJsScript(js) + } + + @Test + fun whenGetScriptWithNullSiteThenFormatsCorrectly() = runTest { + val js = testee.getScript(listOf()) + + verifyJsScript(js) + } + + @Test + fun whenContentScopeScriptsAndUseNewWebCompatApisAreEnabledThenReturnTrue() = runTest { + contentScopeScriptsFeature.self().setRawStoredState(State(enable = true)) + contentScopeScriptsFeature.useNewWebCompatApis().setRawStoredState(State(enable = true)) + assertTrue(testee.isEnabled()) + } + + @Test + fun whenContentScopeScriptsIsDisabledThenReturnFalse() = runTest { + contentScopeScriptsFeature.self().setRawStoredState(State(enable = false)) + assertFalse(testee.isEnabled()) + } + + @Test + fun whenseNewWebCompatApisIsDisabledThenReturnFalse() = runTest { + contentScopeScriptsFeature.useNewWebCompatApis().setRawStoredState(State(enable = false)) + assertFalse(testee.isEnabled()) + } + + @Test + fun whenGetScriptThenPopulateMessagingParameters() = runTest { + val js = testee.getScript(listOf()) + verifyJsScript(js) + verify(mockContentScopeJsReader).getContentScopeJS() + } + + @Test + fun whenGetScriptWithMixedValidAndNullCohortExperimentsThenFiltersOutNullCohorts() = runTest { + val newRegEx = Regex( + "^processConfig\\(\\{\"features\":\\{" + + "\"config1\":\\{\"state\":\"enabled\"\\}," + + "\"config2\":\\{\"state\":\"disabled\"\\}\\}," + + "\"unprotectedTemporary\":\\[" + + "\\{\"domain\":\"example\\.com\",\"reason\":\"reason\"\\}," + + "\\{\"domain\":\"foo\\.com\",\"reason\":\"reason2\"\\}\\]\\}, \\[\"example\\.com\"\\], " + + "\\{\"currentCohorts\":\\[" + + "\\{\"cohort\":\"treatment\",\"feature\":\"contentScopeExperiments\",\"subfeature\":\"test\"}\\]," + + "\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\}," + + "\"locale\":\"en\",\"sessionKey\":\"5678\"," + + "\"desktopModeEnabled\":false,\"messageSecret\":\"([\\da-f]{32})\"," + + "\"messageCallback\":\"([\\da-f]{32})\"," + + "\"javascriptInterface\":\"([\\da-f]{32})\"\\}\\)$", + ) + + val validExperiment = mock() + whenever(validExperiment.getCohort()).thenReturn(Cohort("treatment", weight = 1)) + whenever(validExperiment.featureName()).thenReturn(FeatureName("contentScopeExperiments", "test")) + + val nullCohortExperiment = mock() + whenever(nullCohortExperiment.getCohort()).thenReturn(null) + whenever(nullCohortExperiment.featureName()).thenReturn(FeatureName("contentScopeExperiments", "bloops")) + + val activeExperiments = listOf(validExperiment, nullCohortExperiment) + + val js = testee.getScript(activeExperiments) + + verifyJsScript(js, newRegEx) + } + + @Test + fun whenGetScriptWithExperimentWithoutParentNameThenFiltersOut() = runTest { + val expectedRegEx = contentScopeRegex + + val mockToggle = mock() + whenever(mockToggle.getCohort()).thenReturn(Cohort("treatment", weight = 1)) + whenever(mockToggle.featureName()).thenReturn(FeatureName(null, "test")) + + val activeExperiments = listOf(mockToggle) + + val js = testee.getScript(activeExperiments) + + verifyJsScript(js, expectedRegEx) + } + + private fun verifyJsScript(js: String, regex: Regex = contentScopeRegex) { + val matchResult = regex.find(js) + val messageSecret = matchResult!!.groupValues[1] + val messageCallback = matchResult.groupValues[2] + val messageInterface = matchResult.groupValues[3] + assertTrue(messageSecret != messageCallback && messageSecret != messageInterface && messageCallback != messageInterface) + } + + companion object { + const val contentScopeJS = "processConfig(\$CONTENT_SCOPE\$, \$USER_UNPROTECTED_DOMAINS\$, \$USER_PREFERENCES\$)" + const val config1 = "\"config1\":{\"state\":\"enabled\"}" + const val config2 = "\"config2\":{\"state\":\"disabled\"}" + const val exampleUrl = "example.com" + const val exampleUrl2 = "foo.com" + const val versionCode = 1234 + const val sessionKey = "5678" + val unprotectedTemporaryException = FeatureException(domain = "example.com", reason = "reason") + val unprotectedTemporaryException2 = FeatureException(domain = "foo.com", reason = "reason2") + val contentScopeRegex = Regex( + "^processConfig\\(\\{\"features\":\\{" + + "\"config1\":\\{\"state\":\"enabled\"\\}," + + "\"config2\":\\{\"state\":\"disabled\"\\}\\}," + + "\"unprotectedTemporary\":\\[" + + "\\{\"domain\":\"example\\.com\",\"reason\":\"reason\"\\}," + + "\\{\"domain\":\"foo\\.com\",\"reason\":\"reason2\"\\}\\]\\}, \\[\"example\\.com\"\\], " + + "\\{\"currentCohorts\":\\[\\],\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\},\"locale\":\"en\"," + + "\"sessionKey\":\"5678\",\"desktopModeEnabled\":false," + + "\"messageSecret\":\"([\\da-f]{32})\"," + + "\"messageCallback\":\"([\\da-f]{32})\"," + + "\"javascriptInterface\":\"([\\da-f]{32})\"\\}\\)$", + ) + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClient.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClient.kt index 096c9d338841..ed316bcc25cb 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClient.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClient.kt @@ -20,12 +20,16 @@ import android.graphics.Bitmap import android.webkit.WebView import android.webkit.WebViewClient import androidx.annotation.UiThread +import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.browser.api.JsInjectorPlugin import com.duckduckgo.common.utils.plugins.PluginPoint import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch class DuckChatWebViewClient @Inject constructor( private val jsPlugins: PluginPoint, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : WebViewClient() { @UiThread @@ -34,8 +38,10 @@ class DuckChatWebViewClient @Inject constructor( url: String?, favicon: Bitmap?, ) { - jsPlugins.getPlugins().forEach { - it.onPageStarted(webView, url, null) + appCoroutineScope.launch { + jsPlugins.getPlugins().forEach { + it.onPageStarted(webView, url, null) + } } } } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClientTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClientTest.kt index 743f1d2aab56..be9b4124ec3d 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClientTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClientTest.kt @@ -18,7 +18,10 @@ package com.duckduckgo.duckchat.impl.ui import android.webkit.WebView import com.duckduckgo.browser.api.JsInjectorPlugin +import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.plugins.PluginPoint +import kotlinx.coroutines.test.runTest +import org.junit.Rule import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -26,18 +29,21 @@ import org.mockito.kotlin.whenever class DuckChatWebViewClientTest { + @get:Rule + var coroutineRule = CoroutineTestRule() + @Test - fun whenOnPageStartedCalledThenJsPluginOnPageStartedInvoked() { + fun whenOnPageStartedCalledThenJsPluginOnPageStartedInvoked() = runTest { val mockPlugin: JsInjectorPlugin = mock() val pluginPoint: PluginPoint = mock() whenever(pluginPoint.getPlugins()).thenReturn(listOf(mockPlugin)) - val duckChatWebViewClient = DuckChatWebViewClient(pluginPoint) + val duckChatWebViewClient = DuckChatWebViewClient(pluginPoint, coroutineRule.testScope) val webView: WebView = mock() val url = "https://example.com" duckChatWebViewClient.onPageStarted(webView, url, null) - verify(mockPlugin).onPageStarted(webView, url, null, listOf()) + verify(mockPlugin).onPageStarted(webView, url, null) } }