Skip to content

Add support for addDocumentStartJavaScript #6600

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -207,7 +204,6 @@ class BrowserWebViewClientTest {
mockUriLoadedManager,
mockAndroidFeaturesHeaderPlugin,
mockDuckChat,
mockContentScopeExperiments,
)
testee.webViewClientListener = listener
whenever(webResourceRequest.url).thenReturn(Uri.EMPTY)
Expand All @@ -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
Expand Down Expand Up @@ -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<Toggle>,
) {
): List<Toggle> {
countStarted++
return listOf(mockToggle)
}

override fun onPageFinished(webView: WebView, url: String?, site: Site?) {
override suspend fun onPageFinished(
webView: WebView,
url: String?,
) {
countFinished++
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3006,6 +3006,7 @@ class BrowserTabFragment :
webView?.let {
it.isSafeWebViewEnabled = safeWebViewFeature.self().isEnabled()
it.webViewClient = webViewClient
webViewClient.triggerJSInit(it)
it.webChromeClient = webChromeClient
it.clearSslPreferences()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Toggle> = listOf(),
)
): List<Toggle>

/**
* 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?,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Toggle> = 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<Toggle>,
) {
): List<Toggle> {
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)
}
}
Loading
Loading