Skip to content

Commit f9de3fb

Browse files
authored
SERP Settings Sync: Add initial js message handlers (#7013)
Task/Issue URL: https://app.asana.com/1/137249556945/project/72649045549333/task/1211756086814099?focus=true ## Description Adds handlers to sync settings between SERP and Native settings. - Added domain validation to properly handle subdomains in content scope scripts - Added bidirectional communication between SERP and native app for Duck.ai settings - Created message handlers to respond to SERP requests about Duck.ai settings ## Steps to test this PR See asana task for testing steps: https://app.asana.com/1/137249556945/project/72649045549333/task/1211783183780068?focus=true ## UI changes No UI changes in this PR. The feature is focused on backend integration between SERP and native settings.
1 parent ba87b34 commit f9de3fb

File tree

13 files changed

+630
-4
lines changed

13 files changed

+630
-4
lines changed

content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import android.webkit.JavascriptInterface
2020
import android.webkit.WebView
2121
import androidx.core.net.toUri
2222
import com.duckduckgo.common.utils.DispatcherProvider
23+
import com.duckduckgo.common.utils.extensions.toTldPlusOne
2324
import com.duckduckgo.common.utils.plugins.PluginPoint
2425
import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin
2526
import com.duckduckgo.contentscopescripts.impl.CoreContentScopeScripts
@@ -72,7 +73,7 @@ class ContentScopeScriptsJsMessaging @Inject constructor(
7273
webView.url?.toUri()?.host
7374
}
7475
jsMessage?.let {
75-
if (this.secret == secret && context == jsMessage.context && (allowedDomains.isEmpty() || allowedDomains.contains(domain))) {
76+
if (this.secret == secret && context == jsMessage.context && isUrlAllowed(allowedDomains, domain)) {
7677
if (jsMessage.method == "addDebugFlag") {
7778
// If method is addDebugFlag, we want to handle it for all features
7879
jsMessageCallback.process(
@@ -87,7 +88,7 @@ class ContentScopeScriptsJsMessaging @Inject constructor(
8788
.map { it.getJsMessageHandler() }
8889
.firstOrNull {
8990
it.methods.contains(jsMessage.method) && it.featureName == jsMessage.featureName &&
90-
(it.allowedDomains.isEmpty() || it.allowedDomains.contains(domain))
91+
isUrlAllowed(it.allowedDomains, domain)
9192
}?.process(jsMessage, this, jsMessageCallback)
9293
}
9394
}
@@ -130,4 +131,13 @@ class ContentScopeScriptsJsMessaging @Inject constructor(
130131
)
131132
jsMessageHelper.sendJsResponse(jsResponse, callbackName, secret, webView)
132133
}
134+
135+
private fun isUrlAllowed(
136+
allowedDomains: List<String>,
137+
url: String?,
138+
): Boolean {
139+
if (allowedDomains.isEmpty()) return true
140+
val eTld = url?.toTldPlusOne() ?: return false
141+
return (allowedDomains.contains(eTld))
142+
}
133143
}

content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessagingTest.kt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,38 @@ class ContentScopeScriptsJsMessagingTest {
223223
assertEquals(0, callback.counter)
224224
}
225225

226+
@Test
227+
fun `when processing message with subdomain of allowed domain then process message`() =
228+
runTest {
229+
contentScopeScriptsJsMessaging.register(mockWebView, callback)
230+
whenever(mockWebView.url).thenReturn("https://subdomain.example.com")
231+
232+
val message =
233+
"""
234+
{"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}}
235+
""".trimIndent()
236+
237+
contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret)
238+
239+
assertEquals(1, callback.counter)
240+
}
241+
242+
@Test
243+
fun `when processing message with null url and handler has allowed domains then do nothing`() =
244+
runTest {
245+
contentScopeScriptsJsMessaging.register(mockWebView, callback)
246+
whenever(mockWebView.url).thenReturn(null)
247+
248+
val message =
249+
"""
250+
{"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}}
251+
""".trimIndent()
252+
253+
contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret)
254+
255+
assertEquals(0, callback.counter)
256+
}
257+
226258
private val callback =
227259
object : JsMessageCallback() {
228260
var counter = 0
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.messaging
18+
19+
import com.duckduckgo.app.di.AppCoroutineScope
20+
import com.duckduckgo.common.utils.AppUrl
21+
import com.duckduckgo.common.utils.DispatcherProvider
22+
import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin
23+
import com.duckduckgo.di.scopes.AppScope
24+
import com.duckduckgo.duckchat.api.DuckChat
25+
import com.duckduckgo.js.messaging.api.JsCallbackData
26+
import com.duckduckgo.js.messaging.api.JsMessage
27+
import com.duckduckgo.js.messaging.api.JsMessageCallback
28+
import com.duckduckgo.js.messaging.api.JsMessageHandler
29+
import com.duckduckgo.js.messaging.api.JsMessaging
30+
import com.duckduckgo.settings.api.SettingsPageFeature
31+
import com.squareup.anvil.annotations.ContributesMultibinding
32+
import kotlinx.coroutines.CoroutineScope
33+
import kotlinx.coroutines.launch
34+
import logcat.logcat
35+
import org.json.JSONObject
36+
import javax.inject.Inject
37+
38+
/**
39+
* Handles the isNativeDuckAiEnabled message from SERP to query Duck.ai toggle state.
40+
*
41+
* Purpose: SERP hides its Duck.ai toggle when in native browser and queries
42+
* native state instead, making native settings the single source of truth.
43+
*/
44+
@ContributesMultibinding(AppScope::class)
45+
class IsNativeDuckAiEnabledHandler @Inject constructor(
46+
private val dispatcherProvider: DispatcherProvider,
47+
@AppCoroutineScope private val appScope: CoroutineScope,
48+
private val settingsPageFeature: SettingsPageFeature,
49+
private val duckChat: DuckChat,
50+
) : ContentScopeJsMessageHandlersPlugin {
51+
52+
override fun getJsMessageHandler(): JsMessageHandler =
53+
object : JsMessageHandler {
54+
override fun process(
55+
jsMessage: JsMessage,
56+
jsMessaging: JsMessaging,
57+
jsMessageCallback: JsMessageCallback?,
58+
) {
59+
appScope.launch(dispatcherProvider.main()) {
60+
if (settingsPageFeature.serpSettingsSync().isEnabled()) {
61+
logcat { "SERP-SETTINGS: IsNativeDuckAiEnabledHandler processing message" }
62+
val response = JSONObject().apply {
63+
put("enabled", duckChat.isEnabled())
64+
}
65+
66+
jsMessaging.onResponse(
67+
JsCallbackData(
68+
params = response,
69+
featureName = jsMessage.featureName,
70+
method = jsMessage.method,
71+
id = jsMessage.id ?: "",
72+
),
73+
)
74+
}
75+
}
76+
}
77+
78+
override val allowedDomains: List<String> = listOf(AppUrl.Url.HOST)
79+
override val featureName: String = "serpSettings"
80+
override val methods: List<String> = listOf("isNativeDuckAiEnabled")
81+
}
82+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.messaging
18+
19+
import com.duckduckgo.common.test.CoroutineTestRule
20+
import com.duckduckgo.duckchat.api.DuckChat
21+
import com.duckduckgo.settings.api.SettingsPageFeature
22+
import org.junit.Assert.*
23+
import org.junit.Rule
24+
import org.junit.Test
25+
import org.mockito.kotlin.mock
26+
27+
class IsNativeDuckAiEnabledHandlerTest {
28+
29+
@get:Rule
30+
val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()
31+
32+
private val handler = IsNativeDuckAiEnabledHandler(
33+
dispatcherProvider = coroutineTestRule.testDispatcherProvider,
34+
appScope = coroutineTestRule.testScope,
35+
settingsPageFeature = mock<SettingsPageFeature>(),
36+
duckChat = mock<DuckChat>(),
37+
).getJsMessageHandler()
38+
39+
@Test
40+
fun `only allow duckduckgo dot com domains`() {
41+
val domains = handler.allowedDomains
42+
assertEquals(1, domains.size)
43+
assertEquals("duckduckgo.com", domains.first())
44+
}
45+
46+
@Test
47+
fun `feature name is serpSettings`() {
48+
assertEquals("serpSettings", handler.featureName)
49+
}
50+
51+
@Test
52+
fun `only contains isNativeDuckAiEnabled method`() {
53+
val methods = handler.methods
54+
assertEquals(1, methods.size)
55+
assertEquals("isNativeDuckAiEnabled", methods[0])
56+
}
57+
}

settings/settings-impl/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ dependencies {
4848
implementation project(':js-messaging-api')
4949
implementation project(':statistics-api')
5050
implementation project(':content-scope-scripts-api')
51+
implementation project(':duckchat-api')
5152

5253
implementation "com.squareup.logcat:logcat:_"
5354

settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import com.duckduckgo.common.ui.viewbinding.viewBinding
3232
import com.duckduckgo.di.scopes.ActivityScope
3333
import com.duckduckgo.js.messaging.api.JsMessaging
3434
import com.duckduckgo.navigation.api.getActivityParams
35+
import com.duckduckgo.settings.api.SettingsPageFeature
3536
import com.duckduckgo.settings.api.SettingsWebViewScreenWithParams
3637
import com.duckduckgo.settings.impl.databinding.ActivitySettingsWebviewBinding
3738
import kotlinx.coroutines.flow.launchIn
@@ -52,6 +53,12 @@ class SettingsWebViewActivity : DuckDuckGoActivity() {
5253
@Named("ContentScopeScripts")
5354
lateinit var contentScopeScripts: JsMessaging
5455

56+
@Inject
57+
lateinit var settingsWebViewClient: SettingsWebViewClient
58+
59+
@Inject
60+
lateinit var settingsPageFeature: SettingsPageFeature
61+
5562
private val binding: ActivitySettingsWebviewBinding by viewBinding()
5663

5764
private val toolbar
@@ -116,8 +123,8 @@ class SettingsWebViewActivity : DuckDuckGoActivity() {
116123

117124
@SuppressLint("SetJavaScriptEnabled")
118125
private fun setupWebView() {
119-
binding.settingsWebView.let {
120-
it.settings.apply {
126+
binding.settingsWebView.let { webView ->
127+
webView.settings.apply {
121128
userAgentString = CUSTOM_USER_AGENT
122129
javaScriptEnabled = true
123130
domStorageEnabled = true
@@ -129,6 +136,10 @@ class SettingsWebViewActivity : DuckDuckGoActivity() {
129136
databaseEnabled = false
130137
setSupportZoom(true)
131138
}
139+
140+
if (settingsPageFeature.serpSettingsSync().isEnabled()) {
141+
webView.webViewClient = settingsWebViewClient
142+
}
132143
}
133144
}
134145

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.settings.impl
18+
19+
import android.graphics.Bitmap
20+
import android.webkit.WebView
21+
import android.webkit.WebViewClient
22+
import androidx.annotation.UiThread
23+
import com.duckduckgo.browser.api.JsInjectorPlugin
24+
import com.duckduckgo.common.utils.plugins.PluginPoint
25+
import javax.inject.Inject
26+
27+
class SettingsWebViewClient @Inject constructor(
28+
private val jsPlugins: PluginPoint<JsInjectorPlugin>,
29+
) : WebViewClient() {
30+
31+
@UiThread
32+
override fun onPageStarted(
33+
webView: WebView,
34+
url: String?,
35+
favicon: Bitmap?,
36+
) {
37+
jsPlugins.getPlugins().forEach {
38+
it.onPageStarted(webView, url, null)
39+
}
40+
}
41+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.settings.impl.serpsettings.messaging
18+
19+
import com.duckduckgo.app.di.AppCoroutineScope
20+
import com.duckduckgo.common.utils.AppUrl
21+
import com.duckduckgo.common.utils.DispatcherProvider
22+
import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin
23+
import com.duckduckgo.di.scopes.AppScope
24+
import com.duckduckgo.js.messaging.api.JsMessage
25+
import com.duckduckgo.js.messaging.api.JsMessageCallback
26+
import com.duckduckgo.js.messaging.api.JsMessageHandler
27+
import com.duckduckgo.js.messaging.api.JsMessaging
28+
import com.duckduckgo.settings.api.SettingsPageFeature
29+
import com.squareup.anvil.annotations.ContributesMultibinding
30+
import kotlinx.coroutines.CoroutineScope
31+
import kotlinx.coroutines.launch
32+
import logcat.logcat
33+
import javax.inject.Inject
34+
35+
/**
36+
* Handles the getNativeSettings message from SERP to retrieve stored settings.
37+
*
38+
* If there are no stored settings, it returns an empty JSON object.
39+
*/
40+
@ContributesMultibinding(AppScope::class)
41+
class GetNativeSettingsHandler @Inject constructor(
42+
private val dispatcherProvider: DispatcherProvider,
43+
@AppCoroutineScope private val appScope: CoroutineScope,
44+
private val settingsPageFeature: SettingsPageFeature,
45+
// TODO: Inject data store when implemented
46+
) : ContentScopeJsMessageHandlersPlugin {
47+
48+
override fun getJsMessageHandler(): JsMessageHandler =
49+
object : JsMessageHandler {
50+
override fun process(
51+
jsMessage: JsMessage,
52+
jsMessaging: JsMessaging,
53+
jsMessageCallback: JsMessageCallback?,
54+
) {
55+
appScope.launch(dispatcherProvider.io()) {
56+
if (settingsPageFeature.serpSettingsSync().isEnabled()) {
57+
logcat { "SERP-SETTINGS: GetNativeSettingsHandler processing message" }
58+
59+
// TODO return settings from datastore or empty object
60+
}
61+
}
62+
}
63+
64+
override val allowedDomains: List<String> = listOf(AppUrl.Url.HOST)
65+
override val featureName: String = "serpSettings"
66+
override val methods: List<String> = listOf(GET_NATIVE_SETTINGS_METHOD_NAME)
67+
}
68+
69+
companion object {
70+
private const val GET_NATIVE_SETTINGS_METHOD_NAME = "getNativeSettings"
71+
}
72+
}

0 commit comments

Comments
 (0)